Skip to content

Commit 7e2fe59

Browse files
Editor: Add suggestion diff preview, Apply, and Reject
Phase 3 of the Suggest mode effort. Implements the Apply/Reject lifecycle and inline diff preview for suggestion notes. New in provider.js: - applySuggestion: applies operations to block attributes via updateBlockAttributes, marks note resolved with status 'applied'. Emits a warning snackbar when baseRevision differs from current post modified date (stale suggestion). - rejectSuggestion: marks note resolved with status 'rejected' without changing content. - applyOperations: pure function running attribute-set ops against current block attributes. - parseSuggestionPayload: safe JSON parser for _wp_suggestion meta. New SuggestionDiff component: - Word-level LCS-based diff for text attributes (insertions green underline, deletions red strikethrough). - Attribute label for non-text changes. UI integration in collab-sidebar/comments.js: - SuggestionActions component rendered inside CommentBoard when thread has _wp_suggestion meta. Shows diff + Apply/Reject buttons for pending suggestions, or Applied/Rejected label for resolved. Tests: applyOperations, parseSuggestionPayload, wordDiff (25 total). Refs #73411
1 parent fa199f5 commit 7e2fe59

6 files changed

Lines changed: 528 additions & 13 deletions

File tree

packages/editor/src/components/collab-sidebar/comments.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ import { focusCommentThread, getCommentExcerpt } from './utils';
4444
import { useFloatingThread } from './hooks';
4545
import { AddComment } from './add-comment';
4646
import { store as editorStore } from '../../store';
47+
import {
48+
SuggestionDiff,
49+
parseSuggestionPayload,
50+
useSuggestionsProvider,
51+
} from '../suggestion-mode';
4752

