@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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
183
|
+
await screen.findByText(/page 2 of \d+/);
|
|
184
184
|
|
|
185
185
|
await user.selectOptions(screen.getByLabelText('Source'), 'dropbox');
|
|
186
186
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CARD THAT COUNTS TWENTY FAILURES AND NAMES NONE (bd startsim-ei8gm.2).
|
|
3
|
+
*
|
|
4
|
+
* THE SIXTEEN DAYS. The Tandem Library's Granola card read *"Failing — 20 syncs
|
|
5
|
+
* in a row failed, synced 16d ago"* from 2026-08-12 to 2026-08-27 and nothing a
|
|
6
|
+
* human could open said why. It was our own 256 KiB egress cap: the per-note
|
|
7
|
+
* detail response peaked at 351 KB. Diagnosable in minutes by anyone who could
|
|
8
|
+
* read the error text; sixteen days for someone who could not.
|
|
9
|
+
*
|
|
10
|
+
* `#375` fixed the SERVER half. `SubtreeConnectionsView` now derives the newest
|
|
11
|
+
* failed run's reason and sends it as `last_error`, and `surface.recent_runs`
|
|
12
|
+
* answers a real `error_message` for source runs instead of the `""` that a
|
|
13
|
+
* `getattr` default had returned for every source run ever recorded. Both
|
|
14
|
+
* facts are on prod. Nothing renders either of them, which is this bead.
|
|
15
|
+
*
|
|
16
|
+
* WHY `check_error` COULD NEVER HAVE ANSWERED IT, which is what makes this a
|
|
17
|
+
* missing field rather than a missing render. `listSources` composes its
|
|
18
|
+
* `detail` from `check_error` alone, and `check_error` is empty BY
|
|
19
|
+
* CONSTRUCTION for this class of failure: `check()` and `poll()` are different
|
|
20
|
+
* requests. Granola's check asks for `page_size=1` and passes; its poll asks
|
|
21
|
+
* for thirty notes with transcripts and aborts. The credential was fine the
|
|
22
|
+
* whole time, which is exactly why a reader re-entered a key that was never the
|
|
23
|
+
* problem.
|
|
24
|
+
*
|
|
25
|
+
* BOTH SPELLINGS, and not as a courtesy. `@startsimpli/api` camelCases response
|
|
26
|
+
* keys in the browser while a plain server-side fetch does not — the trap the
|
|
27
|
+
* three timestamps in this adapter already carry a comment about, and one that
|
|
28
|
+
* is invisible until somebody's Library quietly lies.
|
|
29
|
+
*/
|
|
30
|
+
import { describe, expect, it } from 'vitest';
|
|
31
|
+
import { render, screen } from '@testing-library/react';
|
|
32
|
+
|
|
33
|
+
import { LibraryPage } from '../components/pages/LibraryPage';
|
|
34
|
+
import { createCorpusApi } from '../corpus/client';
|
|
35
|
+
import type { CorpusHttp } from '../corpus/client';
|
|
36
|
+
import { FIXTURE_ORG } from '../corpus/fixtures';
|
|
37
|
+
|
|
38
|
+
const SCOPE = { orgId: FIXTURE_ORG };
|
|
39
|
+
const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
|
|
40
|
+
|
|
41
|
+
const NOW = Date.now();
|
|
42
|
+
const ago = (ms: number) => new Date(NOW - ms).toISOString();
|
|
43
|
+
const SIXTEEN_DAYS_AGO = ago(16 * 24 * 60 * 60_000);
|
|
44
|
+
const TEN_MINUTES_AGO = ago(10 * 60_000);
|
|
45
|
+
|
|
46
|
+
/** What production held, verbatim: the connector taxonomy's own code and text. */
|
|
47
|
+
const THE_REAL_REASON =
|
|
48
|
+
'connector_granola poll failed on page 1: [config_error] the response '
|
|
49
|
+
+ 'exceeded the 262144-byte cap and was aborted [granola]';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The WIRE, not the contract — rows go in exactly as central serialises them,
|
|
53
|
+
* the same convention `corpus-source-strip.test.tsx` keeps and for the same
|
|
54
|
+
* reason: a fixture must never be more generous than the server that feeds it.
|
|
55
|
+
*/
|
|
56
|
+
function httpOver(rows: Array<Record<string, unknown>>): CorpusHttp {
|
|
57
|
+
return {
|
|
58
|
+
async get<T>(path: string): Promise<T> {
|
|
59
|
+
if (path.includes('connections')) {
|
|
60
|
+
return { count: rows.length, next: null, previous: null, results: rows } as T;
|
|
61
|
+
}
|
|
62
|
+
return EMPTY_PAGE as T;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sourcesFrom = (rows: Array<Record<string, unknown>>) =>
|
|
68
|
+
createCorpusApi({ http: httpOver(rows), scope: SCOPE }).listSources();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* THE LIVE ROW, as `/api/v1/subtree/connections/` served it on 2026-08-27.
|
|
72
|
+
* `check_error` empty, `status` connected, twenty failures counted, and — since
|
|
73
|
+
* #375 — a reason.
|
|
74
|
+
*/
|
|
75
|
+
const TANDEM_GRANOLA = {
|
|
76
|
+
id: 'shared-granola',
|
|
77
|
+
connector_key: 'granola',
|
|
78
|
+
connector_name: 'Granola',
|
|
79
|
+
status: 'connected',
|
|
80
|
+
check_error: '',
|
|
81
|
+
last_synced_at: SIXTEEN_DAYS_AGO,
|
|
82
|
+
last_attempt_at: TEN_MINUTES_AGO,
|
|
83
|
+
last_checked_at: TEN_MINUTES_AGO,
|
|
84
|
+
consecutive_failures: 20,
|
|
85
|
+
last_error: THE_REAL_REASON,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
describe('listSources — the strip carries the reason central sends', () => {
|
|
89
|
+
it('reads last_error onto the card', async () => {
|
|
90
|
+
const [granola] = await sourcesFrom([TANDEM_GRANOLA]);
|
|
91
|
+
|
|
92
|
+
expect(granola.lastError).toBe(THE_REAL_REASON);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('reads it in the camelCase spelling the browser sees', async () => {
|
|
96
|
+
const [granola] = await sourcesFrom([
|
|
97
|
+
{ ...TANDEM_GRANOLA, last_error: undefined, lastError: THE_REAL_REASON },
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
expect(granola.lastError).toBe(THE_REAL_REASON);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('says WHY rather than only how many times', async () => {
|
|
104
|
+
const [granola] = await sourcesFrom([TANDEM_GRANOLA]);
|
|
105
|
+
|
|
106
|
+
expect(granola.status).toBe('error');
|
|
107
|
+
// The count survives — "one blip" and "sixteen days" are different
|
|
108
|
+
// problems — but it is no longer the whole sentence.
|
|
109
|
+
expect(granola.detail).toMatch(/20 syncs in a row failed/);
|
|
110
|
+
expect(granola.detail).toContain('262144-byte cap');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('still counts alone when the server sent no reason', async () => {
|
|
114
|
+
const [granola] = await sourcesFrom([{ ...TANDEM_GRANOLA, last_error: '' }]);
|
|
115
|
+
|
|
116
|
+
expect(granola.status).toBe('error');
|
|
117
|
+
expect(granola.detail).toMatch(/20 syncs in a row failed/);
|
|
118
|
+
expect(granola.lastError).toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('calls a source failing when its newest run failed, even at a zero streak', async () => {
|
|
122
|
+
// `consecutive_failures` and `last_error` have different writers: the
|
|
123
|
+
// counter lives on the connection and the reason is derived from the newest
|
|
124
|
+
// RUN. Central sends `last_error` only while that newest run is the failed
|
|
125
|
+
// one, so a reason with no streak behind it is still a failing source —
|
|
126
|
+
// and the old branch, which turned only on the counter, called it `stale`.
|
|
127
|
+
const [granola] = await sourcesFrom([
|
|
128
|
+
{ ...TANDEM_GRANOLA, consecutive_failures: 0 },
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
expect(granola.status).toBe('error');
|
|
132
|
+
expect(granola.detail).toContain('262144-byte cap');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('leaves the credential’s own verdict in charge when there is one', async () => {
|
|
136
|
+
// Two different facts and never substitutes (bd startsim-1cow). A rejected
|
|
137
|
+
// key is what the reader must act on; the run reason is the sentence for
|
|
138
|
+
// when the key is FINE and the data still is not arriving.
|
|
139
|
+
const [granola] = await sourcesFrom([
|
|
140
|
+
{
|
|
141
|
+
...TANDEM_GRANOLA,
|
|
142
|
+
status: 'needs_reauth',
|
|
143
|
+
check_error: 'The vendor rejected this key (401). Reconnect Granola.',
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
expect(granola.detail).toBe('The vendor rejected this key (401). Reconnect Granola.');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('never reports a reason on a source that is fine', async () => {
|
|
151
|
+
const [granola] = await sourcesFrom([
|
|
152
|
+
{
|
|
153
|
+
...TANDEM_GRANOLA,
|
|
154
|
+
last_synced_at: ago(60_000),
|
|
155
|
+
consecutive_failures: 0,
|
|
156
|
+
last_error: '',
|
|
157
|
+
},
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
expect(granola.status).toBe('ok');
|
|
161
|
+
expect(granola.detail).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('the Library strip, rendered', () => {
|
|
166
|
+
it('prints the reason next to the corpus it was supposed to fill', async () => {
|
|
167
|
+
// The bug was VISIBLE, so the assertion is made where the reader would have
|
|
168
|
+
// seen it — not on the adapter's return value alone.
|
|
169
|
+
render(
|
|
170
|
+
<LibraryPage
|
|
171
|
+
api={createCorpusApi({ http: httpOver([TANDEM_GRANOLA]), scope: SCOPE })}
|
|
172
|
+
scope={SCOPE}
|
|
173
|
+
/>,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(await screen.findByText(/262144-byte cap/)).toBeInTheDocument();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
|