Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
23 changes: 16 additions & 7 deletions packages/editor/src/components/collab-sidebar/note.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { moreVertical, published } from '@wordpress/icons';
*/
import { NoteCard } from './note-card';
import { NoteForm } from './note-form';
import SuggestionActions from './suggestion-actions';
import { unlock } from '../../lock-unlock';

const { Menu } = unlock( componentsPrivateApis );
Expand Down Expand Up @@ -66,7 +67,11 @@ export function Note( {
const [ actionState, setActionState ] = useState( null );
const actionButtonRef = useRef( null );

const canResolve = note.parent === 0;
// Suggestion threads expose their own Accept/Reject affordance in the
// header; the generic "Resolve" button would duplicate that action with
// a confusingly similar checkmark icon, so hide it for suggestion notes.
const hasSuggestionPayload = !! note?.meta?._wp_suggestion;
const canResolve = note.parent === 0 && ! hasSuggestionPayload;
const isResolutionNote =
note.type === 'note' &&
note.meta &&
Expand Down Expand Up @@ -165,9 +170,10 @@ export function Note( {
);
}

const actions = isSelected ? (
const showActions = isSelected;
const actions = showActions ? (
<>
{ canResolve && onResolve && (
{ isSelected && canResolve && onResolve && (
<Button
label={ _x( 'Resolve', 'Mark note as resolved' ) }
size="small"
Expand All @@ -177,10 +183,12 @@ export function Note( {
onClick={ onResolve }
/>
) }
<NoteActionsMenu
items={ availableItems }
buttonRef={ actionButtonRef }
/>
{ isSelected && (
<NoteActionsMenu
items={ availableItems }
buttonRef={ actionButtonRef }
/>
) }
</>
) : null;

Expand All @@ -191,6 +199,7 @@ export function Note( {
role={ note.parent !== 0 ? 'treeitem' : undefined }
>
{ body }
{ hasSuggestionPayload && <SuggestionActions thread={ note } /> }
{ actionState === 'delete' && (
<ConfirmDialog
isOpen
Expand Down
174 changes: 174 additions & 0 deletions packages/editor/src/components/collab-sidebar/suggestion-actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { useMemo, useState } from '@wordpress/element';
import {
__experimentalText as WCText,
__experimentalConfirmDialog as ConfirmDialog,
Button,
} from '@wordpress/components';
import { Stack } from '@wordpress/ui';
import { useSelect } from '@wordpress/data';
import { store as blockEditorStore } from '@wordpress/block-editor';

/**
* Internal dependencies
*/
import {
parseSuggestionPayload,
useSuggestionsProvider,
} from '../suggestion-mode';
import SuggestionDiff from '../suggestion-mode/suggestion-diff';
import { store as editorStore } from '../../store';

/**
* Read-only status constants — keep in sync with `_wp_suggestion_status`
* enum declared in `block-comments.php`.
*/
const APPLIED = 'applied';
const REJECTED = 'rejected';

/**
* Controls rendered inside a note comment's thread when the comment has
* a `_wp_suggestion` payload. Surfaces the diff, plus Apply / Reject
* buttons. Shows a staleness confirmation when the post has changed since
* the suggestion was captured, and disables Apply when the target block
* is no longer present in the block tree.
*
* @param {{ thread: Object }} props
*/
export default function SuggestionActions( { thread } ) {
const payload = useMemo(
() => parseSuggestionPayload( thread?.meta?._wp_suggestion ),
[ thread?.meta?._wp_suggestion ]
);
const suggestionStatus = thread?.meta?._wp_suggestion_status;
const { applySuggestion, rejectSuggestion } = useSuggestionsProvider();
const [ busy, setBusy ] = useState( false );
const [ showStaleDialog, setShowStaleDialog ] = useState( false );

const { blockExists, isStale } = useSelect(
( select ) => {
const { getBlock } = select( blockEditorStore );
const post = select( editorStore ).getCurrentPost?.();
const currentModified = post?.modified_gmt ?? null;
return {
blockExists: thread?.blockClientId
? !! getBlock( thread.blockClientId )
: false,
isStale:
!! payload?.baseRevision &&
!! currentModified &&
payload.baseRevision !== currentModified,
};
},
[ thread?.blockClientId, payload?.baseRevision ]
);

if ( ! payload ) {
return null;
}

const isResolved =
suggestionStatus === APPLIED || suggestionStatus === REJECTED;

const runApply = async () => {
setBusy( true );
try {
await applySuggestion( {
commentId: thread.id,
clientId: thread.blockClientId,
payload,
} );
} catch {
// Notice surfaced by the provider.
} finally {
setBusy( false );
}
};

const onApplyClick = () => {
if ( isStale ) {
setShowStaleDialog( true );
return;
}
runApply();
};

const onReject = async () => {
setBusy( true );
try {
await rejectSuggestion( { commentId: thread.id } );
} catch {
// Notice surfaced by the provider.
} finally {
setBusy( false );
}
};

const applyDisabled = busy || ( thread?.blockClientId && ! blockExists );
const applyDisabledReason = ! blockExists
? __( 'Target block has been deleted.' )
: undefined;

return (
<Stack
direction="column"
gap="sm"
className="editor-collab-sidebar-panel__suggestion"
>
<SuggestionDiff operations={ payload.operations } />
{ isResolved ? (
<WCText variant="muted" size="12px">
{ suggestionStatus === APPLIED
? __( 'Applied' )
: __( 'Rejected' ) }
</WCText>
) : (
<>
{ applyDisabledReason && (
<WCText variant="muted" size="12px">
{ applyDisabledReason }
</WCText>
) }
<Stack direction="row" gap="sm" justify="flex-start">
<Button
variant="primary"
size="small"
disabled={ applyDisabled }
accessibleWhenDisabled
onClick={ onApplyClick }
>
{ __( 'Apply' ) }
</Button>
<Button
variant="secondary"
size="small"
disabled={ busy }
accessibleWhenDisabled
onClick={ onReject }
>
{ __( 'Reject' ) }
</Button>
</Stack>
</>
) }
{ showStaleDialog && (
<ConfirmDialog
isOpen
onConfirm={ () => {
setShowStaleDialog( false );
runApply();
} }
onCancel={ () => setShowStaleDialog( false ) }
confirmButtonText={ __( 'Apply anyway' ) }
>
{ __(
'The post has changed since this suggestion was made. Applying it may produce unexpected results. Continue?'
) }
</ConfirmDialog>
) }
</Stack>
);
}
3 changes: 3 additions & 0 deletions packages/editor/src/components/suggestion-mode/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ export { default as SuggestionStoreInterceptor } from './store-interceptor';
export {
useSuggestionsProvider,
operationsFromOverlay,
applyOperations,
parseSuggestionPayload,
payloadByteLength,
PAYLOAD_MAX_BYTES,
SCHEMA_VERSION,
} from './provider';
export { default as SuggestionDiff, wordDiff } from './suggestion-diff';
31 changes: 31 additions & 0 deletions packages/editor/src/components/suggestion-mode/overlay-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
useEffect,
useMemo,
useReducer,
useRef,
} from '@wordpress/element';
import { useRegistry, useSelect } from '@wordpress/data';

Expand Down Expand Up @@ -81,6 +82,8 @@ const OverlayContext = createContext( {
setOverlayAttributes: () => {},
clearOverlay: () => {},
hasOverlay: () => false,
requestInterceptorBypass: () => {},
consumeInterceptorBypass: () => false,
} );

/**
Expand Down Expand Up @@ -203,6 +206,30 @@ export function SuggestionOverlayProvider( { children } ) {
[ entries ]
);

// Tracks clientIds whose next block-attribute mutation should bypass the
// store interceptor. The accept-suggestion flow uses this to land applied
// attributes on the live block — without it, the interceptor would treat
// the apply as just another user edit and revert it into the overlay.
// A ref-set rather than reducer state because the value is consumed
// inside `registry.subscribe` (which doesn't react to React state) and
// must clear synchronously when the dispatch is processed.
const bypassClientIdsRef = useRef( new Set() );

const requestInterceptorBypass = useCallback( ( clientId ) => {
if ( clientId ) {
bypassClientIdsRef.current.add( clientId );
}
}, [] );

const consumeInterceptorBypass = useCallback( ( clientId ) => {
const set = bypassClientIdsRef.current;
if ( ! set.has( clientId ) ) {
return false;
}
set.delete( clientId );
return true;
}, [] );

// Prune overlay entries whose block was removed from the editor. This
// prevents stale baselines from persisting after a block is deleted.
// The block-count subscription only runs when there are entries to
Expand Down Expand Up @@ -245,13 +272,17 @@ export function SuggestionOverlayProvider( { children } ) {
setOverlayAttributes,
clearOverlay,
hasOverlay,
requestInterceptorBypass,
consumeInterceptorBypass,
} ),
[
entries,
captureBaseline,
setOverlayAttributes,
clearOverlay,
hasOverlay,
requestInterceptorBypass,
consumeInterceptorBypass,
]
);

Expand Down
Loading
Loading