@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Update client logic for inline comment "Save" and "Cancel" actions

Summary: Ref T13559. Substantially correct the client logic for "Save" and "Cancel" actions to handle unusual cases.

Test Plan:
Quoting behavior:

- Quoted a comment.
- Cancelled the quoted comment without modifying anything.
- Reloaded page.
- Before changes: quoted comment still exists.
- After changes: quoted comment is deleted.
- Looked at comment count in header, saw consistent behavior (before: weird behavior).

Empty suggestion behavior:

- Created a new comment on a suggestable file.
- Clicked "Suggest Edit" to enable suggestions.
- Without making any text or suggestion changes, clicked "Save".
- Before changes: comment saves, but is empty.
- After changes: comment deletes itself without undo.

General behavior:

- Created and saved an empty comment (deletes itself).
- Created and saved a nonempty comment (saves as draft).
- Created and saved an empty comment with an edit suggestion (saves).
- Created and saved an empty comment with a suggestion to entirely delete lines -- that is, no suggestion text (saves).
- Edited a comment, saved without changes (save).
- Edited a comment, save deleting all text (saves -- note that this is intentionally without undo, since this is a lot of steps to do by accident).
- Cancel editing an unchanged comment (cancels without undo).
- Cancel editing a changed comment (cancels with undo).
- Undo'd, got text back.
- Cancel new comment with no text (deletes without undo).
- Cancel new comment with text (deletes with undo).
- Undo'd, got text back.
- Saved a quoted comment with no changes (saves -- note that this is intentionally not a "delete", since just quoting someone seems fine if you click "Save" -- maybe you want to come back to it later).

Maniphest Tasks: T13559

Differential Revision: https://secure.phabricator.com/D21654

