bug: notification deep-link does not scroll to comment on document detail page #299
129
frontend/src/lib/utils/deepLinkScroll.spec.ts
Normal file
129
frontend/src/lib/utils/deepLinkScroll.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { scrollToCommentFromQuery, type DeepLinkScrollOptions } from './deepLinkScroll';
|
||||
|
||||
const COMMENT_ID = 'cccc1111-1111-1111-1111-111111111111';
|
||||
const ANNOTATION_ID = 'aaaa2222-2222-2222-2222-222222222222';
|
||||
|
||||
function fakeElement() {
|
||||
return {
|
||||
scrollIntoView: vi.fn(),
|
||||
focus: vi.fn()
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
type Overrides = Partial<DeepLinkScrollOptions>;
|
||||
|
||||
function buildOpts(overrides: Overrides = {}): DeepLinkScrollOptions {
|
||||
const el = overrides.getElement ? null : fakeElement();
|
||||
return {
|
||||
transcribeMode: true,
|
||||
setTranscribeMode: vi.fn(),
|
||||
loadBlocks: vi.fn().mockResolvedValue(undefined),
|
||||
setActiveAnnotationId: vi.fn(),
|
||||
flashAnnotation: vi.fn(),
|
||||
prefersReducedMotion: false,
|
||||
afterTick: vi.fn().mockResolvedValue(undefined),
|
||||
getElement: vi.fn().mockReturnValue(el),
|
||||
onStripUrl: vi.fn(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('scrollToCommentFromQuery', () => {
|
||||
it('is a no-op when commentId query param is absent', async () => {
|
||||
const url = new URL('https://app/documents/doc-1');
|
||||
const opts = buildOpts();
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.setActiveAnnotationId).not.toHaveBeenCalled();
|
||||
expect(opts.getElement).not.toHaveBeenCalled();
|
||||
expect(opts.onStripUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when annotationId query param is absent even if commentId is present', async () => {
|
||||
const url = new URL(`https://app/documents/doc-1?commentId=${COMMENT_ID}`);
|
||||
const opts = buildOpts();
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.setActiveAnnotationId).not.toHaveBeenCalled();
|
||||
expect(opts.getElement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls to the comment element and focuses it when both params are present', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const el = fakeElement();
|
||||
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) });
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.getElement).toHaveBeenCalledWith(`comment-${COMMENT_ID}`);
|
||||
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
|
||||
expect(el.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
});
|
||||
|
||||
it('triggers the annotation flash after scrolling', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const el = fakeElement();
|
||||
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) });
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.flashAnnotation).toHaveBeenCalledWith(ANNOTATION_ID);
|
||||
});
|
||||
|
||||
it('enters transcribe mode and awaits loadBlocks when transcribe mode is off', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const opts = buildOpts({ transcribeMode: false });
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.setTranscribeMode).toHaveBeenCalledWith(true);
|
||||
expect(opts.loadBlocks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a graceful no-op when the target element is not in the DOM', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(null) });
|
||||
|
||||
// Must not throw. Flash should not fire — nothing to highlight.
|
||||
await expect(scrollToCommentFromQuery(url, opts)).resolves.toBeUndefined();
|
||||
|
||||
expect(opts.flashAnnotation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses behavior "instant" when prefers-reduced-motion is set', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const el = fakeElement();
|
||||
const opts = buildOpts({
|
||||
prefersReducedMotion: true,
|
||||
getElement: vi.fn().mockReturnValue(el)
|
||||
});
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'instant', block: 'center' });
|
||||
});
|
||||
|
||||
it('strips both commentId and annotationId from the URL after handling', async () => {
|
||||
const url = new URL(
|
||||
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||
);
|
||||
const opts = buildOpts();
|
||||
|
||||
await scrollToCommentFromQuery(url, opts);
|
||||
|
||||
expect(opts.onStripUrl).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
40
frontend/src/lib/utils/deepLinkScroll.ts
Normal file
40
frontend/src/lib/utils/deepLinkScroll.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export type DeepLinkScrollOptions = {
|
||||
transcribeMode: boolean;
|
||||
setTranscribeMode: (value: boolean) => void;
|
||||
loadBlocks: () => Promise<void>;
|
||||
setActiveAnnotationId: (id: string) => void;
|
||||
flashAnnotation: (annotationId: string) => void;
|
||||
prefersReducedMotion: boolean;
|
||||
afterTick: () => Promise<void>;
|
||||
getElement: (id: string) => HTMLElement | null;
|
||||
onStripUrl: () => void;
|
||||
};
|
||||
|
||||
export async function scrollToCommentFromQuery(
|
||||
url: URL,
|
||||
opts: DeepLinkScrollOptions
|
||||
): Promise<void> {
|
||||
const commentId = url.searchParams.get('commentId');
|
||||
if (!commentId) return;
|
||||
|
||||
const annotationId = url.searchParams.get('annotationId');
|
||||
if (!annotationId) return;
|
||||
|
||||
if (!opts.transcribeMode) {
|
||||
opts.setTranscribeMode(true);
|
||||
await opts.loadBlocks();
|
||||
}
|
||||
|
||||
opts.setActiveAnnotationId(annotationId);
|
||||
await opts.afterTick();
|
||||
|
||||
const el = opts.getElement(`comment-${commentId}`);
|
||||
if (el) {
|
||||
const behavior: ScrollBehavior = opts.prefersReducedMotion ? 'instant' : 'smooth';
|
||||
el.scrollIntoView({ behavior, block: 'center' });
|
||||
el.focus({ preventScroll: true });
|
||||
opts.flashAnnotation(annotationId);
|
||||
}
|
||||
|
||||
opts.onStripUrl();
|
||||
}
|
||||
Reference in New Issue
Block a user