@startsimpli/integrations 0.2.34 → 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.
@@ -161,11 +161,11 @@ describe('LibraryPage — filtering and paging are SERVER round trips', () => {
161
161
  });
162
162
  render(<LibraryPage api={api} scope={SCOPE} pageSize={2} />);
163
163
 
164
- await screen.findByText(/page 1 of 3/);
164
+ await screen.findByText(/page 1 of \d+/);
165
165
  await user.click(screen.getByRole('button', { name: 'Next' }));
166
166
 
167
167
  await waitFor(() => expect(queries.some((query) => query.page === 2)).toBe(true));
168
- expect(await screen.findByText(/page 2 of 3/)).toBeInTheDocument();
168
+ expect(await screen.findByText(/page 2 of \d+/)).toBeInTheDocument();
169
169
  });
170
170
 
171
171
  it('returns to page 1 when a filter changes', async () => {
@@ -178,9 +178,9 @@ describe('LibraryPage — filtering and paging are SERVER round trips', () => {
178
178
  });
179
179
  render(<LibraryPage api={api} scope={SCOPE} pageSize={2} />);
180
180
 
181
- await screen.findByText(/page 1 of 3/);
181
+ await screen.findByText(/page 1 of \d+/);
182
182
  await user.click(screen.getByRole('button', { name: 'Next' }));
183
- await screen.findByText(/page 2 of 3/);
183
+ await screen.findByText(/page 2 of \d+/);
184
184
 
185
185
  await user.selectOptions(screen.getByLabelText('Source'), 'dropbox');
186
186
 
@@ -0,0 +1,425 @@
1
+ /**
2
+ * THE FOLDER TREE — a navigable directory over the corpus (bd startsim-ei8gm.5,
3
+ * decided on bd startsim-maios: "a navigable tree/directory system is 100%
4
+ * necessary").
5
+ *
6
+ * IT IS A FILE BROWSER AND THAT IS THE WHOLE DESIGN. bd startsim-nbhno settled
7
+ * that the corpus is ONE list categorised by folder structure — "everyone knows
8
+ * how folders work cause they use a computer. we don't need to create a new
9
+ * system here that will confuse people." So there is no second surface, no
10
+ * ranking and no invented taxonomy here: a triangle, a folder, a count, and
11
+ * clicking one shows what is inside it.
12
+ *
13
+ * ONE LEVEL PER REQUEST, PAGED (CLAUDE.md rule 8). Expanding a node asks the
14
+ * server for that node's children and nothing else:
15
+ *
16
+ * GET /api/v1/entities/facets/?type=source_document&attr=path&under=/Contracts
17
+ * -> {value, count, has_children}, paginated
18
+ *
19
+ * The alternative — read every document's path and assemble the tree in the
20
+ * browser — is what this exists to remove. It works on today's 247 records and
21
+ * expires in the low thousands, and it is the rule-8 failure the tenant shipped
22
+ * `/entities/facets/` (start-simpli-api#391) specifically to end.
23
+ *
24
+ * `has_children` IS WHY EXPANDING COSTS NOTHING TO OFFER. The row that names a
25
+ * folder also says whether anything is below it, so the disclosure triangle is
26
+ * drawn from data already in hand rather than from a probe request per node.
27
+ *
28
+ * NOTHING HERE MINTS A FOLDER (bd startsim-maios). A node is a value of the
29
+ * `path` attribute, derived at read time; there is no folder row and no folder
30
+ * id, so a rename re-mints nothing and orphans nothing. That is the correct
31
+ * shape rather than a shortcut — it is how a file browser has always worked.
32
+ *
33
+ * THE SECURITY PROPERTY, AND IT IS THE REASON FOR THE SHAPE OF THE STATE BELOW.
34
+ * The facet read is gated server-side out of `EntityViewSet.get_queryset()`
35
+ * itself, so a member is shown the folders their own documents are in. On a
36
+ * tenant whose `source_document` is `row_scope: personal` that is the entire
37
+ * protection, and a folder name leaks plenty on its own — `/Legal/Terminations`,
38
+ * `/Acquisition-TargetCo`. So every container's children are stored under that
39
+ * container's OWN `under=` key and rendered from that key alone. Nothing here
40
+ * merges two responses, fills one level from another level's answer, or keeps a
41
+ * flat set of every folder ever seen: each of those would put on screen a node
42
+ * the server never returned for THIS reader, which is the leak rebuilt in the
43
+ * browser out of rows the server was careful about.
44
+ *
45
+ * PATHS GO BACK OUT VERBATIM. The next `under=` and the file list's
46
+ * `attr.path=` are the exact string the server returned; a client that re-joins
47
+ * segments has a small escaping bug in it, and the server returns whole paths
48
+ * precisely so no client has to. `folderName()` takes one apart for DISPLAY, and
49
+ * `folderTrail()` does for a breadcrumb — see its own note for why that one is
50
+ * not the same act.
51
+ */
52
+ import * as React from 'react';
53
+
54
+ import { FOLDER_ROOT, folderName } from '../corpus/entities';
55
+ import type { CorpusFolder, CorpusPage, FolderQuery } from '../corpus/types';
56
+
57
+
58
+ export type FolderTreeSlot = 'root' | 'node' | 'nodeSelected' | 'count' | 'muted' | 'error' | 'more';
59
+
60
+ const DEFAULTS: Record<FolderTreeSlot, string> = {
61
+ root: 'space-y-0.5',
62
+ node: 'flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-2 py-1 text-left text-sm text-gray-700 hover:bg-gray-100',
63
+ nodeSelected: 'bg-gray-100 font-medium text-gray-900',
64
+ count: 'ml-auto shrink-0 text-xs tabular-nums text-gray-400',
65
+ muted: 'px-2 py-1 text-xs text-gray-500',
66
+ error: 'px-2 py-1 text-xs text-red-600',
67
+ more: 'px-2 py-1 text-xs font-medium text-gray-600 underline underline-offset-2 hover:text-gray-900 disabled:no-underline disabled:opacity-50',
68
+ };
69
+
70
+ /** Just the read the tree needs, so a caller can hand it a whole `CorpusApi`. */
71
+ export interface FolderSource {
72
+ listFolders?(query: FolderQuery): Promise<CorpusPage<CorpusFolder>>;
73
+ }
74
+
75
+ export interface FolderTreeProps {
76
+ api: FolderSource;
77
+ /** The folder whose contents are on screen, or `null` for the whole corpus. */
78
+ selected: string | null;
79
+ /** A full path the server returned, or `null` for "everything". */
80
+ onSelect: (folder: string | null) => void;
81
+ /**
82
+ * The label on the root row. It is not a folder — selecting it clears the
83
+ * path filter entirely, which is the only thing that keeps records with NO
84
+ * path (a meeting note, an email) reachable. The facet read skips them by
85
+ * construction (`Q(_v__isnull=False)`), so if the root row narrowed anything
86
+ * the majority of today's corpus would be unreachable from this screen.
87
+ */
88
+ rootLabel?: string;
89
+ /** Folders per request. The server pages; this is the page size it is asked for. */
90
+ pageSize?: number;
91
+ /**
92
+ * Told once when the corpus cannot enumerate folders at all — a fork that
93
+ * predates the facet route 404s, an older one 400s. The Library uses it to
94
+ * stop reserving space for a tree that will never draw, and falls back to the
95
+ * flat list it rendered before. Never a half-drawn tree.
96
+ */
97
+ onUnavailable?: () => void;
98
+ classNames?: Partial<Record<FolderTreeSlot, string>>;
99
+ }
100
+
101
+ /** What is known about ONE container's children. Keyed by that container's path. */
102
+ interface LevelState {
103
+ rows: CorpusFolder[];
104
+ /** The server's total for this container, so "is there more" is not guessed. */
105
+ total: number;
106
+ /** Highest page fetched for THIS container. */
107
+ page: number;
108
+ loading: boolean;
109
+ error: string;
110
+ }
111
+
112
+ const EMPTY: LevelState = { rows: [], total: 0, page: 0, loading: false, error: '' };
113
+
114
+ function failure(err: unknown): string {
115
+ const status = (err as { status?: number })?.status;
116
+ if (status === 401) return 'Your session has expired. Sign in again.';
117
+ if (status === 403) return 'You do not have access to these folders.';
118
+ return (err as { message?: string })?.message || 'Could not load folders.';
119
+ }
120
+
121
+ /**
122
+ * A 404/400 means "this corpus has no folder enumeration", not "no folders".
123
+ *
124
+ * BOTH CODES, because both are real deployments. A fork built before
125
+ * start-simpli-api#391 has no `/entities/facets/` route at all and answers 404;
126
+ * one built before the parameters were in its vocabulary screens `attr`/`under`
127
+ * as unknown and answers 400. To a reader they mean the same thing, and the
128
+ * honest response to both is the list that was there before the tree existed.
129
+ */
130
+ function isUnsupported(err: unknown): boolean {
131
+ const status = (err as { status?: number })?.status;
132
+ return status === 404 || status === 400 || status === 501;
133
+ }
134
+
135
+ export function FolderTree({
136
+ api,
137
+ selected,
138
+ onSelect,
139
+ rootLabel = 'All documents',
140
+ pageSize = 100,
141
+ onUnavailable,
142
+ classNames = {},
143
+ }: FolderTreeProps) {
144
+ const cn = { ...DEFAULTS, ...classNames };
145
+
146
+ /**
147
+ * container path -> its children. A map keyed by the request that produced
148
+ * the rows, never a flattened list of every folder seen: keeping them under
149
+ * their own key is what makes it structurally impossible to draw a node out
150
+ * of a response that was not about it.
151
+ */
152
+ const [levels, setLevels] = React.useState<Record<string, LevelState>>({});
153
+ const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
154
+ const [unavailable, setUnavailable] = React.useState(false);
155
+ /** Containers already asked for, so a re-render or a re-open costs nothing. */
156
+ const requested = React.useRef<Set<string>>(new Set());
157
+ const told = React.useRef(false);
158
+
159
+ // A new api object means a new principal or a new workspace. Everything
160
+ // already fetched was gated for the OLD one, so it is dropped rather than
161
+ // reused — a stale folder list is exactly the leak this file is careful about.
162
+ React.useEffect(() => {
163
+ requested.current = new Set();
164
+ told.current = false;
165
+ setLevels({});
166
+ setExpanded({});
167
+ setUnavailable(false);
168
+ }, [api]);
169
+
170
+ const load = React.useCallback(async (under: string, page: number) => {
171
+ if (!api.listFolders) {
172
+ setUnavailable(true);
173
+ return;
174
+ }
175
+ setLevels((prev) => ({
176
+ ...prev,
177
+ [under]: { ...(prev[under] ?? EMPTY), loading: true, error: '' },
178
+ }));
179
+ try {
180
+ const result = await api.listFolders({ under, page, pageSize });
181
+ setLevels((prev) => {
182
+ const before = prev[under] ?? EMPTY;
183
+ return {
184
+ ...prev,
185
+ [under]: {
186
+ // APPEND, and only ever within this same `under=`. Page 2 of a
187
+ // container is more of the answer the server already gave about
188
+ // that container; it is not evidence about any other one.
189
+ rows: page > 1 ? [...before.rows, ...result.results] : result.results,
190
+ total: result.count,
191
+ page,
192
+ loading: false,
193
+ error: '',
194
+ },
195
+ };
196
+ });
197
+ } catch (err) {
198
+ if (isUnsupported(err)) {
199
+ setUnavailable(true);
200
+ return;
201
+ }
202
+ setLevels((prev) => ({
203
+ ...prev,
204
+ [under]: { ...(prev[under] ?? EMPTY), loading: false, error: failure(err) },
205
+ }));
206
+ }
207
+ }, [api, pageSize]);
208
+
209
+ /**
210
+ * Fetch a container's first page, once. The ref rather than a check on
211
+ * `levels` because the guard has to hold BETWEEN renders — two clicks in the
212
+ * same tick, or a StrictMode double-invoke, would otherwise both see an empty
213
+ * map and both fetch.
214
+ */
215
+ const ensure = React.useCallback((under: string) => {
216
+ if (requested.current.has(under)) return;
217
+ requested.current.add(under);
218
+ void load(under, 1);
219
+ }, [load]);
220
+
221
+ React.useEffect(() => {
222
+ if (!unavailable) ensure(FOLDER_ROOT);
223
+ }, [ensure, unavailable]);
224
+
225
+ React.useEffect(() => {
226
+ if (unavailable && !told.current) {
227
+ told.current = true;
228
+ onUnavailable?.();
229
+ }
230
+ }, [unavailable, onUnavailable]);
231
+
232
+ const toggle = React.useCallback((path: string) => {
233
+ setExpanded((prev) => ({ ...prev, [path]: !prev[path] }));
234
+ // Idempotent, so calling it on a COLLAPSE is free — and calling it here
235
+ // rather than inside the updater above keeps the updater pure.
236
+ ensure(path);
237
+ }, [ensure]);
238
+
239
+ if (unavailable) return null;
240
+
241
+ const root = levels[FOLDER_ROOT] ?? EMPTY;
242
+
243
+ return (
244
+ <nav className={cn.root} aria-label="Folders">
245
+ <div className="flex items-center">
246
+ <span aria-hidden="true" className="w-4 shrink-0" />
247
+ <button
248
+ type="button"
249
+ className={`${cn.node} ${selected === null ? cn.nodeSelected : ''}`}
250
+ aria-current={selected === null ? 'true' : undefined}
251
+ onClick={() => onSelect(null)}
252
+ >
253
+ <span className="truncate">{rootLabel}</span>
254
+ </button>
255
+ </div>
256
+
257
+ {root.loading && root.rows.length === 0 ? (
258
+ <p className={cn.muted}>Loading folders…</p>
259
+ ) : null}
260
+ {root.error ? <p className={cn.error} role="alert">{root.error}</p> : null}
261
+
262
+ <FolderLevel
263
+ under={FOLDER_ROOT}
264
+ depth={0}
265
+ levels={levels}
266
+ expanded={expanded}
267
+ selected={selected}
268
+ onSelect={onSelect}
269
+ onToggle={toggle}
270
+ onMore={load}
271
+ classNames={cn}
272
+ />
273
+ </nav>
274
+ );
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /**
280
+ * The children of ONE container, and recursively the expanded ones among them.
281
+ *
282
+ * It reads `levels[under]` and nothing else — the whole map travels down only so
283
+ * recursion needs no context, and each frame touches exactly its own key. There
284
+ * is no path arithmetic here at all: `folder.path` is what the server said, and
285
+ * it is what goes to the next frame.
286
+ */
287
+ function FolderLevel({
288
+ under, depth, levels, expanded, selected, onSelect, onToggle, onMore, classNames: cn,
289
+ }: {
290
+ under: string;
291
+ depth: number;
292
+ levels: Record<string, LevelState>;
293
+ expanded: Record<string, boolean>;
294
+ selected: string | null;
295
+ onSelect: (folder: string) => void;
296
+ onToggle: (path: string) => void;
297
+ onMore: (under: string, page: number) => void;
298
+ classNames: Record<FolderTreeSlot, string>;
299
+ }) {
300
+ const level = levels[under];
301
+ if (!level) return null;
302
+ const remaining = level.total - level.rows.length;
303
+
304
+ return (
305
+ <ul className="space-y-0.5">
306
+ {level.rows.map((folder) => {
307
+ const open = Boolean(expanded[folder.path]);
308
+ const child = levels[folder.path];
309
+ return (
310
+ <li key={folder.path}>
311
+ <div className="flex items-center">
312
+ {/* Indent by DEPTH, not by counting separators in the path — a
313
+ stored path with a doubled slash would indent wrongly, and the
314
+ depth is a fact this render already knows. */}
315
+ <span aria-hidden="true" style={{ width: depth * 14 }} className="shrink-0" />
316
+ {folder.hasChildren ? (
317
+ <button
318
+ type="button"
319
+ className="w-4 shrink-0 rounded text-xs text-gray-400 hover:text-gray-700"
320
+ aria-expanded={open}
321
+ aria-label={`${open ? 'Collapse' : 'Expand'} ${folder.name}`}
322
+ onClick={() => onToggle(folder.path)}
323
+ >
324
+ {open ? '▾' : '▸'}
325
+ </button>
326
+ ) : (
327
+ // A leaf keeps the triangle's width so names stay in one column.
328
+ <span aria-hidden="true" className="w-4 shrink-0" />
329
+ )}
330
+ <button
331
+ type="button"
332
+ className={`${cn.node} ${selected === folder.path ? cn.nodeSelected : ''}`}
333
+ aria-current={selected === folder.path ? 'true' : undefined}
334
+ // SPELLED OUT rather than left to the DOM. Two adjacent spans
335
+ // concatenate with no separator, so the computed name of this
336
+ // control was "Contracts3" — which is what a screen reader would
337
+ // have said. The `title` is the FULL path, because the visible
338
+ // label is only the last segment and a truncated one at that.
339
+ //
340
+ // "INSIDE", not "records": the number is DESCENDANTS, and a
341
+ // reader who has narrowed the list to the shelf would otherwise
342
+ // hear "Decks, 8 records" beside a list of none. The word has to
343
+ // be true of the count whatever the list is currently bounded to.
344
+ aria-label={`${folder.name}, ${folder.count} document${folder.count === 1 ? '' : 's'} inside`}
345
+ title={folder.path}
346
+ onClick={() => onSelect(folder.path)}
347
+ >
348
+ <span className="truncate">{folder.name}</span>
349
+ {/* DESCENDANTS, not direct children — which is why a folder can
350
+ say 12 and open onto no files of its own. */}
351
+ <span className={cn.count} aria-hidden="true">{folder.count}</span>
352
+ </button>
353
+ </div>
354
+
355
+ {open ? (
356
+ <>
357
+ {child?.loading && child.rows.length === 0 ? (
358
+ <p className={cn.muted} style={{ paddingLeft: (depth + 1) * 14 + 16 }}>Loading…</p>
359
+ ) : null}
360
+ {child?.error ? (
361
+ <p className={cn.error} role="alert" style={{ paddingLeft: (depth + 1) * 14 + 16 }}>
362
+ {child.error}
363
+ </p>
364
+ ) : null}
365
+ <FolderLevel
366
+ under={folder.path}
367
+ depth={depth + 1}
368
+ levels={levels}
369
+ expanded={expanded}
370
+ selected={selected}
371
+ onSelect={onSelect}
372
+ onToggle={onToggle}
373
+ onMore={onMore}
374
+ classNames={cn}
375
+ />
376
+ </>
377
+ ) : null}
378
+ </li>
379
+ );
380
+ })}
381
+
382
+ {/* MORE FOLDERS AT THIS LEVEL. The server's own `count` for this container
383
+ against what has been drawn, so a directory with 900 subfolders costs
384
+ one page until somebody asks for the next — rule 8 applies to folders
385
+ exactly as it applies to rows. */}
386
+ {remaining > 0 ? (
387
+ <li>
388
+ <button
389
+ type="button"
390
+ className={cn.more}
391
+ disabled={level.loading}
392
+ style={{ marginLeft: depth * 14 + 16 }}
393
+ onClick={() => onMore(under, level.page + 1)}
394
+ >
395
+ {level.loading ? 'Loading…' : `Show ${remaining} more folder${remaining === 1 ? '' : 's'}`}
396
+ </button>
397
+ </li>
398
+ ) : null}
399
+ </ul>
400
+ );
401
+ }
402
+
403
+ /**
404
+ * The ancestors of a path, for a breadcrumb — `/A/B/C` -> `/A`, `/A/B`, `/A/B/C`.
405
+ *
406
+ * SPLITTING IS SAFE HERE AND THE MODULE HEADER'S RULE STILL HOLDS, because this
407
+ * is not the act that rule forbids. It takes apart a path the server ALREADY
408
+ * RETURNED TO THIS READER, purely to label controls; clicking a crumb then
409
+ * issues its own gated request for that container and renders whatever comes
410
+ * back. It puts no folder on screen that was not already on screen, fills no
411
+ * level from another level's response, and invents no node. The forbidden act is
412
+ * SYNTHESISING a node — deciding a folder exists, or what is in it, from data
413
+ * the server did not return about it — and a breadcrumb over the current
414
+ * selection does none of that.
415
+ */
416
+ export function folderTrail(path: string): Array<{ path: string; name: string }> {
417
+ const segments = String(path ?? '').split('/').filter(Boolean);
418
+ const trail: Array<{ path: string; name: string }> = [];
419
+ let at = '';
420
+ for (const segment of segments) {
421
+ at = `${at}/${segment}`;
422
+ trail.push({ path: at, name: folderName(at) });
423
+ }
424
+ return trail;
425
+ }
@@ -34,6 +34,12 @@ export type {
34
34
  // The corpus layer (bd startsim-768w.38.6, startsim-768w.38.7): the READ side of
35
35
  // connections. Same rule as above — the page composers ship, not just the parts.
36
36
  export * from './pages/LibraryPage';
37
+ // The navigable folder tree the Library stands on (bd startsim-ei8gm.5). Shipped
38
+ // SEPARATELY as well as inside the composer, because "browse this corpus by
39
+ // where things live" is a question every file-shaped source raises and the next
40
+ // surface to ask it should not fork the Library to get an answer.
41
+ export { FolderTree, folderTrail } from './FolderTree';
42
+ export type { FolderSource, FolderTreeProps, FolderTreeSlot } from './FolderTree';
37
43
  export * from './pages/AskPage';
38
44
  export * from './pages/TrackerPage';
39
45