Skip to content
Draft
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
8 changes: 7 additions & 1 deletion packages/editor/src/components/collab-sidebar/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ function NoteFormatEdit( { value, isActive, activeAttributes } ) {
// Static selector getter: the active area is only read when the button is
// clicked, so there's no need to subscribe and re-render on its changes.
const { getActiveComplementaryArea } = useSelect( interfaceStore );
// Subscribe to the selected note so the button can render active while a
// new note/suggestion is being composed ( `selectedNote === 'new'` ).
const selectedNote = useSelect(
( select ) => unlock( select( editorStore ) ).getSelectedNote(),
[]
);

// Toolbar button only relevant on an active selection or when standing on
// an existing inline note marker.
Expand Down Expand Up @@ -66,7 +72,7 @@ function NoteFormatEdit( { value, isActive, activeAttributes } ) {
icon={ commentIcon }
title={ __( 'Add note' ) }
onClick={ onClick }
isActive={ isActive }
isActive={ isActive || selectedNote === 'new' }
/>
);
}
28 changes: 21 additions & 7 deletions packages/editor/src/components/collab-sidebar/note.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import { moreVertical, published } from '@wordpress/icons';
*/
import { NoteCard } from './note-card';
import { NoteForm } from './note-form';
import SuggestionActions, {
SuggestionActionButtons,
} from './suggestion-actions';
import { unlock } from '../../lock-unlock';

const { Menu } = unlock( componentsPrivateApis );
Expand Down Expand Up @@ -90,7 +93,11 @@ export function Note( {
}
}, [ rawContent ] );

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 @@ -190,9 +197,13 @@ export function Note( {
);
}

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