4853
const { useBlockElement } = unlock( blockEditorPrivateApis );
4954
const { Menu } = unlock( componentsPrivateApis );
@@ -968,6 +973,7 @@ const CommentBoard = ( {
968973
: thread?.content?.rendered }
969974
</RawHTML>
970975
) }
976+
<SuggestionActions thread={ thread } />
971977
{ 'delete' === actionState && (
972978
<ConfirmDialog
973979
isOpen={ showConfirmDialog }
@@ -982,4 +988,79 @@ const CommentBoard = ( {
982988
);
983989
};
984990

991+
function SuggestionActions( { thread } ) {
992+
const payload = useMemo(
993+
() => parseSuggestionPayload( thread?.meta?._wp_suggestion ),
994+
[ thread?.meta?._wp_suggestion ]
995+
);
996+
const suggestionStatus = thread?.meta?._wp_suggestion_status;
997+
const { applySuggestion, rejectSuggestion } = useSuggestionsProvider();
998+
const [ busy, setBusy ] = useState( false );
999+
1000+
if ( ! payload ) {
1001+
return null;
1002+
}
1003+
1004+
const isResolved =
1005+
suggestionStatus === 'applied' || suggestionStatus === 'rejected';
1006+
1007+
return (
1008+
<VStack spacing="2" className="editor-collab-sidebar-panel__suggestion">
1009+
<SuggestionDiff operations={ payload.operations } />
1010+
{ isResolved ? (
1011+
<Text variant="muted" size="12px">
1012+
{ suggestionStatus === 'applied'
1013+
? __( 'Applied' )
1014+
: __( 'Rejected' ) }
1015+
</Text>
1016+
) : (
1017+
<HStack spacing="2" justify="flex-start">
1018+
<Button
1019+
variant="primary"
1020+
size="small"
1021+
disabled={ busy }
1022+
accessibleWhenDisabled
1023+
onClick={ async () => {
1024+
setBusy( true );
1025+
try {
1026+
await applySuggestion( {
1027+
commentId: thread.id,
1028+
clientId: thread.blockClientId,
1029+
payload,
1030+
} );
1031+
} catch {
1032+
// Notice surfaced by the provider.
1033+
} finally {
1034+
setBusy( false );
1035+
}
1036+
} }
1037+
>
1038+
{ __( 'Apply' ) }
1039+
</Button>
1040+
<Button
1041+
variant="secondary"
1042+
size="small"
1043+
disabled={ busy }
1044+
accessibleWhenDisabled
1045+
onClick={ async () => {
1046+
setBusy( true );
1047+
try {
1048+
await rejectSuggestion( {
1049+
commentId: thread.id,
1050+
} );
1051+
} catch {
1052+
// Notice surfaced by the provider.
1053+
} finally {
1054+
setBusy( false );
1055+
}
1056+
} }
1057+
>
1058+
{ __( 'Reject' ) }
1059+
</Button>
1060+
</HStack>
1061+
) }
1062+
</VStack>
1063+
);
1064+
}
1065+
9851066
export default Comments;

packages/editor/src/components/suggestion-mode/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,8 @@ export { default as SuggestionCommitBar } from './commit-bar';
1111
export {
1212
useSuggestionsProvider,
1313
operationsFromOverlay,
14+
applyOperations,
15+
parseSuggestionPayload,
1416
SCHEMA_VERSION,
1517
} from './provider';
18+
export { default as SuggestionDiff, wordDiff } from './suggestion-diff';

packages/editor/src/components/suggestion-mode/provider.js

Lines changed: 125 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,53 @@ function isAttributeEqual( a, b ) {
7979
}
8080

8181
/**
82-
* Comment-meta backed suggestions provider. Phase 2 implements only
83-
* `createSuggestion`. Apply and reject are stubbed and will be implemented
84-
* alongside the diff preview in Phase 3. The provider shape is stable so
85-
* Phase 3 / a future Yjs-backed provider can swap in without touching the
86-
* UI.
82+
* Apply a suggestion payload's operations to a block's current attributes
83+
* to produce the new attributes. Pure function — no side effects.
8784
*
88-
* Storage: a new `note` comment with the suggestion payload serialized to
85+
* @param {Object} currentAttributes Block's current attributes.
86+
* @param {SuggestionOperation[]} operations Operations from the payload.
87+
* @return {Object} Merged attributes with suggestions applied.
88+
*/
89+
export function applyOperations( currentAttributes, operations ) {
90+
const result = { ...currentAttributes };
91+
for ( const op of operations ) {
92+
if ( op.type === 'attribute-set' ) {
93+
result[ op.attribute ] = op.after;
94+
}
95+
}
96+
return result;
97+
}
98+
99+
/**
100+
* Parse a `_wp_suggestion` meta value into a typed payload.
101+
*
102+
* @param {string|undefined} raw The raw JSON string from comment meta.
103+
* @return {SuggestionPayload|null} Parsed payload, or null if invalid.
104+
*/
105+
export function parseSuggestionPayload( raw ) {
106+
if ( ! raw ) {
107+
return null;
108+
}
109+
try {
110+
const parsed = JSON.parse( raw );
111+
if (
112+
typeof parsed === 'object' &&
113+
parsed !== null &&
114+
Array.isArray( parsed.operations )
115+
) {
116+
return parsed;
117+
}
118+
return null;
119+
} catch {
120+
return null;
121+
}
122+
}
123+
124+
/**
125+
* Comment-meta backed suggestions provider. The provider shape is stable so
126+
* a future Yjs-backed provider can swap in without touching the UI.
127+
*
128+
* Storage: a `note` comment with the suggestion payload serialized to
89129
* the `_wp_suggestion` comment meta. Linkage to a block reuses the existing
90130
* `metadata.noteId` block attribute.
91131
*
@@ -182,12 +222,85 @@ export function useSuggestionsProvider() {
182222
]
183223
);
184224

185-
const applySuggestion = useCallback( async () => {
186-
throw new Error( 'applySuggestion is not implemented in Phase 2.' );
187-
}, [] );
188-
const rejectSuggestion = useCallback( async () => {
189-
throw new Error( 'rejectSuggestion is not implemented in Phase 2.' );
190-
}, [] );
225+
const { getBlockAttributes: selectBlockAttributes } =
226+
useSelect( blockEditorStore );
227+
228+
const applySuggestion = useCallback(
229+
async ( { commentId, clientId, payload } ) => {
230+
if ( ! payload || ! Array.isArray( payload.operations ) ) {
231+
createNotice( 'error', __( 'Invalid suggestion payload.' ), {
232+
type: 'snackbar',
233+
isDismissible: true,
234+
} );
235+
return;
236+
}
237+
238+
if (
239+
payload.baseRevision &&
240+
postModified &&
241+
payload.baseRevision !== postModified
242+
) {
243+
createNotice(
244+
'warning',
245+
__(
246+
'Post content has changed since this suggestion. Review carefully.'
247+
),
248+
{ type: 'snackbar', isDismissible: true }
249+
);
250+
}
251+
252+
const currentAttributes = selectBlockAttributes( clientId );
253+
const newAttributes = applyOperations(
254+
currentAttributes,
255+
payload.operations
256+
);
257+
updateBlockAttributes( clientId, newAttributes );
258+
259+
await saveEntityRecord(
260+
'root',
261+
'comment',
262+
{
263+
id: commentId,
264+
status: 'approved',
265+
meta: { _wp_suggestion_status: 'applied' },
266+
},
267+
{ throwOnError: true }
268+
);
269+
270+
createNotice( 'snackbar', __( 'Suggestion applied.' ), {
271+
type: 'snackbar',
272+
isDismissible: true,
273+
} );
274+
},
275+
[
276+
postModified,
277+
saveEntityRecord,
278+
updateBlockAttributes,
279+
selectBlockAttributes,
280+
createNotice,
281+
]
282+
);
283+
284+
const rejectSuggestion = useCallback(
285+
async ( { commentId } ) => {
286+
await saveEntityRecord(
287+
'root',
288+
'comment',
289+
{
290+
id: commentId,
291+
status: 'approved',
292+
meta: { _wp_suggestion_status: 'rejected' },
293+
},
294+
{ throwOnError: true }
295+
);
296+
297+
createNotice( 'snackbar', __( 'Suggestion rejected.' ), {
298+
type: 'snackbar',
299+
isDismissible: true,
300+
} );
301+
},
302+
[ saveEntityRecord, createNotice ]
303+
);
191304

192305
return { createSuggestion, applySuggestion, rejectSuggestion };
193306
}

0 commit comments

Comments
 (0)