@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 +5 -3
- package/src/components/collection/__tests__/review.test.ts +61 -0
- package/src/components/collection/index.ts +2 -0
- package/src/components/collection/review.ts +35 -3
- package/src/components/facet-tree/FacetTree.tsx +250 -0
- package/src/components/facet-tree/__tests__/facet-tree.test.tsx +228 -0
- package/src/components/facet-tree/index.ts +26 -0
- package/src/components/facet-tree/path.ts +55 -0
- package/src/components/facet-tree/types.ts +89 -0
- package/src/components/facet-tree/useFacetLevels.ts +197 -0
- package/src/components/index.ts +46 -0
- package/src/components/scope/ScopeSwitcher.tsx +548 -0
- package/src/components/scope/__tests__/scope-client.test.ts +131 -0
- package/src/components/scope/__tests__/scope-switcher.test.tsx +338 -0
- package/src/components/scope/__tests__/use-scope-param.test.tsx +265 -0
- package/src/components/scope/client.ts +145 -0
- package/src/components/scope/index.ts +34 -0
- package/src/components/scope/types.ts +90 -0
- package/src/components/scope/useScopeParam.ts +200 -0
|
@@ -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
|
+
})
|