mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[web] open comments and changes via url (#36546)
* [web] open a comment from a URL * [web] add "View comment" button to digest email * [web] open a tracked change from a URL * [web] add "View suggestion" button to digest email * [web] open the review panel from an open=review-panel URL * [web] drop the digest email CTA and collapse the deep links to t=review * [web] add an Open project button to the section overflow row and plain-text view links * [web] validate the comment and change deep-link ids as object ids * [web] collapse the deep-link resolvers into a single DeepLink component * fix: scrolling into view when below the fold * fix: address claude review * fix: style adjustments * fix: updating types to accomodate accepted/rejected changes --------- Co-authored-by: Jimmy Domagala-Tang <jimmy.domagala-tang@overleaf.com> GitOrigin-RevId: dab535591a93e1c3849a6b2a0dcc3e3934354385
This commit is contained in:
committed by
Copybot
co-authored by
Jimmy Domagala-Tang
parent
5dc8c2b0c3
commit
44910202f3
@@ -106,6 +106,12 @@ function NoCTAEmailTemplate(content) {
|
||||
if (!content.message) {
|
||||
throw new Error('missing message')
|
||||
}
|
||||
if (!content.secondaryMessage) {
|
||||
content.secondaryMessage = () => []
|
||||
}
|
||||
if (!content.footerMessage) {
|
||||
content.footerMessage = () => {}
|
||||
}
|
||||
return {
|
||||
subject(opts) {
|
||||
return content.subject(opts)
|
||||
@@ -113,15 +119,11 @@ function NoCTAEmailTemplate(content) {
|
||||
layout(opts) {
|
||||
return BaseEmailLayout(opts)
|
||||
},
|
||||
footerMessage(opts) {
|
||||
return content.footerMessage(opts)
|
||||
},
|
||||
plainTextTemplate(opts) {
|
||||
return `\
|
||||
${content.greeting(opts)}
|
||||
|
||||
${content.message(opts, true).join('\r\n\r\n')}
|
||||
|
||||
Regards,
|
||||
The ${settings.appName} Team - ${settings.siteUrl}\
|
||||
`
|
||||
return _emailBodyPlainText(content, opts, false)
|
||||
},
|
||||
compiledTemplate(opts) {
|
||||
return NoCTAEmailBody({
|
||||
|
||||
@@ -349,6 +349,7 @@
|
||||
"command_palette": "",
|
||||
"command_palette_search": "",
|
||||
"comment": "",
|
||||
"comment_not_found": "",
|
||||
"comment_only": "",
|
||||
"comment_only_upgrade_for_track_changes": "",
|
||||
"comment_only_upgrade_to_enable_track_changes": "",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { GlobalToastGeneratorEntry } from '@/features/ide-react/components/global-toasts'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const COMMENT_NOT_FOUND_KEY = 'comment:not-found'
|
||||
|
||||
export const CommentNotFoundToast = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return <span>{t('comment_not_found')}</span>
|
||||
}
|
||||
|
||||
const generators: GlobalToastGeneratorEntry[] = [
|
||||
{
|
||||
key: COMMENT_NOT_FOUND_KEY,
|
||||
generator: () => ({
|
||||
content: <CommentNotFoundToast />,
|
||||
type: 'warning',
|
||||
autoHide: true,
|
||||
delay: 4000,
|
||||
isDismissible: true,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
export default generators
|
||||
|
||||
export const showCommentNotFoundToast = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('ide:show-toast', {
|
||||
detail: {
|
||||
key: COMMENT_NOT_FOUND_KEY,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { OLToastContainer } from '@/shared/components/ol/ol-toast-container'
|
||||
import clipboardToastGenerators from '@/features/source-editor/components/clipboard-toasts'
|
||||
import exportDocumentToastGenerators from '@/features/ide-react/components/toolbar/export-document-toasts'
|
||||
import connectionRestoredToastGenerators from '@/features/ide-react/components/connection-restored-toast'
|
||||
import deepLinkToastGenerators from '@/features/ide-react/components/deep-link-toasts'
|
||||
|
||||
const moduleGeneratorsImport = importOverleafModules('toastGenerators') as {
|
||||
import: { default: GlobalToastGeneratorEntry[] }
|
||||
@@ -31,6 +32,7 @@ const GENERATOR_LIST: GlobalToastGeneratorEntry[] = [
|
||||
...clipboardToastGenerators,
|
||||
...exportDocumentToastGenerators,
|
||||
...connectionRestoredToastGenerators,
|
||||
...deepLinkToastGenerators,
|
||||
]
|
||||
const GENERATOR_MAP: Map<string, GlobalToastGenerator> = new Map(
|
||||
GENERATOR_LIST.map(({ key, generator }) => [key, generator])
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
createContext,
|
||||
FC,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
||||
import { showCommentNotFoundToast } from '@/features/ide-react/components/deep-link-toasts'
|
||||
|
||||
const DEEP_LINK_PARAMS = ['doc', 'comment', 'change', 'open'] as const
|
||||
|
||||
// the `open` param names either an editor panel or a settings tab, so it
|
||||
// resolves into one of two fields and the other stays null
|
||||
type DeepLinkPanel = 'review-panel'
|
||||
type DeepLinkSettings = 'project-notifications'
|
||||
|
||||
type DeepLinkParams = {
|
||||
docId: string | null
|
||||
threadId: ThreadId | null
|
||||
changeId: string | null
|
||||
panel: DeepLinkPanel | null
|
||||
settings: DeepLinkSettings | null
|
||||
}
|
||||
|
||||
type DeepLinkContextValue = {
|
||||
deepLinkedDocId: string | null
|
||||
deepLinkedThreadId: ThreadId | null
|
||||
deepLinkedChangeId: string | null
|
||||
deepLinkedPanel: DeepLinkPanel | null
|
||||
deepLinkedSettings: DeepLinkSettings | null
|
||||
clearDeepLinkedThread: () => void
|
||||
clearDeepLinkedChange: () => void
|
||||
reportDeepLinkedThreadNotFound: () => void
|
||||
}
|
||||
|
||||
const DeepLinkContext = createContext<DeepLinkContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
function readPanel(value: string | null): DeepLinkPanel | null {
|
||||
if (value === 'review-panel') {
|
||||
return value
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readSettings(value: string | null): DeepLinkSettings | null {
|
||||
if (value === 'project-notifications') {
|
||||
return value
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readDeepLinkParams(): DeepLinkParams {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const open = params.get('open')
|
||||
|
||||
return {
|
||||
docId: params.get('doc'),
|
||||
threadId: params.get('comment') as ThreadId | null,
|
||||
changeId: params.get('change'),
|
||||
panel: readPanel(open),
|
||||
settings: readSettings(open),
|
||||
}
|
||||
}
|
||||
|
||||
function stripDeepLinkParams() {
|
||||
const url = new URL(window.location.href)
|
||||
let changed = false
|
||||
|
||||
for (const param of DEEP_LINK_PARAMS) {
|
||||
if (url.searchParams.has(param)) {
|
||||
url.searchParams.delete(param)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}
|
||||
}
|
||||
|
||||
export const DeepLinkProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const paramsRef = useRef<DeepLinkParams | null>(null)
|
||||
if (paramsRef.current === null) {
|
||||
paramsRef.current = readDeepLinkParams()
|
||||
}
|
||||
|
||||
const { docId, panel, settings } = paramsRef.current
|
||||
const [threadId, setThreadId] = useState(paramsRef.current.threadId)
|
||||
const [changeId, setChangeId] = useState(paramsRef.current.changeId)
|
||||
|
||||
useEffect(() => {
|
||||
stripDeepLinkParams()
|
||||
}, [])
|
||||
|
||||
const clearDeepLinkedThread = useCallback(() => setThreadId(null), [])
|
||||
const clearDeepLinkedChange = useCallback(() => setChangeId(null), [])
|
||||
|
||||
// several places give up on the comment at different points, so keep the
|
||||
// toast and the clear together in one call rather than pairing them by hand
|
||||
const reportDeepLinkedThreadNotFound = useCallback(() => {
|
||||
showCommentNotFoundToast()
|
||||
setThreadId(null)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
deepLinkedDocId: docId,
|
||||
deepLinkedThreadId: threadId,
|
||||
deepLinkedChangeId: changeId,
|
||||
deepLinkedPanel: panel,
|
||||
deepLinkedSettings: settings,
|
||||
clearDeepLinkedThread,
|
||||
clearDeepLinkedChange,
|
||||
reportDeepLinkedThreadNotFound,
|
||||
}),
|
||||
[
|
||||
docId,
|
||||
threadId,
|
||||
changeId,
|
||||
panel,
|
||||
settings,
|
||||
clearDeepLinkedThread,
|
||||
clearDeepLinkedChange,
|
||||
reportDeepLinkedThreadNotFound,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<DeepLinkContext.Provider value={value}>
|
||||
{children}
|
||||
</DeepLinkContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDeepLinkContext(): DeepLinkContextValue {
|
||||
const context = useContext(DeepLinkContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useDeepLinkContext is only available inside DeepLinkProvider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { convertFileRefToBinaryFile } from '@/features/ide-react/util/file-view'
|
||||
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
|
||||
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
||||
import { showConnectionRestoredToast } from '@/features/ide-react/components/connection-restored-toast'
|
||||
import { useDeepLinkContext } from '@/features/ide-react/context/deep-link-context'
|
||||
|
||||
export interface GotoOffsetOptions {
|
||||
gotoOffset: number
|
||||
@@ -119,6 +120,12 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
|
||||
wantTrackChangesRef.current = wantTrackChanges
|
||||
}, [wantTrackChanges])
|
||||
|
||||
const {
|
||||
deepLinkedDocId,
|
||||
deepLinkedThreadId,
|
||||
reportDeepLinkedThreadNotFound,
|
||||
} = useDeepLinkContext()
|
||||
|
||||
const { fileTreeData } = useFileTreeData()
|
||||
const fileTreeDataRef = useRef(fileTreeData)
|
||||
useEffect(() => {
|
||||
@@ -527,13 +534,34 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
|
||||
|
||||
const openInitialDoc = useCallback(
|
||||
async (fallbackDocId?: string) => {
|
||||
if (deepLinkedDocId) {
|
||||
const deepLinkedDoc = findDocEntityById(fileTreeData, deepLinkedDocId)
|
||||
if (deepLinkedDoc) {
|
||||
return await openDoc(deepLinkedDoc)
|
||||
}
|
||||
|
||||
// the deep link points at a doc that is no longer in the project, so
|
||||
// any comment it referenced is unreachable
|
||||
if (deepLinkedThreadId) {
|
||||
reportDeepLinkedThreadNotFound()
|
||||
}
|
||||
}
|
||||
|
||||
const docId =
|
||||
customLocalStorage.getItem(currentDocumentIdStorageKey) || fallbackDocId
|
||||
if (docId) {
|
||||
return await openDocWithId(docId)
|
||||
}
|
||||
},
|
||||
[currentDocumentIdStorageKey, openDocWithId]
|
||||
[
|
||||
currentDocumentIdStorageKey,
|
||||
deepLinkedDocId,
|
||||
deepLinkedThreadId,
|
||||
fileTreeData,
|
||||
openDoc,
|
||||
openDocWithId,
|
||||
reportDeepLinkedThreadNotFound,
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { ElementType, FC, PropsWithChildren } from 'react'
|
||||
import { ChatProvider } from '@/features/chat/context/chat-context'
|
||||
import { ChangesUsersProvider } from '@/shared/context/changes-users-context'
|
||||
import { ConnectionProvider } from './connection-context'
|
||||
import { DeepLinkProvider } from '@/features/ide-react/context/deep-link-context'
|
||||
import { DetachCompileProvider } from '@/shared/context/detach-compile-context'
|
||||
import { DetachProvider } from '@/shared/context/detach-context'
|
||||
import { EditorManagerProvider } from '@/features/ide-react/context/editor-manager-context'
|
||||
@@ -50,6 +51,7 @@ export const ReactContextRoot: FC<
|
||||
ChatProvider,
|
||||
ChangesUsersProvider,
|
||||
ConnectionProvider,
|
||||
DeepLinkProvider,
|
||||
DetachCompileProvider,
|
||||
DetachProvider,
|
||||
EditorManagerProvider,
|
||||
@@ -98,78 +100,80 @@ export const ReactContextRoot: FC<
|
||||
)
|
||||
|
||||
return (
|
||||
<Providers.SplitTestProvider>
|
||||
<Providers.ModalsContextProvider>
|
||||
<Providers.ConnectionProvider>
|
||||
<Providers.ProjectProvider>
|
||||
<Providers.UserSettingsProvider>
|
||||
<Providers.IdeReactProvider>
|
||||
<Providers.UserProvider>
|
||||
<Providers.SnapshotProvider>
|
||||
<Providers.DetachProvider>
|
||||
<Providers.EditorOpenDocProvider>
|
||||
<Providers.EditorViewProvider>
|
||||
<Providers.EditorPropertiesProvider>
|
||||
<Providers.EditorProvider>
|
||||
<Providers.TutorialProvider>
|
||||
<Providers.FileTreeDataProvider>
|
||||
<Providers.FileTreePathProvider>
|
||||
<Providers.UserFeaturesProvider>
|
||||
<Providers.PermissionsProvider>
|
||||
<Providers.RailProvider>
|
||||
<Providers.LayoutProvider>
|
||||
<Providers.ProjectSettingsProvider>
|
||||
<Providers.EditorManagerProvider>
|
||||
<Providers.UnsavedDocsProvider>
|
||||
<Providers.ReferencesProvider>
|
||||
<Providers.LocalCompileProvider>
|
||||
<Providers.DetachCompileProvider>
|
||||
<Providers.ChatProvider>
|
||||
<Providers.FileTreeOpenProvider>
|
||||
<Providers.TabsProvider>
|
||||
<Providers.OnlineUsersProvider>
|
||||
<Providers.MetadataProvider>
|
||||
<Providers.OutlineProvider>
|
||||
<Providers.CommandRegistryProvider>
|
||||
<Providers.EditorSelectionProvider>
|
||||
<Providers.ChangesUsersProvider>
|
||||
{
|
||||
childrenWrappedWithDynamicProviders
|
||||
}
|
||||
</Providers.ChangesUsersProvider>
|
||||
</Providers.EditorSelectionProvider>
|
||||
</Providers.CommandRegistryProvider>
|
||||
</Providers.OutlineProvider>
|
||||
</Providers.MetadataProvider>
|
||||
</Providers.OnlineUsersProvider>
|
||||
</Providers.TabsProvider>
|
||||
</Providers.FileTreeOpenProvider>
|
||||
</Providers.ChatProvider>
|
||||
</Providers.DetachCompileProvider>
|
||||
</Providers.LocalCompileProvider>
|
||||
</Providers.ReferencesProvider>
|
||||
</Providers.UnsavedDocsProvider>
|
||||
</Providers.EditorManagerProvider>
|
||||
</Providers.ProjectSettingsProvider>
|
||||
</Providers.LayoutProvider>
|
||||
</Providers.RailProvider>
|
||||
</Providers.PermissionsProvider>
|
||||
</Providers.UserFeaturesProvider>
|
||||
</Providers.FileTreePathProvider>
|
||||
</Providers.FileTreeDataProvider>
|
||||
</Providers.TutorialProvider>
|
||||
</Providers.EditorProvider>
|
||||
</Providers.EditorPropertiesProvider>
|
||||
</Providers.EditorViewProvider>
|
||||
</Providers.EditorOpenDocProvider>
|
||||
</Providers.DetachProvider>
|
||||
</Providers.SnapshotProvider>
|
||||
</Providers.UserProvider>
|
||||
</Providers.IdeReactProvider>
|
||||
</Providers.UserSettingsProvider>
|
||||
</Providers.ProjectProvider>
|
||||
</Providers.ConnectionProvider>
|
||||
</Providers.ModalsContextProvider>
|
||||
</Providers.SplitTestProvider>
|
||||
<Providers.DeepLinkProvider>
|
||||
<Providers.SplitTestProvider>
|
||||
<Providers.ModalsContextProvider>
|
||||
<Providers.ConnectionProvider>
|
||||
<Providers.ProjectProvider>
|
||||
<Providers.UserSettingsProvider>
|
||||
<Providers.IdeReactProvider>
|
||||
<Providers.UserProvider>
|
||||
<Providers.SnapshotProvider>
|
||||
<Providers.DetachProvider>
|
||||
<Providers.EditorOpenDocProvider>
|
||||
<Providers.EditorViewProvider>
|
||||
<Providers.EditorPropertiesProvider>
|
||||
<Providers.EditorProvider>
|
||||
<Providers.TutorialProvider>
|
||||
<Providers.FileTreeDataProvider>
|
||||
<Providers.FileTreePathProvider>
|
||||
<Providers.UserFeaturesProvider>
|
||||
<Providers.PermissionsProvider>
|
||||
<Providers.RailProvider>
|
||||
<Providers.LayoutProvider>
|
||||
<Providers.ProjectSettingsProvider>
|
||||
<Providers.EditorManagerProvider>
|
||||
<Providers.UnsavedDocsProvider>
|
||||
<Providers.ReferencesProvider>
|
||||
<Providers.LocalCompileProvider>
|
||||
<Providers.DetachCompileProvider>
|
||||
<Providers.ChatProvider>
|
||||
<Providers.FileTreeOpenProvider>
|
||||
<Providers.TabsProvider>
|
||||
<Providers.OnlineUsersProvider>
|
||||
<Providers.MetadataProvider>
|
||||
<Providers.OutlineProvider>
|
||||
<Providers.CommandRegistryProvider>
|
||||
<Providers.EditorSelectionProvider>
|
||||
<Providers.ChangesUsersProvider>
|
||||
{
|
||||
childrenWrappedWithDynamicProviders
|
||||
}
|
||||
</Providers.ChangesUsersProvider>
|
||||
</Providers.EditorSelectionProvider>
|
||||
</Providers.CommandRegistryProvider>
|
||||
</Providers.OutlineProvider>
|
||||
</Providers.MetadataProvider>
|
||||
</Providers.OnlineUsersProvider>
|
||||
</Providers.TabsProvider>
|
||||
</Providers.FileTreeOpenProvider>
|
||||
</Providers.ChatProvider>
|
||||
</Providers.DetachCompileProvider>
|
||||
</Providers.LocalCompileProvider>
|
||||
</Providers.ReferencesProvider>
|
||||
</Providers.UnsavedDocsProvider>
|
||||
</Providers.EditorManagerProvider>
|
||||
</Providers.ProjectSettingsProvider>
|
||||
</Providers.LayoutProvider>
|
||||
</Providers.RailProvider>
|
||||
</Providers.PermissionsProvider>
|
||||
</Providers.UserFeaturesProvider>
|
||||
</Providers.FileTreePathProvider>
|
||||
</Providers.FileTreeDataProvider>
|
||||
</Providers.TutorialProvider>
|
||||
</Providers.EditorProvider>
|
||||
</Providers.EditorPropertiesProvider>
|
||||
</Providers.EditorViewProvider>
|
||||
</Providers.EditorOpenDocProvider>
|
||||
</Providers.DetachProvider>
|
||||
</Providers.SnapshotProvider>
|
||||
</Providers.UserProvider>
|
||||
</Providers.IdeReactProvider>
|
||||
</Providers.UserSettingsProvider>
|
||||
</Providers.ProjectProvider>
|
||||
</Providers.ConnectionProvider>
|
||||
</Providers.ModalsContextProvider>
|
||||
</Providers.SplitTestProvider>
|
||||
</Providers.DeepLinkProvider>
|
||||
)
|
||||
}
|
||||
|
||||
+4
-7
@@ -1,9 +1,11 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSettingsModalContext } from '../context/settings-modal-context'
|
||||
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
||||
import { useDeepLinkContext } from '@/features/ide-react/context/deep-link-context'
|
||||
|
||||
export default function useOpenSettingsViaQueryParam() {
|
||||
const { setShow, setActiveTab } = useSettingsModalContext()
|
||||
const { deepLinkedSettings } = useDeepLinkContext()
|
||||
|
||||
useEffect(() => {
|
||||
const inNotificationsSplitTest = isSplitTestEnabled('email-notifications')
|
||||
@@ -11,16 +13,11 @@ export default function useOpenSettingsViaQueryParam() {
|
||||
return
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('open') !== 'project-notifications') {
|
||||
if (deepLinkedSettings !== 'project-notifications') {
|
||||
return
|
||||
}
|
||||
|
||||
setShow(true)
|
||||
setActiveTab('project_notifications')
|
||||
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('open')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
}, [setShow, setActiveTab])
|
||||
}, [deepLinkedSettings, setShow, setActiveTab])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useDeepLinkContext } from '@/features/ide-react/context/deep-link-context'
|
||||
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { EditorSelection } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
|
||||
import { useRangesContext } from '../context/ranges-context'
|
||||
import { useThreadsContext } from '../context/threads-context'
|
||||
import useReviewPanelLayout from '../hooks/use-review-panel-layout'
|
||||
|
||||
// how long to wait for a doc to restore its stored scroll position. The restore
|
||||
// runs two ticks after the doc opens, so this only has to outlast a couple of
|
||||
// frames. It doubles as the delay the user waits when the restore has already
|
||||
// run and the event never arrives, so keep it short.
|
||||
const SCROLL_RESTORE_TIMEOUT = 100
|
||||
|
||||
export default function DeepLink() {
|
||||
const {
|
||||
deepLinkedPanel,
|
||||
deepLinkedThreadId,
|
||||
deepLinkedChangeId,
|
||||
clearDeepLinkedChange,
|
||||
reportDeepLinkedThreadNotFound,
|
||||
} = useDeepLinkContext()
|
||||
const { features } = useProjectContext()
|
||||
const { currentDocumentId } = useEditorOpenDocContext()
|
||||
const ranges = useRangesContext()
|
||||
const threads = useThreadsContext()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const { openReviewPanel } = useReviewPanelLayout()
|
||||
|
||||
const scrolledRangeRef = useRef<string | null>(null)
|
||||
const pendingScrollsRef = useRef(new Set<() => void>())
|
||||
|
||||
// the review panel only renders an entry for a range that the editor has
|
||||
// rendered, so scroll the deep-linked range into view first. Without this the
|
||||
// entry never mounts for a range below the initial viewport, and the panel
|
||||
// opens on nothing. Ranges rebuild on every edit, so only scroll once per id.
|
||||
const scrollRangeIntoView = useCallback(
|
||||
(id: string, position: number) => {
|
||||
if (scrolledRangeRef.current === id) {
|
||||
return
|
||||
}
|
||||
|
||||
scrolledRangeRef.current = id
|
||||
|
||||
const pendingScrolls = pendingScrollsRef.current
|
||||
|
||||
const cancel = () => {
|
||||
window.clearTimeout(timer)
|
||||
window.removeEventListener('editor:scroll-position-restored', scroll)
|
||||
pendingScrolls.delete(cancel)
|
||||
}
|
||||
|
||||
// set the cursor at the range without taking focus, so that the review
|
||||
// panel entry takes the focus once the panel has positioned it
|
||||
function scroll() {
|
||||
cancel()
|
||||
|
||||
const selection = EditorSelection.cursor(
|
||||
Math.min(position, view.state.doc.length)
|
||||
)
|
||||
|
||||
view.dispatch({
|
||||
selection,
|
||||
effects: EditorView.scrollIntoView(selection, { y: 'center' }),
|
||||
})
|
||||
}
|
||||
|
||||
// a doc restores its stored scroll position two ticks after it opens,
|
||||
// which would drag the editor away from the range again, so wait for the
|
||||
// restore. It has already run when the doc was open before the range
|
||||
// resolved, so give up waiting after a short delay.
|
||||
const timer = window.setTimeout(scroll, SCROLL_RESTORE_TIMEOUT)
|
||||
window.addEventListener('editor:scroll-position-restored', scroll)
|
||||
|
||||
pendingScrolls.add(cancel)
|
||||
},
|
||||
[view]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const pendingScrolls = pendingScrollsRef.current
|
||||
|
||||
return () => {
|
||||
for (const cancel of Array.from(pendingScrolls)) {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// without track changes the review panel is not rendered at all, and the
|
||||
// ranges and threads providers are absent, so every effect below no-ops
|
||||
|
||||
useEffect(() => {
|
||||
if (deepLinkedPanel !== 'review-panel' || !features.trackChangesVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
openReviewPanel()
|
||||
}, [deepLinkedPanel, features.trackChangesVisible, openReviewPanel])
|
||||
|
||||
useEffect(() => {
|
||||
if (!deepLinkedThreadId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!features.trackChangesVisible) {
|
||||
reportDeepLinkedThreadNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
// ranges are only built for the currently open doc, so wait for the
|
||||
// deep-linked doc to finish opening before looking for the comment
|
||||
if (!threads || !ranges || ranges.docId !== currentDocumentId) {
|
||||
return
|
||||
}
|
||||
|
||||
const comment = ranges.comments.find(
|
||||
({ op }) => op.t === deepLinkedThreadId
|
||||
)
|
||||
const thread = threads[deepLinkedThreadId]
|
||||
|
||||
if (!comment || !thread || thread.messages.length === 0) {
|
||||
reportDeepLinkedThreadNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
openReviewPanel()
|
||||
scrollRangeIntoView(deepLinkedThreadId, comment.op.p)
|
||||
|
||||
// the review panel renders no entry for a resolved comment, so nothing
|
||||
// would select and nothing would explain why. Scroll to the comment anyway
|
||||
// so the panel shows the right part of the doc, then say it is not there.
|
||||
if (comment.resolved || thread.resolved) {
|
||||
reportDeepLinkedThreadNotFound()
|
||||
}
|
||||
}, [
|
||||
currentDocumentId,
|
||||
deepLinkedThreadId,
|
||||
features.trackChangesVisible,
|
||||
openReviewPanel,
|
||||
ranges,
|
||||
reportDeepLinkedThreadNotFound,
|
||||
scrollRangeIntoView,
|
||||
threads,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!deepLinkedChangeId || !features.trackChangesVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
// ranges are only built for the currently open doc, so wait for the
|
||||
// deep-linked doc to finish opening before looking for the change
|
||||
if (!ranges || ranges.docId !== currentDocumentId) {
|
||||
return
|
||||
}
|
||||
|
||||
openReviewPanel()
|
||||
|
||||
// aggregated deletions stay in ranges.changes even when they are folded
|
||||
// into the preceding insert's entry visually, so a single lookup covers
|
||||
// both cases
|
||||
const change = ranges.changes.find(({ id }) => id === deepLinkedChangeId)
|
||||
|
||||
if (!change) {
|
||||
// the change was accepted or rejected before the link was followed,
|
||||
// leave the review panel open and drop the pending id silently
|
||||
clearDeepLinkedChange()
|
||||
return
|
||||
}
|
||||
|
||||
scrollRangeIntoView(deepLinkedChangeId, change.op.p)
|
||||
}, [
|
||||
clearDeepLinkedChange,
|
||||
currentDocumentId,
|
||||
deepLinkedChangeId,
|
||||
features.trackChangesVisible,
|
||||
openReviewPanel,
|
||||
ranges,
|
||||
scrollRangeIntoView,
|
||||
])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
} from '@/features/review-panel/components/review-panel-action-icons'
|
||||
import { useDeepLinkContext } from '@/features/ide-react/context/deep-link-context'
|
||||
|
||||
export const ReviewPanelChange = memo<{
|
||||
change: Change<EditOperation>
|
||||
@@ -50,6 +51,7 @@ export const ReviewPanelChange = memo<{
|
||||
const changesUsers = useChangesUsersContext()
|
||||
const { showGenericMessageModal } = useModalsContext()
|
||||
const user = useUserContext()
|
||||
const { deepLinkedChangeId, clearDeepLinkedChange } = useDeepLinkContext()
|
||||
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
|
||||
@@ -124,6 +126,11 @@ export const ReviewPanelChange = memo<{
|
||||
handleEnter={handleMouseEnter}
|
||||
handleLeave={handleMouseLeave}
|
||||
entryIndicator="edit"
|
||||
autoSelect={
|
||||
deepLinkedChangeId === change.id ||
|
||||
(aggregate != null && deepLinkedChangeId === aggregate.id)
|
||||
}
|
||||
onAutoSelected={clearDeepLinkedChange}
|
||||
>
|
||||
<div
|
||||
className="review-panel-entry-content"
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { useDeepLinkContext } from '@/features/ide-react/context/deep-link-context'
|
||||
|
||||
export const ReviewPanelComment = memo<{
|
||||
comment: Change<CommentOperation>
|
||||
@@ -38,6 +39,7 @@ export const ReviewPanelComment = memo<{
|
||||
const { showGenericMessageModal } = useModalsContext()
|
||||
const { t } = useTranslation()
|
||||
const permissions = usePermissionsContext()
|
||||
const { deepLinkedThreadId, clearDeepLinkedThread } = useDeepLinkContext()
|
||||
|
||||
const [processing, setProcessing] = useState(false)
|
||||
|
||||
@@ -170,6 +172,8 @@ export const ReviewPanelComment = memo<{
|
||||
handleEnter={handleMouseEnter}
|
||||
handleLeave={handleMouseLeave}
|
||||
entryIndicator="comment"
|
||||
autoSelect={deepLinkedThreadId === comment.op.t}
|
||||
onAutoSelected={clearDeepLinkedThread}
|
||||
>
|
||||
<ReviewPanelCommentContent
|
||||
comment={comment}
|
||||
|
||||
@@ -17,6 +17,12 @@ import useReviewPanelLayout from '../hooks/use-review-panel-layout'
|
||||
import { EntryIndicator } from './review-panel-entry-indicator'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
// how many frames to wait for the panel to position and show an entry. The
|
||||
// panel repositions on a 100ms debounce and animates the move over 300ms, so
|
||||
// 60 frames is roughly a second at 60fps: long enough to outlast both, short
|
||||
// enough that a stuck entry stops polling instead of spinning forever.
|
||||
const MAX_FOCUS_FRAMES = 60
|
||||
|
||||
export const ReviewPanelEntry: FC<
|
||||
React.PropsWithChildren<{
|
||||
position: number
|
||||
@@ -30,6 +36,8 @@ export const ReviewPanelEntry: FC<
|
||||
handleEnter?: () => void
|
||||
handleLeave?: () => void
|
||||
entryIndicator?: 'comment' | 'edit'
|
||||
autoSelect?: boolean
|
||||
onAutoSelected?: () => void
|
||||
}>
|
||||
> = ({
|
||||
children,
|
||||
@@ -44,6 +52,8 @@ export const ReviewPanelEntry: FC<
|
||||
handleEnter,
|
||||
handleLeave,
|
||||
entryIndicator,
|
||||
autoSelect,
|
||||
onAutoSelected,
|
||||
}) => {
|
||||
const state = useCodeMirrorStateContext()
|
||||
const view = useCodeMirrorViewContext()
|
||||
@@ -57,13 +67,13 @@ export const ReviewPanelEntry: FC<
|
||||
const mousePressedRef = useRef(false)
|
||||
|
||||
const selectEntry = useCallback(
|
||||
(event: React.FocusEvent | React.MouseEvent) => {
|
||||
(event?: React.FocusEvent | React.MouseEvent) => {
|
||||
setFocused(true)
|
||||
|
||||
// The add-comment input is a CodeMirror editor (contenteditable div)
|
||||
// rather than a textarea, so also treat focus landing inside it as a
|
||||
// text-input focus.
|
||||
const target = event.target
|
||||
const target = event?.target
|
||||
const isTextInput =
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLElement &&
|
||||
@@ -144,6 +154,46 @@ export const ReviewPanelEntry: FC<
|
||||
]
|
||||
)
|
||||
|
||||
const autoSelectDoneRef = useRef(false)
|
||||
const focusFrameRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSelect || autoSelectDoneRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
autoSelectDoneRef.current = true
|
||||
selectEntry()
|
||||
onAutoSelected?.()
|
||||
|
||||
// the panel positions entries asynchronously and keeps them hidden until
|
||||
// then, and focus() does nothing on a hidden element, so wait for the entry
|
||||
// to become visible
|
||||
let attempts = 0
|
||||
|
||||
const focusWhenVisible = () => {
|
||||
const entry = entryRef.current
|
||||
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.style.visibility === 'hidden' && attempts < MAX_FOCUS_FRAMES) {
|
||||
attempts++
|
||||
focusFrameRef.current = window.requestAnimationFrame(focusWhenVisible)
|
||||
return
|
||||
}
|
||||
|
||||
entry.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
focusFrameRef.current = window.requestAnimationFrame(focusWhenVisible)
|
||||
}, [autoSelect, onAutoSelected, selectEntry])
|
||||
|
||||
useEffect(() => {
|
||||
return () => window.cancelAnimationFrame(focusFrameRef.current)
|
||||
}, [])
|
||||
|
||||
// Clear op highlight on dismount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ReviewPanelProviders } from '@/features/review-panel/context/review-pan
|
||||
import { ReviewPanelRoot } from '@/features/review-panel/components/review-panel-root'
|
||||
import ReviewPanelTabsHeaderPortal from '@/features/review-panel/components/review-panel-tabs-header-portal'
|
||||
import ReviewTooltipMenu from '@/features/review-panel/components/review-tooltip-menu'
|
||||
import DeepLink from '@/features/review-panel/components/deep-link'
|
||||
import EditorFloatingMenu from '@/features/editor-floating-menu/editor-floating-menu'
|
||||
import AddCommentCommand from '@/features/editor-floating-menu/components/add-comment-command'
|
||||
import {
|
||||
@@ -109,6 +110,7 @@ function CodeMirrorEditorComponents({
|
||||
|
||||
<MathPreviewTooltip />
|
||||
<EditorContextMenu />
|
||||
<DeepLink />
|
||||
{features.trackChangesVisible &&
|
||||
(writefullToolbarMigrationEnabled ? (
|
||||
<>
|
||||
|
||||
@@ -446,6 +446,7 @@
|
||||
"command_palette": "Command palette",
|
||||
"command_palette_search": "Command palette search",
|
||||
"comment": "Comment",
|
||||
"comment_not_found": "Comment not found",
|
||||
"comment_only": "Comment only",
|
||||
"comment_only_upgrade_for_track_changes": "Comment only. Upgrade for track changes.",
|
||||
"comment_only_upgrade_to_enable_track_changes": "Comment only. <0>Upgrade</0> to enable track changes.",
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import CodeMirrorEditor from '../../../../frontend/js/features/source-editor/components/codemirror-editor'
|
||||
import { GlobalToasts } from '../../../../frontend/js/features/ide-react/components/global-toasts'
|
||||
import {
|
||||
EditorProviders,
|
||||
makeProjectProvider,
|
||||
USER_EMAIL,
|
||||
USER_ID,
|
||||
} from '../../helpers/editor-providers'
|
||||
import { mockScope } from '../source-editor/helpers/mock-scope'
|
||||
import { TestContainer } from '../source-editor/helpers/test-container'
|
||||
import { docId, mockDocContent } from '../source-editor/helpers/mock-doc'
|
||||
import { mockProject } from '../source-editor/helpers/mock-project'
|
||||
|
||||
const userData = {
|
||||
avatar_text: 'User',
|
||||
email: USER_EMAIL,
|
||||
hue: 180,
|
||||
id: USER_ID,
|
||||
isSelf: true,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
}
|
||||
|
||||
const unresolvedThreadId = 'unresolved-thread-id'
|
||||
|
||||
const commentOps = [
|
||||
{
|
||||
id: 'unresolved-op-id',
|
||||
op: { p: 210, c: 'Your results', t: unresolvedThreadId },
|
||||
},
|
||||
]
|
||||
|
||||
const threads = {
|
||||
[unresolvedThreadId]: {
|
||||
messages: [
|
||||
{
|
||||
content: 'unresolved comment text',
|
||||
id: `${unresolvedThreadId}-1`,
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
||||
user: userData,
|
||||
user_id: USER_ID,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const resolvedThreadId = 'resolved-thread-id'
|
||||
|
||||
const resolvedCommentOps = [
|
||||
{
|
||||
id: 'resolved-op-id',
|
||||
op: { p: 210, c: 'Your results', t: resolvedThreadId },
|
||||
},
|
||||
]
|
||||
|
||||
const resolvedThreads = {
|
||||
[resolvedThreadId]: {
|
||||
resolved: true,
|
||||
resolved_at: '2025-01-02T00:00:00.000Z',
|
||||
resolved_by_user_id: USER_ID,
|
||||
resolved_by_user: userData,
|
||||
messages: [
|
||||
{
|
||||
content: 'resolved comment text',
|
||||
id: `${resolvedThreadId}-1`,
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
||||
user: userData,
|
||||
user_id: USER_ID,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const insertChangeId = 'insert-change-id'
|
||||
const aggregatedDeleteChangeId = 'aggregated-delete-change-id'
|
||||
const standaloneDeleteChangeId = 'standalone-delete-change-id'
|
||||
|
||||
const changesOps = [
|
||||
{
|
||||
metadata: {
|
||||
user_id: USER_ID,
|
||||
ts: new Date('2025-01-01T00:00:00.000Z'),
|
||||
},
|
||||
id: insertChangeId,
|
||||
op: { p: 166, t: insertChangeId, i: 'introduction' },
|
||||
},
|
||||
// adjacent same-user deletion starting at insert end -> aggregated into
|
||||
// the preceding insert's entry by review-panel-current-file
|
||||
{
|
||||
metadata: {
|
||||
user_id: USER_ID,
|
||||
ts: new Date('2025-01-01T00:00:01.000Z'),
|
||||
},
|
||||
id: aggregatedDeleteChangeId,
|
||||
op: {
|
||||
p: 166 + 'introduction'.length,
|
||||
t: aggregatedDeleteChangeId,
|
||||
d: 'preface',
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
user_id: USER_ID,
|
||||
ts: new Date('2025-01-01T01:00:00.000Z'),
|
||||
},
|
||||
id: standaloneDeleteChangeId,
|
||||
op: { p: 110, t: standaloneDeleteChangeId, d: 'beautiful ' },
|
||||
},
|
||||
]
|
||||
|
||||
// a doc long enough that its end sits outside the rendered CodeMirror viewport,
|
||||
// so the review panel renders no entry for a range there until the editor
|
||||
// scrolls to it
|
||||
const belowFoldMarker = 'content line 1900'
|
||||
const belowFoldContent = mockDocContent(
|
||||
Array.from(Array(2000), (e, i) => `content line ${i}`).join('\n')
|
||||
)
|
||||
const belowFoldPosition = belowFoldContent.indexOf(belowFoldMarker)
|
||||
|
||||
const belowFoldThreadId = 'below-fold-thread-id'
|
||||
|
||||
const belowFoldCommentOps = [
|
||||
{
|
||||
id: 'below-fold-op-id',
|
||||
op: { p: belowFoldPosition, c: belowFoldMarker, t: belowFoldThreadId },
|
||||
},
|
||||
]
|
||||
|
||||
const belowFoldThreads = {
|
||||
[belowFoldThreadId]: {
|
||||
messages: [
|
||||
{
|
||||
content: 'below fold comment text',
|
||||
id: `${belowFoldThreadId}-1`,
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
||||
user: userData,
|
||||
user_id: USER_ID,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const belowFoldChangeId = 'below-fold-change-id'
|
||||
|
||||
const belowFoldChangeOps = [
|
||||
{
|
||||
metadata: {
|
||||
user_id: USER_ID,
|
||||
ts: new Date('2025-01-01T00:00:00.000Z'),
|
||||
},
|
||||
id: belowFoldChangeId,
|
||||
op: { p: belowFoldPosition, t: belowFoldChangeId, i: belowFoldMarker },
|
||||
},
|
||||
]
|
||||
|
||||
type MountOptions = {
|
||||
search?: string
|
||||
comments?: typeof commentOps
|
||||
changes?: typeof changesOps
|
||||
threads?: object
|
||||
trackChangesVisible?: boolean
|
||||
content?: string
|
||||
}
|
||||
|
||||
function mountEditor({
|
||||
search = '',
|
||||
comments = [],
|
||||
changes = [],
|
||||
threads = {},
|
||||
trackChangesVisible = true,
|
||||
content,
|
||||
}: MountOptions = {}) {
|
||||
cy.intercept('GET', '/project/*/changes/users', [
|
||||
{
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
},
|
||||
])
|
||||
|
||||
cy.intercept('GET', '/project/*/threads', threads)
|
||||
|
||||
cy.intercept('GET', '/project/*/ranges', [
|
||||
{
|
||||
id: docId,
|
||||
ranges: { changes, comments, docId },
|
||||
},
|
||||
])
|
||||
|
||||
cy.intercept('POST', `/project/*/doc/${docId}/metadata`, {})
|
||||
|
||||
cy.window().then(win => {
|
||||
win.history.replaceState({}, '', `${win.location.pathname}${search}`)
|
||||
})
|
||||
|
||||
const scope = mockScope(content, {
|
||||
docOptions: {
|
||||
rangesOptions: {
|
||||
comments,
|
||||
changes,
|
||||
getChanges: cy.stub().returns([]),
|
||||
removeChangeIds: cy.stub(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const project = mockProject({
|
||||
projectOwner: { _id: USER_ID },
|
||||
projectFeatures: { trackChanges: false, trackChangesVisible },
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<TestContainer className="rp-size-expanded">
|
||||
<EditorProviders
|
||||
scope={scope}
|
||||
providers={{ ProjectProvider: makeProjectProvider(project) }}
|
||||
>
|
||||
{/* mounted first so the toast listener is registered before the
|
||||
editor, as it is in the real IDE page */}
|
||||
<GlobalToasts />
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
// Wait for the editor to be ready before making assertions
|
||||
cy.get('.cm-content').should('have.css', 'opacity', '1')
|
||||
}
|
||||
|
||||
describe('<DeepLink />', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||
|
||||
cy.interceptEvents()
|
||||
})
|
||||
|
||||
describe('review panel deep link', function () {
|
||||
it('opens the review panel', function () {
|
||||
mountEditor({ search: '?open=review-panel' })
|
||||
|
||||
cy.findByTestId('review-panel').should(
|
||||
'not.have.class',
|
||||
'review-panel-mini'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips the deep link param from the URL', function () {
|
||||
mountEditor({ search: '?open=review-panel' })
|
||||
|
||||
cy.location('search').should('not.contain', 'open=')
|
||||
})
|
||||
|
||||
it('leaves the review panel in mini mode without the param', function () {
|
||||
mountEditor({ comments: commentOps, threads })
|
||||
|
||||
cy.findByTestId('review-panel').should('have.class', 'review-panel-mini')
|
||||
})
|
||||
|
||||
it('does not render the review panel when track changes is not available', function () {
|
||||
mountEditor({
|
||||
search: '?open=review-panel',
|
||||
trackChangesVisible: false,
|
||||
})
|
||||
|
||||
cy.findByTestId('review-panel').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('comment deep link', function () {
|
||||
function mountWithDeepLink(
|
||||
threadId: string,
|
||||
{ trackChangesVisible = true } = {}
|
||||
) {
|
||||
mountEditor({
|
||||
search: `?comment=${threadId}`,
|
||||
comments: commentOps,
|
||||
threads,
|
||||
trackChangesVisible,
|
||||
})
|
||||
}
|
||||
|
||||
it('opens the review panel and selects an unresolved comment', function () {
|
||||
mountWithDeepLink(unresolvedThreadId)
|
||||
|
||||
cy.findByTestId('review-panel').should(
|
||||
'not.have.class',
|
||||
'review-panel-mini'
|
||||
)
|
||||
cy.findByText('unresolved comment text')
|
||||
.closest('.review-panel-entry')
|
||||
.should('have.class', 'review-panel-entry-selected')
|
||||
})
|
||||
|
||||
it('strips the deep link params from the URL', function () {
|
||||
mountWithDeepLink(unresolvedThreadId)
|
||||
|
||||
cy.location('search').should('not.contain', 'comment=')
|
||||
})
|
||||
|
||||
it('selects and focuses a comment below the rendered viewport', function () {
|
||||
mountEditor({
|
||||
search: `?comment=${belowFoldThreadId}`,
|
||||
comments: belowFoldCommentOps,
|
||||
threads: belowFoldThreads,
|
||||
content: belowFoldContent,
|
||||
})
|
||||
|
||||
cy.findByText('below fold comment text')
|
||||
.closest('.review-panel-entry')
|
||||
.should('have.class', 'review-panel-entry-selected')
|
||||
.and('have.focus')
|
||||
})
|
||||
|
||||
it('shows a toast when the comment does not exist', function () {
|
||||
mountWithDeepLink('missing-thread-id')
|
||||
|
||||
cy.findByText('Comment not found').should('exist')
|
||||
})
|
||||
|
||||
it('opens the review panel and shows a toast when the comment is resolved', function () {
|
||||
mountEditor({
|
||||
search: `?comment=${resolvedThreadId}`,
|
||||
comments: resolvedCommentOps,
|
||||
threads: resolvedThreads,
|
||||
})
|
||||
|
||||
cy.findByTestId('review-panel').should(
|
||||
'not.have.class',
|
||||
'review-panel-mini'
|
||||
)
|
||||
cy.findByText('Comment not found').should('exist')
|
||||
})
|
||||
|
||||
it('shows a toast when track changes is not available', function () {
|
||||
mountWithDeepLink(unresolvedThreadId, { trackChangesVisible: false })
|
||||
|
||||
cy.findByText('Comment not found').should('exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tracked change deep link', function () {
|
||||
function mountWithDeepLink(
|
||||
changeId: string,
|
||||
{ trackChangesVisible = true } = {}
|
||||
) {
|
||||
mountEditor({
|
||||
search: `?change=${changeId}`,
|
||||
changes: changesOps,
|
||||
trackChangesVisible,
|
||||
})
|
||||
}
|
||||
|
||||
it('opens the review panel and selects the matching change', function () {
|
||||
mountWithDeepLink(standaloneDeleteChangeId)
|
||||
|
||||
cy.findByTestId('review-panel').should(
|
||||
'not.have.class',
|
||||
'review-panel-mini'
|
||||
)
|
||||
cy.findByText('beautiful')
|
||||
.closest('.review-panel-entry')
|
||||
.should('have.class', 'review-panel-entry-selected')
|
||||
})
|
||||
|
||||
it('selects the aggregated entry when the id belongs to the absorbed deletion', function () {
|
||||
mountWithDeepLink(aggregatedDeleteChangeId)
|
||||
|
||||
cy.findByText('preface')
|
||||
.closest('.review-panel-entry')
|
||||
.should('have.class', 'review-panel-entry-selected')
|
||||
.and('have.class', 'review-panel-entry-insert')
|
||||
})
|
||||
|
||||
it('selects and focuses a change below the rendered viewport', function () {
|
||||
mountEditor({
|
||||
search: `?change=${belowFoldChangeId}`,
|
||||
changes: belowFoldChangeOps,
|
||||
content: belowFoldContent,
|
||||
})
|
||||
|
||||
// the marker text also appears in the document, so scope the lookup to
|
||||
// the review panel
|
||||
cy.findByTestId('review-panel')
|
||||
.findByText(belowFoldMarker)
|
||||
.closest('.review-panel-entry')
|
||||
.should('have.class', 'review-panel-entry-selected')
|
||||
.and('have.focus')
|
||||
})
|
||||
|
||||
it('strips the deep link params from the URL', function () {
|
||||
mountWithDeepLink(insertChangeId)
|
||||
|
||||
cy.location('search').should('not.contain', 'change=')
|
||||
})
|
||||
|
||||
it('opens the review panel and drops the pending id when the change does not exist', function () {
|
||||
mountWithDeepLink('missing-change-id')
|
||||
|
||||
cy.findByTestId('review-panel').should(
|
||||
'not.have.class',
|
||||
'review-panel-mini'
|
||||
)
|
||||
cy.findAllByText('Comment not found').should('not.exist')
|
||||
})
|
||||
|
||||
it('silently opens the doc when track changes is not available', function () {
|
||||
mountWithDeepLink(insertChangeId, { trackChangesVisible: false })
|
||||
|
||||
cy.findAllByText('Comment not found').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user