trigger-ref-panel.test.tsx17.0 KBView on GitHub /**
* The agent panel inside a trigger — `triggerRef`.
*
* A ref in a `<trigger>` is an agent the trigger runs, and the sentence under it is what
* THIS trigger wants from THAT agent. It used to be an inline file chip with nowhere to
* put that sentence; it is now a block node whose ProseMirror content IS the `<ref>`
* element's body.
*
* The load-bearing test here is the third one. An empty instruction area shows a
* placeholder, and if that placeholder were seeded as text content instead of drawn in
* CSS, it would round-trip into the XML and every ref in every playbook would ship with
* the words "Instructions for this trigger…" as its real, model-visible instruction. So:
* the placeholder must be visible to the eye and absent from `editor.getJSON()`.
*
* `getJSON()` is the honest assertion surface for that, not a string of XML — it is the
* exact input `serializePlaybookJsonToXml` is handed, and the other half of the chain
* (empty `triggerRef` content ⇒ `<ref id="…"/>`, placeholder or not) is pinned on the
* server at apps/server/src/services/document-saving/__tests__/playbook-trigger-ref-roundtrip.test.ts.
* apps/mail cannot import that module — it pulls yjs and the board/table serializers —
* which is why the guarantee is two links rather than one.
*/
import { useEffect, useRef } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useEditor, EditorContent } from '@tiptap/react';
import type { Editor } from '@tiptap/core';
import type { JSONContent } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
const AGENT_DOC = '11111111-1111-1111-1111-111111111111';
const OTHER_AGENT_DOC = '22222222-2222-2222-2222-222222222222';
const BOARD_DOC = '33333333-3333-3333-3333-333333333333';
const AGENTS = [
{
agentId: 'agent-coach',
name: 'coach-meeting',
avatar: 'teal/glasses',
documentId: AGENT_DOC,
},
{
agentId: 'agent-next-steps',
name: 'next-steps',
avatar: null,
documentId: OTHER_AGENT_DOC,
},
];
const DOCS: Record<string, { id: string; title: string; path: string }> = {
[BOARD_DOC]: {
id: BOARD_DOC,
title: 'Renewals board',
path: 'user/playbooks/resources/renewals-board',
},
};
// `mock`-prefixed so babel-plugin-jest-hoist allows the factory below to close over it.
const mockCedarState = {
setSelectedArtifact: jest.fn(),
selectDocumentId: jest.fn(),
setActiveFolderContext: jest.fn(),
};
jest.mock('@/modules/store', () => ({
useCedarStore: (selector: (state: typeof mockCedarState) => unknown) => selector(mockCedarState),
}));
jest.mock('@/modules/documents/playbook/usePlaybookAop', () => ({
usePlaybookAop: () => ({
aopName: 'Deals',
conversationFieldDefs: {},
customFieldDefs: {},
statusOptions: [],
}),
}));
const trpc = {
agent: {
list: {
queryOptions: (opts: Record<string, unknown> = {}) => ({
queryKey: ['agent.list'],
queryFn: async () => AGENTS,
...opts,
}),
},
},
documents: {
getDoc: {
queryOptions: (input: { documentId: string }, opts: Record<string, unknown> = {}) => ({
queryKey: ['documents.getDoc', input.documentId],
queryFn: async () => DOCS[input.documentId] ?? null,
...opts,
}),
},
},
};
jest.mock('@/providers/query-provider', () => ({ useTRPC: () => trpc }));
import {
TriggerRefNode,
TRIGGER_REF_PLACEHOLDER,
} from '@/modules/documents/playbook/TriggerRefNode';
import { createPlaybookExtensions } from '@/modules/documents/playbook/playbookExtensions';
const CRON = JSON.stringify({ type: 'cron', schedule: '0 16 * * 5' });
/** A trigger callout holding block prose plus whatever panels the case needs. */
function triggerDoc(panels: JSONContent[]): JSONContent {
return {
type: 'doc',
content: [
{
type: 'triggerNode',
attrs: { config: CRON },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Only score calls where an external party attended.' }],
},
...panels,
],
},
],
};
}
function panel(documentId: string, instruction?: string): JSONContent {
return {
type: TriggerRefNode.name,
attrs: { documentId },
...(instruction ? { content: [{ type: 'text', text: instruction }] } : {}),
};
}
/** Collect every node of a type out of an editor JSON tree. */
function collect(node: JSONContent, type: string): JSONContent[] {
const found: JSONContent[] = [];
if (node.type === type) found.push(node);
for (const child of node.content ?? []) found.push(...collect(child, type));
return found;
}
function Harness({
doc,
onReady,
onOpenDocument,
}: {
doc: JSONContent;
onReady: (editor: Editor) => void;
onOpenDocument?: (documentId: string) => void;
}) {
const seeded = useRef(false);
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
...createPlaybookExtensions({ aopId: 'test-aop', onOpenDocument }),
],
content: '',
immediatelyRender: true,
});
useEffect(() => {
if (!editor || seeded.current) return;
seeded.current = true;
// Deferred for the same reason CompositePlaybookDocument defers: TipTap renders React
// node views through flushSync.
queueMicrotask(() => {
if (editor.isDestroyed) return;
editor.commands.setContent(doc);
onReady(editor);
});
}, [editor, doc, onReady]);
return <EditorContent editor={editor} />;
}
function renderPlaybook(doc: JSONContent, onOpenDocument?: (documentId: string) => void) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const captured: { editor: Editor | null } = { editor: null };
render(
<QueryClientProvider client={client}>
<Harness
doc={doc}
onReady={(editor) => {
captured.editor = editor;
}}
onOpenDocument={onOpenDocument}
/>
</QueryClientProvider>,
);
return captured;
}
beforeEach(() => {
mockCedarState.setSelectedArtifact.mockClear();
mockCedarState.selectDocumentId.mockClear();
});
// ─── Two panels, one instructed and one empty ─────────────────────────────────
describe('a trigger with two agent refs', () => {
const doc = triggerDoc([
panel(AGENT_DOC, 'Score against the discovery rubric.'),
panel(OTHER_AGENT_DOC),
]);
it('renders one panel per ref, each naming its agent', async () => {
renderPlaybook(doc);
expect(await screen.findByRole('button', { name: /coach-meeting/ })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: /next-steps/ })).toBeInTheDocument();
const panels = document.querySelectorAll('[data-trigger-ref]');
expect(panels).toHaveLength(2);
// Both resolved through `agent.list`, so both draw the agent avatar rather than the
// file glyph — the difference the design makes legible instead of hidden.
for (const el of Array.from(panels)) {
expect(el.getAttribute('data-resolves-to')).toBe('agent');
}
});
it('shows the instruction on one and the placeholder on the other', async () => {
renderPlaybook(doc);
expect(await screen.findByText('Score against the discovery rubric.')).toBeInTheDocument();
const areas = document.querySelectorAll('[data-trigger-ref-instruction]');
expect(areas).toHaveLength(2);
const [instructed, blank] = Array.from(areas);
expect(instructed?.getAttribute('data-empty')).toBe('false');
expect(blank?.getAttribute('data-empty')).toBe('true');
expect(blank?.getAttribute('data-placeholder')).toBe(TRIGGER_REF_PLACEHOLDER);
});
it('never lets the placeholder become content', async () => {
// THE test. Two independent halves, because either one alone can pass while the
// feature is broken:
//
// 1. it is not in the DOM as text — so it is drawn by CSS (`::before` fed by
// `attr(data-placeholder)`), not by a text node the user can select and
// ProseMirror will happily save;
// 2. it is nowhere in `editor.getJSON()` — the exact tree the XML serializer is
// handed — and the empty panel carries NO content at all, which is what makes
// the serializer write `<ref id="…"/>`.
const captured = renderPlaybook(doc);
await screen.findByRole('button', { name: /next-steps/ });
const blank = document.querySelector('[data-trigger-ref-instruction][data-empty="true"]');
expect(blank).not.toBeNull();
expect(blank?.textContent ?? '').not.toContain(TRIGGER_REF_PLACEHOLDER);
await waitFor(() => expect(captured.editor).not.toBeNull());
const json = captured.editor?.getJSON() ?? {};
expect(JSON.stringify(json)).not.toContain(TRIGGER_REF_PLACEHOLDER);
const refs = collect(json, TriggerRefNode.name);
expect(refs.map((r) => r.attrs?.documentId)).toEqual([AGENT_DOC, OTHER_AGENT_DOC]);
expect(refs[0]?.content).toEqual([{ type: 'text', text: 'Score against the discovery rubric.' }]);
expect(refs[1]?.content ?? []).toEqual([]);
});
it('opens the agent from the title row', async () => {
renderPlaybook(doc);
const row = await screen.findByRole('button', { name: /coach-meeting/ });
await userEvent.click(row);
expect(mockCedarState.setSelectedArtifact).toHaveBeenCalledWith({
kind: 'agent',
id: 'agent-coach',
});
});
});
// ─── A ref whose document is not an agent ─────────────────────────────────────
describe('a ref that is not an agent', () => {
const doc = triggerDoc([panel(BOARD_DOC, 'Only the rows in Renewal Risk.')]);
it('still renders, as the file chip, with its instruction intact', async () => {
const captured = renderPlaybook(doc);
expect(await screen.findByRole('button', { name: /Renewals board/ })).toBeInTheDocument();
const el = document.querySelector('[data-trigger-ref]');
expect(el?.getAttribute('data-resolves-to')).toBe('document');
expect(screen.getByText('Only the rows in Renewal Risk.')).toBeInTheDocument();
// …and it round-trips: the node keeps its id and its body, so the serializer still
// writes `<ref id="…">Only the rows in Renewal Risk.</ref>`.
await waitFor(() => expect(captured.editor).not.toBeNull());
const refs = collect(captured.editor?.getJSON() ?? {}, TriggerRefNode.name);
expect(refs).toHaveLength(1);
expect(refs[0]?.attrs?.documentId).toBe(BOARD_DOC);
expect(refs[0]?.content).toEqual([{ type: 'text', text: 'Only the rows in Renewal Risk.' }]);
});
it('opens it as a document, not as an agent', async () => {
const onOpenDocument = jest.fn();
renderPlaybook(doc, onOpenDocument);
await userEvent.click(await screen.findByRole('button', { name: /Renewals board/ }));
expect(onOpenDocument).toHaveBeenCalledWith(BOARD_DOC);
expect(mockCedarState.setSelectedArtifact).not.toHaveBeenCalled();
});
});
// ─── section / when ───────────────────────────────────────────────────────────
describe('section and when', () => {
it('are declared on the node, so the editor cannot drop them', async () => {
// `compile-playbook.ts` reads both off a `<ref>`. An attribute the schema does not
// declare is filtered out by `type.create()`, which is exactly how they used to be
// lost: the document went through the editor once and came back without them.
const captured = renderPlaybook(
triggerDoc([
{
type: TriggerRefNode.name,
attrs: { documentId: AGENT_DOC, section: 'discovery', when: 'stage is 2' },
content: [{ type: 'text', text: 'Score it.' }],
},
]),
);
await screen.findByRole('button', { name: /coach-meeting/ });
await waitFor(() => expect(captured.editor).not.toBeNull());
const [ref] = collect(captured.editor?.getJSON() ?? {}, TriggerRefNode.name);
expect(ref?.attrs).toMatchObject({
documentId: AGENT_DOC,
section: 'discovery',
when: 'stage is 2',
});
});
it('default to null rather than an empty string on a ref that has neither', async () => {
// `<ref id="x" section="">` is not the same document as `<ref id="x">`, and the
// serializer only emits an attribute that is actually set.
const captured = renderPlaybook(triggerDoc([panel(AGENT_DOC, 'Score it.')]));
await screen.findByRole('button', { name: /coach-meeting/ });
await waitFor(() => expect(captured.editor).not.toBeNull());
const [ref] = collect(captured.editor?.getJSON() ?? {}, TriggerRefNode.name);
expect(ref?.attrs?.section).toBeNull();
expect(ref?.attrs?.when).toBeNull();
});
});
// ─── The chip's way in ────────────────────────────────────────────────────────
//
// Every ref authored before per-trigger instructions existed is self-closing, so it parses
// to the inline `fileLink` chip and has NOWHERE to put an instruction. That is not a
// migration problem to wait out — it is every ref in every playbook today. The chip's
// second half is the way in, and these tests pin that it appears only where an instruction
// would mean something, and that pressing it loses nothing.
/** A chip alone in its paragraph — what a round-tripped `<ref id="…"/>` always looks like. */
function chipParagraph(documentId: string, attrs?: Record<string, unknown>): JSONContent {
return {
type: 'paragraph',
content: [{ type: 'fileLink', attrs: { documentId, ...attrs } }],
};
}
const ADD_INSTRUCTIONS = /add instructions for this trigger/i;
describe('adding an instruction to a ref that has none', () => {
it('offers the chip a second half inside a trigger', async () => {
renderPlaybook(triggerDoc([chipParagraph('doc-coach')]));
expect(await screen.findByRole('button', { name: ADD_INSTRUCTIONS })).toBeInTheDocument();
});
it('does NOT offer it outside a trigger — a resource ref has no trigger to speak for', async () => {
renderPlaybook({
type: 'doc',
content: [chipParagraph('doc-resource')],
});
await screen.findByText(/file|doc-resource/i).catch(() => null);
await waitFor(() => {
expect(screen.queryByRole('button', { name: ADD_INSTRUCTIONS })).not.toBeInTheDocument();
});
});
it('turns the chip into a panel, leaving no empty paragraph behind', async () => {
const captured = renderPlaybook(triggerDoc([chipParagraph('doc-coach')]));
const button = await screen.findByRole('button', { name: ADD_INSTRUCTIONS });
await userEvent.click(button);
await waitFor(() => {
const json = captured.editor!.getJSON();
expect(collect(json, 'triggerRef')).toHaveLength(1);
expect(collect(json, 'fileLink')).toHaveLength(0);
});
const json = captured.editor!.getJSON();
const panelNode = collect(json, 'triggerRef')[0]!;
expect(panelNode.attrs?.documentId).toBe('doc-coach');
// Empty content, so it still serializes to `<ref id="…"/>` until something is typed.
expect(panelNode.content ?? []).toHaveLength(0);
// The block's own prose survives; the chip's paragraph does not linger as an empty one.
const trigger = collect(json, 'triggerNode')[0]!;
const emptyParagraphs = (trigger.content ?? []).filter(
(child) => child.type === 'paragraph' && (child.content ?? []).length === 0,
);
expect(emptyParagraphs).toHaveLength(0);
});
it('carries section/when across the conversion rather than silently dropping them', async () => {
// The compiler reads both off a `<ref>`. Losing them here would turn "add an
// instruction" into "add an instruction and quietly widen this ref's scope".
const captured = renderPlaybook(
triggerDoc([chipParagraph('doc-coach', { section: 'discovery', when: 'friday' })]),
);
await userEvent.click(await screen.findByRole('button', { name: ADD_INSTRUCTIONS }));
await waitFor(() => {
const panelNode = collect(captured.editor!.getJSON(), 'triggerRef')[0];
expect(panelNode?.attrs?.section).toBe('discovery');
expect(panelNode?.attrs?.when).toBe('friday');
});
});
it('keeps surrounding prose when the chip shared its paragraph', async () => {
const captured = renderPlaybook(
triggerDoc([
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Only after a real meeting: ' },
{ type: 'fileLink', attrs: { documentId: 'doc-coach' } },
],
},
]),
);
await userEvent.click(await screen.findByRole('button', { name: ADD_INSTRUCTIONS }));
await waitFor(() => {
expect(collect(captured.editor!.getJSON(), 'triggerRef')).toHaveLength(1);
});
// The prose was the block's note, not the ref's — it stays where the author put it.
expect(captured.editor!.getText()).toContain('Only after a real meeting:');
});
});