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:');
  });
});