bug: notification deep-link does not scroll to comment on document detail page #299

Merged
marcel merged 8 commits from feat/issue-276-notification-deep-link-scroll into main 2026-04-21 15:06:02 +02:00
2 changed files with 169 additions and 0 deletions
Showing only changes of commit 251eb9c3fc - Show all commits

View 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();
});
});

View 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();
}