@startsimpli/integrations 0.2.33 → 0.2.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/connection-activity.test.tsx +160 -0
- package/src/__tests__/corpus-folders.test.tsx +727 -0
- package/src/__tests__/corpus-pages.test.tsx +4 -4
- package/src/__tests__/library-failure-reason.test.tsx +178 -0
- package/src/components/FolderTree.tsx +425 -0
- package/src/components/index.ts +6 -0
- package/src/components/pages/ConnectionsPage.tsx +174 -68
- package/src/components/pages/LibraryPage.tsx +333 -35
- package/src/corpus/client.ts +123 -9
- package/src/corpus/entities.ts +75 -0
- package/src/corpus/fixtures.ts +263 -1
- package/src/corpus/index.ts +8 -1
- package/src/corpus/types.ts +121 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE FOLDER TREE (bd startsim-ei8gm.5), over the REAL client and the REAL
|
|
3
|
+
* tenant contract.
|
|
4
|
+
*
|
|
5
|
+
* WHY THE FIXTURE HTTP AND NOT A HAND-WRITTEN `CorpusApi`. The bug this feature
|
|
6
|
+
* can most easily ship with is not a rendering bug — it is a REQUEST bug, and a
|
|
7
|
+
* stubbed api hides exactly those. The facet action refuses unknown parameters
|
|
8
|
+
* (`_Scoped.initial()` screens it against `_FACET_FILTERS`), `has_children`
|
|
9
|
+
* arrives camel-cased through the app's fetch wrapper, and `__under` is a union
|
|
10
|
+
* rather than a prefix. So these tests drive `createCorpusApi` over
|
|
11
|
+
* `createFixtureCorpusHttp`, whose facet route mirrors
|
|
12
|
+
* `apps/api/filters.py::EntityQuery.facets` including the parts that REFUSE.
|
|
13
|
+
*
|
|
14
|
+
* THE THREE THINGS THAT MUST HOLD, in the order they would break:
|
|
15
|
+
*
|
|
16
|
+
* 1. NOTHING IS ASSEMBLED IN THE BROWSER (CLAUDE.md rule 8). One request per
|
|
17
|
+
* level, paged. A tree that read every document's path would pass a
|
|
18
|
+
* rendering test and fail in the low thousands.
|
|
19
|
+
* 2. NO NODE APPEARS THAT THE SERVER DID NOT RETURN FOR THIS READER. The facet
|
|
20
|
+
* read is gated out of `EntityViewSet.get_queryset()`, and a folder name
|
|
21
|
+
* leaks plenty on its own — `/Legal/Terminations`. Every level is drawn
|
|
22
|
+
* from its own response and nothing is merged across two.
|
|
23
|
+
* 3. A CONTROL, NEVER A SENTENCE, where the reader is asking what they may do.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, expect, it } from 'vitest';
|
|
26
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
27
|
+
import userEvent from '@testing-library/user-event';
|
|
28
|
+
|
|
29
|
+
import { FolderTree, folderTrail } from '../components/FolderTree';
|
|
30
|
+
import { LibraryPage } from '../components/pages/LibraryPage';
|
|
31
|
+
import { createCorpusApi } from '../corpus/client';
|
|
32
|
+
import { toFolder } from '../corpus/entities';
|
|
33
|
+
import {
|
|
34
|
+
FIXTURE_ORG,
|
|
35
|
+
FIXTURE_SOURCE_LABELS,
|
|
36
|
+
createFixtureCorpusHttp,
|
|
37
|
+
} from '../corpus/fixtures';
|
|
38
|
+
import type { CorpusApi, CorpusFolder, CorpusPage, FolderQuery } from '../corpus/types';
|
|
39
|
+
|
|
40
|
+
const SCOPE = { orgId: FIXTURE_ORG };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A folder ROW by its exact name.
|
|
44
|
+
*
|
|
45
|
+
* The accessible name of a node is "<name> <count>", so a loose `/^Contracts/`
|
|
46
|
+
* also matches `Contracts-old` — which is the very sibling these tests exist to
|
|
47
|
+
* keep apart. Anchoring both ends is not test hygiene here; a matcher that
|
|
48
|
+
* conflated them would hide the bug.
|
|
49
|
+
*/
|
|
50
|
+
function folderRow(name: string): RegExp {
|
|
51
|
+
return new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}, \\d+ documents? inside$`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The real adapter over the fixture tenant, recording every request it makes. */
|
|
55
|
+
function tenant(options: Parameters<typeof createFixtureCorpusHttp>[0] = {}) {
|
|
56
|
+
const seen: Array<{ path: string; params: Record<string, unknown> }> = [];
|
|
57
|
+
const { http } = createFixtureCorpusHttp({
|
|
58
|
+
...options,
|
|
59
|
+
onRequest: (path, params) => {
|
|
60
|
+
seen.push({ path, params });
|
|
61
|
+
options.onRequest?.(path, params);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const api = createCorpusApi({
|
|
65
|
+
http,
|
|
66
|
+
scope: SCOPE,
|
|
67
|
+
sourceLabels: FIXTURE_SOURCE_LABELS,
|
|
68
|
+
// The fixture tenant reports its own `applied_filters`, which is what the
|
|
69
|
+
// composer's honesty banner reads. Nothing is declared here.
|
|
70
|
+
resolveParticipants: false,
|
|
71
|
+
});
|
|
72
|
+
return { api, seen, facets: () => seen.filter((r) => r.path.includes('facets')) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// THE WIRE
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
describe('the facet read, as the tenant really answers it', () => {
|
|
80
|
+
it('asks for one level with the attribute and the container, and nothing else', async () => {
|
|
81
|
+
const { api, facets } = tenant();
|
|
82
|
+
await api.listFolders!({ under: '/' });
|
|
83
|
+
|
|
84
|
+
expect(facets()).toHaveLength(1);
|
|
85
|
+
expect(facets()[0].params).toMatchObject({
|
|
86
|
+
type: 'source_document', attr: 'path', under: '/', page: 1,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('sends NONE of the document filters, which the facet action would refuse', async () => {
|
|
91
|
+
// The failure being prevented: reusing the list's parameter builder. The
|
|
92
|
+
// facet action screens its vocabulary and 400s the whole read on a stray
|
|
93
|
+
// `occurred_after` — so a Library with a date filter set would lose its
|
|
94
|
+
// tree, and only in the deployments where somebody used the filters.
|
|
95
|
+
const { api, facets } = tenant();
|
|
96
|
+
await api.listFolders!({ under: '/Contracts', page: 2, pageSize: 10 });
|
|
97
|
+
|
|
98
|
+
expect(Object.keys(facets()[0].params).sort()).toEqual(
|
|
99
|
+
['attr', 'page', 'page_size', 'type', 'under'],
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns FULL child paths, which is what goes back out unchanged', async () => {
|
|
104
|
+
const { api } = tenant();
|
|
105
|
+
const page = await api.listFolders!({ under: '/Contracts' });
|
|
106
|
+
|
|
107
|
+
// `/Contracts/Acme`, not `Acme`. A client that got a bare segment would have
|
|
108
|
+
// to re-join, which is a small escaping bug in every client that tries.
|
|
109
|
+
expect(page.results.map((row) => row.path)).toContain('/Contracts/Acme');
|
|
110
|
+
expect(page.results.map((row) => row.name)).toContain('Acme');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('counts DESCENDANTS, so a collapsed folder reports what is really under it', async () => {
|
|
114
|
+
const { api } = tenant();
|
|
115
|
+
const page = await api.listFolders!({ under: '/' });
|
|
116
|
+
const contracts = page.results.find((row) => row.path === '/Contracts')!;
|
|
117
|
+
|
|
118
|
+
// `/Contracts` holds one file of its own and one under `/Contracts/Acme`
|
|
119
|
+
// and one under `/Contracts/Acme/2026`.
|
|
120
|
+
expect(contracts.count).toBe(3);
|
|
121
|
+
expect(contracts.hasChildren).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('never returns a sibling whose name merely starts the same way', async () => {
|
|
125
|
+
// `/Contracts-old` is the reason `__under` is one parameter and not a
|
|
126
|
+
// `startswith` a client assembled.
|
|
127
|
+
const { api } = tenant();
|
|
128
|
+
const page = await api.listFolders!({ under: '/Contracts' });
|
|
129
|
+
|
|
130
|
+
expect(page.results.map((row) => row.path)).not.toContain('/Contracts-old');
|
|
131
|
+
const root = await api.listFolders!({ under: '/' });
|
|
132
|
+
expect(root.results.map((row) => row.path)).toContain('/Contracts-old');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('does not turn the files sitting IN a folder into a nameless child folder', async () => {
|
|
136
|
+
const { api } = tenant();
|
|
137
|
+
const page = await api.listFolders!({ under: '/Contracts' });
|
|
138
|
+
|
|
139
|
+
expect(page.results.map((row) => row.path)).not.toContain('/Contracts/');
|
|
140
|
+
expect(page.results.every((row) => row.name !== '')).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('reads `has_children` in BOTH spellings, because the browser sees one of them', () => {
|
|
144
|
+
// `@startsimpli/api`'s fetch wrapper camel-cases every response key. Reading
|
|
145
|
+
// only the wire spelling renders every folder as a childless leaf — a tree
|
|
146
|
+
// that silently stops at depth one, in the browser only.
|
|
147
|
+
expect(toFolder({ value: '/A', count: 2, has_children: true }).hasChildren).toBe(true);
|
|
148
|
+
expect(toFolder({ value: '/A', count: 2, hasChildren: true }).hasChildren).toBe(true);
|
|
149
|
+
// Absent means NO, not unknown: a triangle that cannot expand is worse than
|
|
150
|
+
// no triangle.
|
|
151
|
+
expect(toFolder({ value: '/A', count: 2 }).hasChildren).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('the document list, bounded by a folder', () => {
|
|
156
|
+
it('asks for the shelf with attr.path when browsing', async () => {
|
|
157
|
+
const { api, seen } = tenant();
|
|
158
|
+
await api.listDocuments({ folder: '/Contracts' });
|
|
159
|
+
|
|
160
|
+
const list = seen.filter((r) => !r.path.includes('facets')).pop()!;
|
|
161
|
+
expect(list.params['attr.path']).toBe('/Contracts');
|
|
162
|
+
expect(list.params['attr.path__under']).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('asks for the SUBTREE with attr.path__under when searching', async () => {
|
|
166
|
+
const { api, seen } = tenant();
|
|
167
|
+
await api.listDocuments({ underFolder: '/Contracts' });
|
|
168
|
+
|
|
169
|
+
const list = seen.filter((r) => !r.path.includes('facets')).pop()!;
|
|
170
|
+
expect(list.params['attr.path__under']).toBe('/Contracts');
|
|
171
|
+
// NEVER BOTH: the list endpoint ANDs its filters, so sending both would
|
|
172
|
+
// intersect a folder with its own subtree and quietly answer with the shelf.
|
|
173
|
+
expect(list.params['attr.path']).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('the subtree really is a union, not a prefix', async () => {
|
|
177
|
+
const { api } = tenant();
|
|
178
|
+
const under = await api.listDocuments({ underFolder: '/Contracts', pageSize: 50 });
|
|
179
|
+
const titles = under.results.map((document) => document.title);
|
|
180
|
+
|
|
181
|
+
// The folder's own file AND the ones below it…
|
|
182
|
+
expect(titles).toContain('NDA (countersigned).pdf');
|
|
183
|
+
expect(titles).toContain('Statement of work — phase 2.pdf');
|
|
184
|
+
// …and not the sibling that merely starts the same way.
|
|
185
|
+
expect(titles).not.toContain('Superseded MSA 2024.pdf');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('carries the record’s own container through to the row', async () => {
|
|
189
|
+
const { api } = tenant();
|
|
190
|
+
const page = await api.listDocuments({ folder: '/Contracts/Acme' });
|
|
191
|
+
expect(page.results[0].folder).toBe('/Contracts/Acme');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// THE TREE
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
function tree(api: CorpusApi, onSelect = () => {}) {
|
|
200
|
+
return render(<FolderTree api={api} selected={null} onSelect={onSelect} />);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
describe('FolderTree — one level per request, and only ever one', () => {
|
|
204
|
+
it('draws the top level from a single request', async () => {
|
|
205
|
+
const { api, facets } = tenant();
|
|
206
|
+
tree(api);
|
|
207
|
+
|
|
208
|
+
expect(await screen.findByRole('button', { name: folderRow('Contracts') })).toBeInTheDocument();
|
|
209
|
+
expect(await screen.findByRole('button', { name: folderRow('Decks') })).toBeInTheDocument();
|
|
210
|
+
await waitFor(() => expect(facets()).toHaveLength(1));
|
|
211
|
+
expect(facets()[0].params.under).toBe('/');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('does NOT read the corpus to build itself', async () => {
|
|
215
|
+
// The rejected design, and the one this API exists to remove: page every
|
|
216
|
+
// document, read `path` off each row, assemble the tree in the browser. It
|
|
217
|
+
// works at 247 records and expires in the low thousands.
|
|
218
|
+
const { api, seen } = tenant();
|
|
219
|
+
tree(api);
|
|
220
|
+
await screen.findByRole('button', { name: folderRow('Contracts') });
|
|
221
|
+
|
|
222
|
+
expect(seen.filter((r) => !r.path.includes('facets'))).toHaveLength(0);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('fetches a child level only when the node is expanded', async () => {
|
|
226
|
+
const user = userEvent.setup();
|
|
227
|
+
const { api, facets } = tenant();
|
|
228
|
+
tree(api);
|
|
229
|
+
|
|
230
|
+
await screen.findByRole('button', { name: folderRow('Contracts') });
|
|
231
|
+
expect(facets()).toHaveLength(1);
|
|
232
|
+
|
|
233
|
+
await user.click(screen.getByRole('button', { name: 'Expand Contracts' }));
|
|
234
|
+
|
|
235
|
+
await screen.findByRole('button', { name: folderRow('Acme') });
|
|
236
|
+
await waitFor(() => expect(facets()).toHaveLength(2));
|
|
237
|
+
// Addressed by the FULL path the first response returned.
|
|
238
|
+
expect(facets()[1].params.under).toBe('/Contracts');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('goes deeper than one level, which is the whole point of a tree', async () => {
|
|
242
|
+
const user = userEvent.setup();
|
|
243
|
+
const { api, facets } = tenant();
|
|
244
|
+
tree(api);
|
|
245
|
+
|
|
246
|
+
await screen.findByRole('button', { name: folderRow('Contracts') });
|
|
247
|
+
await user.click(screen.getByRole('button', { name: 'Expand Contracts' }));
|
|
248
|
+
await screen.findByRole('button', { name: 'Expand Acme' });
|
|
249
|
+
await user.click(screen.getByRole('button', { name: 'Expand Acme' }));
|
|
250
|
+
|
|
251
|
+
expect(await screen.findByRole('button', { name: folderRow('2026') })).toBeInTheDocument();
|
|
252
|
+
expect(facets().map((r) => r.params.under)).toEqual(['/', '/Contracts', '/Contracts/Acme']);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('offers no disclosure control on a leaf, and asks nothing about it', async () => {
|
|
256
|
+
const { api } = tenant();
|
|
257
|
+
tree(api);
|
|
258
|
+
|
|
259
|
+
// `/Pricing` holds one file and no folders.
|
|
260
|
+
await screen.findByRole('button', { name: folderRow('Pricing') });
|
|
261
|
+
expect(screen.queryByRole('button', { name: 'Expand Pricing' })).toBeNull();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('re-expanding a node costs no second request', async () => {
|
|
265
|
+
const user = userEvent.setup();
|
|
266
|
+
const { api, facets } = tenant();
|
|
267
|
+
tree(api);
|
|
268
|
+
|
|
269
|
+
await screen.findByRole('button', { name: folderRow('Contracts') });
|
|
270
|
+
await user.click(screen.getByRole('button', { name: 'Expand Contracts' }));
|
|
271
|
+
await screen.findByRole('button', { name: folderRow('Acme') });
|
|
272
|
+
await user.click(screen.getByRole('button', { name: 'Collapse Contracts' }));
|
|
273
|
+
await user.click(screen.getByRole('button', { name: 'Expand Contracts' }));
|
|
274
|
+
|
|
275
|
+
await waitFor(() => expect(facets()).toHaveLength(2));
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('pages a wide level instead of asking for all of it', async () => {
|
|
279
|
+
// Rule 8 applies to FOLDERS too. A directory with 900 subfolders must cost
|
|
280
|
+
// one page until somebody asks for the next.
|
|
281
|
+
const user = userEvent.setup();
|
|
282
|
+
const rows: CorpusFolder[] = Array.from({ length: 7 }, (_, index) => ({
|
|
283
|
+
path: `/Wide/f${index}`, name: `f${index}`, count: 1, hasChildren: false,
|
|
284
|
+
}));
|
|
285
|
+
const asked: FolderQuery[] = [];
|
|
286
|
+
const paged: CorpusApi = {
|
|
287
|
+
listDocuments: async () => ({ count: 0, next: null, previous: null, results: [] }),
|
|
288
|
+
getDocument: async () => { throw new Error('unused'); },
|
|
289
|
+
listParties: async () => ({ count: 0, next: null, previous: null, results: [] }),
|
|
290
|
+
listSources: async () => [],
|
|
291
|
+
async listFolders(query): Promise<CorpusPage<CorpusFolder>> {
|
|
292
|
+
asked.push(query);
|
|
293
|
+
const page = query.page ?? 1;
|
|
294
|
+
return {
|
|
295
|
+
count: rows.length,
|
|
296
|
+
next: page * 3 < rows.length ? 'more' : null,
|
|
297
|
+
previous: null,
|
|
298
|
+
results: rows.slice((page - 1) * 3, page * 3),
|
|
299
|
+
};
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
render(<FolderTree api={paged} selected={null} onSelect={() => {}} pageSize={3} />);
|
|
303
|
+
|
|
304
|
+
await screen.findByRole('button', { name: folderRow('f0') });
|
|
305
|
+
expect(screen.queryByRole('button', { name: folderRow('f3') })).toBeNull();
|
|
306
|
+
|
|
307
|
+
await user.click(screen.getByRole('button', { name: 'Show 4 more folders' }));
|
|
308
|
+
|
|
309
|
+
expect(await screen.findByRole('button', { name: folderRow('f3') })).toBeInTheDocument();
|
|
310
|
+
// Still there — appending page 2 to page 1 of the SAME container.
|
|
311
|
+
expect(screen.getByRole('button', { name: folderRow('f0') })).toBeInTheDocument();
|
|
312
|
+
expect(asked.map((query) => query.page)).toEqual([1, 2]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('renders nothing at all when the corpus cannot enumerate folders', async () => {
|
|
316
|
+
// A fork built before start-simpli-api#391 has no facet route and answers
|
|
317
|
+
// 404. A half-drawn tree would be worse than none.
|
|
318
|
+
const { api } = tenant({ missing: ['facets'] });
|
|
319
|
+
const { container } = render(
|
|
320
|
+
<FolderTree api={api} selected={null} onSelect={() => {}} />,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
await waitFor(() => expect(container.querySelector('nav')).toBeNull());
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('a member with a full corpus and NO folders at all', () => {
|
|
328
|
+
/**
|
|
329
|
+
* NOT AN ERROR, NOT AN EMPTY CORPUS, AND IT IS THE COMMON CASE. Verified in
|
|
330
|
+
* production the day this shipped: `jpo@ogmc.ai` reads 51 documents on tandem
|
|
331
|
+
* and the facet read answers `{"count": 0, "results": []}` — every one of his
|
|
332
|
+
* records is a Granola meeting note, which has no `path` because it is not a
|
|
333
|
+
* file. A screen that showed him "no documents", an error, or a broken panel
|
|
334
|
+
* would be lying about a corpus that is entirely there.
|
|
335
|
+
*
|
|
336
|
+
* (The same member's colleague sees `/Demo Day Presentations`, `/ERA22 Shared`
|
|
337
|
+
* and `/Topmarq` and jpo sees NONE of those NAMES, which is the gate working.)
|
|
338
|
+
*/
|
|
339
|
+
const pathless: CorpusApi = {
|
|
340
|
+
listDocuments: async () => ({
|
|
341
|
+
count: 51, next: null, previous: null, appliedFilters: [],
|
|
342
|
+
results: [{
|
|
343
|
+
id: 'n1', title: 'Meeting note 1', body: 'Owners and next steps agreed.',
|
|
344
|
+
sections: [], occurredAt: null, source: 'granola', sourceLabel: 'Granola',
|
|
345
|
+
sourceRef: 'not_1', participants: [], scope: SCOPE,
|
|
346
|
+
}],
|
|
347
|
+
}),
|
|
348
|
+
getDocument: async () => { throw new Error('unused'); },
|
|
349
|
+
listParties: async () => ({ count: 0, next: null, previous: null, results: [] }),
|
|
350
|
+
listSources: async () => [],
|
|
351
|
+
listFolders: async () => ({ count: 0, next: null, previous: null, results: [] }),
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
it('still lists every record, under a folder panel that is simply empty', async () => {
|
|
355
|
+
render(<LibraryPage api={pathless} scope={SCOPE} />);
|
|
356
|
+
|
|
357
|
+
expect(await screen.findByText('Meeting note 1')).toBeInTheDocument();
|
|
358
|
+
expect(await screen.findByText(/51 records/)).toBeInTheDocument();
|
|
359
|
+
// The panel is THERE and quiet. Not hidden — a member who connects a drive
|
|
360
|
+
// tomorrow should not watch the layout rearrange — and not apologising.
|
|
361
|
+
const folders = await screen.findByLabelText('Folders');
|
|
362
|
+
expect(within(folders).getByRole('button', { name: 'All documents' })).toHaveAttribute(
|
|
363
|
+
'aria-current', 'true',
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('says nothing about it — no error, no warning, no empty state', async () => {
|
|
368
|
+
render(<LibraryPage api={pathless} scope={SCOPE} />);
|
|
369
|
+
await screen.findByText('Meeting note 1');
|
|
370
|
+
|
|
371
|
+
expect(screen.queryByRole('alert')).toBeNull();
|
|
372
|
+
expect(screen.queryByText(/no folders/i)).toBeNull();
|
|
373
|
+
expect(screen.queryByText(/nothing/i)).toBeNull();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('sends no path filter at all, so a pathless record is never dropped', async () => {
|
|
377
|
+
// The facet read skips rows with no `path` (`Q(_v__isnull=False)`). If the
|
|
378
|
+
// top of the tree narrowed to anything — an "unfiled" bucket, `path=""`,
|
|
379
|
+
// `path=/` — this member's entire corpus would vanish from his own Library.
|
|
380
|
+
const asked: Array<Record<string, unknown>> = [];
|
|
381
|
+
render(
|
|
382
|
+
<LibraryPage
|
|
383
|
+
api={{ ...pathless, listDocuments: async (query) => {
|
|
384
|
+
asked.push(query as Record<string, unknown>);
|
|
385
|
+
return { count: 51, next: null, previous: null, results: [], appliedFilters: [] };
|
|
386
|
+
} }}
|
|
387
|
+
scope={SCOPE}
|
|
388
|
+
/>,
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
await waitFor(() => expect(asked.length).toBeGreaterThan(0));
|
|
392
|
+
expect(asked[0].folder).toBeUndefined();
|
|
393
|
+
expect(asked[0].underFolder).toBeUndefined();
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe('FolderTree — nothing is synthesised from another level’s answer', () => {
|
|
398
|
+
it('shows only what the server returned for the container it was asked about', async () => {
|
|
399
|
+
// THE LEAK, REBUILT IN THE BROWSER, is the failure this guards. `/Shared` is
|
|
400
|
+
// visible; `/Legal/Terminations` is a folder this reader's own documents are
|
|
401
|
+
// not in, so the gated facet read never names it — and nothing in the client
|
|
402
|
+
// may infer it from anywhere else.
|
|
403
|
+
const asked: string[] = [];
|
|
404
|
+
const gated: CorpusApi = {
|
|
405
|
+
listDocuments: async () => ({ count: 0, next: null, previous: null, results: [] }),
|
|
406
|
+
getDocument: async () => { throw new Error('unused'); },
|
|
407
|
+
listParties: async () => ({ count: 0, next: null, previous: null, results: [] }),
|
|
408
|
+
listSources: async () => [],
|
|
409
|
+
async listFolders({ under }) {
|
|
410
|
+
asked.push(under);
|
|
411
|
+
const byContainer: Record<string, CorpusFolder[]> = {
|
|
412
|
+
'/': [{ path: '/Shared', name: 'Shared', count: 1, hasChildren: true }],
|
|
413
|
+
'/Shared': [{ path: '/Shared/Public', name: 'Public', count: 1, hasChildren: false }],
|
|
414
|
+
};
|
|
415
|
+
return {
|
|
416
|
+
count: (byContainer[under] ?? []).length,
|
|
417
|
+
next: null,
|
|
418
|
+
previous: null,
|
|
419
|
+
results: byContainer[under] ?? [],
|
|
420
|
+
};
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
const user = userEvent.setup();
|
|
424
|
+
render(<FolderTree api={gated} selected={null} onSelect={() => {}} />);
|
|
425
|
+
|
|
426
|
+
await screen.findByRole('button', { name: folderRow('Shared') });
|
|
427
|
+
await user.click(screen.getByRole('button', { name: 'Expand Shared' }));
|
|
428
|
+
await screen.findByRole('button', { name: folderRow('Public') });
|
|
429
|
+
|
|
430
|
+
expect(screen.queryByText(/Terminations/)).toBeNull();
|
|
431
|
+
expect(asked).toEqual(['/', '/Shared']);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('drops everything it fetched when the principal changes', async () => {
|
|
435
|
+
// The rows on screen were gated for the OLD reader. Reusing them across a
|
|
436
|
+
// workspace switch is a stale-folder leak wearing a cache.
|
|
437
|
+
const forA: CorpusApi = {
|
|
438
|
+
listDocuments: async () => ({ count: 0, next: null, previous: null, results: [] }),
|
|
439
|
+
getDocument: async () => { throw new Error('unused'); },
|
|
440
|
+
listParties: async () => ({ count: 0, next: null, previous: null, results: [] }),
|
|
441
|
+
listSources: async () => [],
|
|
442
|
+
listFolders: async () => ({
|
|
443
|
+
count: 1, next: null, previous: null,
|
|
444
|
+
results: [{ path: '/OnlyA', name: 'OnlyA', count: 1, hasChildren: false }],
|
|
445
|
+
}),
|
|
446
|
+
};
|
|
447
|
+
const forB: CorpusApi = {
|
|
448
|
+
...forA,
|
|
449
|
+
listFolders: async () => ({
|
|
450
|
+
count: 1, next: null, previous: null,
|
|
451
|
+
results: [{ path: '/OnlyB', name: 'OnlyB', count: 1, hasChildren: false }],
|
|
452
|
+
}),
|
|
453
|
+
};
|
|
454
|
+
const view = render(<FolderTree api={forA} selected={null} onSelect={() => {}} />);
|
|
455
|
+
await screen.findByRole('button', { name: folderRow('OnlyA') });
|
|
456
|
+
|
|
457
|
+
view.rerender(<FolderTree api={forB} selected={null} onSelect={() => {}} />);
|
|
458
|
+
|
|
459
|
+
await screen.findByRole('button', { name: folderRow('OnlyB') });
|
|
460
|
+
expect(screen.queryByRole('button', { name: folderRow('OnlyA') })).toBeNull();
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe('folderTrail', () => {
|
|
465
|
+
it('names every ancestor of a path the server already returned', () => {
|
|
466
|
+
expect(folderTrail('/Decks/2022 pitch/fonts')).toEqual([
|
|
467
|
+
{ path: '/Decks', name: 'Decks' },
|
|
468
|
+
{ path: '/Decks/2022 pitch', name: '2022 pitch' },
|
|
469
|
+
{ path: '/Decks/2022 pitch/fonts', name: 'fonts' },
|
|
470
|
+
]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('has nothing to say about the root', () => {
|
|
474
|
+
expect(folderTrail('/')).toEqual([]);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// THE LIBRARY, WITH THE TREE IN IT
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
describe('LibraryPage — one list, categorised by folder structure', () => {
|
|
483
|
+
it('narrows the list to the folder that was clicked, server-side', async () => {
|
|
484
|
+
const user = userEvent.setup();
|
|
485
|
+
const { api, seen } = tenant();
|
|
486
|
+
render(<LibraryPage api={api} scope={SCOPE} pageSize={50} />);
|
|
487
|
+
|
|
488
|
+
await screen.findByRole('button', { name: folderRow('Decks') });
|
|
489
|
+
await user.click(screen.getByRole('button', { name: folderRow('Pricing') }));
|
|
490
|
+
|
|
491
|
+
await waitFor(() => {
|
|
492
|
+
const list = seen.filter((r) => !r.path.includes('facets')).pop()!;
|
|
493
|
+
expect(list.params['attr.path__under']).toBe('/Pricing');
|
|
494
|
+
});
|
|
495
|
+
expect(await screen.findByText('Rate card 2026')).toBeInTheDocument();
|
|
496
|
+
await waitFor(() =>
|
|
497
|
+
expect(screen.queryByText('Poppins-BoldItalic.ttf')).toBeNull());
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('a CONTAINER folder lists what is under it, not an empty shelf', async () => {
|
|
501
|
+
// THE BUG THIS PINS, seen in the browser before it was fixed: the sidebar
|
|
502
|
+
// said "Decks 8" and the screen said "0 records" under the sentence
|
|
503
|
+
// "Nothing matches those filters. Clear them to see everything." — filters
|
|
504
|
+
// the reader never set, in the place a control belongs. A folder's count is
|
|
505
|
+
// DESCENDANTS, so a list bounded to the exact shelf disagrees with it for
|
|
506
|
+
// every folder that holds its documents in subfolders. On live tandem that
|
|
507
|
+
// is the two biggest: /ERA22 Shared (142) and /Demo Day Presentations (37).
|
|
508
|
+
const user = userEvent.setup();
|
|
509
|
+
const { api } = tenant();
|
|
510
|
+
render(<LibraryPage api={api} scope={SCOPE} pageSize={50} />);
|
|
511
|
+
|
|
512
|
+
await user.click(await screen.findByRole('button', { name: folderRow('Decks') }));
|
|
513
|
+
|
|
514
|
+
// `/Decks` itself holds no files at all — everything is one level down.
|
|
515
|
+
expect(await screen.findByText('Poppins-BoldItalic.ttf')).toBeInTheDocument();
|
|
516
|
+
// TWO rows called "Lilo Deck V4.key", one per pitch folder — which is bd
|
|
517
|
+
// startsim-maios's own example of why the flat list was unreadable, and now
|
|
518
|
+
// the tree beside them says which is which.
|
|
519
|
+
expect(await screen.findAllByText('Lilo Deck V4.key')).toHaveLength(2);
|
|
520
|
+
expect(screen.queryByText(/Nothing matches those filters/)).toBeNull();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('the count beside a folder is the count above its list', async () => {
|
|
524
|
+
const user = userEvent.setup();
|
|
525
|
+
const { api } = tenant();
|
|
526
|
+
render(<LibraryPage api={api} scope={SCOPE} pageSize={50} />);
|
|
527
|
+
|
|
528
|
+
const decks = await screen.findByRole('button', { name: folderRow('Decks') });
|
|
529
|
+
// The accessible name carries the tree's own number, so the two claims can
|
|
530
|
+
// be compared without reaching into the DOM for a count badge.
|
|
531
|
+
const sidebar = Number(/(\d+) document/.exec(decks.getAttribute('aria-label') ?? '')![1]);
|
|
532
|
+
await user.click(decks);
|
|
533
|
+
|
|
534
|
+
expect(await screen.findByText(new RegExp(`^${sidebar} records`))).toBeInTheDocument();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('offers the remedy as a control when the shelf really is empty', async () => {
|
|
538
|
+
// Narrowing to `/Decks` itself is a true zero, and the reader asked for it —
|
|
539
|
+
// but the generic empty state says "Nothing matches those filters. Clear
|
|
540
|
+
// them to see everything.", which is advice about filters they never set.
|
|
541
|
+
// A control that does the one useful thing stands there instead.
|
|
542
|
+
const user = userEvent.setup();
|
|
543
|
+
const { api } = tenant();
|
|
544
|
+
render(<LibraryPage api={api} scope={SCOPE} pageSize={50} />);
|
|
545
|
+
|
|
546
|
+
await user.click(await screen.findByRole('button', { name: folderRow('Decks') }));
|
|
547
|
+
await screen.findByText('Poppins-BoldItalic.ttf');
|
|
548
|
+
await user.click(screen.getByLabelText('Include subfolders'));
|
|
549
|
+
|
|
550
|
+
const remedy = await screen.findByRole('button', { name: 'Include subfolders' });
|
|
551
|
+
expect(screen.queryByText(/Nothing matches those filters/)).toBeNull();
|
|
552
|
+
|
|
553
|
+
await user.click(remedy);
|
|
554
|
+
expect(await screen.findByText('Poppins-BoldItalic.ttf')).toBeInTheDocument();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('does not carry "only this shelf" into the NEXT folder', async () => {
|
|
558
|
+
// Sticky, this is the container-folder disagreement again in two clicks:
|
|
559
|
+
// uncheck on `/Decks`, click `/Contracts` — which DOES have files of its own
|
|
560
|
+
// — and the list shows 2 of the 7 the sidebar claims.
|
|
561
|
+
const user = userEvent.setup();
|
|
562
|
+
const { api, seen } = tenant();
|
|
563
|
+
render(<LibraryPage api={api} scope={SCOPE} pageSize={50} />);
|
|
564
|
+
|
|
565
|
+
await user.click(await screen.findByRole('button', { name: folderRow('Decks') }));
|
|
566
|
+
await screen.findByText('Poppins-BoldItalic.ttf');
|
|
567
|
+
await user.click(screen.getByLabelText('Include subfolders'));
|
|
568
|
+
await screen.findByRole('button', { name: 'Include subfolders' });
|
|
569
|
+
|
|
570
|
+
await user.click(screen.getByRole('button', { name: folderRow('Contracts') }));
|
|
571
|
+
|
|
572
|
+
await waitFor(() => {
|
|
573
|
+
const list = seen.filter((r) => !r.path.includes('facets')).pop()!;
|
|
574
|
+
expect(list.params['attr.path__under']).toBe('/Contracts');
|
|
575
|
+
});
|
|
576
|
+
expect((screen.getByLabelText('Include subfolders') as HTMLInputElement).checked).toBe(true);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('offers a control to narrow to the shelf, and it really narrows', async () => {
|
|
580
|
+
// The other reading stays available — as a CONTROL, not as a hidden mode
|
|
581
|
+
// that depended on whether the search box happened to have a term in it.
|
|
582
|
+
const user = userEvent.setup();
|
|
583
|
+
const { api, seen } = tenant();
|
|
584
|
+
render(<LibraryPage api={api} scope={SCOPE} pageSize={50} />);
|
|
585
|
+
|
|
586
|
+
await user.click(await screen.findByRole('button', { name: folderRow('Decks') }));
|
|
587
|
+
await screen.findByText('Poppins-BoldItalic.ttf');
|
|
588
|
+
|
|
589
|
+
await user.click(screen.getByLabelText('Include subfolders'));
|
|
590
|
+
|
|
591
|
+
await waitFor(() => {
|
|
592
|
+
const list = seen.filter((r) => !r.path.includes('facets')).pop()!;
|
|
593
|
+
expect(list.params['attr.path']).toBe('/Decks');
|
|
594
|
+
expect(list.params['attr.path__under']).toBeUndefined();
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('tells the reader where they are, and lets them climb back out', async () => {
|
|
599
|
+
const user = userEvent.setup();
|
|
600
|
+
const { api } = tenant();
|
|
601
|
+
render(<LibraryPage api={api} scope={SCOPE} pageSize={50} />);
|
|
602
|
+
|
|
603
|
+
await screen.findByRole('button', { name: folderRow('Decks') });
|
|
604
|
+
await user.click(screen.getByRole('button', { name: 'Expand Decks' }));
|
|
605
|
+
await user.click(await screen.findByRole('button', { name: folderRow('2022 pitch') }));
|
|
606
|
+
|
|
607
|
+
const trail = await screen.findByLabelText('Folder path');
|
|
608
|
+
expect(within(trail).getByText('Decks')).toBeInTheDocument();
|
|
609
|
+
expect(within(trail).getByText('2022 pitch')).toBeInTheDocument();
|
|
610
|
+
|
|
611
|
+
await user.click(within(trail).getByRole('button', { name: 'All documents' }));
|
|
612
|
+
await waitFor(() => expect(screen.queryByLabelText('Folder path')).toBeNull());
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('keeps records that are in NO folder reachable from the top', async () => {
|
|
616
|
+
// The facet read enumerates values of `path` and skips records without one,
|
|
617
|
+
// so a meeting note is in no folder — which on today's tandem is most of the
|
|
618
|
+
// corpus. The top of the tree therefore clears the filter rather than
|
|
619
|
+
// narrowing to an invented "unfiled" bucket.
|
|
620
|
+
const { api, seen } = tenant();
|
|
621
|
+
render(<LibraryPage api={api} scope={SCOPE} pageSize={50} />);
|
|
622
|
+
|
|
623
|
+
expect(await screen.findByText('Acme <> StartSimpli weekly sync')).toBeInTheDocument();
|
|
624
|
+
const list = seen.filter((r) => !r.path.includes('facets'))[0];
|
|
625
|
+
expect(list.params['attr.path']).toBeUndefined();
|
|
626
|
+
expect(list.params['attr.path__under']).toBeUndefined();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('searches the whole subtree of the open folder, not just its shelf', async () => {
|
|
630
|
+
const user = userEvent.setup();
|
|
631
|
+
const { api, seen } = tenant({ searchBackend: 'postgres_fulltext' });
|
|
632
|
+
render(<LibraryPage api={api} scope={SCOPE} pageSize={50} />);
|
|
633
|
+
|
|
634
|
+
await screen.findByRole('button', { name: folderRow('Contracts') });
|
|
635
|
+
await user.click(screen.getByRole('button', { name: folderRow('Contracts') }));
|
|
636
|
+
await user.type(screen.getByLabelText('Search'), 'migration');
|
|
637
|
+
await user.click(screen.getByRole('button', { name: 'Search' }));
|
|
638
|
+
|
|
639
|
+
await waitFor(() => {
|
|
640
|
+
const list = seen.filter((r) => !r.path.includes('facets')).pop()!;
|
|
641
|
+
expect(list.params['attr.path__under']).toBe('/Contracts');
|
|
642
|
+
expect(list.params.search).toBe('migration');
|
|
643
|
+
});
|
|
644
|
+
// Two folders down, and found.
|
|
645
|
+
expect(await screen.findByText('Statement of work — phase 2.pdf')).toBeInTheDocument();
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('says so on screen when the server did not narrow by the folder', async () => {
|
|
649
|
+
// A tree the server cannot act on is worse than no tree: the reader clicks a
|
|
650
|
+
// folder, the list does not change, and nothing says why.
|
|
651
|
+
const user = userEvent.setup();
|
|
652
|
+
const deaf: CorpusApi = {
|
|
653
|
+
listDocuments: async () => ({
|
|
654
|
+
count: 0, next: null, previous: null, results: [], appliedFilters: [],
|
|
655
|
+
}),
|
|
656
|
+
getDocument: async () => { throw new Error('unused'); },
|
|
657
|
+
listParties: async () => ({ count: 0, next: null, previous: null, results: [] }),
|
|
658
|
+
listSources: async () => [],
|
|
659
|
+
listFolders: async () => ({
|
|
660
|
+
count: 1, next: null, previous: null,
|
|
661
|
+
results: [{ path: '/Contracts', name: 'Contracts', count: 4, hasChildren: false }],
|
|
662
|
+
}),
|
|
663
|
+
};
|
|
664
|
+
render(<LibraryPage api={deaf} scope={SCOPE} />);
|
|
665
|
+
|
|
666
|
+
await user.click(await screen.findByRole('button', { name: folderRow('Contracts') }));
|
|
667
|
+
|
|
668
|
+
expect(await screen.findByText(/does not filter by folder/)).toBeInTheDocument();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('renders the flat list, and no tree, on a corpus that cannot enumerate folders', async () => {
|
|
672
|
+
const { api } = tenant({ missing: ['facets'] });
|
|
673
|
+
render(<LibraryPage api={api} scope={SCOPE} pageSize={50} />);
|
|
674
|
+
|
|
675
|
+
expect(await screen.findByText('Rate card 2026')).toBeInTheDocument();
|
|
676
|
+
await waitFor(() => expect(screen.queryByLabelText('Folders')).toBeNull());
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
describe('a row whose text cannot be pulled offers a DISABLED control, not a sentence', () => {
|
|
681
|
+
it('replaces the explanation with a greyed-out button', async () => {
|
|
682
|
+
const { api } = tenant();
|
|
683
|
+
render(<LibraryPage api={api} scope={SCOPE} pageSize={50} />);
|
|
684
|
+
|
|
685
|
+
// THE SENTENCE IS GONE. "No text was captured for this record." stood where
|
|
686
|
+
// a control belongs; it answered "what happened" to a reader asking "what
|
|
687
|
+
// can I do".
|
|
688
|
+
expect(await screen.findByText('Poppins-BoldItalic.ttf')).toBeInTheDocument();
|
|
689
|
+
expect(screen.queryByText(/No text was captured/)).toBeNull();
|
|
690
|
+
|
|
691
|
+
const buttons = await screen.findAllByRole('button', { name: 'Get text' });
|
|
692
|
+
expect(buttons.length).toBeGreaterThan(0);
|
|
693
|
+
// Greyed out, because the action genuinely cannot be performed: content is
|
|
694
|
+
// opt-in and this deployment pulls none (bd startsim-h6xnu).
|
|
695
|
+
expect(buttons.every((button) => (button as HTMLButtonElement).disabled)).toBe(true);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it('is live, and calls through, when the deployment CAN fetch the text', async () => {
|
|
699
|
+
// The seam is not decorative: bd startsim-99g8.2 lands into it and the same
|
|
700
|
+
// control wakes up with no change here.
|
|
701
|
+
const user = userEvent.setup();
|
|
702
|
+
const asked: string[] = [];
|
|
703
|
+
const { api } = tenant();
|
|
704
|
+
render(
|
|
705
|
+
<LibraryPage
|
|
706
|
+
api={api}
|
|
707
|
+
scope={SCOPE}
|
|
708
|
+
pageSize={50}
|
|
709
|
+
onRequestText={(document) => { asked.push(document.title); }}
|
|
710
|
+
/>,
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
const button = (await screen.findAllByRole('button', { name: 'Get text' }))[0];
|
|
714
|
+
expect((button as HTMLButtonElement).disabled).toBe(false);
|
|
715
|
+
await user.click(button);
|
|
716
|
+
|
|
717
|
+
expect(asked).toHaveLength(1);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('offers no such control on a row that HAS text', async () => {
|
|
721
|
+
const { api } = tenant();
|
|
722
|
+
render(<LibraryPage api={api} scope={SCOPE} pageSize={50} />);
|
|
723
|
+
|
|
724
|
+
const row = (await screen.findByText('Rate card 2026')).closest('div.rounded-lg')!;
|
|
725
|
+
expect(within(row as HTMLElement).queryByRole('button', { name: 'Get text' })).toBeNull();
|
|
726
|
+
});
|
|
727
|
+
});
|