@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,155 @@
1
+ /**
2
+ * THE ONE SEAM (bd startsim-nq39j, epic startsim-ux39t).
3
+ *
4
+ * `withScope(client, scope)` returns a `CollectionClient` whose LIST reads carry
5
+ * the active scope and whose WRITES cannot cross one. Because every review
6
+ * surface in this package is client-injected — the app wrapper is ~10 LOC and
7
+ * hands in its authed tenant API — wrapping that one object scopes the whole
8
+ * surface at once. Nothing else in `collection/` knows what a scope is, and
9
+ * nothing else should learn: a second place that narrows is a second place that
10
+ * can forget to.
11
+ *
12
+ * THE WIRE SPELLING OF "EVERYTHING" IS SILENCE, and this is the decision the
13
+ * bead asked to be taken deliberately. The BROWSER URL spells it `?scope=all`
14
+ * (`SCOPE_ALL`), because there a missing parameter has to stay distinguishable
15
+ * from an asserted one — see `../scope/useScopeParam`. The WIRE cannot borrow
16
+ * that spelling and must not borrow the empty one either:
17
+ *
18
+ * • `?scope=all` is a 400 — every stored path begins with the separator, so a
19
+ * value that does not is refused as a value that could only match nothing.
20
+ * • `?scope=` (blank) is ALSO a 400, and it is the dangerous one:
21
+ * `normalize_container("")` returns `/`, so the obvious implementation reads
22
+ * an empty parameter as EVERY scoped row (bd startsim-mj609).
23
+ *
24
+ * So "no scope" OMITS THE KEY. That is not merely the safe encoding, it is the
25
+ * only correct one: an omitted parameter returns unscoped rows too, which today
26
+ * is every row, while `?scope=/` would return only rows that carry a path.
27
+ *
28
+ * A PATH IS PASSED THROUGH VERBATIM, INCLUDING A BAD ONE. A value that is not
29
+ * `all` and not blank goes on the wire as the caller wrote it, even without a
30
+ * leading separator, because the server refuses that and a refusal is a better
31
+ * answer than a client that quietly drops a narrowing it did not understand.
32
+ *
33
+ * REFERENTIALLY STABLE OR NOT AT ALL. The returned client is the query key's
34
+ * input and react-query's identity for the injected API; building a new one on
35
+ * every render is the same mistake as building a new `ScopeClient` on every
36
+ * render (see `../scope/types.ts`). Memoize it on the scope, or use
37
+ * `useScopedCollectionClient` from `@startsimpli/ui/scope`, which does.
38
+ */
39
+ import type { CollectionClient, EntityFilters, ListEntitiesOptions } from './types';
40
+
41
+ /**
42
+ * The query parameter the tenant recognises. It is a plain lowercase word, so
43
+ * unlike `attr.x__gte` it survives a camel/snake rewrite unharmed — but it still
44
+ * travels the {@link EntityFilters} path, because one narrowing mechanism is the
45
+ * whole point of `EntityFilters` existing.
46
+ */
47
+ export const SCOPE_FILTER = 'scope';
48
+
49
+ /**
50
+ * The URL's reserved word for "no bound at all". Recognised here as well as in
51
+ * `useScopeParam` because a caller can reach a raw `?scope=` value without going
52
+ * through the hook — a server component reading `searchParams`, a test, an app
53
+ * that keeps the scope somewhere else — and `all` must mean the same thing to
54
+ * every one of them.
55
+ */
56
+ const SCOPE_ALL_VALUE = 'all';
57
+
58
+ /**
59
+ * A `CollectionClient` that knows what it is bounded to.
60
+ *
61
+ * `scope` is exposed for ONE reason and it is not decoration: a cached read is
62
+ * only valid for the scope it was made under, so a consumer's react-query key
63
+ * has to name it. {@link scopeKey} is that read.
64
+ */
65
+ export interface ScopedCollectionClient extends CollectionClient {
66
+ /** The active path, or `null` for no bound. */
67
+ readonly scope: string | null;
68
+ }
69
+
70
+ /**
71
+ * Resolve any spelling of a scope to the one the wire uses: a path, or `null`.
72
+ *
73
+ * `null`, `undefined`, empty, whitespace and the reserved `all` all resolve to
74
+ * `null` — "no bound" — and `null` is spelled on the wire by sending nothing.
75
+ */
76
+ export function resolveScope(scope: string | null | undefined): string | null {
77
+ if (typeof scope !== 'string') return null;
78
+ const trimmed = scope.trim();
79
+ if (!trimmed || trimmed === SCOPE_ALL_VALUE) return null;
80
+ return trimmed;
81
+ }
82
+
83
+ /**
84
+ * The filters a scope contributes to a list read — `{}` when there is no bound.
85
+ *
86
+ * IT RETURNS AN ABSENT KEY, NOT AN EMPTY ONE. `{ scope: '' }` is a 400 that
87
+ * would be read as "the root" by an unlucky implementation; the only encoding of
88
+ * "everything" this package will ever put on the wire is no key at all.
89
+ */
90
+ export function scopeFilters(scope: string | null | undefined): EntityFilters {
91
+ const resolved = resolveScope(scope);
92
+ return resolved === null ? {} : { [SCOPE_FILTER]: resolved };
93
+ }
94
+
95
+ /**
96
+ * The cache-key fragment for a client's bound. `'all'` for no bound, so an
97
+ * UNWRAPPED client (a plain `CollectionClient`, which has no `scope` at all)
98
+ * keys identically to an explicitly unbounded one and an existing consumer's
99
+ * cache does not split in two for no reason.
100
+ */
101
+ export function scopeKey(client: Pick<ScopedCollectionClient, 'scope'> | CollectionClient | null | undefined): string {
102
+ const scope = (client as ScopedCollectionClient | null | undefined)?.scope;
103
+ return resolveScope(scope) ?? SCOPE_ALL_VALUE;
104
+ }
105
+
106
+ /**
107
+ * Bind a `CollectionClient` to a scope.
108
+ *
109
+ * LIST reads gain `?scope=<path>`; WRITES are handed on BYTE-FOR-BYTE. A write
110
+ * that carried the active scope would be able to MOVE a row: patch a row while
111
+ * `/clients/acme` is selected and it would silently leave `/clients/globex`.
112
+ * Changing a row's scope is an explicit act with its own affordance, never a
113
+ * side effect of editing it somewhere. (The bead's phrase "writes stamp it"
114
+ * belongs to CREATE, on the scope-aware upsert path — there is no create on this
115
+ * interface, so there is nothing here to stamp.)
116
+ */
117
+ export function withScope(
118
+ client: CollectionClient,
119
+ scope: string | null | undefined,
120
+ ): ScopedCollectionClient {
121
+ const resolved = resolveScope(scope);
122
+
123
+ return {
124
+ scope: resolved,
125
+
126
+ // Passed on with NO arguments at all. The schema read carries no `?type=`,
127
+ // and `?scope=` without `?type=` is a 400 (bd startsim-mj609) — so this is
128
+ // not an omission to tidy up later, it is the refusal being respected.
129
+ listTypes: () => client.listTypes(),
130
+
131
+ listAllEntities: (type: string, options?: ListEntitiesOptions) => {
132
+ // A type-less list read cannot carry a scope for the same reason
133
+ // `listTypes` cannot: the parameter resolves through ONE type's
134
+ // declaration. A blank type is already a bad request; adding a scope to it
135
+ // would turn a legible 400 into a confusing one.
136
+ const scopable = typeof type === 'string' && type.trim() !== '';
137
+ const applies = scopable && options?.scoped !== false;
138
+ const extra = applies ? scopeFilters(resolved) : {};
139
+ // Merged UNDER the caller's own filters, not over them: a consumer that
140
+ // deliberately sent its own `scope` (a fixture, a one-off drill-down)
141
+ // means it, and the decorator does not overrule it.
142
+ const filters: EntityFilters = { ...extra, ...(options?.filters ?? {}) };
143
+ const next: ListEntitiesOptions = { ...options, filters };
144
+ // No filters and no options in means no options through — an
145
+ // implementation that only ever took `(type)` sees exactly what it did
146
+ // before, so wrapping an old client is not a behaviour change.
147
+ if (Object.keys(filters).length === 0 && options === undefined) {
148
+ return client.listAllEntities(type);
149
+ }
150
+ return client.listAllEntities(type, next);
151
+ },
152
+
153
+ updateEntity: (id, input) => client.updateEntity(id, input),
154
+ };
155
+ }
@@ -42,13 +42,64 @@ export interface Paginated<T> {
42
42
  results: T[];
43
43
  }
