@startsimpli/ui 0.4.109 → 0.4.111

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.109",
3
+ "version": "0.4.111",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -21,6 +21,8 @@
21
21
  "./blog/resolve": "./src/components/blog/resolveArticle.ts",
22
22
  "./activity": "./src/components/activity/index.ts",
23
23
  "./collection": "./src/components/collection/index.ts",
24
+ "./scope": "./src/components/scope/index.ts",
25
+ "./facet-tree": "./src/components/facet-tree/index.ts",
24
26
  "./document-editor": "./src/components/document-editor/index.ts",
25
27
  "./email-editor": "./src/components/email-editor/index.ts",
26
28
  "./workflows": "./src/components/workflows/index.ts",
@@ -121,8 +123,8 @@
121
123
  "tailwindcss": "^3.4.19",
122
124
  "tsup": "^8.5.1",
123
125
  "typescript": "^6.0.3",
124
- "@startsimpli/i18n": "0.1.3",
125
- "@startsimpli/realtime": "0.0.2"
126
+ "@startsimpli/realtime": "0.0.2",
127
+ "@startsimpli/i18n": "0.1.3"
126
128
  },
127
129
  "keywords": [
128
130
  "ui",
@@ -223,3 +223,64 @@ describe('reviewHostOf / fieldStr', () => {
223
223
  expect(fieldStr(undefined, 'x')).toBe('');
224
224
  });
225
225
  });
