playbookFileTree.test.tsx5.3 KBView on GitHub /**
* A conversation type's playbook, as a file system.
*
* Three things, each of which the flat one-scope list this replaced got wrong:
*
* 1. THE PLAYBOOK IS PINNED FIRST. It is the document the screen is about and the only one
* Cedar runs; left to the tree's own order it moved every time another file was opened.
* 2. USER AND ORG ARE THE ROOTS. Two workspaces, two sets of permissions — who a playbook
* file applies to is the most load-bearing fact about it, and a flat list rooted in one
* of them said it quietly.
* 3. NEITHER ROOT LISTS ITS OWN PLAYBOOK.md. The pinned row opens the COMBINED editor; the
* two halves are one document, and listing them invites editing a half.
*/
import { fireEvent, render, screen } from '@testing-library/react';
/**
* A row's test id sits on its wrapper; the activator is the `<button>` inside it (the row
* renders its columns as siblings of the hit area — see `FileListRow`).
*/
const clickRow = (testId: string) => {
const button = screen.getByTestId(testId).querySelector('button');
if (!button) throw new Error(`row "${testId}" has no activator`);
fireEvent.click(button);
};
const mockUseFileTree = jest.fn();
jest.mock('@/modules/conversations/components/files/ConversationFileTree', () => ({
useFileTree: (options: unknown) => mockUseFileTree(options),
}));
jest.mock('@/modules/auth/utils/auth-client', () => ({
useSession: () => ({ data: { user: { id: 'user_1' } } }),
}));
jest.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: { id: 'org_1' } }) }));
jest.mock('@/providers/query-provider', () => ({
useTRPC: () => ({ organisation: { getMyOrg: { queryOptions: () => ({}) } } }),
}));
import { PlaybookFileTree } from '@/modules/brain/components/PlaybookFileTree';
const node = (over: Partial<{ id: string; documentType: string; path: string; title: string }>) => ({
id: 'n1',
parentId: null,
documentType: 'document',
path: 'user/playbooks/aop_1/notes.md',
title: 'Notes',
...over,
});
/** One row per tree, so each scope folder has something under it. */
function treeWith(rows: ReturnType<typeof node>[]) {
return {
rows: rows.map((n) => ({ node: n, depth: 0, isFolder: false, expanded: false })),
rootMissing: false,
toggleExpand: jest.fn(),
isLoading: false,
};
}
beforeEach(() => {
mockUseFileTree.mockReset();
mockUseFileTree.mockReturnValue(treeWith([node({})]));
});
describe('PlaybookFileTree', () => {
it('pins the Playbook as the very first row, above both scope folders', () => {
render(
<PlaybookFileTree
aopId="aop_1"
orgAopId="org_aop_1"
onOpenPlaybook={jest.fn()}
onOpenFile={jest.fn()}
/>,
);
const order = Array.from(
document.querySelectorAll('[data-testid="playbook-row"],[data-testid^="playbook-scope-"]'),
).map((n) => n.getAttribute('data-testid'));
expect(order).toEqual(['playbook-row', 'playbook-scope-user', 'playbook-scope-org']);
});
it('roots each folder in its OWN workspace, at that workspace’s playbook path', () => {
render(
<PlaybookFileTree
aopId="aop_1"
orgAopId="org_aop_1"
onOpenPlaybook={jest.fn()}
onOpenFile={jest.fn()}
/>,
);
const calls = mockUseFileTree.mock.calls.map(([o]) => [o.scope, o.rootPath]);
expect(calls).toEqual([
[{ type: 'user', id: 'user_1' }, 'user/playbooks/aop_1'],
[{ type: 'org', id: 'org_1' }, 'organisation/playbooks/org_aop_1'],
]);
});
it('hides each copy’s own PLAYBOOK.md — the pinned row is the combined one', () => {
render(
<PlaybookFileTree
aopId="aop_1"
orgAopId="org_aop_1"
onOpenPlaybook={jest.fn()}
onOpenFile={jest.fn()}
/>,
);
for (const [options] of mockUseFileTree.mock.calls) {
expect(options.includeNode(node({ documentType: 'playbook' }))).toBe(false);
expect(options.includeNode(node({ documentType: 'document' }))).toBe(true);
}
});
it('drops the Org root entirely when the team has no copy', () => {
render(
<PlaybookFileTree aopId="aop_1" onOpenPlaybook={jest.fn()} onOpenFile={jest.fn()} />,
);
expect(screen.getByTestId('playbook-scope-user')).toBeInTheDocument();
expect(screen.queryByTestId('playbook-scope-org')).not.toBeInTheDocument();
});
it('opens the combined playbook from the pinned row, and a file from a tree row', () => {
const onOpenPlaybook = jest.fn();
const onOpenFile = jest.fn();
mockUseFileTree.mockReturnValue(treeWith([node({ id: 'doc_9' })]));
render(
<PlaybookFileTree
aopId="aop_1"
onOpenPlaybook={onOpenPlaybook}
onOpenFile={onOpenFile}
/>,
);
clickRow('playbook-row');
expect(onOpenPlaybook).toHaveBeenCalled();
clickRow('playbook-file-doc_9');
expect(onOpenFile).toHaveBeenCalledWith(expect.objectContaining({ id: 'doc_9' }));
});
it('nests a scope’s files one level under its root row', () => {
render(
<PlaybookFileTree aopId="aop_1" onOpenPlaybook={jest.fn()} onOpenFile={jest.fn()} />,
);
// The scope row sits at the list's own indent; its contents step in by one.
expect(screen.getByTestId('playbook-scope-user').dataset.depth).toBe('0');
expect(screen.getByTestId('playbook-file-n1').dataset.depth).toBe('1');
});
});