@startsimpli/ui 0.4.110 → 0.4.112

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,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
+ }
@@ -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
+ }
@@ -190,3 +190,50 @@ 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
+ useScopedCollectionClient,
203
+ createScopeClient,
204
+ SCOPE_PARAM,
205
+ SCOPE_ALL,
206
+ } from './scope'
207
+ export type {
208
+ ScopeSwitcherProps,
209
+ ScopeSwitcherControlledProps,
210
+ ScopeSwitcherBaseProps,
211
+ ScopeSwitcherLabels,
212
+ ScopeParam,
213
+ UseScopeParamOptions,
214
+ ScopeClient,
215
+ ScopeNode,
216
+ ScopeMatch,
217
+ ScopeSearchQuery,
218
+ ScopeHttp,
219
+ CreateScopeClientOptions,
220
+ } from './scope'
221
+ export {
222
+ FacetTree,
223
+ useFacetLevels,
224
+ isTreeUnsupported,
225
+ facetName,
226
+ facetTrail,
227
+ FACET_ROOT,
228
+ PATH_SEPARATOR,
229
+ } from './facet-tree'
230
+ export type {
231
+ FacetTreeProps,
232
+ FacetTreeSlot,
233
+ FacetLevels,
234
+ UseFacetLevelsOptions,
235
+ FacetNode,
236
+ FacetLevelQuery,
237
+ FacetLevelLoader,
238
+ FacetLevelState,
239
+ } from './facet-tree'