226
+
227
+ // ---- decision vocabulary (bd startsim-b313v) ----
228
+ //
229
+ // THE DEFECT THESE COVER. The three labels were string literals inside
230
+ // `reviewDecisions`, so every tenant's every reviewable type said the same three
231
+ // words. On the OGMC tenant that collided in the customer's hands: approving a
232
+ // TOPIC ("this subject is worth writing") and approving a DRAFT ("this piece is
233
+ // publishable") are different KINDS of decision, and both rendered "Approve".
234
+ // Her question, mid-walkthrough: "so if I go, I click approve now, does it
235
+ // approve all of them?"
236
+
237
+ describe('decision labels are the consumer’s to name', () => {
238
+ const type = {
239
+ id: 't',
240
+ key: 'topic',
241
+ label: 'Topic',
242
+ attributes: [
243
+ {
244
+ id: 'a',
245
+ name: 'status',
246
+ dataType: 'enum' as const,
247
+ required: false,
248
+ config: { choices: ['suggested', 'ready', 'written', 'rejected'] },
249
+ },
250
+ ],
251
+ };
252
+
253
+ it('keeps the generic words when a consumer supplies none', () => {
254
+ expect(reviewDecisions(resolveReviewConfig(type)).map((d) => d.label)).toEqual([
255
+ 'Approve',
256
+ 'Needs work',
257
+ 'Reject',
258
+ ]);
259
+ });
260
+
261
+ it('lets a consumer name what the decision is ABOUT', () => {
262
+ const cfg = resolveReviewConfig(type, {
263
+ decisionLabels: { approve: 'Approve topic', reject: 'Reject topic' },
264
+ });
265
+ const byKey = Object.fromEntries(reviewDecisions(cfg).map((d) => [d.key, d.label]));
266
+ expect(byKey.approve).toBe('Approve topic');
267
+ expect(byKey.reject).toBe('Reject topic');
268
+ // Unnamed decisions keep the default rather than going blank.
269
+ expect(byKey.needs_work).toBe('Needs work');
270
+ });
271
+
272
+ it('ignores a blank override instead of rendering an unlabelled button', () => {
273
+ const cfg = resolveReviewConfig(type, { decisionLabels: { approve: ' ' } });
274
+ expect(reviewDecisions(cfg).find((d) => d.key === 'approve')?.label).toBe('Approve');
275
+ });
276
+
277
+ it('leaves every other part of the decision alone — this is copy, not behaviour', () => {
278
+ const plain = reviewDecisions(resolveReviewConfig(type));
279
+ const named = reviewDecisions(
280
+ resolveReviewConfig(type, { decisionLabels: { approve: 'Approve topic' } }),
281
+ );
282
+ expect(named.map((d) => [d.key, d.status, d.verdict, d.tone, d.advance, d.shortcut])).toEqual(
283
+ plain.map((d) => [d.key, d.status, d.verdict, d.tone, d.advance, d.shortcut]),
284
+ );
285
+ });
286
+ });
@@ -13,6 +13,8 @@ export {
13
13
  resolveReviewConfig,
14
14
  deriveReviewTransitions,
15
15
  reviewDecisions,
16
+ decisionLabel,
17
+ DEFAULT_DECISION_LABELS,
16
18
  applyDecisionToData,
17
19
  type ReviewDecision,
18
20
  pickStatusAttr,
@@ -169,6 +169,23 @@ export interface ReviewConfig {
169
169
  sourceAttrs?: string[];
170
170
  /** Drop the middle "Needs work" decision — a binary accept/reject flow (default false). */
171
171
  omitNeedsWork?: boolean;
172
+ /**
173
+ * What each decision is CALLED, by decision key ('approve' | 'needs_work' |
174
+ * 'reject'). Anything omitted keeps the generic default.
175
+ *
176
+ * WHY THIS EXISTS (bd startsim-b313v). The labels used to be string literals
177
+ * in `reviewDecisions` below, so every tenant's every reviewable type rendered
178
+ * the same three words. On OGMC that collided in the customer's hands: a
179
+ * reviewer approving a TOPIC ("this subject is worth writing") and a reviewer
180
+ * approving a DRAFT ("this piece is publishable") both pressed a button
181
+ * labelled "Approve", and she asked, mid-walkthrough, "so if I go, I click
182
+ * approve now, does it approve all of them?"
183
+ *
184
+ * A workspace cannot answer that from here — only the consumer knows what its
185
+ * records ARE — so the vocabulary is the consumer's to supply. Defaults are
186
+ * unchanged, so nothing that does not pass this renders differently.
187
+ */
188
+ decisionLabels?: Record<string, string>;
172
189
  }
173
190
 
174
191
  export interface ResolvedReview {
@@ -185,6 +202,7 @@ export interface ResolvedReview {
185
202
  metaAttrs: string[];
186
203
  sourceAttrs: string[];
187
204
  omitNeedsWork: boolean;
205
+ decisionLabels: Record<string, string>;
188
206
  }
189
207
 
190
208
  const DEFAULT_SOURCE_ATTRS = ['source_1', 'source_2', 'source_3', 'sources'];
@@ -216,9 +234,23 @@ export function resolveReviewConfig(
216
234
  // Keep source attrs the caller asked for; default to the ones this type declares.
217
235
  sourceAttrs: (config.sourceAttrs ?? DEFAULT_SOURCE_ATTRS).filter((a) => declared.has(a)),
218
236
  omitNeedsWork: config.omitNeedsWork ?? false,
237
+ decisionLabels: config.decisionLabels ?? {},
219
238
  };
220
239
  }
221
240
 
241
+ /** The default name of a decision, when the consumer supplies none. */
242
+ export const DEFAULT_DECISION_LABELS: Record<string, string> = {
243
+ approve: 'Approve',
244
+ needs_work: 'Needs work',
245
+ reject: 'Reject',
246
+ };
247
+
248
+ /** What a decision is called on THIS surface — the consumer's word, else the default. */
249
+ export function decisionLabel(key: string, cfg: Pick<ResolvedReview, 'decisionLabels'>): string {
250
+ const supplied = cfg.decisionLabels?.[key];
251
+ return supplied && supplied.trim() ? supplied : DEFAULT_DECISION_LABELS[key] ?? key;
252
+ }
253
+
222
254
  // ---- the single review decision (Approve / Needs work / Reject) ----
223
255
  //
224
256
  // status and team_verdict are two fields with different consumers (status = the
@@ -262,7 +294,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
262
294
  if (r.transitions.approve) {
263
295
  out.push({
264
296
  key: 'approve',
265
- label: 'Approve',
297
+ label: decisionLabel('approve', r),
266
298
  status: r.transitions.approve,
267
299
  verdict: verdictOf('good'),
268
300
  tone: 'good',
@@ -276,7 +308,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
276
308
  if (!r.omitNeedsWork && entry && (editVerdict || entry !== r.transitions.approve)) {
277
309
  out.push({
278
310
  key: 'needs_work',
279
- label: 'Needs work',
311
+ label: decisionLabel('needs_work', r),
280
312
  status: entry,
281
313
  verdict: editVerdict,
282
314
  tone: 'neutral',
@@ -289,7 +321,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
289
321
  if (r.transitions.reject) {
290
322
  out.push({
291
323
  key: 'reject',
292
- label: 'Reject',
324
+ label: decisionLabel('reject', r),
293
325
  status: r.transitions.reject,
294
326
  verdict: verdictOf('bad'),
295
327
  tone: 'bad',
@@ -0,0 +1,250 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * THE TREE, drawn from a level cache and nothing else.
5
+ *
6
+ * Purely presentational: every row it renders comes out of `levels[under]` for
7
+ * the container it is currently rendering, so the security property described in
8
+ * `useFacetLevels` survives the render. There is no path arithmetic here at all
9
+ * — `node.path` is what the server said and it is what goes to the next frame.
10
+ *
11
+ * A11Y, AND WHAT IT DELIBERATELY DOES NOT CLAIM. This is a `<ul>` of disclosure
12
+ * buttons, not a `role="tree"`. The full WAI-ARIA tree pattern owes a reader
13
+ * roving-tabindex focus management and arrow-key traversal; claiming the roles
14
+ * without them tells a screen reader to expect navigation that is not there,
15
+ * which is worse than the plain list this actually is. Tab reaches every control
16
+ * natively, `aria-expanded` states the disclosure, `aria-current` states the
17
+ * selection, and each row's name is spelled out rather than left to the DOM.
18
+ */
19
+
20
+ import * as React from 'react'
21
+ import { FACET_ROOT } from './path'
22
+ import { EMPTY_LEVEL } from './types'
23
+ import type { FacetLevelState } from './types'
24
+
25
+ export type FacetTreeSlot = 'root' | 'node' | 'nodeSelected' | 'count' | 'muted' | 'error' | 'more'
26
+
27
+ const DEFAULTS: Record<FacetTreeSlot, string> = {
28
+ root: 'space-y-0.5',
29
+ node: 'flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-2 py-1 text-left text-sm hover:bg-accent hover:text-accent-foreground',
30
+ nodeSelected: 'bg-accent font-medium text-accent-foreground',
31
+ count: 'ml-auto shrink-0 text-xs tabular-nums opacity-60',
32
+ muted: 'px-2 py-1 text-xs text-muted-foreground',
33
+ error: 'px-2 py-1 text-xs text-destructive',
34
+ more: 'px-2 py-1 text-xs font-medium underline underline-offset-2 opacity-80 hover:opacity-100 disabled:no-underline disabled:opacity-50',
35
+ }
36
+
37
+ export interface FacetTreeProps {
38
+ levels: Record<string, FacetLevelState>
39
+ expanded: Record<string, boolean>
40
+ /** The full path currently in scope, or `null` for "no bound at all". */
41
+ selected: string | null
42
+ /** Handed a full path the server returned, verbatim. */
43
+ onSelect: (path: string) => void
44
+ onToggle: (path: string) => void
45
+ onMore: (under: string, page: number) => void
46
+ /** The top of the tree. `/` means everything. */
47
+ root?: string
48
+ /**
49
+ * The noun in a row's accessible name — "record", "document", "topic".
50
+ *
51
+ * "INSIDE" is appended rather than "matching", because the number is
52
+ * DESCENDANTS: a reader who has narrowed the list would otherwise hear
53
+ * "Acme, 8 records" beside a list of none. The word has to stay true whatever
54
+ * the list is currently bounded to.
55
+ */
56
+ countNoun?: string
57
+ /** The noun in "Show N more …" — "folder", "scope", "category". */
58
+ nodeNoun?: string
59
+ /** Pixels of indent per level. */
60
+ indent?: number
61
+ classNames?: Partial<Record<FacetTreeSlot, string>>
62
+ }
63
+
64
+ export function FacetTree({
65
+ levels,
66
+ expanded,
67
+ selected,
68
+ onSelect,
69
+ onToggle,
70
+ onMore,
71
+ root = FACET_ROOT,
72
+ countNoun = 'record',
73
+ nodeNoun = 'item',
74
+ indent = 14,
75
+ classNames = {},
76
+ }: FacetTreeProps) {
77
+ const cn = { ...DEFAULTS, ...classNames }
78
+ const top = levels[root] ?? EMPTY_LEVEL
79
+
80
+ return (
81
+ <div className={cn.root}>
82
+ {top.loading && top.rows.length === 0 ? <p className={cn.muted}>Loading…</p> : null}
83
+ {top.error ? (
84
+ <p className={cn.error} role="alert">
85
+ {top.error}
86
+ </p>
87
+ ) : null}
88
+ <FacetLevel
89
+ under={root}
90
+ depth={0}
91
+ levels={levels}
92
+ expanded={expanded}
93
+ selected={selected}
94
+ onSelect={onSelect}
95
+ onToggle={onToggle}
96
+ onMore={onMore}
97
+ countNoun={countNoun}
98
+ nodeNoun={nodeNoun}
99
+ indent={indent}
100
+ classNames={cn}
101
+ />
102
+ </div>
103
+ )
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * The children of ONE container, and recursively the expanded ones among them.
110
+ *
111
+ * It reads `levels[under]` and nothing else — the whole map travels down only so
112
+ * that recursion needs no context, and each frame touches exactly its own key.
113
+ */
114
+ function FacetLevel({
115
+ under,
116
+ depth,
117
+ levels,
118
+ expanded,
119
+ selected,
120
+ onSelect,
121
+ onToggle,
122
+ onMore,
123
+ countNoun,
124
+ nodeNoun,
125
+ indent,
126
+ classNames: cn,
127
+ }: {
128
+ under: string
129
+ depth: number
130
+ levels: Record<string, FacetLevelState>
131
+ expanded: Record<string, boolean>
132
+ selected: string | null
133
+ onSelect: (path: string) => void
134
+ onToggle: (path: string) => void
135
+ onMore: (under: string, page: number) => void
136
+ countNoun: string
137
+ nodeNoun: string
138
+ indent: number
139
+ classNames: Record<FacetTreeSlot, string>
140
+ }) {
141
+ const level = levels[under]
142
+ if (!level) return null
143
+ const remaining = level.total - level.rows.length
144
+
145
+ return (
146
+ <ul className="space-y-0.5">
147
+ {level.rows.map((node) => {
148
+ const open = Boolean(expanded[node.path])
149
+ const child = levels[node.path]
150
+ return (
151
+ <li key={node.path}>
152
+ <div className="flex items-center">
153
+ {/* Indent by DEPTH, not by counting separators in the path — a
154
+ stored path with a doubled slash would indent wrongly, and the
155
+ depth is a fact this render already knows. */}
156
+ <span aria-hidden="true" style={{ width: depth * indent }} className="shrink-0" />
157
+ {node.hasChildren ? (
158
+ <button
159
+ type="button"
160
+ className="w-4 shrink-0 rounded text-xs opacity-50 hover:opacity-100"
161
+ aria-expanded={open}
162
+ aria-label={`${open ? 'Collapse' : 'Expand'} ${node.name}`}
163
+ onClick={() => onToggle(node.path)}
164
+ >
165
+ {open ? '▾' : '▸'}
166
+ </button>
167
+ ) : (
168
+ // A leaf keeps the triangle's width so names stay in one column.
169
+ <span aria-hidden="true" className="w-4 shrink-0" />
170
+ )}
171
+ <button
172
+ type="button"
173
+ className={`${cn.node} ${selected === node.path ? cn.nodeSelected : ''}`}
174
+ aria-current={selected === node.path ? 'true' : undefined}
175
+ // SPELLED OUT rather than left to the DOM. Two adjacent spans
176
+ // concatenate with no separator, so the computed name of this
177
+ // control would be "Acme1204" — which is what a screen reader
178
+ // would say. The `title` is the FULL path, because the visible
179
+ // label is only the last segment and a truncated one at that.
180
+ aria-label={`${node.name}, ${node.count.toLocaleString()} ${countNoun}${node.count === 1 ? '' : 's'} inside`}
181
+ title={node.path}
182
+ onClick={() => onSelect(node.path)}
183
+ >
184
+ <span className="truncate">{node.name}</span>
185
+ {/* DESCENDANTS, not direct children — which is why a node can say
186
+ 1,204 and open onto nothing of its own. */}
187
+ <span className={cn.count} aria-hidden="true">
188
+ {node.count.toLocaleString()}
189
+ </span>
190
+ </button>
191
+ </div>
192
+
193
+ {open ? (
194
+ <>
195
+ {child?.loading && child.rows.length === 0 ? (
196
+ <p className={cn.muted} style={{ paddingLeft: (depth + 1) * indent + 16 }}>
197
+ Loading…
198
+ </p>
199
+ ) : null}
200
+ {child?.error ? (
201
+ <p
202
+ className={cn.error}
203
+ role="alert"
204
+ style={{ paddingLeft: (depth + 1) * indent + 16 }}
205
+ >
206
+ {child.error}
207
+ </p>
208
+ ) : null}
209
+ <FacetLevel
210
+ under={node.path}
211
+ depth={depth + 1}
212
+ levels={levels}
213
+ expanded={expanded}
214
+ selected={selected}
215
+ onSelect={onSelect}
216
+ onToggle={onToggle}
217
+ onMore={onMore}
218
+ countNoun={countNoun}
219
+ nodeNoun={nodeNoun}
220
+ indent={indent}
221
+ classNames={cn}
222
+ />
223
+ </>
224
+ ) : null}
225
+ </li>
226
+ )
227
+ })}
228
+
229
+ {/* MORE AT THIS LEVEL. The server's own `count` for this container against
230
+ what has been drawn, so a container with 900 children costs one page
231
+ until somebody asks for the next — rule 8 applies to the tree exactly as
232
+ it applies to rows. */}
233
+ {remaining > 0 ? (
234
+ <li>
235
+ <button
236
+ type="button"
237
+ className={cn.more}
238
+ disabled={level.loading}
239
+ style={{ marginLeft: depth * indent + 16 }}
240
+ onClick={() => onMore(under, level.page + 1)}
241
+ >
242
+ {level.loading
243
+ ? 'Loading…'
244
+ : `Show ${remaining} more ${nodeNoun}${remaining === 1 ? '' : 's'}`}
245
+ </button>
246
+ </li>
247
+ ) : null}
248
+ </ul>
249
+ )
250
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The facet tree engine (bd startsim-6fceh).
3
+ *
4
+ * These pin the two properties that are not obvious from the render: it asks the
5
+ * server for ONE LEVEL at a time, and it draws each level from that level's own
6
+ * response and nothing else. The second one is the security property — a tree
7
+ * that merged responses, or kept a flat set of every node seen, would put on
8
+ * screen a node the server never returned for this reader.
9
+ */
10
+ import * as React from 'react'
11
+ import { render, screen, waitFor } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+
14
+ import { FacetTree } from '../FacetTree'
15
+ import { useFacetLevels } from '../useFacetLevels'
16
+ import { facetName, facetTrail, FACET_ROOT } from '../path'
17
+ import type { FacetLevelLoader, FacetLevelQuery, FacetNode, Paginated } from '../types'
18
+
19
+ function node(path: string, count: number, hasChildren = false): FacetNode {
20
+ return { path, name: facetName(path), count, hasChildren }
21
+ }
22
+
23
+ function page(results: FacetNode[], count = results.length): Paginated<FacetNode> {
24
+ return { count, next: null, previous: null, results }
25
+ }
26
+
27
+ /** The fixture tree. `/Clients-old` is a SIBLING of `/Clients`, not a child. */
28
+ const TREE: Record<string, Paginated<FacetNode>> = {
29
+ '/': page([
30
+ node('/Clients', 1204, true),
31
+ node('/Clients-old', 3),
32
+ node('/Internal', 12),
33
+ ]),
34
+ '/Clients': page([node('/Clients/Acme', 900, true), node('/Clients/Globex', 300)]),
35
+ '/Clients/Acme': page([node('/Clients/Acme/GCC Weekly', 41)]),
36
+ }
37
+
38
+ function loaderFor(tree = TREE): jest.MockedFunction<FacetLevelLoader> {
39
+ return jest.fn(async (query: FacetLevelQuery) => tree[query.under] ?? page([]))
40
+ }
41
+
42
+ function Harness({
43
+ load,
44
+ pageSize,
45
+ onUnavailable,
46
+ }: {
47
+ load?: FacetLevelLoader
48
+ pageSize?: number
49
+ onUnavailable?: () => void
50
+ }) {
51
+ const [selected, setSelected] = React.useState<string | null>(null)
52
+ const levels = useFacetLevels({ load, pageSize, onUnavailable })
53
+ return (
54
+ <>
55
+ <span data-testid="selected">{selected ?? 'none'}</span>
56
+ <FacetTree
57
+ levels={levels.levels}
58
+ expanded={levels.expanded}
59
+ selected={selected}
60
+ onSelect={setSelected}
61
+ onToggle={levels.toggle}
62
+ onMore={levels.loadMore}
63
+ countNoun="record"
64
+ nodeNoun="scope"
65
+ />
66
+ </>
67
+ )
68
+ }
69
+
70
+ describe('useFacetLevels — one level per request', () => {
71
+ it('asks for the root and nothing else on mount', async () => {
72
+ const load = loaderFor()
73
+ render(<Harness load={load} />)
74
+
75
+ await screen.findByRole('button', { name: /^Clients, 1,204 records inside$/ })
76
+ expect(load).toHaveBeenCalledTimes(1)
77
+ expect(load).toHaveBeenCalledWith(expect.objectContaining({ under: FACET_ROOT, page: 1 }))
78
+ })
79
+
80
+ it('draws the disclosure triangle from has_children, with no probe request', async () => {
81
+ const load = loaderFor()
82
+ render(<Harness load={load} />)
83
+
84
+ await screen.findByRole('button', { name: 'Expand Clients' })
85
+ // `/Internal` and `/Clients-old` said hasChildren:false, so they get no control.
86
+ expect(screen.queryByRole('button', { name: 'Expand Internal' })).toBeNull()
87
+ expect(screen.queryByRole('button', { name: 'Expand Clients-old' })).toBeNull()
88
+ // Still exactly the one root request — the triangles cost nothing.
89
+ expect(load).toHaveBeenCalledTimes(1)
90
+ })
91
+
92
+ it('fetches a child level only when it is expanded, and only once', async () => {
93
+ const user = userEvent.setup()
94
+ const load = loaderFor()
95
+ render(<Harness load={load} />)
96
+
97
+ await user.click(await screen.findByRole('button', { name: 'Expand Clients' }))
98
+ await screen.findByRole('button', { name: /^Acme, 900 records inside$/ })
99
+ expect(load).toHaveBeenCalledWith(expect.objectContaining({ under: '/Clients', page: 1 }))
100
+ expect(load).toHaveBeenCalledTimes(2)
101
+
102
+ // Collapse and re-expand: the level is already known.
103
+ await user.click(screen.getByRole('button', { name: 'Collapse Clients' }))
104
+ await user.click(await screen.findByRole('button', { name: 'Expand Clients' }))
105
+ await screen.findByRole('button', { name: /^Acme, 900 records inside$/ })
106
+ expect(load).toHaveBeenCalledTimes(2)
107
+ })
108
+
109
+ it('never draws a sibling under the node it merely shares a prefix with', async () => {
110
+ const user = userEvent.setup()
111
+ render(<Harness load={loaderFor()} />)
112
+
113
+ await user.click(await screen.findByRole('button', { name: 'Expand Clients' }))
114
+ await screen.findByRole('button', { name: /^Acme, 900 records inside$/ })
115
+
116
+ // `/Clients-old` is at the ROOT level and stays there. A prefix match would
117
+ // have pulled it under `/Clients`; a level cache keyed by `under=` cannot.
118
+ const clientsList = screen
119
+ .getByRole('button', { name: /^Acme, 900 records inside$/ })
120
+ .closest('ul') as HTMLElement
121
+ expect(clientsList).toBeTruthy()
122
+ expect(
123
+ Array.from(clientsList.querySelectorAll('button')).map((b) => b.getAttribute('title')),
124
+ ).toEqual(expect.not.arrayContaining(['/Clients-old']))
125
+ expect(screen.getByRole('button', { name: /^Clients-old, 3 records inside$/ })).toBeTruthy()
126
+ })
127
+
128
+ it('hands back the full path the server returned, verbatim', async () => {
129
+ const user = userEvent.setup()
130
+ render(<Harness load={loaderFor()} />)
131
+
132
+ await user.click(await screen.findByRole('button', { name: 'Expand Clients' }))
133
+ await user.click(await screen.findByRole('button', { name: /^Acme, 900 records inside$/ }))
134
+ expect(screen.getByTestId('selected')).toHaveTextContent('/Clients/Acme')
135
+ })
136
+
137
+ it('goes three levels deep, one request per level', async () => {
138
+ const user = userEvent.setup()
139
+ const load = loaderFor()
140
+ render(<Harness load={load} />)
141
+
142
+ await user.click(await screen.findByRole('button', { name: 'Expand Clients' }))
143
+ await user.click(await screen.findByRole('button', { name: 'Expand Acme' }))
144
+ await screen.findByRole('button', { name: /^GCC Weekly, 41 records inside$/ })
145
+ expect(load).toHaveBeenCalledTimes(3)
146
+ })
147
+ })
148
+
149
+ describe('useFacetLevels — paging', () => {
150
+ it('offers the server’s remaining count and appends the next page', async () => {
151
+ const user = userEvent.setup()
152
+ const first = page([node('/A', 1), node('/B', 2)], 4)
153
+ const second = page([node('/C', 3), node('/D', 4)], 4)
154
+ const load = jest.fn(async (query: FacetLevelQuery) => (query.page === 1 ? first : second))
155
+
156
+ render(<Harness load={load} pageSize={2} />)
157
+
158
+ const more = await screen.findByRole('button', { name: 'Show 2 more scopes' })
159
+ expect(load).toHaveBeenCalledWith({ under: '/', page: 1, pageSize: 2 })
160
+
161
+ await user.click(more)
162
+ await screen.findByRole('button', { name: /^D, 4 records inside$/ })
163
+ // APPENDED, not replaced.
164
+ expect(screen.getByRole('button', { name: /^A, 1 record inside$/ })).toBeTruthy()
165
+ expect(screen.queryByRole('button', { name: /Show .* more/ })).toBeNull()
166
+ expect(load).toHaveBeenLastCalledWith({ under: '/', page: 2, pageSize: 2 })
167
+ })
168
+
169
+ it('says "1 more scope" rather than "1 more scopes"', async () => {
170
+ const load = jest.fn(async () => page([node('/A', 1)], 2))
171
+ render(<Harness load={load} />)
172
+ expect(await screen.findByRole('button', { name: 'Show 1 more scope' })).toBeTruthy()
173
+ })
174
+ })
175
+
176
+ describe('useFacetLevels — a deployment that cannot answer', () => {
177
+ it.each([404, 400, 501])('treats %s as "no tree here" and says so once', async (status) => {
178
+ const onUnavailable = jest.fn()
179
+ const load = jest.fn(async () => {
180
+ throw Object.assign(new Error('nope'), { status })
181
+ })
182
+ render(<Harness load={load} onUnavailable={onUnavailable} />)
183
+
184
+ await waitFor(() => expect(onUnavailable).toHaveBeenCalledTimes(1))
185
+ expect(screen.queryByRole('alert')).toBeNull()
186
+ })
187
+
188
+ it('reports a real failure on screen instead of pretending there is no tree', async () => {
189
+ const onUnavailable = jest.fn()
190
+ const load = jest.fn(async () => {
191
+ throw Object.assign(new Error('Boom'), { status: 500 })
192
+ })
193
+ render(<Harness load={load} onUnavailable={onUnavailable} />)
194
+
195
+ expect(await screen.findByRole('alert')).toHaveTextContent('Boom')
196
+ expect(onUnavailable).not.toHaveBeenCalled()
197
+ })
198
+
199
+ it('names the 401 and 403 cases in words a reader can act on', async () => {
200
+ const load = jest.fn(async () => {
201
+ throw Object.assign(new Error('x'), { status: 401 })
202
+ })
203
+ render(<Harness load={load} />)
204
+ expect(await screen.findByRole('alert')).toHaveTextContent(/session has expired/i)
205
+ })
206
+
207
+ it('reports unavailable when the client has no loader at all', async () => {
208
+ const onUnavailable = jest.fn()
209
+ render(<Harness onUnavailable={onUnavailable} />)
210
+ await waitFor(() => expect(onUnavailable).toHaveBeenCalledTimes(1))
211
+ })
212
+ })
213
+
214
+ describe('path helpers', () => {
215
+ it('names a node by its last segment only', () => {
216
+ expect(facetName('/Clients/Acme/GCC Weekly')).toBe('GCC Weekly')
217
+ expect(facetName('/Clients')).toBe('Clients')
218
+ expect(facetName('/')).toBe('/')
219
+ })
220
+
221
+ it('builds a trail of ancestors, each carrying a whole path', () => {
222
+ expect(facetTrail('/A/B/C')).toEqual([
223
+ { path: '/A', name: 'A' },
224
+ { path: '/A/B', name: 'B' },
225
+ { path: '/A/B/C', name: 'C' },
226
+ ])
227
+ })
228
+ })