Expand All @@ -216,6 +229,7 @@ export function Note( {
role={ note.parent !== 0 ? 'treeitem' : undefined }
>
{ body }
{ hasSuggestionPayload && <SuggestionActions thread={ note } /> }
{ actionState === 'delete' && (
<ConfirmDialog
isOpen
Expand Down
312 changes: 312 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,312 @@
/**
* 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';
import { check, closeSmall } from '@wordpress/icons';

/**
* Internal dependencies
*/
import {
hasAttributeConflict,
parseSuggestionPayload,
useSuggestionsProvider,
} from '../suggestion-mode';
import SuggestionSummary from '../suggestion-mode/suggestion-summary';
import { findSuggestionText } from '../inline-suggestions';

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

/**
* Shared accept/reject wiring for a note that carries a suggestion payload.
* Both the header icon buttons and the body (for resolution state and the
* staleness dialog) consume the same hook so their behavior never diverges.
*
* @param {Object} thread The note thread.
* @return {Object|null} Controls, or null if the thread has no payload.
*/
function useSuggestionDecision( 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 );

// "Conflict" is checked per attribute rather than from the post's
// `modified_gmt`: every auto-saved suggestion bumps the post's
// modification time, so a post-level revision compare flags nearly
// every suggestion as stale even when the block content hasn't
// diverged. We only prompt when the specific attributes a suggestion
// targets have actually moved away from the captured baseline.
const { blockExists, hasConflict } = useSelect(
( select ) => {
const { getBlock, getBlockAttributes } = select( blockEditorStore );
const currentAttributes = thread?.blockClientId
? getBlockAttributes( thread.blockClientId )
: null;
return {
blockExists: thread?.blockClientId
? !! getBlock( thread.blockClientId )
: false,
hasConflict:
!! payload &&
!! currentAttributes &&
hasAttributeConflict(
currentAttributes,
payload.operations
),
};
},
[ thread?.blockClientId, payload ]
);

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 ( hasConflict ) {
setShowStaleDialog( true );
return;
}
runApply();
};

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

// Derive the disabled state and its reason from ONE predicate so the
// button and the explanatory text can't disagree. A thread without a
// resolved blockClientId has no live target either.
const isTargetMissing = ! thread?.blockClientId || ! blockExists;
const applyDisabled = busy || isTargetMissing;
const applyDisabledReason = isTargetMissing
? __( 'Target block has been deleted.' )
: undefined;

return {
payload,
suggestionStatus,
isResolved,
busy,
onApplyClick,
onReject,
applyDisabled,
applyDisabledReason,
showStaleDialog,
dismissStaleDialog: () => setShowStaleDialog( false ),
confirmStaleApply: () => {
setShowStaleDialog( false );
runApply();
},
};
}

/**
* Header-slot icon buttons (check and close) for accepting or rejecting a
* suggestion. Rendered inline with the note's author info so the decision
* affordance is always in view, even when the thread is long.
*
* @param {{ thread: Object }} props
*/
export function SuggestionActionButtons( { thread } ) {
const decision = useSuggestionDecision( thread );
if ( ! decision || decision.isResolved ) {
return null;
}

const { showStaleDialog, dismissStaleDialog, confirmStaleApply } = decision;

return (
<Stack
direction="row"
justify="flex-end"
gap="0"
className="editor-collab-sidebar-panel__suggestion-header-actions"
onClick={ ( event ) => {
// Keep the click from bubbling into the thread's expand/
// collapse handler — the icon button is its own affordance.
event.stopPropagation();
} }
>
<Button
size="compact"
className="editor-collab-sidebar-panel__suggestion-accept"
icon={ check }
iconSize={ 24 }
label={ __( 'Accept suggestion' ) }
showTooltip
disabled={ decision.applyDisabled }
accessibleWhenDisabled
onClick={ decision.onApplyClick }
/>
<Button
size="compact"
icon={ closeSmall }
iconSize={ 24 }
label={ __( 'Reject suggestion' ) }
showTooltip
disabled={ decision.busy }
accessibleWhenDisabled
onClick={ decision.onReject }
/>
{ /*
Render the staleness dialog from the same hook instance that
owns the click handlers — `SuggestionActionButtons` and
`SuggestionActions` each call `useSuggestionDecision`, so
their `showStaleDialog` states are independent. Keeping the
dialog colocated with the buttons ensures the click that
opens it and the dialog itself share state.
*/ }
{ showStaleDialog && (
<ConfirmDialog
isOpen
onConfirm={ confirmStaleApply }
onCancel={ dismissStaleDialog }
confirmButtonText={ __( 'Apply anyway' ) }
>
{ __(
'This block has changed since the suggestion was made. Applying it will overwrite the newer edit. Continue?'
) }
</ConfirmDialog>
) }
</Stack>
);
}

/**
* Render a suggestion's summary, resolving inline-suggestion marker text from
* the linked block's live content first.
*
* An inline suggestion (Option B) stores no before/after text in its payload —
* the proposed words live in the in-content `<mark>` marker, so its text is
* derived on read (`findSuggestionText`) the same way the canvas decoration
* derives the marker's range. Resolution happens inside `useSelect` so the
* summary tracks the marker as contiguous typing grows it, then the enriched
* operations feed the pure `summarizeOperations`. Threads with no inline op
* (structural or whole-attribute suggestions) pass through untouched.
*
* @param {{ thread: Object, operations: Array }} props
*/
function ResolvedSuggestionSummary( { thread, operations } ) {
const resolvedOperations = useSelect(
( select ) => {
if ( ! thread?.blockClientId ) {
return operations;
}
const attributes = select( blockEditorStore ).getBlockAttributes(
thread.blockClientId
);
if ( ! attributes ) {
return operations;
}
return operations.map( ( op ) => {
if (
op.type !== 'inline-suggestion' ||
! op.attribute ||
op.text
) {
return op;
}
const text = findSuggestionText(
attributes[ op.attribute ],
thread.id
);
return text ? { ...op, text } : op;
} );
},
[ operations, thread?.blockClientId, thread?.id ]
);

return <SuggestionSummary operations={ resolvedOperations } />;
}

/**
* Body for a note that carries a suggestion payload: the compact
* Add/Delete/Formatting summary and a resolved-state label if applicable.
* Accept/Reject and the staleness dialog live in the header slot via
* `SuggestionActionButtons` so the click and the dialog share state.
*
* @param {{ thread: Object }} props
*/
export default function SuggestionActions( { thread } ) {
const decision = useSuggestionDecision( thread );
if ( ! decision ) {
return null;
}

const { payload, suggestionStatus, isResolved, applyDisabledReason } =
decision;

return (
<Stack
direction="column"
gap="sm"
className="editor-collab-sidebar-panel__suggestion"
>
<ResolvedSuggestionSummary
thread={ thread }
operations={ payload.operations }
/>
{ isResolved && (
<WCText variant="muted" size="12px">
{ suggestionStatus === APPLIED
? __( 'Applied' )
: __( 'Rejected' ) }
</WCText>
) }
{ ! isResolved && applyDisabledReason && (
<WCText variant="muted" size="12px">
{ applyDisabledReason }
</WCText>
) }
</Stack>
);
}
Loading
Loading