@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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire shape of a lazily-paged, server-navigable tree over a materialized
|
|
3
|
+
* path attribute — `GET /api/v1/entities/facets/?type=<t>&attr=<a>&under=/x`.
|
|
4
|
+
*
|
|
5
|
+
* DELIBERATELY FIELD-IDENTICAL TO `CorpusFolder` / `FolderQuery` / `CorpusPage`
|
|
6
|
+
* in `@startsimpli/integrations` (bd startsim-ei8gm.5). That package built the
|
|
7
|
+
* first consumer of this API — the Library folder tree — and it cannot import
|
|
8
|
+
* this one (neither package depends on the other today, see bd startsim-6fceh's
|
|
9
|
+
* notes). Keeping the field names identical means the day the two converge is a
|
|
10
|
+
* rename and a re-export, not a redesign.
|
|
11
|
+
*
|
|
12
|
+
* `Paginated<T>` is reused from `../collection/types` rather than minted a third
|
|
13
|
+
* time: the server hands back one DRF envelope and this package should describe
|
|
14
|
+
* it once.
|
|
15
|
+
*/
|
|
16
|
+
import type { Paginated } from '../collection/types'
|
|
17
|
+
|
|
18
|
+
export type { Paginated }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* ONE NODE of the tree, exactly as `/entities/facets/` describes it.
|
|
22
|
+
*
|
|
23
|
+
* Every field here is answered by the SAME response that listed the node, which
|
|
24
|
+
* is what makes a lazy tree cost one request per expanded level and no probe
|
|
25
|
+
* request per node.
|
|
26
|
+
*/
|
|
27
|
+
export interface FacetNode {
|
|
28
|
+
/**
|
|
29
|
+
* The FULL path, as the server returned it — `/Clients/Acme`, never `Acme`.
|
|
30
|
+
*
|
|
31
|
+
* It goes straight back out unmodified as the next `under=` and as the value
|
|
32
|
+
* a consumer filters by. The client never joins segments; see `./path.ts`.
|
|
33
|
+
*/
|
|
34
|
+
path: string
|
|
35
|
+
/** The last segment, for display only. Derived from `path`, never sent back. */
|
|
36
|
+
name: string
|
|
37
|
+
/**
|
|
38
|
+
* DESCENDANT records, not direct children — `/Clients` counts the rows under
|
|
39
|
+
* `/Clients/Acme/GCC` too. That is the number a person reading a collapsed
|
|
40
|
+
* node wants, and it is why a node can say 1,204 and open onto nothing of its
|
|
41
|
+
* own.
|
|
42
|
+
*/
|
|
43
|
+
count: number
|
|
44
|
+
/**
|
|
45
|
+
* Whether anything sits BELOW this node. The disclosure control, answered by
|
|
46
|
+
* the response that listed the node — so drawing a triangle costs no extra
|
|
47
|
+
* request.
|
|
48
|
+
*/
|
|
49
|
+
hasChildren: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ONE LEVEL of the tree. `under` is a path the server previously returned, or
|
|
54
|
+
* `FACET_ROOT` (`/`) for the top, which means everything.
|
|
55
|
+
*/
|
|
56
|
+
export interface FacetLevelQuery {
|
|
57
|
+
under: string
|
|
58
|
+
page?: number
|
|
59
|
+
pageSize?: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The single read a tree needs, injected. A caller hands in a method off its own
|
|
64
|
+
* authed client; this package never constructs a request of its own.
|
|
65
|
+
*
|
|
66
|
+
* IT MUST BE REFERENTIALLY STABLE across renders. Its identity is the reset key:
|
|
67
|
+
* a new loader means a new principal or a new workspace, and everything already
|
|
68
|
+
* fetched was gated for the OLD one, so it is dropped rather than reused.
|
|
69
|
+
*/
|
|
70
|
+
export type FacetLevelLoader = (query: FacetLevelQuery) => Promise<Paginated<FacetNode>>
|
|
71
|
+
|
|
72
|
+
/** What is known about ONE container's children. Keyed by that container's path. */
|
|
73
|
+
export interface FacetLevelState {
|
|
74
|
+
rows: FacetNode[]
|
|
75
|
+
/** The server's own total for this container, so "is there more" is not guessed. */
|
|
76
|
+
total: number
|
|
77
|
+
/** Highest page fetched for THIS container. */
|
|
78
|
+
page: number
|
|
79
|
+
loading: boolean
|
|
80
|
+
error: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const EMPTY_LEVEL: FacetLevelState = {
|
|
84
|
+
rows: [],
|
|
85
|
+
total: 0,
|
|
86
|
+
page: 0,
|
|
87
|
+
loading: false,
|
|
88
|
+
error: '',
|
|
89
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THE LEVEL CACHE — the whole state machine of a lazily-paged tree, with no
|
|
5
|
+
* opinion about what the tree is a tree OF.
|
|
6
|
+
*
|
|
7
|
+
* ONE LEVEL PER REQUEST, PAGED (CLAUDE.md rule 8). Expanding a node asks the
|
|
8
|
+
* server for that node's children and nothing else. The alternative — read every
|
|
9
|
+
* record's path and assemble the tree in the browser — works on today's rows and
|
|
10
|
+
* expires in the low thousands, and it is the failure `/entities/facets/`
|
|
11
|
+
* (start-simpli-api#391) was shipped to end.
|
|
12
|
+
*
|
|
13
|
+
* THE SECURITY PROPERTY IS THE SHAPE OF THE STATE, and it is why `levels` is a
|
|
14
|
+
* map keyed by `under=` rather than a flat set of nodes seen. The facet read is
|
|
15
|
+
* gated server-side out of `EntityViewSet.get_queryset()` itself, so a member is
|
|
16
|
+
* shown the containers their own readable rows are in — and a container name
|
|
17
|
+
* leaks plenty on its own (`/Clients/Acquisition-TargetCo`). So every
|
|
18
|
+
* container's children are stored under that container's OWN key and rendered
|
|
19
|
+
* from that key alone. Nothing here merges two responses, fills one level from
|
|
20
|
+
* another level's answer, or infers a parent from a child: each of those would
|
|
21
|
+
* put on screen a node the server never returned for THIS reader, which is the
|
|
22
|
+
* leak rebuilt in the browser out of rows the server was careful about.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as React from 'react'
|
|
26
|
+
import { FACET_ROOT } from './path'
|
|
27
|
+
import { EMPTY_LEVEL } from './types'
|
|
28
|
+
import type { FacetLevelLoader, FacetLevelState } from './types'
|
|
29
|
+
|
|
30
|
+
export interface UseFacetLevelsOptions {
|
|
31
|
+
/**
|
|
32
|
+
* The injected read. OPTIONAL because a client can legitimately not offer one
|
|
33
|
+
* — an older fork has no facet route at all — and the honest response to that
|
|
34
|
+
* is the same as to a 404: no tree.
|
|
35
|
+
*
|
|
36
|
+
* MUST BE STABLE. See `FacetLevelLoader`; its identity is the reset key.
|
|
37
|
+
*/
|
|
38
|
+
load?: FacetLevelLoader
|
|
39
|
+
/** Nodes per request. The server pages; this is the page size it is asked for. */
|
|
40
|
+
pageSize?: number
|
|
41
|
+
/** The top of the tree. `/` means everything. */
|
|
42
|
+
root?: string
|
|
43
|
+
/**
|
|
44
|
+
* Set false to hold every request. A switcher that has never been opened needs
|
|
45
|
+
* no data — its label comes from the URL — so a closed one costs no round trip.
|
|
46
|
+
*/
|
|
47
|
+
enabled?: boolean
|
|
48
|
+
/**
|
|
49
|
+
* Told once when this data source cannot enumerate the tree at all — a fork
|
|
50
|
+
* that predates the facet route 404s, an older one 400s. A consumer uses it to
|
|
51
|
+
* stop reserving space for a tree that will never draw.
|
|
52
|
+
*/
|
|
53
|
+
onUnavailable?: () => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface FacetLevels {
|
|
57
|
+
levels: Record<string, FacetLevelState>
|
|
58
|
+
expanded: Record<string, boolean>
|
|
59
|
+
/** True once the source has said, in any of its several dialects, "no tree here". */
|
|
60
|
+
unavailable: boolean
|
|
61
|
+
levelOf: (under: string) => FacetLevelState
|
|
62
|
+
toggle: (path: string) => void
|
|
63
|
+
loadMore: (under: string, page: number) => void
|
|
64
|
+
/** Fetch a container's first page if it has never been asked for. Idempotent. */
|
|
65
|
+
ensure: (under: string) => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function failure(err: unknown): string {
|
|
69
|
+
const status = (err as { status?: number })?.status
|
|
70
|
+
if (status === 401) return 'Your session has expired. Sign in again.'
|
|
71
|
+
if (status === 403) return 'You do not have access to these.'
|
|
72
|
+
return (err as { message?: string })?.message || 'Could not load.'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A 404/400 means "this deployment has no tree enumeration", not "no nodes".
|
|
77
|
+
*
|
|
78
|
+
* BOTH CODES, because both are real deployments. A fork built before
|
|
79
|
+
* start-simpli-api#391 has no `/entities/facets/` route and answers 404; one
|
|
80
|
+
* built before the parameters were in its vocabulary screens `attr`/`under` as
|
|
81
|
+
* unknown and answers 400. To a reader they mean the same thing.
|
|
82
|
+
*/
|
|
83
|
+
export function isTreeUnsupported(err: unknown): boolean {
|
|
84
|
+
const status = (err as { status?: number })?.status
|
|
85
|
+
return status === 404 || status === 400 || status === 501
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function useFacetLevels({
|
|
89
|
+
load,
|
|
90
|
+
pageSize = 100,
|
|
91
|
+
root = FACET_ROOT,
|
|
92
|
+
enabled = true,
|
|
93
|
+
onUnavailable,
|
|
94
|
+
}: UseFacetLevelsOptions): FacetLevels {
|
|
95
|
+
const [levels, setLevels] = React.useState<Record<string, FacetLevelState>>({})
|
|
96
|
+
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({})
|
|
97
|
+
const [unavailable, setUnavailable] = React.useState(false)
|
|
98
|
+
|
|
99
|
+
/** Containers already asked for, so a re-render or a re-open costs nothing. */
|
|
100
|
+
const requested = React.useRef<Set<string>>(new Set())
|
|
101
|
+
const told = React.useRef(false)
|
|
102
|
+
|
|
103
|
+
// A new loader means a new principal or a new workspace. Everything already
|
|
104
|
+
// fetched was gated for the OLD one, so it is dropped rather than reused — a
|
|
105
|
+
// stale level is exactly the leak this file is careful about.
|
|
106
|
+
React.useEffect(() => {
|
|
107
|
+
requested.current = new Set()
|
|
108
|
+
told.current = false
|
|
109
|
+
setLevels({})
|
|
110
|
+
setExpanded({})
|
|
111
|
+
setUnavailable(false)
|
|
112
|
+
}, [load])
|
|
113
|
+
|
|
114
|
+
const fetchLevel = React.useCallback(
|
|
115
|
+
async (under: string, page: number) => {
|
|
116
|
+
if (!load) {
|
|
117
|
+
setUnavailable(true)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
setLevels((prev) => ({
|
|
121
|
+
...prev,
|
|
122
|
+
[under]: { ...(prev[under] ?? EMPTY_LEVEL), loading: true, error: '' },
|
|
123
|
+
}))
|
|
124
|
+
try {
|
|
125
|
+
const result = await load({ under, page, pageSize })
|
|
126
|
+
setLevels((prev) => {
|
|
127
|
+
const before = prev[under] ?? EMPTY_LEVEL
|
|
128
|
+
return {
|
|
129
|
+
...prev,
|
|
130
|
+
[under]: {
|
|
131
|
+
// APPEND, and only ever within this same `under=`. Page 2 of a
|
|
132
|
+
// container is more of the answer the server already gave about
|
|
133
|
+
// that container; it is not evidence about any other one.
|
|
134
|
+
rows: page > 1 ? [...before.rows, ...result.results] : result.results,
|
|
135
|
+
total: result.count,
|
|
136
|
+
page,
|
|
137
|
+
loading: false,
|
|
138
|
+
error: '',
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (isTreeUnsupported(err)) {
|
|
144
|
+
setUnavailable(true)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
setLevels((prev) => ({
|
|
148
|
+
...prev,
|
|
149
|
+
[under]: { ...(prev[under] ?? EMPTY_LEVEL), loading: false, error: failure(err) },
|
|
150
|
+
}))
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
[load, pageSize],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The ref rather than a check on `levels`, because the guard has to hold
|
|
158
|
+
* BETWEEN renders — two clicks in the same tick, or a StrictMode
|
|
159
|
+
* double-invoke, would otherwise both see an empty map and both fetch.
|
|
160
|
+
*/
|
|
161
|
+
const ensure = React.useCallback(
|
|
162
|
+
(under: string) => {
|
|
163
|
+
if (requested.current.has(under)) return
|
|
164
|
+
requested.current.add(under)
|
|
165
|
+
void fetchLevel(under, 1)
|
|
166
|
+
},
|
|
167
|
+
[fetchLevel],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
React.useEffect(() => {
|
|
171
|
+
if (enabled && !unavailable) ensure(root)
|
|
172
|
+
}, [ensure, enabled, unavailable, root])
|
|
173
|
+
|
|
174
|
+
React.useEffect(() => {
|
|
175
|
+
if (unavailable && !told.current) {
|
|
176
|
+
told.current = true
|
|
177
|
+
onUnavailable?.()
|
|
178
|
+
}
|
|
179
|
+
}, [unavailable, onUnavailable])
|
|
180
|
+
|
|
181
|
+
const toggle = React.useCallback(
|
|
182
|
+
(path: string) => {
|
|
183
|
+
setExpanded((prev) => ({ ...prev, [path]: !prev[path] }))
|
|
184
|
+
// Idempotent, so calling it on a COLLAPSE is free — and calling it here
|
|
185
|
+
// rather than inside the updater above keeps the updater pure.
|
|
186
|
+
ensure(path)
|
|
187
|
+
},
|
|
188
|
+
[ensure],
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const levelOf = React.useCallback(
|
|
192
|
+
(under: string) => levels[under] ?? EMPTY_LEVEL,
|
|
193
|
+
[levels],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return { levels, expanded, unavailable, levelOf, toggle, loadMore: fetchLevel, ensure }
|
|
197
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -190,3 +190,49 @@ export * from './validation'
|
|
|
190
190
|
// scorecard) + ReviewNotes (critique thread). Controlled + presentational; light
|
|
191
191
|
// (React + lucide only), so they ride the main barrel like health / validation.
|
|
192
192
|
export * from './review'
|
|
193
|
+
|
|
194
|
+
// Scope switcher (bd startsim-6fceh) + the data-agnostic facet tree it is built
|
|
195
|
+
// on. Named exports rather than `export *` so the DRF `Paginated` envelope does
|
|
196
|
+
// not enter the main barrel — it belongs to the '@startsimpli/ui/collection'
|
|
197
|
+
// contract and re-exporting it here would give it two homes.
|
|
198
|
+
export {
|
|
199
|
+
ScopeSwitcher,
|
|
200
|
+
ScopeSwitcherControlled,
|
|
201
|
+
useScopeParam,
|
|
202
|
+
createScopeClient,
|
|
203
|
+
SCOPE_PARAM,
|
|
204
|
+
SCOPE_ALL,
|
|
205
|
+
} from './scope'
|
|
206
|
+
export type {
|
|
207
|
+
ScopeSwitcherProps,
|
|
208
|
+
ScopeSwitcherControlledProps,
|
|
209
|
+
ScopeSwitcherBaseProps,
|
|
210
|
+
ScopeSwitcherLabels,
|
|
211
|
+
ScopeParam,
|
|
212
|
+
UseScopeParamOptions,
|
|
213
|
+
ScopeClient,
|
|
214
|
+
ScopeNode,
|
|
215
|
+
ScopeMatch,
|
|
216
|
+
ScopeSearchQuery,
|
|
217
|
+
ScopeHttp,
|
|
218
|
+
CreateScopeClientOptions,
|
|
219
|
+
} from './scope'
|
|
220
|
+
export {
|
|
221
|
+
FacetTree,
|
|
222
|
+
useFacetLevels,
|
|
223
|
+
isTreeUnsupported,
|
|
224
|
+
facetName,
|
|
225
|
+
facetTrail,
|
|
226
|
+
FACET_ROOT,
|
|
227
|
+
PATH_SEPARATOR,
|
|
228
|
+
} from './facet-tree'
|
|
229
|
+
export type {
|
|
230
|
+
FacetTreeProps,
|
|
231
|
+
FacetTreeSlot,
|
|
232
|
+
FacetLevels,
|
|
233
|
+
UseFacetLevelsOptions,
|
|
234
|
+
FacetNode,
|
|
235
|
+
FacetLevelQuery,
|
|
236
|
+
FacetLevelLoader,
|
|
237
|
+
FacetLevelState,
|
|
238
|
+
} from './facet-tree'
|