mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-15 14:43:46 +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
@@ -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