diff --git a/services/web/app/src/Features/Email/EmailBuilder.mjs b/services/web/app/src/Features/Email/EmailBuilder.mjs index 8a6c3f1ef7..508a912454 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.mjs +++ b/services/web/app/src/Features/Email/EmailBuilder.mjs @@ -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({ diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index a58eb45d9c..d786618613 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/ide-react/components/deep-link-toasts.tsx b/services/web/frontend/js/features/ide-react/components/deep-link-toasts.tsx new file mode 100644 index 0000000000..e83b17d40b --- /dev/null +++ b/services/web/frontend/js/features/ide-react/components/deep-link-toasts.tsx @@ -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 {t('comment_not_found')} +} + +const generators: GlobalToastGeneratorEntry[] = [ + { + key: COMMENT_NOT_FOUND_KEY, + generator: () => ({ + content: , + 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, + }, + }) + ) +} diff --git a/services/web/frontend/js/features/ide-react/components/global-toasts.tsx b/services/web/frontend/js/features/ide-react/components/global-toasts.tsx index 46c58972ee..e2796ea7f4 100644 --- a/services/web/frontend/js/features/ide-react/components/global-toasts.tsx +++ b/services/web/frontend/js/features/ide-react/components/global-toasts.tsx @@ -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 = new Map( GENERATOR_LIST.map(({ key, generator }) => [key, generator]) diff --git a/services/web/frontend/js/features/ide-react/context/deep-link-context.tsx b/services/web/frontend/js/features/ide-react/context/deep-link-context.tsx new file mode 100644 index 0000000000..f68eb5c905 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/context/deep-link-context.tsx @@ -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( + 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 = ({ children }) => { + const paramsRef = useRef(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 ( + + {children} + + ) +} + +export function useDeepLinkContext(): DeepLinkContextValue { + const context = useContext(DeepLinkContext) + + if (!context) { + throw new Error( + 'useDeepLinkContext is only available inside DeepLinkProvider' + ) + } + + return context +} diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx index 76e1687649..6c550a6a91 100644 --- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx @@ -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 = ({ 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 = ({ 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(() => { diff --git a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx index 80a30b3de2..3c73a1045d 100644 --- a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx +++ b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx @@ -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 ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { - childrenWrappedWithDynamicProviders - } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + childrenWrappedWithDynamicProviders + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/services/web/frontend/js/features/ide-settings/hooks/use-open-settings-via-query-param.tsx b/services/web/frontend/js/features/ide-settings/hooks/use-open-settings-via-query-param.tsx index 83c868df28..82f8d27137 100644 --- a/services/web/frontend/js/features/ide-settings/hooks/use-open-settings-via-query-param.tsx +++ b/services/web/frontend/js/features/ide-settings/hooks/use-open-settings-via-query-param.tsx @@ -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]) } diff --git a/services/web/frontend/js/features/review-panel/components/deep-link.tsx b/services/web/frontend/js/features/review-panel/components/deep-link.tsx new file mode 100644 index 0000000000..64dfdceed8 --- /dev/null +++ b/services/web/frontend/js/features/review-panel/components/deep-link.tsx @@ -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(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 +} diff --git a/services/web/frontend/js/features/review-panel/components/review-panel-change.tsx b/services/web/frontend/js/features/review-panel/components/review-panel-change.tsx index 80beccc63f..418c115742 100644 --- a/services/web/frontend/js/features/review-panel/components/review-panel-change.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-panel-change.tsx @@ -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 @@ -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} >
@@ -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} > 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 () => { diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx index bb59ce2fbc..78c3779d10 100644 --- a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx +++ b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx @@ -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({ + {features.trackChangesVisible && (writefullToolbarMigrationEnabled ? ( <> diff --git a/services/web/locales/en.json b/services/web/locales/en.json index d0c4412297..bbf230a147 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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 to enable track changes.", diff --git a/services/web/test/frontend/features/review-panel/deep-link.spec.tsx b/services/web/test/frontend/features/review-panel/deep-link.spec.tsx new file mode 100644 index 0000000000..13e4271f2f --- /dev/null +++ b/services/web/test/frontend/features/review-panel/deep-link.spec.tsx @@ -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( + + + {/* mounted first so the toast listener is registered before the + editor, as it is in the real IDE page */} + + + + + ) + + // Wait for the editor to be ready before making assertions + cy.get('.cm-content').should('have.css', 'opacity', '1') +} + +describe('', 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') + }) + }) +})