@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.
@@ -0,0 +1,548 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * `<ScopeSwitcher/>` — pick a client or a project, hierarchically, and stay there
5
+ * (bd startsim-6fceh, epic startsim-ux39t).
6
+ *
7
+ * WHAT IT IS MADE OF, AND WHAT IT DELIBERATELY IS NOT. The tree underneath is
8
+ * `@startsimpli/ui/facet-tree`, which is data-agnostic and paged one level per
9
+ * request; this file adds the switcher shell — a trigger, a popover, a search
10
+ * box and an "All scopes" root — and the URL binding. It is NOT a third
11
+ * hierarchical picker: `FolderTree` in `@startsimpli/integrations` is the same
12
+ * engine over documents and is meant to converge onto `facet-tree` (the node and
13
+ * query types here are field-identical to its own so that convergence is a
14
+ * rename), and `EntitySwitcher` in `../navigation` is the FLAT, in-memory
15
+ * searchable switcher this one could not reuse: its contract is an array of
16
+ * every item, which a lazily-paged tree of unbounded depth cannot produce.
17
+ *
18
+ * THE ROOT ROW CLEARS THE BOUND, IT DOES NOT SELECT `/`. The facet read skips
19
+ * rows whose attribute is null, so a scope-less record — and every record is
20
+ * scope-less until a tenant declares its scopes — is invisible to the tree by
21
+ * construction. Had "All scopes" narrowed to a synthetic root, the entire
22
+ * unassigned corpus would have vanished from the screen of whoever selected it.
23
+ * That is not hypothetical: it is the case that nearly shipped in the Library,
24
+ * where a member with 51 documents and zero folders would have lost all of them.
25
+ *
26
+ * TWO READS, NOT ONE. Browsing pages the facet tree; SEARCHING is a row read
27
+ * across the whole type, because "find the scope called Weekly" is a question
28
+ * about the tree the reader has not expanded. Where the deployment cannot answer
29
+ * the second, the box filters what is already loaded and SAYS SO — a search
30
+ * control that quietly answers about a fraction of the tree is worse than one
31
+ * that admits its bound.
32
+ *
33
+ * NEVER OFFERS A SCOPE THE VIEWER CANNOT READ. Both reads are gated server-side
34
+ * out of `EntityViewSet.get_queryset()`; this component renders exactly what came
35
+ * back for the request it made and composes nothing across responses. It also
36
+ * assumes it cannot list everything: the trigger label for a scope that is not in
37
+ * any loaded response is derived from the URL the viewer is already holding, not
38
+ * from a lookup this component is not entitled to make.
39
+ */
40
+
41
+ import * as React from 'react'
42
+ import { ChevronsUpDown } from 'lucide-react'
43
+ import { cn } from '../../lib/utils'
44
+ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
45
+ import { Input } from '../ui/input'
46
+ import { FacetTree } from '../facet-tree/FacetTree'
47
+ import { FACET_ROOT, facetName, facetTrail } from '../facet-tree/path'
48
+ import { useFacetLevels } from '../facet-tree/useFacetLevels'
49
+ import { useScopeParam } from './useScopeParam'
50
+ import type { UseScopeParamOptions } from './useScopeParam'
51
+ import type { ScopeClient, ScopeMatch } from './types'
52
+
53
+ export interface ScopeSwitcherLabels {
54
+ /** The row that clears the bound. */
55
+ root?: string
56
+ /** Names the control itself — "Scope", "Client", "Workspace". */
57
+ control?: string
58
+ /** Search input placeholder. */
59
+ searchPlaceholder?: string
60
+ /** Accessible name for the search input. */
61
+ searchLabel?: string
62
+ /** Shown when a search matches nothing. */
63
+ empty?: string
64
+ /** Shown when the tree comes back with nothing at all. */
65
+ emptyTree?: string
66
+ /** The noun in a row's accessible name — "record", "topic", "draft". */
67
+ countNoun?: string
68
+ /** The noun in "Show N more …". */
69
+ nodeNoun?: string
70
+ /** Said when search can only see what is loaded. */
71
+ localSearchNotice?: string
72
+ }
73
+
74
+ const DEFAULT_LABELS: Required<ScopeSwitcherLabels> = {
75
+ root: 'All scopes',
76
+ control: 'Scope',
77
+ searchPlaceholder: 'Search scopes…',
78
+ searchLabel: 'Search scopes',
79
+ empty: 'No matching scopes',
80
+ emptyTree: 'No scopes yet',
81
+ countNoun: 'record',
82
+ nodeNoun: 'scope',
83
+ localSearchNotice: 'Searching the scopes loaded so far.',
84
+ }
85
+
86
+ export interface ScopeSwitcherBaseProps {
87
+ /**
88
+ * The injected reads. MEMOIZE IT: its identity is the reset key, so an object
89
+ * literal rebuilt every render would re-fetch the tree forever.
90
+ */
91
+ client: ScopeClient
92
+ labels?: ScopeSwitcherLabels
93
+ /** Scopes per request, per level. */
94
+ pageSize?: number
95
+ /** Told once when this deployment cannot enumerate scopes at all. */
96
+ onUnavailable?: () => void
97
+ disabled?: boolean
98
+ className?: string
99
+ triggerClassName?: string
100
+ contentClassName?: string
101
+ }
102
+
103
+ export interface ScopeSwitcherControlledProps extends ScopeSwitcherBaseProps {
104
+ /** The full path in scope, or `null` for no bound. */
105
+ value: string | null
106
+ /** Handed a full path the server returned, or `null` to clear the bound. */
107
+ onChange: (next: string | null) => void
108
+ }
109
+
110
+ /**
111
+ * The URL-bound switcher, and the export every app should reach for. It takes NO
112
+ * `value` — the scope is read from the query string on every render, which is
113
+ * what makes the stale-layout reading in `./useScopeParam` unreachable for a
114
+ * consumer rather than merely documented.
115
+ */
116
+ export interface ScopeSwitcherProps extends ScopeSwitcherBaseProps, UseScopeParamOptions {}
117
+
118
+ export function ScopeSwitcher({
119
+ param,
120
+ remember,
121
+ rememberKey,
122
+ history,
123
+ ...rest
124
+ }: ScopeSwitcherProps) {
125
+ // `useSearchParams()` opts a statically-rendered page into client rendering
126
+ // unless it sits under a Suspense boundary, and a consumer should not have to
127
+ // know that. The fallback is the same trigger, inert, so the rail does not
128
+ // jump. Only the trigger's own props are forwarded to it — spreading the rest
129
+ // would put a client object on a DOM node.
130
+ const fallback = (
131
+ <ScopeTrigger
132
+ disabled
133
+ value={null}
134
+ label={rest.labels?.control ?? DEFAULT_LABELS.control}
135
+ rootLabel={rest.labels?.root ?? DEFAULT_LABELS.root}
136
+ className={rest.className}
137
+ triggerClassName={rest.triggerClassName}
138
+ />
139
+ )
140
+
141
+ return (
142
+ <React.Suspense fallback={fallback}>
143
+ <UrlBoundScopeSwitcher
144
+ param={param}
145
+ remember={remember}
146
+ rememberKey={rememberKey}
147
+ history={history}
148
+ {...rest}
149
+ />
150
+ </React.Suspense>
151
+ )
152
+ }
153
+
154
+ function UrlBoundScopeSwitcher({
155
+ param,
156
+ remember,
157
+ rememberKey,
158
+ history,
159
+ ...rest
160
+ }: ScopeSwitcherProps) {
161
+ const { scope, setScope } = useScopeParam({ param, remember, rememberKey, history })
162
+ return <ScopeSwitcherControlled value={scope} onChange={setScope} {...rest} />
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /**
168
+ * The switcher with its selection supplied. Exported for a host that is not
169
+ * Next.js and for tests; an app inside Next should use `ScopeSwitcher`, whose
170
+ * selection cannot go stale.
171
+ */
172
+ export function ScopeSwitcherControlled({
173
+ client,
174
+ value,
175
+ onChange,
176
+ labels,
177
+ pageSize = 100,
178
+ onUnavailable,
179
+ disabled,
180
+ className,
181
+ triggerClassName,
182
+ contentClassName,
183
+ }: ScopeSwitcherControlledProps) {
184
+ const text = { ...DEFAULT_LABELS, ...labels }
185
+ const baseId = React.useId()
186
+ const searchId = `${baseId}-search`
187
+
188
+ const [open, setOpen] = React.useState(false)
189
+ // Once opened, the tree stays loaded — but a switcher nobody has opened costs
190
+ // no round trip, and its label never needed one.
191
+ const [everOpened, setEverOpened] = React.useState(false)
192
+ const [query, setQuery] = React.useState('')
193
+
194
+ const load = React.useCallback(
195
+ (levelQuery: Parameters<ScopeClient['listScopeChildren']>[0]) =>
196
+ client.listScopeChildren(levelQuery),
197
+ [client],
198
+ )
199
+
200
+ const tree = useFacetLevels({
201
+ load,
202
+ pageSize,
203
+ root: FACET_ROOT,
204
+ enabled: everOpened,
205
+ onUnavailable,
206
+ })
207
+
208
+ const search = useScopeSearch(client, query, tree.levels)
209
+
210
+ const handleOpenChange = (next: boolean) => {
211
+ setOpen(next)
212
+ if (next) {
213
+ setEverOpened(true)
214
+ setQuery('')
215
+ }
216
+ }
217
+
218
+ const choose = (next: string | null) => {
219
+ onChange(next)
220
+ setOpen(false)
221
+ }
222
+
223
+ const rootLevel = tree.levelOf(FACET_ROOT)
224
+ const treeIsEmpty =
225
+ !tree.unavailable &&
226
+ !rootLevel.loading &&
227
+ !rootLevel.error &&
228
+ rootLevel.page > 0 &&
229
+ rootLevel.rows.length === 0
230
+
231
+ return (
232
+ <Popover open={open} onOpenChange={handleOpenChange}>
233
+ <PopoverTrigger asChild>
234
+ <ScopeTrigger
235
+ value={value}
236
+ label={text.control}
237
+ rootLabel={text.root}
238
+ open={open}
239
+ disabled={disabled}
240
+ className={className}
241
+ triggerClassName={triggerClassName}
242
+ />
243
+ </PopoverTrigger>
244
+
245
+ <PopoverContent align="start" className={cn('w-72 p-0', contentClassName)}>
246
+ <div className="border-b p-2">
247
+ <label className="sr-only" htmlFor={searchId}>
248
+ {text.searchLabel}
249
+ </label>
250
+ <Input
251
+ id={searchId}
252
+ type="text"
253
+ value={query}
254
+ onChange={(event) => setQuery(event.target.value)}
255
+ placeholder={text.searchPlaceholder}
256
+ autoComplete="off"
257
+ className="h-8"
258
+ />
259
+ </div>
260
+
261
+ <div className="max-h-72 overflow-y-auto p-1">
262
+ {query.trim() ? (
263
+ <ScopeSearchResults
264
+ state={search}
265
+ selected={value}
266
+ onSelect={choose}
267
+ emptyLabel={text.empty}
268
+ localNotice={text.localSearchNotice}
269
+ countNoun={text.countNoun}
270
+ />
271
+ ) : (
272
+ <>
273
+ <button
274
+ type="button"
275
+ className={cn(
276
+ 'flex w-full items-center rounded-md px-2 py-1 text-left text-sm hover:bg-accent hover:text-accent-foreground',
277
+ value === null && 'bg-accent font-medium text-accent-foreground',
278
+ )}
279
+ aria-current={value === null ? 'true' : undefined}
280
+ // NULL, not `FACET_ROOT`. See the file header: selecting this row
281
+ // must remove the bound entirely, or every record that carries no
282
+ // scope becomes unreachable from the one control meant to reach
283
+ // everything. (In the URL that `null` is SPELLED `?scope=all`, so
284
+ // "everything" is a link somebody can send — see `useScopeParam`.
285
+ // The bound is still gone; only the spelling is explicit.)
286
+ onClick={() => choose(null)}
287
+ >
288
+ {text.root}
289
+ </button>
290
+ <FacetTree
291
+ levels={tree.levels}
292
+ expanded={tree.expanded}
293
+ selected={value}
294
+ onSelect={choose}
295
+ onToggle={tree.toggle}
296
+ onMore={tree.loadMore}
297
+ root={FACET_ROOT}
298
+ countNoun={text.countNoun}
299
+ nodeNoun={text.nodeNoun}
300
+ />
301
+ {treeIsEmpty ? (
302
+ <p className="px-2 py-3 text-center text-xs text-muted-foreground">
303
+ {text.emptyTree}
304
+ </p>
305
+ ) : null}
306
+ </>
307
+ )}
308
+ </div>
309
+ </PopoverContent>
310
+ </Popover>
311
+ )
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+
316
+ interface ScopeTriggerProps {
317
+ value?: string | null
318
+ label: string
319
+ rootLabel?: string
320
+ open?: boolean
321
+ disabled?: boolean
322
+ className?: string
323
+ triggerClassName?: string
324
+ }
325
+
326
+ /**
327
+ * The trigger, whose label is derived from the SELECTED PATH and never from a
328
+ * lookup.
329
+ *
330
+ * The path is a string the viewer is already holding — it is in their address
331
+ * bar. Resolving it to a prettier name would mean a read this component is not
332
+ * entitled to make on behalf of somebody who followed a link to a scope they
333
+ * cannot see, and the honest answer there is the path itself.
334
+ */
335
+ const ScopeTrigger = React.forwardRef<HTMLButtonElement, ScopeTriggerProps>(
336
+ function ScopeTrigger(
337
+ { value, label, rootLabel = DEFAULT_LABELS.root, open, disabled, className, triggerClassName, ...rest },
338
+ ref,
339
+ ) {
340
+ const trail = value ? facetTrail(value) : []
341
+ const name = value ? facetName(value) : rootLabel
342
+ // The ancestors, so two projects both called "Weekly" are told apart without
343
+ // opening the control.
344
+ const context = trail.length > 1 ? trail.slice(0, -1).map((step) => step.name).join(' / ') : ''
345
+
346
+ return (
347
+ <button
348
+ ref={ref}
349
+ type="button"
350
+ aria-expanded={open}
351
+ // The visible label is a leaf name; the accessible one says what the leaf
352
+ // IS, because "GCC Weekly" on its own does not tell a screen-reader user
353
+ // that this control changes the scope.
354
+ aria-label={`${label}: ${value ? value : rootLabel}`}
355
+ title={value ?? rootLabel}
356
+ disabled={disabled}
357
+ className={cn(
358
+ 'flex w-full items-center justify-between gap-2 rounded-md border border-white/15 bg-white/5 px-3 py-2',
359
+ 'text-left text-sm font-medium text-inherit transition-colors',
360
+ 'hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40',
361
+ 'disabled:cursor-not-allowed disabled:opacity-50',
362
+ className,
363
+ triggerClassName,
364
+ )}
365
+ {...rest}
366
+ >
367
+ <span className="min-w-0 flex-1">
368
+ <span className="block truncate">{name}</span>
369
+ {context ? <span className="block truncate text-xs opacity-60">{context}</span> : null}
370
+ </span>
371
+ <ChevronsUpDown className="h-4 w-4 flex-shrink-0 opacity-60" aria-hidden="true" />
372
+ </button>
373
+ )
374
+ },
375
+ )
376
+
377
+ // ---------------------------------------------------------------------------
378
+
379
+ interface ScopeSearchState {
380
+ rows: ScopeMatch[]
381
+ loading: boolean
382
+ error: string
383
+ /** True when the answer came from the levels already loaded, not the server. */
384
+ local: boolean
385
+ active: boolean
386
+ }
387
+
388
+ const SEARCH_DEBOUNCE_MS = 200
389
+
390
+ /**
391
+ * SEARCH IS A SERVER READ, and falls back to a LOCAL one only when the server
392
+ * cannot answer.
393
+ *
394
+ * The local fallback flattens `levels` — which is safe, and is not the flat set
395
+ * `useFacetLevels` refuses to keep. Every row in that map was returned to THIS
396
+ * reader for the exact container it is filed under; showing a subset of them
397
+ * puts nothing new on screen. What it cannot do is see a subtree nobody has
398
+ * expanded, which is precisely why the caller labels it.
399
+ */
400
+ function useScopeSearch(
401
+ client: ScopeClient,
402
+ query: string,
403
+ levels: Record<string, { rows: ScopeMatch[] }>,
404
+ ): ScopeSearchState {
405
+ const term = query.trim()
406
+ const [state, setState] = React.useState<{ rows: ScopeMatch[]; loading: boolean; error: string }>({
407
+ rows: [],
408
+ loading: false,
409
+ error: '',
410
+ })
411
+ // Sticky: once a deployment has said it cannot search, every later keystroke
412
+ // goes straight to the local filter instead of re-asking and re-failing.
413
+ const [serverCanSearch, setServerCanSearch] = React.useState(true)
414
+
415
+ React.useEffect(() => {
416
+ setServerCanSearch(true)
417
+ }, [client])
418
+
419
+ const canAskServer = Boolean(client.searchScopes) && serverCanSearch
420
+
421
+ React.useEffect(() => {
422
+ if (!term || !canAskServer) {
423
+ setState({ rows: [], loading: false, error: '' })
424
+ return
425
+ }
426
+ let cancelled = false
427
+ setState((prev) => ({ ...prev, loading: true, error: '' }))
428
+ const timer = setTimeout(() => {
429
+ client
430
+ .searchScopes?.({ search: term, pageSize: 50 })
431
+ .then((page) => {
432
+ if (cancelled) return
433
+ setState({ rows: page.results, loading: false, error: '' })
434
+ })
435
+ .catch((err: unknown) => {
436
+ if (cancelled) return
437
+ const status = (err as { status?: number })?.status
438
+ if (status === 404 || status === 400 || status === 501) {
439
+ setServerCanSearch(false)
440
+ setState({ rows: [], loading: false, error: '' })
441
+ return
442
+ }
443
+ setState({
444
+ rows: [],
445
+ loading: false,
446
+ error: (err as { message?: string })?.message || 'Could not search scopes.',
447
+ })
448
+ })
449
+ }, SEARCH_DEBOUNCE_MS)
450
+ return () => {
451
+ cancelled = true
452
+ clearTimeout(timer)
453
+ }
454
+ }, [client, term, canAskServer])
455
+
456
+ const local = React.useMemo(() => {
457
+ if (!term || canAskServer) return []
458
+ const needle = term.toLowerCase()
459
+ const seen = new Set<string>()
460
+ const out: ScopeMatch[] = []
461
+ for (const level of Object.values(levels)) {
462
+ for (const row of level.rows) {
463
+ if (seen.has(row.path)) continue
464
+ seen.add(row.path)
465
+ if (row.name.toLowerCase().includes(needle) || row.path.toLowerCase().includes(needle)) {
466
+ out.push(row)
467
+ }
468
+ }
469
+ }
470
+ return out.sort((a, b) => a.path.localeCompare(b.path))
471
+ }, [term, canAskServer, levels])
472
+
473
+ if (!canAskServer) {
474
+ return { rows: local, loading: false, error: '', local: true, active: Boolean(term) }
475
+ }
476
+ return { ...state, local: false, active: Boolean(term) }
477
+ }
478
+
479
+ function ScopeSearchResults({
480
+ state,
481
+ selected,
482
+ onSelect,
483
+ emptyLabel,
484
+ localNotice,
485
+ countNoun,
486
+ }: {
487
+ state: ScopeSearchState
488
+ selected: string | null
489
+ onSelect: (path: string) => void
490
+ emptyLabel: string
491
+ localNotice: string
492
+ countNoun: string
493
+ }) {
494
+ if (state.loading) {
495
+ return <p className="px-2 py-3 text-center text-xs text-muted-foreground">Searching…</p>
496
+ }
497
+ if (state.error) {
498
+ return (
499
+ <p className="px-2 py-3 text-xs text-destructive" role="alert">
500
+ {state.error}
501
+ </p>
502
+ )
503
+ }
504
+ return (
505
+ <>
506
+ {state.local ? (
507
+ <p className="px-2 pb-1 pt-0.5 text-[11px] text-muted-foreground">{localNotice}</p>
508
+ ) : null}
509
+ {state.rows.length === 0 ? (
510
+ <p className="px-2 py-3 text-center text-xs text-muted-foreground">{emptyLabel}</p>
511
+ ) : (
512
+ <ul className="space-y-0.5">
513
+ {state.rows.map((row) => (
514
+ <li key={row.path}>
515
+ <button
516
+ type="button"
517
+ className={cn(
518
+ 'flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1 text-left text-sm hover:bg-accent hover:text-accent-foreground',
519
+ selected === row.path && 'bg-accent font-medium text-accent-foreground',
520
+ )}
521
+ aria-current={selected === row.path ? 'true' : undefined}
522
+ // The FULL path in the accessible name, because a flat result
523
+ // list is exactly where two scopes with the same leaf name meet.
524
+ aria-label={
525
+ typeof row.count === 'number'
526
+ ? `${row.path}, ${row.count.toLocaleString()} ${countNoun}${row.count === 1 ? '' : 's'} inside`
527
+ : row.path
528
+ }
529
+ title={row.path}
530
+ onClick={() => onSelect(row.path)}
531
+ >
532
+ <span className="min-w-0 flex-1">
533
+ <span className="block truncate">{row.name}</span>
534
+ <span className="block truncate text-xs opacity-60">{row.path}</span>
535
+ </span>
536
+ {typeof row.count === 'number' ? (
537
+ <span className="shrink-0 text-xs tabular-nums opacity-60" aria-hidden="true">
538
+ {row.count.toLocaleString()}
539
+ </span>
540
+ ) : null}
541
+ </button>
542
+ </li>
543
+ ))}
544
+ </ul>
545
+ )}
546
+ </>
547
+ )
548
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The adapter over the tenant's generic entity endpoints (bd startsim-6fceh).
3
+ *
4
+ * The parameter builders are the point. The facets action SCREENS its query
5
+ * parameters against its own vocabulary before any handler runs, so what this
6
+ * sends is a compatibility contract and not a detail — and browsing and
7
+ * searching are two different endpoints, which is the whole reason there are two
8
+ * builders here rather than one shared with a list.
9
+ */
10
+ import { createScopeClient } from '../client'
11
+
12
+ function httpStub(response: unknown) {
13
+ const get = jest.fn(async () => response)
14
+ return { get: get as unknown as <T>(p: string, c?: unknown) => Promise<T>, calls: get }
15
+ }
16
+
17
+ describe('createScopeClient — the tree read', () => {
18
+ it('sends exactly type, attr, under and the page', async () => {
19
+ const http = httpStub({ count: 0, results: [] })
20
+ const client = createScopeClient({ http })
21
+
22
+ await client.listScopeChildren({ under: '/Clients', page: 2, pageSize: 25 })
23
+
24
+ expect(http.calls).toHaveBeenCalledWith('api/v1/entities/facets', {
25
+ params: { type: 'scope', attr: 'path', under: '/Clients', page: 2, page_size: 25 },
26
+ })
27
+ })
28
+
29
+ it('defaults `under` to the root, which means every scope', async () => {
30
+ const http = httpStub({ count: 0, results: [] })
31
+ await createScopeClient({ http }).listScopeChildren({ under: '' })
32
+ expect(http.calls).toHaveBeenCalledWith(
33
+ 'api/v1/entities/facets',
34
+ expect.objectContaining({ params: expect.objectContaining({ under: '/' }) }),
35
+ )
36
+ })
37
+
38
+ it('caps page_size at the tenant’s own maximum', async () => {
39
+ const http = httpStub({ count: 0, results: [] })
40
+ await createScopeClient({ http }).listScopeChildren({ under: '/', pageSize: 5000 })
41
+ expect(http.calls).toHaveBeenCalledWith(
42
+ 'api/v1/entities/facets',
43
+ expect.objectContaining({ params: expect.objectContaining({ page_size: 200 }) }),
44
+ )
45
+ })
46
+
47
+ it('maps a facet row to a node, in either key dialect', async () => {
48
+ const http = httpStub({
49
+ count: 2,
50
+ next: 'p2',
51
+ previous: null,
52
+ results: [
53
+ // raw wire
54
+ { value: '/Clients/Acme', count: 900, has_children: true },
55
+ // camelCased by a house API client
56
+ { value: '/Clients/Globex', count: 300, hasChildren: false },
57
+ ],
58
+ })
59
+
60
+ const result = await createScopeClient({ http }).listScopeChildren({ under: '/Clients' })
61
+
62
+ expect(result).toEqual({
63
+ count: 2,
64
+ next: 'p2',
65
+ previous: null,
66
+ results: [
67
+ { path: '/Clients/Acme', name: 'Acme', count: 900, hasChildren: true },
68
+ { path: '/Clients/Globex', name: 'Globex', count: 300, hasChildren: false },
69
+ ],
70
+ })
71
+ })
72
+
73
+ it('takes the type and attribute names from the tenant, not from a constant', async () => {
74
+ const http = httpStub({ count: 0, results: [] })
75
+ await createScopeClient({ http, type: 'client', attr: 'tree_path' }).listScopeChildren({
76
+ under: '/',
77
+ })
78
+ expect(http.calls).toHaveBeenCalledWith(
79
+ 'api/v1/entities/facets',
80
+ expect.objectContaining({
81
+ params: expect.objectContaining({ type: 'client', attr: 'tree_path' }),
82
+ }),
83
+ )
84
+ })
85
+ })
86
+
87
+ describe('createScopeClient — the search read', () => {
88
+ it('is a ROW read on the collection, not a facet read', async () => {
89
+ const http = httpStub({ count: 0, results: [] })
90
+ await createScopeClient({ http }).searchScopes?.({ search: 'weekly', pageSize: 50 })
91
+
92
+ expect(http.calls).toHaveBeenCalledWith('api/v1/entities', {
93
+ params: { type: 'scope', search: 'weekly', page: 1, page_size: 50 },
94
+ })
95
+ })
96
+
97
+ it('reads the path off the record and prefers the record’s own name', async () => {
98
+ const http = httpStub({
99
+ count: 1,
100
+ results: [{ name: 'GCC Weekly', data: { path: '/Clients/Acme/gcc-weekly' } }],
101
+ })
102
+ const result = await createScopeClient({ http }).searchScopes?.({ search: 'gcc' })
103
+ expect(result?.results).toEqual([{ path: '/Clients/Acme/gcc-weekly', name: 'GCC Weekly' }])
104
+ })
105
+
106
+ it('falls back to the last path segment when a record has no name', async () => {
107
+ const http = httpStub({ count: 1, results: [{ data: { path: '/Clients/Acme' } }] })
108
+ const result = await createScopeClient({ http }).searchScopes?.({ search: 'a' })
109
+ expect(result?.results[0].name).toBe('Acme')
110
+ })
111
+
112
+ it('drops a scope with no path but keeps the server’s own count', async () => {
113
+ // A record with no path cannot be selected — a selection carries the path
114
+ // and nothing else — so it is dropped rather than shown as a dead row. The
115
+ // count stays the SERVER's, or "show more" would stop early.
116
+ const http = httpStub({
117
+ count: 2,
118
+ results: [{ name: 'Unfiled', data: {} }, { name: 'Acme', data: { path: '/Clients/Acme' } }],
119
+ })
120
+ const result = await createScopeClient({ http }).searchScopes?.({ search: 'a' })
121
+ expect(result?.results).toHaveLength(1)
122
+ expect(result?.count).toBe(2)
123
+ })
124
+
125
+ it('bounds the search term, because the server bounds text filters', async () => {
126
+ const http = httpStub({ count: 0, results: [] })
127
+ await createScopeClient({ http }).searchScopes?.({ search: 'x'.repeat(900) })
128
+ const params = (http.calls.mock.calls[0][1] as { params: { search: string } }).params
129
+ expect(params.search).toHaveLength(500)
130
+ })
131
+ })