@startsimpli/ui 0.4.110 → 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 +7 -5
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
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",
|
|
@@ -45,9 +47,9 @@
|
|
|
45
47
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
46
48
|
"react": "^18.0.0 || ^19.0.0",
|
|
47
49
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
50
|
+
"@startsimpli/api": "^0.5.58",
|
|
48
51
|
"@startsimpli/auth": "^0.4.35",
|
|
49
|
-
"@startsimpli/i18n": "^0.1.3"
|
|
50
|
-
"@startsimpli/api": "^0.5.58"
|
|
52
|
+
"@startsimpli/i18n": "^0.1.3"
|
|
51
53
|
},
|
|
52
54
|
"peerDependenciesMeta": {
|
|
53
55
|
"@startsimpli/api": {
|
|
@@ -121,8 +123,8 @@
|
|
|
121
123
|
"tailwindcss": "^3.4.19",
|
|
122
124
|
"tsup": "^8.5.1",
|
|
123
125
|
"typescript": "^6.0.3",
|
|
124
|
-
"@startsimpli/
|
|
125
|
-
"@startsimpli/
|
|
126
|
+
"@startsimpli/realtime": "0.0.2",
|
|
127
|
+
"@startsimpli/i18n": "0.1.3"
|
|
126
128
|
},
|
|
127
129
|
"keywords": [
|
|
128
130
|
"ui",
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/ui/facet-tree — a lazily-paged, server-navigable tree over a
|
|
3
|
+
* materialized path attribute.
|
|
4
|
+
*
|
|
5
|
+
* DATA-AGNOSTIC ON PURPOSE. It knows about `{path, name, count, hasChildren}`
|
|
6
|
+
* and an injected `load({under, page, pageSize})`, and nothing else — not what a
|
|
7
|
+
* scope is, not what a document is. `ScopeSwitcher` (`../scope`) is the first
|
|
8
|
+
* consumer; the Library folder tree in `@startsimpli/integrations` is the second
|
|
9
|
+
* one waiting to converge onto it (bd startsim-6fceh's notes carry that bead).
|
|
10
|
+
*/
|
|
11
|
+
export { FACET_ROOT, PATH_SEPARATOR, facetName, facetTrail } from './path'
|
|
12
|
+
export { FacetTree, type FacetTreeProps, type FacetTreeSlot } from './FacetTree'
|
|
13
|
+
export {
|
|
14
|
+
useFacetLevels,
|
|
15
|
+
isTreeUnsupported,
|
|
16
|
+
type FacetLevels,
|
|
17
|
+
type UseFacetLevelsOptions,
|
|
18
|
+
} from './useFacetLevels'
|
|
19
|
+
export {
|
|
20
|
+
EMPTY_LEVEL,
|
|
21
|
+
type FacetNode,
|
|
22
|
+
type FacetLevelQuery,
|
|
23
|
+
type FacetLevelLoader,
|
|
24
|
+
type FacetLevelState,
|
|
25
|
+
type Paginated,
|
|
26
|
+
} from './types'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path arithmetic for a materialized-path tree — the ONLY file in this folder
|
|
3
|
+
* allowed to take a path apart, and only ever for DISPLAY.
|
|
4
|
+
*
|
|
5
|
+
* The rule the rest of the tree obeys: a path the server returned goes back out
|
|
6
|
+
* VERBATIM as the next `under=` and as the next `?attr.<name>=`. A client that
|
|
7
|
+
* re-joins segments has a small escaping bug in it, and the server returns whole
|
|
8
|
+
* paths precisely so that nobody has to. Both helpers below take apart a string
|
|
9
|
+
* the server already handed to THIS reader, purely to label a control; neither
|
|
10
|
+
* synthesizes a node, and nothing here is ever sent back to the server.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The separator the tenant declares at `/api/v1/capabilities/#path_separator`. */
|
|
14
|
+
export const PATH_SEPARATOR = '/'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The root of any materialized-path tree.
|
|
18
|
+
*
|
|
19
|
+
* `under=/` means EVERYTHING, not "the top-level rows": the server's
|
|
20
|
+
* `subtree_bound()` makes the root's descendant prefix `/`, which every stored
|
|
21
|
+
* path begins with. It is the reading a person expects from the top of a file
|
|
22
|
+
* browser.
|
|
23
|
+
*/
|
|
24
|
+
export const FACET_ROOT = PATH_SEPARATOR
|
|
25
|
+
|
|
26
|
+
/** The last segment of a full path, for display. `/A/B` -> `B`. */
|
|
27
|
+
export function facetName(path: string): string {
|
|
28
|
+
const segments = String(path ?? '')
|
|
29
|
+
.split(PATH_SEPARATOR)
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
return segments.length > 0 ? segments[segments.length - 1] : String(path ?? '')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The ancestors of a path, for a breadcrumb — `/A/B/C` -> `/A`, `/A/B`, `/A/B/C`.
|
|
36
|
+
*
|
|
37
|
+
* SPLITTING IS SAFE HERE and the module header's rule still holds, because this
|
|
38
|
+
* is not the act that rule forbids. It puts no node on screen that the server did
|
|
39
|
+
* not already name to this reader, and fills no level from another level's
|
|
40
|
+
* response. The forbidden act is SYNTHESISING a node — deciding that a container
|
|
41
|
+
* exists, or what is in it, from data the server did not return about it — and a
|
|
42
|
+
* breadcrumb over a path the reader is already holding does none of that.
|
|
43
|
+
*/
|
|
44
|
+
export function facetTrail(path: string): Array<{ path: string; name: string }> {
|
|
45
|
+
const segments = String(path ?? '')
|
|
46
|
+
.split(PATH_SEPARATOR)
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
const trail: Array<{ path: string; name: string }> = []
|
|
49
|
+
let at = ''
|
|
50
|
+
for (const segment of segments) {
|
|
51
|
+
at = `${at}${PATH_SEPARATOR}${segment}`
|
|
52
|
+
trail.push({ path: at, name: segment })
|
|
53
|
+
}
|
|
54
|
+
return trail
|
|
55
|
+
}
|