Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@ function useSuggestionDecision( thread ) {
const onReject = async () => {
setBusy( true );
try {
await rejectSuggestion( { commentId: thread.id } );
await rejectSuggestion( {
commentId: thread.id,
clientId: thread.blockClientId,
payload,
} );
} catch {
// Notice surfaced by the provider.
} finally {
Expand Down
39 changes: 31 additions & 8 deletions packages/editor/src/components/suggestion-mode/auto-save.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,35 @@ export function fingerprintOperations( operations ) {
}
}

/**
* Derive the operation list a given overlay entry should persist.
*
* Structural entries (block-remove, block-insert-after, block-move) carry
* a single pre-built op in `entry.structuralOp`; the interceptor wrote it
* after detecting the corresponding tree mutation. Attribute-set entries
* derive their ops from the baseline-vs-overlay diff. An entry can have
* both — a user can edit attributes on a block that was suggested for
* removal — in which case the structural op leads and any attribute ops
* follow.
*
* @param {Object} entry Overlay entry.
* @return {Array} Ops describing the entry's pending suggestion.
*/
export function operationsForEntry( entry ) {
const ops = [];
if ( entry.structuralOp ) {
ops.push( entry.structuralOp );
}
const attrOps = operationsFromOverlay(
entry.baselineAttributes,
entry.overlayAttributes
);
for ( const op of attrOps ) {
ops.push( op );
}
return ops;
}

/**
* Invisible component that auto-commits pending overlay edits to the server
* as note comments. Replaces the manual "Submit suggestion" button — in
Expand Down Expand Up @@ -122,10 +151,7 @@ export default function SuggestionAutoSave() {
if ( ! entry ) {
return;
}
const operations = operationsFromOverlay(
entry.baselineAttributes,
entry.overlayAttributes
);
const operations = operationsForEntry( entry );
const fingerprint = fingerprintOperations( operations );
if ( fingerprint === entry.syncedOpsKey ) {
return;
Expand Down Expand Up @@ -208,10 +234,7 @@ export default function SuggestionAutoSave() {
const timers = timersRef.current;

for ( const [ clientId, entry ] of Object.entries( entries ) ) {
const operations = operationsFromOverlay(
entry.baselineAttributes,
entry.overlayAttributes
);
const operations = operationsForEntry( entry );
const fingerprint = fingerprintOperations( operations );
if ( fingerprint === entry.syncedOpsKey ) {
continue;
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/src/components/suggestion-mode/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export {
hasAttributeConflict,
parseSuggestionPayload,
payloadByteLength,
findStructuralOp,
clearSuggestionMarkerAttributes,
PAYLOAD_MAX_BYTES,
SCHEMA_VERSION,
} from './provider';
Expand Down
33 changes: 33 additions & 0 deletions packages/editor/src/components/suggestion-mode/overlay-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const OverlayContext = createContext( {
clearOverlay: () => {},
setCommentId: () => {},
setSyncedOpsKey: () => {},
setStructuralOp: () => {},
hasOverlay: () => false,
requestInterceptorBypass: () => {},
consumeInterceptorBypass: () => false,
Expand Down Expand Up @@ -161,6 +162,25 @@ export function overlayReducer( state, action ) {
},
};
}
case 'SET_STRUCTURAL_OP': {
// Structural ops (block-remove, block-insert-after, block-move)
// don't have a baseline-vs-overlay attribute diff; the operation
// itself describes the change. Auto-save reads `structuralOp`
// straight through. Replaces any existing op for the same block
// — only one structural marker can be pending at a time.
const existing = state[ action.clientId ];
return {
...state,
[ action.clientId ]: {
blockName: action.blockName,
baselineAttributes: existing?.baselineAttributes ?? {},
overlayAttributes: existing?.overlayAttributes ?? {},
commentId: existing?.commentId ?? null,
syncedOpsKey: existing?.syncedOpsKey ?? null,
structuralOp: action.op,
},
};
}
case 'PRUNE_ORPHANS': {
// Action carries a serializable array; the reducer materializes a
// Set internally for the lookup. Keeps actions Redux-DevTools-
Expand Down Expand Up @@ -236,6 +256,17 @@ export function SuggestionOverlayProvider( { children } ) {
[]
);

const setStructuralOp = useCallback(
( clientId, blockName, op ) =>
dispatch( {
type: 'SET_STRUCTURAL_OP',
clientId,
blockName,
op,
} ),
[]
);

const hasEntries = Object.keys( entries ).length > 0;

const hasOverlay = useCallback(
Expand Down Expand Up @@ -315,6 +346,7 @@ export function SuggestionOverlayProvider( { children } ) {
clearOverlay,
setCommentId,
setSyncedOpsKey,
setStructuralOp,
hasOverlay,
requestInterceptorBypass,
consumeInterceptorBypass,
Expand All @@ -326,6 +358,7 @@ export function SuggestionOverlayProvider( { children } ) {
clearOverlay,
setCommentId,
setSyncedOpsKey,
setStructuralOp,
hasOverlay,
requestInterceptorBypass,
consumeInterceptorBypass,
Expand Down
149 changes: 143 additions & 6 deletions packages/editor/src/components/suggestion-mode/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,57 @@ function isAttributeEqual( a, b ) {
return true;
}

/**
* Operation types that mutate the block tree's structure rather than a
* single block's attributes. These flow through a different apply/reject
* path than `attribute-set`: Apply dispatches the corresponding block-
* editor action (`removeBlock`, `insertBlock`, `moveBlockToPosition`),
* Reject just clears the `metadata.suggestion` marker.
*/
const STRUCTURAL_OP_TYPES = new Set( [
'block-remove',
'block-insert-after',
'block-move',
] );

/**
* Locate the structural operation in a suggestion payload. v2 payloads carry
* at most one structural op per suggestion (the auto-save loop persists each
* structural mutation as its own note); attribute-set ops can ride along
* inside the same payload but the structural op leads.
*
* @param {SuggestionOperation[]} operations Payload operations.
* @return {SuggestionOperation|null} Structural op, or null when none.
*/
export function findStructuralOp( operations ) {
if ( ! Array.isArray( operations ) ) {
return null;
}
for ( const op of operations ) {
if ( op && STRUCTURAL_OP_TYPES.has( op.type ) ) {
return op;
}
}
return null;
}

/**
* Build attributes that clear the `metadata.suggestion` marker on a block
* while preserving every other metadata field. Used by Apply (after the
* mutation lands) and by Reject (to drop the pending state).
*
* @param {Object} currentAttributes Block's current attributes.
* @return {Object} Partial attributes payload safe for `updateBlockAttributes`.
*/
export function clearSuggestionMarkerAttributes( currentAttributes ) {
const meta = currentAttributes?.metadata;
if ( ! meta || meta.suggestion === undefined ) {
return null;
}
const { suggestion: _drop, ...rest } = meta;
return { metadata: rest };
}

/**
* Apply a suggestion payload's operations to a block's current attributes
* to produce the new attributes. Pure function — no side effects.
Expand Down Expand Up @@ -328,7 +379,8 @@ export function useSuggestionsProvider() {

const { saveEntityRecord } = useDispatch( coreStore );
const { createNotice } = useDispatch( noticesStore );
const { updateBlockAttributes } = useDispatch( blockEditorStore );
const { updateBlockAttributes, removeBlock } =
useDispatch( blockEditorStore );
const {
getBlockAttributes: selectBlockAttributes,
getClientIdsWithDescendants: selectClientIdsWithDescendants,
Expand Down Expand Up @@ -575,6 +627,58 @@ export function useSuggestionsProvider() {
return;
}

// Structural ops (block-remove for now, block-insert-after and
// block-move in follow-up PRs) can't ride the
// updateBlockAttributes path: their apply mutates the tree
// rather than a single block's attributes. Branch out, run the
// matching block-editor action, and short-circuit before the
// attribute-set rollback machinery below.
const structuralOp = findStructuralOp( payload.operations );
if ( structuralOp ) {
try {
if ( structuralOp.type === 'block-remove' ) {
// Bypass twice: the marker-clear dispatch lands
// first (so the live block ends without the
// pending-remove flag should the removeBlock fail),
// then the actual removal.
const clearAttrs = clearSuggestionMarkerAttributes(
selectBlockAttributes( targetClientId )
);
if ( clearAttrs ) {
requestInterceptorBypass( targetClientId );
updateBlockAttributes( targetClientId, clearAttrs );
}
requestInterceptorBypass( targetClientId );
clearOverlay( targetClientId );
removeBlock( targetClientId );
}

await saveEntityRecord(
'root',
'comment',
{
id: commentId,
status: 'approved',
meta: { _wp_suggestion_status: 'applied' },
},
{ throwOnError: true }
);

createNotice( 'snackbar', __( 'Suggestion applied.' ), {
type: 'snackbar',
isDismissible: true,
} );
} catch ( error ) {
createNotice(
'error',
error?.message ||
__( 'Failed to save suggestion status.' ),
{ type: 'snackbar', isDismissible: true }
);
}
return;
}

const currentAttributes = selectBlockAttributes( targetClientId );
const newAttributes = applyOperations(
currentAttributes,
Expand Down Expand Up @@ -645,6 +749,7 @@ export function useSuggestionsProvider() {
[
saveEntityRecord,
updateBlockAttributes,
removeBlock,
selectBlockAttributes,
selectClientIdsWithDescendants,
createNotice,
Expand All @@ -657,14 +762,39 @@ export function useSuggestionsProvider() {
* Reject a suggestion by setting the comment's lifecycle status. The
* comment itself stays as a thread (status `approved`) so the
* conversation persists as evidence that the suggestion was reviewed.
* For structural suggestions (e.g. `block-remove`), also clears the
* `metadata.suggestion` marker on the live block so the dimmed/struck
* visual treatment goes away.
*
* @param {Object} args Reject arguments.
* @param {number|string} args.commentId Comment id of the rejected
* suggestion.
* @param {Object} args Reject arguments.
* @param {number|string} args.commentId Comment id of the rejected
* suggestion.
* @param {string} [args.clientId] Target block clientId, if
* known.
* @param {SuggestionPayload} [args.payload] Parsed suggestion payload —
* inspected to detect a
* structural op so the marker
* can be cleared on the live
* block.
* @return {Promise<void>}
*/
const rejectSuggestion = useCallback(
async ( { commentId } ) => {
async ( { commentId, clientId, payload } ) => {
// Clear the live block's suggestion marker for structural
// rejects. Attribute-set suggestions don't carry a marker, so
// nothing to clear.
const structuralOp = findStructuralOp( payload?.operations );
if ( structuralOp && clientId ) {
const clearAttrs = clearSuggestionMarkerAttributes(
selectBlockAttributes( clientId )
);
if ( clearAttrs ) {
requestInterceptorBypass( clientId );
updateBlockAttributes( clientId, clearAttrs );
}
clearOverlay( clientId );
}

try {
await saveEntityRecord(
'root',
Expand All @@ -689,7 +819,14 @@ export function useSuggestionsProvider() {
);
}
},
[ saveEntityRecord, createNotice ]
[
saveEntityRecord,
createNotice,
selectBlockAttributes,
updateBlockAttributes,
requestInterceptorBypass,
clearOverlay,
]
);

return {
Expand Down
Loading
Loading