Merge pull request #30884 from overleaf/mg-upgrade-modal-tracked-changes

Show modal when user initiates tracked changes from context menu

GitOrigin-RevId: 09ce0aef3eea113cc0b8fc83db00cb8607a6ef9a
This commit is contained in:
Malik Glossop
2026-01-23 14:37:04 +01:00
committed by Copybot
parent 80f6355def
commit 5d47879865
8 changed files with 142 additions and 65 deletions

View File

@@ -1,4 +1,4 @@
import { forwardRef, memo, MouseEventHandler, useState } from 'react'
import { forwardRef, memo, MouseEventHandler } from 'react'
import {
Dropdown,
DropdownMenu,
@@ -7,10 +7,7 @@ import {
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
import MaterialIcon from '@/shared/components/material-icon'
import classNames from 'classnames'
import {
useTrackChangesStateActionsContext,
useTrackChangesStateContext,
} from '../context/track-changes-state-context'
import { useTrackChangesStateActionsContext } from '../context/track-changes-state-context'
import { useUserContext } from '@/shared/context/user-context'
import { useTranslation } from 'react-i18next'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
@@ -18,41 +15,20 @@ import usePersistedState from '@/shared/hooks/use-persisted-state'
import { sendMB } from '@/infrastructure/event-tracking'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useProjectContext } from '@/shared/context/project-context'
import UpgradeTrackChangesModal from './upgrade-track-changes-modal'
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
type Mode = 'view' | 'review' | 'edit'
const useCurrentMode = (): Mode => {
const trackChanges = useTrackChangesStateContext()
const user = useUserContext()
const trackChangesForCurrentUser =
trackChanges?.onForEveryone ||
(user?.id && trackChanges?.onForMembers[user.id]) ||
(!user?.id && trackChanges?.onForGuests)
const { permissionsLevel } = useIdeReactContext()
if (permissionsLevel === 'readOnly') {
return 'view'
} else if (permissionsLevel === 'review') {
return 'review'
} else if (trackChangesForCurrentUser) {
return 'review'
} else {
return 'edit'
}
}
import { useEditorContext } from '@/shared/context/editor-context'
import { useTrackingChangesMode } from '@/shared/hooks/use-tracking-changes-mode'
function ReviewModeSwitcher() {
const { t } = useTranslation()
const user = useUserContext()
const { saveTrackChangesForCurrentUser, saveTrackChanges } =
useTrackChangesStateActionsContext()
const mode = useCurrentMode()
const mode = useTrackingChangesMode()
const { permissionsLevel } = useIdeReactContext()
const { write, trackedWrite } = usePermissionsContext()
const { features } = useProjectContext()
const [showUpgradeModal, setShowUpgradeModal] = useState(false)
const { setShowUpgradeModal } = useEditorContext()
const showViewOption = permissionsLevel === 'readOnly'
const view = useCodeMirrorViewContext()
@@ -133,10 +109,6 @@ function ReviewModeSwitcher() {
)}
</DropdownMenu>
</Dropdown>
<UpgradeTrackChangesModal
show={showUpgradeModal}
setShow={setShowUpgradeModal}
/>
</div>
)
}
@@ -146,7 +118,7 @@ const ModeSwitcherToggleButton = forwardRef<
{ onClick: MouseEventHandler<HTMLButtonElement>; 'aria-expanded': boolean }
>(({ onClick, 'aria-expanded': ariaExpanded }, ref) => {
const { t } = useTranslation()
const mode = useCurrentMode()
const mode = useTrackingChangesMode()
if (mode === 'edit') {
return (

View File

@@ -16,22 +16,20 @@ import OLButton from '@/shared/components/ol/ol-button'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import MaterialIcon from '@/shared/components/material-icon'
import { useEditorContext } from '@/shared/context/editor-context'
type UpgradeTrackChangesModalProps = {
show: boolean
setShow: React.Dispatch<React.SetStateAction<boolean>>
}
function UpgradeTrackChangesModal({
show,
setShow,
}: UpgradeTrackChangesModalProps) {
function UpgradeTrackChangesModal() {
const { t } = useTranslation()
const { project } = useProjectContext()
const user = useUserContext()
const { showUpgradeModal, setShowUpgradeModal } = useEditorContext()
if (!showUpgradeModal) {
return null
}
return (
<OLModal show={show} onHide={() => setShow(false)}>
<OLModal show={showUpgradeModal} onHide={() => setShowUpgradeModal(false)}>
<OLModalHeader>
<OLModalTitle>{t('upgrade_to_review')}</OLModalTitle>
</OLModalHeader>
@@ -94,7 +92,10 @@ function UpgradeTrackChangesModal({
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={() => setShow(false)}>
<OLButton
variant="secondary"
onClick={() => setShowUpgradeModal(false)}
>
{t('close')}
</OLButton>
</OLModalFooter>

View File

@@ -24,6 +24,7 @@ import { useProjectContext } from '@/shared/context/project-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
import UpgradeTrackChangesModal from '@/features/review-panel/components/upgrade-track-changes-modal'
// TODO: remove this when definitely no longer used
export * from './codemirror-context'
@@ -100,6 +101,7 @@ function CodeMirrorEditorComponents({
<EditorContextMenu />
{features.trackChangesVisible && <ReviewTooltipMenu />}
{features.trackChangesVisible && <ReviewPanelRoot />}
{features.trackChangesVisible && <UpgradeTrackChangesModal />}
{sourceEditorComponents.map(
({ import: { default: Component }, path }) => (

View File

@@ -24,6 +24,8 @@ import {
pasteWithFormatting,
} from '../commands/clipboard'
import { isVisual } from '../extensions/visual/visual'
import { useEditorContext } from '@/shared/context/editor-context'
import { useTrackingChangesMode } from '@/shared/hooks/use-tracking-changes-mode'
export const useContextMenuItems = () => {
const view = useCodeMirrorViewContext()
@@ -38,6 +40,9 @@ export const useContextMenuItems = () => {
const { shortcuts } = useCommandRegistry()
const { features } = useProjectContext()
const requestedPdfSyncRef = useRef(false)
const { setShowUpgradeModal } = useEditorContext()
const trackingChangesMode = useTrackingChangesMode()
const isReview = trackingChangesMode === 'review'
const closeMenu = useCallback(() => {
view.dispatch({ effects: closeContextMenuEffect.of(null) })
@@ -97,6 +102,11 @@ export const useContextMenuItems = () => {
const handleDelete = wrapForContextMenu(() => commands.deleteSelection(view))
const handleToggleTrackChanges = wrapForContextMenu(() => {
// Matching the logic in review toggle to ensure consistency for server pro
if (!features.trackChanges && !isReview) {
setShowUpgradeModal(true)
return true
}
window.dispatchEvent(new Event('toggle-track-changes'))
return true
})
@@ -173,10 +183,9 @@ export const useContextMenuItems = () => {
{
label: wantTrackChanges ? t('back_to_editing') : t('suggest_edits'),
handler: handleToggleTrackChanges,
// disable for now, future work opens upgrade modal
disabled: !features.trackChanges,
disabled: false,
separatorAbove: true,
show: canEdit,
show: canEdit && features.trackChangesVisible,
shortcut: getShortcut('toggle-track-changes'),
},
{

View File

@@ -39,6 +39,8 @@ export const EditorContext = createContext<
premiumSuggestionResetDate: Date
writefullInstance: WritefullAPI | null
setWritefullInstance: (instance: WritefullAPI) => void
showUpgradeModal: boolean
setShowUpgradeModal: Dispatch<SetStateAction<boolean>>
}
| undefined
>(undefined)
@@ -95,6 +97,8 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
: new Date()
})
const [showUpgradeModal, setShowUpgradeModal] = useState(false)
const isPendingEditor = useMemo(
() =>
Boolean(
@@ -186,6 +190,8 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
setPremiumSuggestionResetDate,
writefullInstance,
setWritefullInstance,
showUpgradeModal,
setShowUpgradeModal,
}),
[
cobranding,
@@ -205,6 +211,8 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
setPremiumSuggestionResetDate,
writefullInstance,
setWritefullInstance,
showUpgradeModal,
setShowUpgradeModal,
]
)

View File

@@ -0,0 +1,28 @@
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useTrackChangesStateContext } from '@/features/review-panel/context/track-changes-state-context'
import { useUserContext } from '../context/user-context'
type Mode = 'view' | 'review' | 'edit'
export const useTrackingChangesMode = (): Mode => {
const trackChanges = useTrackChangesStateContext()
const user = useUserContext()
const { permissionsLevel } = useIdeReactContext()
if (permissionsLevel === 'readOnly') {
return 'view'
} else if (permissionsLevel === 'review') {
return 'review'
}
const trackChangesForCurrentUser =
trackChanges?.onForEveryone ||
(user?.id && trackChanges?.onForMembers[user.id]) ||
(!user?.id && trackChanges?.onForGuests)
if (trackChangesForCurrentUser) {
return 'review'
} else {
return 'edit'
}
}

View File

@@ -90,6 +90,10 @@ describe('editor context menu', { scrollBehavior: false }, function () {
window.metaAttributesCache.set('ol-splitTestVariants', {
'editor-context-menu': 'enabled',
})
cy.intercept('POST', '/project/*/track_changes', {
statusCode: 200,
body: {},
}).as('trackChanges')
cy.interceptEvents()
cy.interceptMetadata()
})
@@ -184,7 +188,10 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<EditorProviders
scope={scope}
features={{ trackChangesVisible: true }}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
@@ -224,7 +231,10 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<EditorProviders
scope={scope}
features={{ trackChangesVisible: true }}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
@@ -369,7 +379,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
})
})
describe('track changes toggle', function () {
describe('when clicking the track changes buttons', function () {
let toggleTrackChangesListener: Cypress.Agent<sinon.SinonStub>
beforeEach(function () {
@@ -394,11 +404,19 @@ describe('editor context menu', { scrollBehavior: false }, function () {
<TestContainer>
<EditorProviders
scope={scope}
projectFeatures={{ trackChanges: true }}
providers={{
EditorPropertiesProvider: makeEditorPropertiesProvider({
wantTrackChanges: false,
}),
ProjectProvider: makeProjectProvider(
mockProject({
trackChangesState: false,
projectFeatures: {
trackChanges: true,
trackChangesVisible: true,
},
})
),
}}
>
<CodeMirrorEditor />
@@ -432,11 +450,17 @@ describe('editor context menu', { scrollBehavior: false }, function () {
<TestContainer>
<EditorProviders
scope={scope}
projectFeatures={{ trackChanges: true }}
providers={{
EditorPropertiesProvider: makeEditorPropertiesProvider({
wantTrackChanges: true,
}),
ProjectProvider: makeProjectProvider(
mockProject({
// Re-assigns `withTrackChanges` value in the `track-changes-state-context` useEffect hook
trackChangesState: true,
projectFeatures: {
trackChanges: true,
trackChangesVisible: true,
},
})
),
}}
>
<CodeMirrorEditor />
@@ -463,14 +487,40 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('@toggleTrackChanges').should('have.been.calledOnce')
})
it('should disable suggest edits when project does not support track changes', function () {
it('should open upgrade modal when user does not support track changes', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
projectFeatures={{ trackChanges: false }}
features={{ trackChangesVisible: true, trackChanges: false }}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(10).rightclick()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /suggest edits/i }).click()
})
cy.findByRole('dialog').should('be.visible')
cy.findByRole('dialog').should('contain.text', 'Upgrade to Review')
})
})
describe('when trackChangesVisible feature is disabled', function () {
it('should hide the track changes button', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
features={{ trackChangesVisible: false }}
>
<CodeMirrorEditor />
</EditorProviders>
@@ -481,9 +531,10 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
'have.attr',
'aria-disabled',
'true'
'not.exist'
)
cy.findByRole('menuitem', { name: /back to editing/i }).should(
'not.exist'
)
})
})
@@ -559,6 +610,9 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
'not.exist'
)
cy.findByRole('menuitem', { name: /back to editing/i }).should(
'not.exist'
)
cy.findByRole('menuitem', { name: /comment/i }).should('be.enabled')
})
})
@@ -821,7 +875,6 @@ describe('editor context menu', { scrollBehavior: false }, function () {
<TestContainer>
<EditorProviders
scope={scope}
projectFeatures={{ trackChangesVisible: true }}
features={{ trackChangesVisible: true }}
>
<CodeMirrorEditor />
@@ -964,7 +1017,10 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<EditorProviders
scope={scope}
features={{ trackChangesVisible: true }}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>

View File

@@ -11,6 +11,7 @@ export const mockProject = ({
projectOwner = undefined,
spellCheckLanguage = 'en',
rootFolder = null,
trackChangesState = false,
}: any = {}) => {
return {
_id: 'test-project',
@@ -63,7 +64,7 @@ export const mockProject = ({
},
compiler: 'pdflatex' as ProjectCompiler,
imageName: 'texlive-full:2024.1',
trackChangesState: false,
trackChangesState,
invites: [],
members: [],
owner: projectOwner || {