44
44
 
45
+ /**
46
+ * Server-side narrowing, spelled EXACTLY as the wire spells it — `type`,
47
+ * `scope`, `attr.published_at__gte`, `attr.topic_ref__in`. The value is a string
48
+ * because it is a query-string value and nothing else.
49
+ *
50
+ * THE KEY GOES IN THE URL, NEVER THROUGH A CLIENT'S `params` BAG. The shared
51
+ * `@startsimpli/api` client rewrites request keys between camelCase and
52
+ * snake_case, and `attr.published_at__gte` is neither — it comes out mangled,
53
+ * the server does not recognise it, and (on an image with
54
+ * TENANT_ALLOW_UNKNOWN_FILTERS on) reads as "no filter" rather than as an
55
+ * error. That is a widening, and a widening under an active filter chip is the
56
+ * failure that cost nine production tandem rows on 2026-08-27. The fork's
57
+ * `listEntities` already builds the query string itself for this reason and is
58
+ * guarded by its own `src/lib/__tests__/entity-filters.test.ts`; this type is
59
+ * the same contract, named, so a `CollectionClient` implementation inherits the
60
+ * requirement instead of rediscovering it.
61
+ */
62
+ export type EntityFilters = Record<string, string>;
63
+
64
+ /**
65
+ * How a list read is narrowed. Deliberately the SAME SHAPE the fork's
66
+ * `listAllEntities(type, { filters })` already accepts (bd startsim-nq39j), so
67
+ * the existing, tested filter path is extended rather than joined by a second
68
+ * one — an app that already implements it satisfies this interface unchanged.
69
+ */
70
+ export interface ListEntitiesOptions {
71
+ /** Server-side narrowing — see {@link EntityFilters}. */
72
+ filters?: EntityFilters;
73
+ /**
74
+ * Opt this read OUT of the active scope. Default: scoped.
75
+ *
76
+ * THE SHARED CORPUS IS WHY THIS EXISTS (epic startsim-ux39t, decision 2: "one
77
+ * news_item corpus, one source registry"). A source registry or an article
78
+ * corpus is subscribed to by several scopes rather than ingested per scope, so
79
+ * narrowing a LOOKUP read of one to the active scope answers the wrong
80
+ * question: either the type declares no scope path and the server 400s
81
+ * (`?scope=` on an unscoped type is a refusal, bd startsim-mj609), or it does
82
+ * and the lookup silently loses every row outside the bound. Deliverable reads
83
+ * — the topics and drafts a client is owed — stay scoped.
84
+ *
85
+ * The polarity is deliberate: forgetting this flag NARROWS loudly, it never
86
+ * widens silently. Do not delete it as dead config.
87
+ */
88
+ scoped?: boolean;
89
+ }
90
+
45
91
  /**
46
92
  * The tenant-API surface a review workspace needs. The app implements this over
47
93
  * its authed same-origin client (keys are camel/snake-normalized by that client;
48
94
  * PATCH replaces the whole data blob, so callers send the full merged data).
95
+ *
96
+ * `listTypes` TAKES NO NARROWING AND MUST NOT. It is a schema read with no
97
+ * `?type=`, and `?scope=` REQUIRES `?type=` server-side — a 400, by design,
98
+ * because the parameter resolves through one type's declaration and an
99
+ * unfiltered list spans many (bd startsim-mj609).
49
100
  */
50
101
  export interface CollectionClient {
51
102
  listTypes(): Promise<Paginated<EntityTypeDef>>;
52
- listAllEntities(type: string): Promise<EntityRecord[]>;
103
+ listAllEntities(type: string, options?: ListEntitiesOptions): Promise<EntityRecord[]>;
53
104
  updateEntity(id: number | string, input: { name?: string; data?: Record<string, unknown> }): Promise<EntityRecord>;
54
105
  }
@@ -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
+ }