diff --git a/services/web/frontend/js/features/editor-floating-menu/components/tracked-changes-actions.tsx b/services/web/frontend/js/features/editor-floating-menu/components/tracked-changes-actions.tsx index 2251a4164c..ae38eeb019 100644 --- a/services/web/frontend/js/features/editor-floating-menu/components/tracked-changes-actions.tsx +++ b/services/web/frontend/js/features/editor-floating-menu/components/tracked-changes-actions.tsx @@ -1,91 +1,15 @@ -import { FC, useCallback, useMemo } from 'react' +import { FC } from 'react' import { useTranslation } from 'react-i18next' import MaterialIcon from '@/shared/components/material-icon' import OLTooltip from '@/shared/components/ol/ol-tooltip' -import { - useCodeMirrorStateContext, - useCodeMirrorViewContext, -} from '@/features/source-editor/components/codemirror-context' import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' -import { useModalsContext } from '@/features/ide-react/context/modals-context' -import { - useRangesActionsContext, - useRangesContext, -} from '@/features/review-panel/context/ranges-context' -import { numberOfChangesInSelection } from '@/features/review-panel/utils/changes-in-selection' -import { isInsertOperation } from '@/utils/operations' -import { captureException } from '@/infrastructure/error-reporter' +import { useTrackedChangesActions } from '@/features/review-panel/hooks/use-tracked-changes-actions' const TrackedChangesActions: FC = () => { const { t } = useTranslation() - const view = useCodeMirrorViewContext() - const state = useCodeMirrorStateContext() const permissions = usePermissionsContext() - const ranges = useRangesContext() - const { acceptChanges, rejectChanges } = useRangesActionsContext() - const { showGenericConfirmModal } = useModalsContext() - - const changesInSelection = useMemo(() => { - return (ranges?.changes ?? []).filter(({ op }) => { - const opFrom = op.p - const opLength = isInsertOperation(op) ? op.i.length : 0 - const opTo = opFrom + opLength - const selection = state.selection.main - return opFrom >= selection.from && opTo <= selection.to - }) - }, [ranges, state.selection.main]) - - const acceptChangesHandler = useCallback(() => { - const nChanges = numberOfChangesInSelection( - ranges, - view.state.selection.main - ) - showGenericConfirmModal({ - message: t('confirm_accept_selected_changes', { count: nChanges }), - title: t('accept_selected_changes'), - onConfirm: async () => { - try { - await acceptChanges(...changesInSelection) - } catch (err: any) { - captureException(err) - } - }, - primaryVariant: 'danger', - }) - }, [ - acceptChanges, - changesInSelection, - ranges, - showGenericConfirmModal, - view, - t, - ]) - - const rejectChangesHandler = useCallback(() => { - const nChanges = numberOfChangesInSelection( - ranges, - view.state.selection.main - ) - showGenericConfirmModal({ - message: t('confirm_reject_selected_changes', { count: nChanges }), - title: t('reject_selected_changes'), - onConfirm: async () => { - try { - await rejectChanges(...changesInSelection) - } catch (err: any) { - captureException(err) - } - }, - primaryVariant: 'danger', - }) - }, [ - showGenericConfirmModal, - t, - ranges, - view, - rejectChanges, - changesInSelection, - ]) + const { changesInSelection, acceptChangesHandler, rejectChangesHandler } = + useTrackedChangesActions() if (!permissions.comment || changesInSelection.length === 0) { return null diff --git a/services/web/frontend/js/features/review-panel/context/ranges-context.tsx b/services/web/frontend/js/features/review-panel/context/ranges-context.tsx index 0ef1ccda50..dba1de57e5 100644 --- a/services/web/frontend/js/features/review-panel/context/ranges-context.tsx +++ b/services/web/frontend/js/features/review-panel/context/ranges-context.tsx @@ -101,7 +101,9 @@ const buildRangesFromHistoryOT = (currentDocument: DocumentContainer) => { ) } -const RangesActionsContext = createContext(undefined) +export const RangesActionsContext = createContext( + undefined +) export const RangesProvider: FC = ({ children }) => { const view = useCodeMirrorViewContext() diff --git a/services/web/frontend/js/features/review-panel/hooks/use-tracked-changes-actions.ts b/services/web/frontend/js/features/review-panel/hooks/use-tracked-changes-actions.ts new file mode 100644 index 0000000000..3c092e809d --- /dev/null +++ b/services/web/frontend/js/features/review-panel/hooks/use-tracked-changes-actions.ts @@ -0,0 +1,93 @@ +import { useCallback, useContext, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { + useCodeMirrorStateContext, + useCodeMirrorViewContext, +} from '@/features/source-editor/components/codemirror-context' +import { useModalsContext } from '@/features/ide-react/context/modals-context' +import { + RangesActionsContext, + useRangesContext, +} from '@/features/review-panel/context/ranges-context' +import { numberOfChangesInSelection } from '@/features/review-panel/utils/changes-in-selection' +import { isInsertOperation } from '@/utils/operations' +import { captureException } from '@/infrastructure/error-reporter' + +export const useTrackedChangesActions = () => { + const { t } = useTranslation() + const view = useCodeMirrorViewContext() + const state = useCodeMirrorStateContext() + const ranges = useRangesContext() + const rangesActions = useContext(RangesActionsContext) + const { showGenericConfirmModal } = useModalsContext() + + const changesInSelection = useMemo(() => { + return (ranges?.changes ?? []).filter(({ op }) => { + const opFrom = op.p + const opLength = isInsertOperation(op) ? op.i.length : 0 + const opTo = opFrom + opLength + const selection = state.selection.main + return opFrom >= selection.from && opTo <= selection.to + }) + }, [ranges, state.selection.main]) + + const acceptChangesHandler = useCallback(() => { + if (!rangesActions) { + return + } + const nChanges = numberOfChangesInSelection( + ranges, + view.state.selection.main + ) + showGenericConfirmModal({ + message: t('confirm_accept_selected_changes', { count: nChanges }), + title: t('accept_selected_changes'), + onConfirm: async () => { + try { + await rangesActions.acceptChanges(...changesInSelection) + } catch (err: any) { + captureException(err) + } + }, + primaryVariant: 'danger', + }) + }, [ + rangesActions, + changesInSelection, + ranges, + showGenericConfirmModal, + view, + t, + ]) + + const rejectChangesHandler = useCallback(() => { + if (!rangesActions) { + return + } + const nChanges = numberOfChangesInSelection( + ranges, + view.state.selection.main + ) + showGenericConfirmModal({ + message: t('confirm_reject_selected_changes', { count: nChanges }), + title: t('reject_selected_changes'), + onConfirm: async () => { + try { + await rangesActions.rejectChanges(...changesInSelection) + } catch (err: any) { + captureException(err) + } + }, + primaryVariant: 'danger', + }) + }, [ + showGenericConfirmModal, + t, + ranges, + view, + rangesActions, + changesInSelection, + ]) + + return { changesInSelection, acceptChangesHandler, rejectChangesHandler } +} diff --git a/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx b/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx index 2dd4b1020b..61a1dbed83 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx +++ b/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx @@ -33,6 +33,7 @@ import { } from '../utils/context-menu-analytics' import { isCursorOnEmptyLine } from '../utils/is-cursor-on-empty-line' import { selectAll } from '@codemirror/commands' +import { useTrackedChangesActions } from '@/features/review-panel/hooks/use-tracked-changes-actions' export const useContextMenuItems = () => { const view = useCodeMirrorViewContext() @@ -55,6 +56,8 @@ export const useContextMenuItems = () => { const { setUpgradeTrackChangesModal } = useEditorContext() const trackingChangesMode = useTrackingChangesMode() const isReview = trackingChangesMode === 'review' + const { changesInSelection, acceptChangesHandler, rejectChangesHandler } = + useTrackedChangesActions() const closeMenu = useCallback(() => { view.dispatch({ effects: closeContextMenuEffect.of(null) }) @@ -173,6 +176,16 @@ export const useContextMenuItems = () => { return true }) + const handleAcceptChanges = wrapForContextMenu('accept-changes', () => { + acceptChangesHandler() + return true + }) + + const handleRejectChanges = wrapForContextMenu('reject-changes', () => { + rejectChangesHandler() + return true + }) + // Sync-to-PDF is special: it needs to wait for async completion before closing const handleSyncToPdf = useCallback(() => { // Switch to split view only when in editor-only mode with non-detached PDF @@ -264,6 +277,20 @@ export const useContextMenuItems = () => { show: permissions.comment, shortcut: getShortcut('insert-comment'), }, + { + label: t('accept_selected_changes'), + handler: handleAcceptChanges, + disabled: false, + show: permissions.comment && changesInSelection.length > 0, + shortcut: undefined, + }, + { + label: t('reject_selected_changes'), + handler: handleRejectChanges, + disabled: false, + show: permissions.comment && changesInSelection.length > 0, + shortcut: undefined, + }, ].filter(item => item.show), } } diff --git a/services/web/frontend/js/features/source-editor/utils/context-menu-analytics.ts b/services/web/frontend/js/features/source-editor/utils/context-menu-analytics.ts index 29ceb8be46..22504601b5 100644 --- a/services/web/frontend/js/features/source-editor/utils/context-menu-analytics.ts +++ b/services/web/frontend/js/features/source-editor/utils/context-menu-analytics.ts @@ -13,6 +13,8 @@ export type ContextMenuItemSegmentation = | 'suggest-edits' | 'back-to-editing' | 'comment' + | 'accept-changes' + | 'reject-changes' export type ContextMenuAnalyticsEvents = { 'menu-expand': { diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx index 22ec6e1a99..1635e96324 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx @@ -3,9 +3,11 @@ import { EditorProviders, makeEditorPropertiesProvider, makeProjectProvider, + USER_ID, } from '../../../helpers/editor-providers' import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { TestContainer } from '../helpers/test-container' +import { docId } from '../helpers/mock-doc' import { FC } from 'react' import { PermissionsContext } from '@/features/ide-react/context/permissions-context' import { Permissions } from '@/features/ide-react/types/permissions' @@ -1324,4 +1326,137 @@ describe('editor context menu', { scrollBehavior: false }, function () { cy.findByRole('menu').should('be.visible') }) }) + + describe('tracked-change actions', function () { + function mountEditorWithChanges() { + cy.intercept('POST', `/project/*/doc/${docId}/changes/accept`, {}).as( + 'acceptChange' + ) + + const changes = [ + { + metadata: { + user_id: USER_ID, + ts: new Date('2025-01-01T00:00:00.000Z'), + }, + id: 'inserted-op-id', + op: { p: 166, t: 'inserted-op-id', i: 'introduction' }, + }, + { + metadata: { + user_id: USER_ID, + ts: new Date('2025-01-01T01:00:00.000Z'), + }, + id: 'deleted-op-id', + op: { p: 110, t: 'deleted-op-id', d: 'beautiful ' }, + }, + ] + const getChanges = cy.stub().as('getChanges').returns([]) + const removeChangeIds = cy.stub().as('removeChangeIds') + + const scope = mockScope(undefined, { + docOptions: { + rangesOptions: { changes, getChanges, removeChangeIds }, + }, + }) + + cy.mount( + + + + + + ) + } + + // Select a deletion and an insertion so the bulk-action items appear, then + // right-click inside the selection (which preserves it). + function openMenuOverChanges() { + cy.findByText('\\maketitle').type( + '{home}{shift}' + '{downArrow}'.repeat(10), + { scrollBehavior: false } + ) + cy.findByText('\\maketitle').rightclick({ scrollBehavior: false }) + } + + it('shows accept and reject items when the selection covers changes', function () { + mountEditorWithChanges() + openMenuOverChanges() + + cy.findByRole('menu').within(() => { + cy.findByRole('menuitem', { name: 'Accept selected changes' }).should( + 'be.visible' + ) + cy.findByRole('menuitem', { name: 'Reject selected changes' }).should( + 'be.visible' + ) + }) + }) + + it('hides accept and reject items when the selection covers no changes', function () { + mountEditorWithChanges() + + cy.get('.cm-line').eq(2).rightclick({ scrollBehavior: false }) + + cy.findByRole('menu').should('be.visible') + cy.findByRole('menu').within(() => { + cy.findByRole('menuitem', { name: 'Accept selected changes' }).should( + 'not.exist' + ) + cy.findByRole('menuitem', { name: 'Reject selected changes' }).should( + 'not.exist' + ) + }) + }) + + it('accepts the selected changes', function () { + mountEditorWithChanges() + openMenuOverChanges() + + cy.findByRole('menu').within(() => { + cy.findByRole('menuitem', { name: 'Accept selected changes' }).click({ + scrollBehavior: false, + }) + }) + + cy.findByRole('dialog').within(() => { + cy.findByText( + 'Are you sure you want to accept the selected 2 changes?' + ).should('exist') + cy.findByRole('button', { name: 'OK' }).click({ scrollBehavior: false }) + }) + + cy.wait('@acceptChange') + cy.get('@removeChangeIds').should('have.been.calledWith', [ + 'inserted-op-id', + 'deleted-op-id', + ]) + }) + + it('rejects the selected changes', function () { + mountEditorWithChanges() + openMenuOverChanges() + + cy.findByRole('menu').within(() => { + cy.findByRole('menuitem', { name: 'Reject selected changes' }).click({ + scrollBehavior: false, + }) + }) + + cy.findByRole('dialog').within(() => { + cy.findByText( + 'Are you sure you want to reject the selected 2 changes?' + ).should('exist') + cy.findByRole('button', { name: 'OK' }).click({ scrollBehavior: false }) + }) + + cy.get('@getChanges').should('have.been.calledWith', [ + 'inserted-op-id', + 'deleted-op-id', + ]) + }) + }) })