+179 -104
+12 -12
resources/celerity/map.php
··· 13 13 'core.pkg.js' => '68f29322', 14 14 'dark-console.pkg.js' => '187792c2', 15 15 'differential.pkg.css' => 'ffb69e3d', 16 - 'differential.pkg.js' => '59453886', 16 + 'differential.pkg.js' => '8deec4cd', 17 17 'diffusion.pkg.css' => '42c75c37', 18 18 'diffusion.pkg.js' => '78c9885d', 19 19 'maniphest.pkg.css' => '35995d6d', ··· 385 385 'rsrc/js/application/dashboard/behavior-dashboard-tab-panel.js' => '0116d3e8', 386 386 'rsrc/js/application/diff/DiffChangeset.js' => 'd7d3ba75', 387 387 'rsrc/js/application/diff/DiffChangesetList.js' => 'cc2c5de5', 388 - 'rsrc/js/application/diff/DiffInline.js' => '26664c24', 389 - 'rsrc/js/application/diff/DiffInlineContentState.js' => '68e6339d', 388 + 'rsrc/js/application/diff/DiffInline.js' => '9c775532', 389 + 'rsrc/js/application/diff/DiffInlineContentState.js' => 'aa51efb4', 390 390 'rsrc/js/application/diff/DiffPathView.js' => '8207abf9', 391 391 'rsrc/js/application/diff/DiffTreeView.js' => '5d83623b', 392 392 'rsrc/js/application/differential/behavior-diff-radios.js' => '925fe8cd', ··· 788 788 'phabricator-dashboard-css' => '5a205b9d', 789 789 'phabricator-diff-changeset' => 'd7d3ba75', 790 790 'phabricator-diff-changeset-list' => 'cc2c5de5', 791 - 'phabricator-diff-inline' => '26664c24', 792 - 'phabricator-diff-inline-content-state' => '68e6339d', 791 + 'phabricator-diff-inline' => '9c775532', 792 + 'phabricator-diff-inline-content-state' => 'aa51efb4', 793 793 'phabricator-diff-path-view' => '8207abf9', 794 794 'phabricator-diff-tree-view' => '5d83623b', 795 795 'phabricator-drag-and-drop-file-upload' => '4370900d', ··· 1162 1162 'javelin-json', 1163 1163 'phabricator-prefab', 1164 1164 ), 1165 - '26664c24' => array( 1166 - 'javelin-dom', 1167 - 'phabricator-diff-inline-content-state', 1168 - ), 1169 1165 '289bf236' => array( 1170 1166 'javelin-install', 1171 1167 'javelin-util', ··· 1549 1545 'javelin-install', 1550 1546 'javelin-dom', 1551 1547 ), 1552 - '68e6339d' => array( 1553 - 'javelin-dom', 1554 - ), 1555 1548 '6a1583a8' => array( 1556 1549 'javelin-behavior', 1557 1550 'javelin-history', ··· 1823 1816 'javelin-dom', 1824 1817 'javelin-workflow', 1825 1818 ), 1819 + '9c775532' => array( 1820 + 'javelin-dom', 1821 + 'phabricator-diff-inline-content-state', 1822 + ), 1826 1823 '9cec214e' => array( 1827 1824 'javelin-behavior', 1828 1825 'javelin-stratcom', ··· 1911 1908 'javelin-dom', 1912 1909 'javelin-typeahead', 1913 1910 'javelin-typeahead-ondemand-source', 1911 + 'javelin-dom', 1912 + ), 1913 + 'aa51efb4' => array( 1914 1914 'javelin-dom', 1915 1915 ), 1916 1916 'aa6d2308' => array(
+3 -1
src/infrastructure/diff/PhabricatorInlineCommentController.php
··· 172 172 $inline = $this->loadCommentByIDForEdit($this->getCommentID()); 173 173 174 174 if ($is_delete) { 175 - $inline->setIsDeleted(1); 175 + $inline 176 + ->setIsEditing(false) 177 + ->setIsDeleted(1); 176 178 } else { 177 179 $inline->setIsDeleted(0); 178 180 }
+98 -91
webroot/rsrc/js/application/diff/DiffInline.js
··· 44 44 _undoRow: null, 45 45 _undoType: null, 46 46 _undoState: null, 47 - _preventUndo: false, 48 47 49 48 _draftRequest: null, 50 49 _skipFocus: false, ··· 159 158 160 159 getEndOffset: function() { 161 160 return this._endOffset; 162 - }, 163 - 164 - _setPreventUndo: function(prevent_undo) { 165 - this._preventUndo = prevent_undo; 166 - }, 167 - 168 - _getPreventUndo: function() { 169 - return this._preventUndo; 170 161 }, 171 162 172 163 setIsSelected: function(is_selected) { ··· 452 443 this._undoText = null; 453 444 } 454 445 455 - var uri = this._getInlineURI(); 456 - var handler = JX.bind(this, this._oneditresponse); 457 - 458 - var data = this._newRequestData('edit', content_state); 459 - 460 - this.setLoading(true); 461 - 462 - new JX.Request(uri, handler) 463 - .setData(data) 464 - .send(); 446 + this._applyEdit(content_state); 465 447 }, 466 448 467 449 delete: function(is_ref) { 468 450 var uri = this._getInlineURI(); 469 - var handler = JX.bind(this, this._ondeleteresponse); 451 + var handler = JX.bind(this, this._ondeleteresponse, false); 470 452 471 453 // NOTE: This may be a direct delete (the user clicked on the inline 472 454 // itself) or a "refdelete" (the user clicked somewhere else, like the ··· 586 568 this._readInlineState(response.inline); 587 569 this._drawEditRows(rows); 588 570 589 - this.setLoading(false); 590 571 this.setInvisible(true); 591 572 }, 592 573 ··· 617 598 return new JX.DiffInlineContentState().readWireFormat(map); 618 599 }, 619 600 620 - _ondeleteresponse: function() { 621 - // If there's an existing "unedit" undo element, remove it. 622 - if (this._undoRow) { 623 - JX.DOM.remove(this._undoRow); 624 - this._undoRow = null; 625 - } 601 + _ondeleteresponse: function(prevent_undo) { 602 + if (!prevent_undo) { 603 + // If there's an existing "unedit" undo element, remove it. 604 + if (this._undoRow) { 605 + JX.DOM.remove(this._undoRow); 606 + this._undoRow = null; 607 + } 626 608 627 - // If there's an existing editor, remove it. This happens when you 628 - // delete a comment from the comment preview area. In this case, we 629 - // read and preserve the text so "Undo" restores it. 630 - var state = null; 631 - if (this._editRow) { 632 - state = this._getActiveContentState().getWireFormat(); 633 - JX.DOM.remove(this._editRow); 634 - this._editRow = null; 635 - } 609 + // If there's an existing editor, remove it. This happens when you 610 + // delete a comment from the comment preview area. In this case, we 611 + // read and preserve the text so "Undo" restores it. 612 + var state = null; 613 + if (this._editRow) { 614 + state = this._getActiveContentState().getWireFormat(); 615 + JX.DOM.remove(this._editRow); 616 + this._editRow = null; 617 + } 636 618 637 - if (!this._getPreventUndo()) { 638 619 this._drawUndeleteRows(state); 639 620 } 640 621 ··· 692 673 var row = first_row; 693 674 var anchor = cursor || this._row; 694 675 cursor = cursor || this._row.nextSibling; 695 - 696 676 697 677 var result_row; 698 678 var next_row; ··· 837 817 838 818 save: function() { 839 819 if (this._shouldDeleteOnSave()) { 840 - this._setPreventUndo(true); 841 - this._applyDelete(); 820 + JX.DOM.remove(this._editRow); 821 + this._editRow = null; 822 + 823 + this._applyDelete(true); 842 824 return; 843 825 } 844 826 ··· 846 828 }, 847 829 848 830 _shouldDeleteOnSave: function() { 849 - var state = this._getActiveContentState(); 831 + var active = this._getActiveContentState(); 832 + var initial = this._getInitialContentState(); 850 833 851 - // TODO: This is greatly simplified because we don't track all the 852 - // state we need yet. 834 + // When a user clicks "Save", it counts as a "delete" if the content 835 + // of the comment is functionally empty. 853 836 854 - return !state.getText().length; 855 - }, 837 + // This isn't a delete if there's any text. Even if the text is a 838 + // quote (so the state is the same as the initial state), we preserve 839 + // it when the user clicks "Save". 840 + if (!active.isTextEmpty()) { 841 + return false; 842 + } 856 843 857 - _shouldDeleteOnCancel: function() { 858 - var state = this._getActiveContentState(); 844 + // This isn't a delete if there's a suggestion and that suggestion is 845 + // different from the initial state. (This means that an inline which 846 + // purely suggests a block of code should be deleted is non-empty.) 847 + if (active.getHasSuggestion()) { 848 + if (!active.isSuggestionSimilar(initial)) { 849 + return false; 850 + } 851 + } 859 852 860 - // TODO: This is greatly simplified, too. 861 - 862 - return !state.getText().length; 853 + // Otherwise, this comment is functionally empty, so we can just treat 854 + // a "Save" as a "delete". 855 + return true; 863 856 }, 864 857 865 858 _shouldUndoOnCancel: function() { 866 - var new_state = this._getActiveContentState().getWireFormat(); 867 - var old_state = this._getCommittedContentState().getWireFormat(); 868 - 869 - // TODO: This is also simplified. 859 + var committed = this._getCommittedContentState(); 860 + var active = this._getActiveContentState(); 861 + var initial = this._getInitialContentState(); 870 862 871 - var is_empty = this._isVoidContentState(new_state); 872 - var is_same = this._isSameContentState(new_state, old_state); 863 + // When a user clicks "Cancel", we only offer to let them "Undo" the 864 + // action if the undo would be substantive. 873 865 874 - if (!is_empty && !is_same) { 866 + // The undo is substantive if the text is nonempty, and not similar to 867 + // the last state. 868 + var versus = committed || initial; 869 + if (!active.isTextEmpty() && !active.isTextSimilar(versus)) { 875 870 return true; 871 + } 872 + 873 + // The undo is substantive if there's a suggestion, and the suggestion 874 + // is not similar to the last state. 875 + if (active.getHasSuggestion()) { 876 + if (!active.isSuggestionSimilar(versus)) { 877 + return true; 878 + } 876 879 } 877 880 878 881 return false; ··· 887 890 this._applyCall(handler, data); 888 891 }, 889 892 890 - _applyDelete: function() { 891 - var handler = JX.bind(this, this._ondeleteresponse); 893 + _applyDelete: function(prevent_undo) { 894 + var handler = JX.bind(this, this._ondeleteresponse, prevent_undo); 892 895 893 896 var data = this._newRequestData('delete'); 894 897 ··· 899 902 var handler = JX.bind(this, this._onCancelResponse); 900 903 901 904 var data = this._newRequestData('cancel', state); 905 + 906 + this._applyCall(handler, data); 907 + }, 908 + 909 + _applyEdit: function(state) { 910 + var handler = JX.bind(this, this._oneditresponse); 911 + 912 + var data = this._newRequestData('edit', state); 902 913 903 914 this._applyCall(handler, data); 904 915 }, ··· 946 957 }, 947 958 948 959 cancel: function() { 960 + // NOTE: Read the state before we remove the editor. Otherwise, we might 961 + // miss text the user has entered into the textarea. 962 + var state = this._getActiveContentState().getWireFormat(); 963 + 949 964 JX.DOM.remove(this._editRow); 950 965 this._editRow = null; 951 966 952 - if (this._shouldDeleteOnCancel()) { 953 - this._setPreventUndo(true); 954 - this._applyDelete(); 955 - return; 956 - } 967 + // When a user clicks "Cancel", we delete the comment if it has never 968 + // been saved: we don't have a non-empty display state to revert to. 969 + var is_delete = (this._getCommittedContentState() === null); 957 970 958 - if (this._shouldUndoOnCancel()) { 959 - var state = this._getActiveContentState().getWireFormat(); 960 - this._drawUneditRows(state); 961 - } 971 + var is_undo = this._shouldUndoOnCancel(); 962 972 963 973 // If you "undo" to restore text ("AB") and then "Cancel", we put you 964 974 // back in the original text state ("A"). We also send the original 965 975 // text ("A") to the server as the current persistent state. 966 976 967 - this.setEditing(false); 968 - this.setInvisible(false); 977 + if (is_undo) { 978 + this._drawUneditRows(state); 979 + } 969 980 970 - var old_state = this._getCommittedContentState(); 971 - this._applyCancel(old_state.getWireFormat()); 981 + if (is_delete) { 982 + // NOTE: We're always suppressing the undo from "delete". We want to 983 + // use the "undo" we just added above instead, which will get us 984 + // back to the ephemeral, client-side editor state. 985 + this._applyDelete(true); 986 + } else { 987 + this.setEditing(false); 988 + this.setInvisible(false); 972 989 973 - this._didUpdate(true); 990 + var old_state = this._getCommittedContentState(); 991 + this._applyCancel(old_state.getWireFormat()); 992 + 993 + this._didUpdate(true); 994 + } 974 995 }, 975 996 976 997 _onCancelResponse: function(response) { ··· 1067 1088 return null; 1068 1089 } 1069 1090 1070 - var state = this._getActiveContentState().getWireFormat(); 1071 - if (this._isVoidContentState(state)) { 1091 + var state = this._getActiveContentState(); 1092 + if (state.isStateEmpty()) { 1072 1093 return null; 1073 1094 } 1074 1095 ··· 1077 1098 id: this.getID(), 1078 1099 }; 1079 1100 1080 - JX.copy(draft_data, state); 1101 + JX.copy(draft_data, state.getWireFormat()); 1081 1102 1082 1103 return draft_data; 1083 1104 }, ··· 1193 1214 suggestionText: '', 1194 1215 hasSuggestion: false 1195 1216 }; 1196 - }, 1217 + } 1197 1218 1198 - _isVoidContentState: function(state) { 1199 - if (!state.text) { 1200 - return true; 1201 - } 1202 - return (!state.text.length && !state.suggestionText.length); 1203 - }, 1204 - 1205 - _isSameContentState: function(u, v) { 1206 - return ( 1207 - ((u === null) === (v === null)) && 1208 - (u.text === v.text) && 1209 - (u.suggestionText === v.suggestionText) && 1210 - (u.hasSuggestion === v.hasSuggestion)); 1211 - } 1212 1219 } 1213 1220 1214 1221 });
+66
webroot/rsrc/js/application/diff/DiffInlineContentState.js
··· 59 59 return text; 60 60 }, 61 61 62 + isStateEmpty: function() { 63 + return (this.isTextEmpty() && this.isSuggestionEmpty()); 64 + }, 65 + 66 + isTextEmpty: function() { 67 + var text = this.getText(); 68 + if (text === null) { 69 + return true; 70 + } 71 + 72 + if (this._isStringSimilar(text, '')) { 73 + return true; 74 + } 75 + 76 + return false; 77 + }, 78 + 79 + isSuggestionEmpty: function() { 80 + if (!this.getHasSuggestion()) { 81 + return true; 82 + } 83 + 84 + var suggestion = this.getSuggestionText(); 85 + if (suggestion === null) { 86 + return true; 87 + } 88 + 89 + if (this._isStringSimilar(suggestion, '')) { 90 + return true; 91 + } 92 + 93 + return false; 94 + }, 95 + 96 + isTextSimilar: function(v) { 97 + if (!v) { 98 + return false; 99 + } 100 + 101 + var us = this.getText(); 102 + var vs = v.getText(); 103 + 104 + return this._isStringSimilar(us, vs); 105 + }, 106 + 107 + isSuggestionSimilar: function(v) { 108 + // If we don't have a comparison state, treat them as dissimilar. This 109 + // is expected to occur in old inline comments that did not save an 110 + // initial state. 111 + 112 + if (!v) { 113 + return false; 114 + } 115 + 116 + var us = this.getSuggestionText(); 117 + var vs = v.getSuggestionText(); 118 + 119 + return this._isStringSimilar(us, vs); 120 + }, 121 + 122 + _isStringSimilar: function(u, v) { 123 + u = u || ''; 124 + v = v || ''; 125 + return (u === v); 126 + }, 127 + 62 128 _getSuggestionNode: function(row) { 63 129 try { 64 130 return JX.DOM.find(row, 'textarea', 'inline-content-suggestion');