feat(transcription): re-edit @mention via a pencil affordance (#628)
Hosts each mention as a Tiptap NodeView (mentionNodeView.ts) that renders the @displayName token (textContent — never innerHTML) plus a contenteditable=false pencil button in a fixed-width slot, revealed on whole-token hover and keyboard focus (instant opacity swap, no reflow). Activating the pencil (click or Enter/ Space) opens the single mention dropdown via the controller, anchored at the token and pre-filled with the stored displayName. commitRelink swaps ONLY personId in place via setNodeMarkup, sourcing the id solely from the selected Person — the stored displayName is preserved by construction (AC-3), even after the search input is edited (AC-5, the #380 AC-1 invariant). renderHTML/renderText stay for serialization + clipboard. AC-1/AC-2/AC-3/AC-5 + serializer round-trip covered by browser tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -771,3 +771,190 @@ describe('PersonMentionEditor — touch target', () => {
|
||||
expect(option.className).toContain('min-h-[44px]');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #628: re-edit an existing @mention via the pencil affordance ─────────────
|
||||
|
||||
describe('PersonMentionEditor — #628 re-edit pencil', () => {
|
||||
// A saved mention seeded into the editor: text "@Aug " + sidecar. The typed
|
||||
// displayName is "Aug" (short form, #380 AC-1), distinct from the DB name.
|
||||
const SAVED = { personId: 'p-aug', displayName: 'Aug' };
|
||||
|
||||
function editPencil() {
|
||||
return page.getByRole('button', { name: m.person_mention_edit_label() });
|
||||
}
|
||||
|
||||
async function renderSavedMention() {
|
||||
const host = renderHost({ value: '@Aug ', mentionedPersons: [SAVED] });
|
||||
await expect.element(editPencil()).toBeInTheDocument();
|
||||
return host;
|
||||
}
|
||||
|
||||
async function pencilElement(): Promise<HTMLElement> {
|
||||
return (await editPencil().element()) as HTMLElement;
|
||||
}
|
||||
|
||||
function clickPencil(btn: HTMLElement) {
|
||||
// Native dispatch — CDP userEvent.click is unreliable for handlers attached
|
||||
// imperatively inside a ProseMirror NodeView (same rationale as the option
|
||||
// mousedown dispatch elsewhere in this file).
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
async function pickOption(name: RegExp) {
|
||||
const el = (await page.getByRole('option', { name }).element()) as HTMLElement;
|
||||
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
// AC-1 ----------------------------------------------------------------------
|
||||
|
||||
it('renders an edit pencil on a saved mention, labelled via aria-label', async () => {
|
||||
await renderSavedMention();
|
||||
await expect.element(editPencil()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('the pencil is keyboard-focusable (tabindex 0, focus lands on it)', async () => {
|
||||
await renderSavedMention();
|
||||
const btn = await pencilElement();
|
||||
expect(btn.getAttribute('tabindex')).toBe('0');
|
||||
btn.focus();
|
||||
expect(document.activeElement).toBe(btn);
|
||||
});
|
||||
|
||||
it('hides the pencil by default and reveals it on hover + focus-within (opacity swap)', async () => {
|
||||
await renderSavedMention();
|
||||
const btn = await pencilElement();
|
||||
// Reveal is a class-driven instant opacity swap (no Tailwind CSS in the
|
||||
// component test env — assert the mechanism structurally, as the existing
|
||||
// touch-target test does for min-h-[44px]).
|
||||
expect(btn.className).toContain('opacity-0');
|
||||
expect(btn.className).toContain('group-hover/mention:opacity-100');
|
||||
expect(btn.className).toContain('group-focus-within/mention:opacity-100');
|
||||
});
|
||||
|
||||
it('renders the pencil in an always-present fixed-width slot (no reflow)', async () => {
|
||||
await renderSavedMention();
|
||||
const slot = document.querySelector('.mention-edit-slot') as HTMLElement | null;
|
||||
expect(slot).not.toBeNull();
|
||||
expect(slot!.className).toContain('w-4');
|
||||
});
|
||||
|
||||
// AC-2 ----------------------------------------------------------------------
|
||||
|
||||
it('opens the dropdown pre-filled with the stored displayName when the pencil is clicked', async () => {
|
||||
mockFetchEmpty();
|
||||
await renderSavedMention();
|
||||
clickPencil(await pencilElement());
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the dropdown when the pencil is activated by keyboard (Enter)', async () => {
|
||||
mockFetchEmpty();
|
||||
await renderSavedMention();
|
||||
const btn = await pencilElement();
|
||||
btn.focus();
|
||||
btn.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
||||
);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
|
||||
});
|
||||
});
|
||||
|
||||
// AC-3 ----------------------------------------------------------------------
|
||||
|
||||
it('relinks in place: picking a different person swaps data-person-id', async () => {
|
||||
mockFetchWithPersons(); // AUGUSTE (p-aug) + ANNA (p-anna)
|
||||
await renderSavedMention();
|
||||
clickPencil(await pencilElement());
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
|
||||
});
|
||||
await pickOption(/Anna Schmidt/);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const token = document.querySelector('[data-type="mention"]') as HTMLElement;
|
||||
expect(token.getAttribute('data-person-id')).toBe('p-anna');
|
||||
});
|
||||
});
|
||||
|
||||
it('relink preserves the displayed token text exactly (only personId changes)', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = await renderSavedMention();
|
||||
clickPencil(await pencilElement());
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
|
||||
});
|
||||
await pickOption(/Anna Schmidt/);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-anna', displayName: 'Aug' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// Serializer regression — text byte-identical, only personId swapped --------
|
||||
|
||||
it('serializer regression: re-link keeps the text byte-identical, swaps only personId', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = await renderSavedMention();
|
||||
clickPencil(await pencilElement());
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
|
||||
});
|
||||
await pickOption(/Anna Schmidt/);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(host.snapshot.value).toBe('@Aug ');
|
||||
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-anna', displayName: 'Aug' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// AC-5 — edited-then-picked (highest value): personId updates, text invariant
|
||||
|
||||
it('AC-5: editing the search input then picking sets the new personId', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = await renderSavedMention();
|
||||
clickPencil(await pencilElement());
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||||
});
|
||||
|
||||
await page.getByRole('searchbox').fill('Anna');
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
|
||||
});
|
||||
await pickOption(/Anna Schmidt/);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna');
|
||||
});
|
||||
});
|
||||
|
||||
it('AC-5: editing the search input then picking keeps the ORIGINAL displayName', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = await renderSavedMention();
|
||||
clickPencil(await pencilElement());
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||||
});
|
||||
|
||||
await page.getByRole('searchbox').fill('Anna');
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
|
||||
});
|
||||
await pickOption(/Anna Schmidt/);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// The #380 AC-1 invariant wins over the edited search input.
|
||||
expect(host.snapshot.mentionedPersons[0].displayName).toBe('Aug');
|
||||
expect(host.snapshot.value).toBe('@Aug ');
|
||||
expect(host.snapshot.value).not.toContain('@Anna');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user