@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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * A `ScopeClient` over the tenant data plane's generic `/api/v1/entities/*`
3
+ * endpoints — the ONE place in this package that knows how a scope is stored.
4
+ *
5
+ * It exists so that every app does not re-derive the same two parameter builders
6
+ * (CLAUDE.md rule 9). An app that already has its own adapter can ignore it and
7
+ * implement `ScopeClient` directly; nothing else in this package imports it.
8
+ *
9
+ * THE FACET READ HAS ITS OWN PARAMETER BUILDER, and that is not tidiness. The
10
+ * facets action SCREENS its query parameters against a vocabulary before any
11
+ * handler runs (`_Scoped.initial` -> `_FACET_FILTERS`), so handing it a list
12
+ * builder's parameters risks 400-ing the whole tree on a filter it does not
13
+ * know. This sends exactly `type`, `attr`, `under` and the page, which every
14
+ * deployment that has the route at all understands.
15
+ *
16
+ * SERVER-SIDE, ALWAYS (CLAUDE.md rule 8). Both reads are paginated by the server
17
+ * and neither is filtered in the browser. There is no read of "all scopes" here
18
+ * to slice up locally, and there must not be.
19
+ */
20
+ import { facetName } from '../facet-tree/path'
21
+ import type { FacetLevelQuery, Paginated, ScopeClient, ScopeMatch, ScopeNode, ScopeSearchQuery } from './types'
22
+
23
+ /** The tenant page envelope as the wire spells it, before key normalization. */
24
+ interface WirePage<T> {
25
+ count?: number
26
+ next?: string | null
27
+ previous?: string | null
28
+ results?: T[]
29
+ }
30
+
31
+ /**
32
+ * One `/entities/facets/` row. Spelled BOTH WAYS because the house API clients
33
+ * camelCase response keys and a raw `fetch` does not, so `has_children` arrives
34
+ * under either name depending on which client the app injected.
35
+ */
36
+ interface WireFacetRow {
37
+ value?: string
38
+ count?: number
39
+ has_children?: boolean
40
+ hasChildren?: boolean
41
+ }
42
+
43
+ /** One `/entities/` row, of which the switcher needs only the name and the path. */
44
+ interface WireEntity {
45
+ name?: string
46
+ data?: Record<string, unknown> | null
47
+ }
48
+
49
+ export interface ScopeHttp {
50
+ get<T>(path: string, config?: { params?: Record<string, unknown> }): Promise<T>
51
+ }
52
+
53
+ export interface CreateScopeClientOptions {
54
+ http: ScopeHttp
55
+ /** The declared entity type key. */
56
+ type?: string
57
+ /** The attribute carrying the materialized path. */
58
+ attr?: string
59
+ /** Collection path, for a deployment that mounts the API somewhere else. */
60
+ entitiesPath?: string
61
+ }
62
+
63
+ /** The tenant caps `page_size`; asking for more is silently overruled. */
64
+ const MAX_PAGE_SIZE = 200
65
+
66
+ /** `?search=` is a text filter and the server bounds text filters. */
67
+ const TEXT_FILTER_MAX = 500
68
+
69
+ function pageParams(query: { page?: number; pageSize?: number }): Record<string, unknown> {
70
+ const out: Record<string, unknown> = { page: query.page ?? 1 }
71
+ if (query.pageSize) out.page_size = Math.min(query.pageSize, MAX_PAGE_SIZE)
72
+ return out
73
+ }
74
+
75
+ function envelope<T, U>(response: WirePage<T>, map: (row: T) => U | null): Paginated<U> {
76
+ const rows = response?.results ?? []
77
+ const mapped = rows.map(map).filter((row): row is U => row !== null)
78
+ return {
79
+ // The server's own total, NOT the mapped length: a row dropped below is
80
+ // still a row the server counted, and reporting the shorter number would
81
+ // make "Show N more" stop early.
82
+ count: response?.count ?? rows.length,
83
+ next: response?.next ?? null,
84
+ previous: response?.previous ?? null,
85
+ results: mapped,
86
+ }
87
+ }
88
+
89
+ export function createScopeClient({
90
+ http,
91
+ type = 'scope',
92
+ attr = 'path',
93
+ entitiesPath = 'api/v1/entities',
94
+ }: CreateScopeClientOptions): ScopeClient {
95
+ function toNode(row: WireFacetRow): ScopeNode | null {
96
+ const path = typeof row?.value === 'string' ? row.value : ''
97
+ if (!path) return null
98
+ return {
99
+ path,
100
+ name: facetName(path),
101
+ count: row?.count ?? 0,
102
+ hasChildren: Boolean(row?.has_children ?? row?.hasChildren),
103
+ }
104
+ }
105
+
106
+ function toMatch(row: WireEntity): ScopeMatch | null {
107
+ const raw = row?.data ? (row.data as Record<string, unknown>)[attr] : undefined
108
+ const path = typeof raw === 'string' ? raw : ''
109
+ // A scope record with no path is not addressable — a selection carries the
110
+ // path and nothing else — so it is dropped rather than shown as a row that
111
+ // does nothing when clicked.
112
+ if (!path) return null
113
+ return { path, name: row?.name || facetName(path) }
114
+ }
115
+
116
+ return {
117
+ listScopeChildren(query: FacetLevelQuery): Promise<Paginated<ScopeNode>> {
118
+ return http
119
+ .get<WirePage<WireFacetRow>>(`${entitiesPath}/facets`, {
120
+ params: {
121
+ type,
122
+ attr,
123
+ // The FULL path the server handed back, verbatim. `/` is the root
124
+ // and means EVERY scope — `subtree_bound()` makes the root's
125
+ // descendant prefix `/`, which every stored path begins with.
126
+ under: query.under || '/',
127
+ ...pageParams(query),
128
+ },
129
+ })
130
+ .then((response) => envelope(response, toNode))
131
+ },
132
+
133
+ searchScopes(query: ScopeSearchQuery): Promise<Paginated<ScopeMatch>> {
134
+ return http
135
+ .get<WirePage<WireEntity>>(entitiesPath, {
136
+ params: {
137
+ type,
138
+ search: query.search.slice(0, TEXT_FILTER_MAX),
139
+ ...pageParams(query),
140
+ },
141
+ })
142
+ .then((response) => envelope(response, toMatch))
143
+ },
144
+ }
145
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @startsimpli/ui/scope — the scope switcher and its URL binding
3
+ * (bd startsim-6fceh, epic startsim-ux39t).
4
+ *
5
+ * A scope is one self-nesting type: a root scope is a client, a child scope is a
6
+ * project, and depth is not capped. Selection lives in the query string, so a
7
+ * scoped view is a link somebody can send and a reload lands where it left off.
8
+ */
9
+ export {
10
+ ScopeSwitcher,
11
+ ScopeSwitcherControlled,
12
+ type ScopeSwitcherProps,
13
+ type ScopeSwitcherControlledProps,
14
+ type ScopeSwitcherBaseProps,
15
+ type ScopeSwitcherLabels,
16
+ } from './ScopeSwitcher'
17
+ export {
18
+ useScopeParam,
19
+ SCOPE_PARAM,
20
+ SCOPE_ALL,
21
+ type ScopeParam,
22
+ type UseScopeParamOptions,
23
+ } from './useScopeParam'
24
+ export {
25
+ createScopeClient,
26
+ type ScopeHttp,
27
+ type CreateScopeClientOptions,
28
+ } from './client'
29
+ export type {
30
+ ScopeClient,
31
+ ScopeNode,
32
+ ScopeMatch,
33
+ ScopeSearchQuery,
34
+ } from './types'
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @startsimpli/ui/scope — the contract the `<ScopeSwitcher/>` is handed
3
+ * (bd startsim-6fceh, epic startsim-ux39t).
4
+ *
5
+ * A SCOPE IS ONE SELF-NESTING TYPE, not client-and-project as two. A root scope
6
+ * is what a person calls a client, a child scope is what they call a project,
7
+ * and depth is not capped by the model — the UI may print "Client" and "Project"
8
+ * but that is a label on a `kind` attribute, not a second table. Nesting is a
9
+ * MATERIALIZED PATH (epic startsim-ux39t), which is why this file describes a
10
+ * tree of paths rather than a graph of edges.
11
+ *
12
+ * CLIENT-INJECTED, the same way `CollectionClient` and `CorpusApi` are: the app
13
+ * passes an object implementing this shape over its own authed HTTP client, and
14
+ * nothing in this package constructs a request or knows an origin.
15
+ * `createScopeClient` in `./client` is a ready adapter over the tenant's generic
16
+ * `/api/v1/entities/*` endpoints for apps that want one.
17
+ *
18
+ * WHAT THE SERVER GUARANTEES AND THIS PACKAGE MUST NOT ASSUME AWAY: both reads
19
+ * are gated out of `EntityViewSet.get_queryset()` itself — type gate, row gate,
20
+ * field gate — so a viewer is shown the scopes their own readable rows are in.
21
+ * The component therefore renders only what came back for the exact request it
22
+ * made, and never composes a scope out of two responses.
23
+ */
24
+ import type { FacetLevelQuery, FacetNode, Paginated } from '../facet-tree/types'
25
+
26
+ export type { FacetLevelQuery, Paginated }
27
+
28
+ /**
29
+ * A scope as the tree read describes it: full path, display name, descendant
30
+ * record count, and whether anything nests below it.
31
+ *
32
+ * It is exactly a `FacetNode` — a scope is one value of the `path` attribute,
33
+ * derived at read time. There is no scope row minted here and no scope id, so a
34
+ * rename orphans nothing.
35
+ */
36
+ export type ScopeNode = FacetNode
37
+
38
+ /**
39
+ * A scope as the SEARCH read describes it, and it is deliberately thinner.
40
+ *
41
+ * Search is a row read (`GET /entities/?type=scope&search=…`), not a facet read,
42
+ * so it returns the scope RECORDS that matched rather than grouped path values.
43
+ * That read knows the scope's own `name` — which is the label a person gave it,
44
+ * better than the last path segment — but it has no descendant count to offer,
45
+ * so `count` is optional and a row simply shows none. Inventing one would mean a
46
+ * second round trip per result, or a number that is not the one the tree shows.
47
+ */
48
+ export interface ScopeMatch {
49
+ /** The FULL path, verbatim from the server. The value a selection carries. */
50
+ path: string
51
+ /** What to call it. The record's own name where there is one, else the last segment. */
52
+ name: string
53
+ /** Descendant records, when the read happened to know. Usually absent. */
54
+ count?: number
55
+ }
56
+
57
+ export interface ScopeSearchQuery {
58
+ search: string
59
+ page?: number
60
+ pageSize?: number
61
+ }
62
+
63
+ /**
64
+ * THE TWO READS A SCOPE SWITCHER NEEDS.
65
+ *
66
+ * They are different endpoints on purpose. Browsing is a FACET read — the child
67
+ * scopes of one node, with counts and disclosure, one level per request. Searching
68
+ * is a ROW read — "the scopes whose record matches this term", anywhere in the
69
+ * tree, flat, each carrying its own full path. Neither is expressible as the
70
+ * other, and doing search by filtering already-loaded levels would answer
71
+ * confidently about a subtree the reader has never expanded.
72
+ */
73
+ export interface ScopeClient {
74
+ /**
75
+ * One level of the tree. `under` is a path the server previously returned, or
76
+ * `/` for the top — which means EVERY scope, not just the roots.
77
+ *
78
+ * MUST BE REFERENTIALLY STABLE (memoize the client object). Its identity is
79
+ * the switcher's reset key: a new client means a new principal, and everything
80
+ * already fetched was gated for the old one.
81
+ */
82
+ listScopeChildren(query: FacetLevelQuery): Promise<Paginated<ScopeNode>>
83
+ /**
84
+ * OPTIONAL, because a deployment can legitimately not offer it. Where it is
85
+ * absent — or answers 404/400 — the switcher filters the levels it has already
86
+ * loaded and SAYS SO on screen, rather than rendering a partial answer under a
87
+ * control that implies it searched everything.
88
+ */
89
+ searchScopes?(query: ScopeSearchQuery): Promise<Paginated<ScopeMatch>>
90
+ }
@@ -0,0 +1,200 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * THE SCOPE LIVES IN THE URL. This hook is the only thing that reads or writes
5
+ * it, and the switcher is built on it rather than on state of its own.
6
+ *
7
+ * WHY THE URL AND NOT A CONTEXT (bd startsim-6fceh). A scoped view has to be a
8
+ * link somebody can send, and it has to survive a reload. A provider gives you
9
+ * neither, and a provider mounted in a Next layout gives you something worse —
10
+ * see below.
11
+ *
12
+ * WHY THIS IS `'use client'` AND READS `useSearchParams()` ON EVERY RENDER.
13
+ * Next.js does not re-render a shared LAYOUT on client-side navigation: layouts
14
+ * are cached per segment path, ignoring the query string, so state a SERVER
15
+ * layout derived from the query (or from a middleware header carrying it)
16
+ * survives a soft nav to a differently-scoped URL and renders stale — that is
17
+ * `feedback_no_searchparam_state_in_layouts`, hit for real on auth-web tenant
18
+ * branding, where a hard nav passed and a soft nav kept the wrong tenant. A
19
+ * CLIENT component is not subject to it: `useSearchParams()` is backed by the
20
+ * app router's own context, so a component reading it re-renders on every
21
+ * navigation no matter which layout it happens to sit in. The consuming app's
22
+ * sidebar sits in exactly such a layout, which is why the switcher takes no
23
+ * `value` prop for a server layout to compute and hand it — the only shape that
24
+ * makes the stale reading unreachable is the component owning the read itself.
25
+ *
26
+ * "ALL SCOPES" IS SPELLED `?scope=all`, AND IT HAS TO BE SPELLED SOMEHOW. The
27
+ * obvious design deletes the parameter for "everything", and it has a hole in
28
+ * it: a URL with no parameter then says two different things — "I chose
29
+ * everything" and "nobody has chosen" — and `remember` below cannot tell them
30
+ * apart, so a colleague's link meaning "look at all of it" would open at
31
+ * whatever scope the reader's own tab was last on. `all` is a reserved value
32
+ * that no path can collide with, because every path begins with `/`. Both
33
+ * spellings read out as `scope: null`; the difference is only whether the URL is
34
+ * ASSERTING it.
35
+ *
36
+ * TWO MECHANISMS KEEP A SCOPE ALIVE ACROSS NAVIGATION, and they are not
37
+ * redundant:
38
+ *
39
+ * • `withScope` — the RIGHT one. A `<Link href="/drafts">` drops the query
40
+ * string, so a sidebar built from bare hrefs loses the scope on every click.
41
+ * Wrap the href and the link carries whatever the URL is currently asserting,
42
+ * which keeps the URL the whole truth.
43
+ * • `remember` — the SAFETY NET, on by default. The last explicit choice is
44
+ * kept in `sessionStorage` and written back onto a route reached with NO
45
+ * parameter at all. THE READING IT COMMITS TO: a bare URL means "this tab's
46
+ * scope", and only a `?scope=` parameter is an assertion about scope. So a
47
+ * link that names a scope wins, a link that says `all` wins, and a bare
48
+ * `/drafts` picks up where the reader left off. An app that wants the bare
49
+ * URL to mean "everything" instead should pass `remember={false}` and rely on
50
+ * `withScope` alone.
51
+ */
52
+
53
+ import * as React from 'react'
54
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation'
55
+
56
+ /** The query parameter. Short and legible, because a person reads this URL. */
57
+ export const SCOPE_PARAM = 'scope'
58
+
59
+ /**
60
+ * The reserved parameter value that ASSERTS "no bound at all".
61
+ *
62
+ * It cannot collide with a scope: a path is a materialized path and always
63
+ * begins with the separator, so `all` is not a value the server can ever return.
64
+ */
65
+ export const SCOPE_ALL = 'all'
66
+
67
+ export interface UseScopeParamOptions {
68
+ /** Override the parameter name where an app already uses `scope` for something else. */
69
+ param?: string
70
+ /**
71
+ * Keep the last explicit choice for this browser tab and restore it onto a URL
72
+ * that carries no parameter at all. Set false to make the URL the only
73
+ * mechanism — see the header for which reading each posture commits to.
74
+ */
75
+ remember?: boolean
76
+ /** Namespace for the remembered value, so two apps on one origin do not collide. */
77
+ rememberKey?: string
78
+ /**
79
+ * `replace` (default) keeps the back button meaning "the page before", which is
80
+ * what a filter control should do. `push` makes each scope change its own
81
+ * history entry.
82
+ */
83
+ history?: 'replace' | 'push'
84
+ }
85
+
86
+ export interface ScopeParam {
87
+ /** The path in scope, or `null` for "no bound at all". */
88
+ scope: string | null
89
+ /**
90
+ * True when the URL is ASSERTING a scope — a path, or `all`. False on a bare
91
+ * URL, where `scope` is `null` because nobody has said anything yet.
92
+ */
93
+ asserted: boolean
94
+ /** Write a full path the server returned, or `null` to bound nothing. */
95
+ setScope: (next: string | null) => void
96
+ /** Carry the URL's current assertion onto an href, so a `<Link>` does not drop it. */
97
+ withScope: (href: string) => string
98
+ }
99
+
100
+ /** `sessionStorage` throws outright in some embeddings; never let that break a render. */
101
+ function readRemembered(key: string): string | null {
102
+ try {
103
+ const raw = window.sessionStorage.getItem(key)
104
+ return raw && raw.trim() ? raw : null
105
+ } catch {
106
+ return null
107
+ }
108
+ }
109
+
110
+ function writeRemembered(key: string, value: string): void {
111
+ try {
112
+ window.sessionStorage.setItem(key, value)
113
+ } catch {
114
+ /* private mode, blocked storage — the URL still carries the truth */
115
+ }
116
+ }
117
+
118
+ export function useScopeParam({
119
+ param = SCOPE_PARAM,
120
+ remember = true,
121
+ rememberKey,
122
+ history = 'replace',
123
+ }: UseScopeParamOptions = {}): ScopeParam {
124
+ const searchParams = useSearchParams()
125
+ const router = useRouter()
126
+ const pathname = usePathname()
127
+
128
+ const storageKey = rememberKey ?? `startsimpli.${param}`
129
+ // THE STRING, not the object. Everything below depends on the query as a
130
+ // VALUE, so a host whose `useSearchParams()` happens to hand back a fresh
131
+ // instance per render cannot turn the restore effect below into a redirect
132
+ // loop. Next memoizes it today; this does not rely on that.
133
+ const queryString = searchParams?.toString() ?? ''
134
+ const raw = searchParams?.get(param) ?? null
135
+ // A blank parameter asserts nothing. `?scope=` is what a hand-edited URL leaves
136
+ // behind, and treating it as an assertion would be reading intent into a typo.
137
+ const asserted = Boolean(raw && raw.trim())
138
+ const value = asserted ? (raw as string).trim() : null
139
+ const scope = value === SCOPE_ALL ? null : value
140
+
141
+ const navigate = React.useCallback(
142
+ (next: string) => {
143
+ const params = new URLSearchParams(queryString)
144
+ params.set(param, next)
145
+ const url = `${pathname}?${params.toString()}`
146
+ const go = history === 'push' ? router.push : router.replace
147
+ go(url, { scroll: false })
148
+ },
149
+ [queryString, pathname, router, param, history],
150
+ )
151
+
152
+ const setScope = React.useCallback(
153
+ (next: string | null) => {
154
+ // WRITTEN, never deleted. A choice of "everything" is a choice, and the URL
155
+ // is where a choice is legible — see the header on `SCOPE_ALL`.
156
+ const written = next === null ? SCOPE_ALL : next
157
+ if (remember) writeRemembered(storageKey, written)
158
+ navigate(written)
159
+ },
160
+ [remember, storageKey, navigate],
161
+ )
162
+
163
+ // THE SAFETY NET. A route reached with NO parameter — a `<Link>` that dropped
164
+ // the query, a bookmark of the bare path — takes the tab's last explicit
165
+ // choice, whether that was a scope or `all`. It never runs when the URL
166
+ // asserts anything, so a link somebody sent always wins.
167
+ //
168
+ // ONE ATTEMPT PER VISIT, and the ref is what makes that true. Without it, a
169
+ // `replace` the host declines to honour would leave the effect's inputs
170
+ // unchanged and the restore would fire again on the next render — a redirect
171
+ // loop out of a convenience. (It is keyed on the pathname, so leaving a route
172
+ // and coming back earns a fresh attempt, which is what a return visit wants.)
173
+ const restoredFor = React.useRef<string | null>(null)
174
+ React.useEffect(() => {
175
+ if (!remember || asserted) return
176
+ if (restoredFor.current === pathname) return
177
+ const remembered = readRemembered(storageKey)
178
+ if (!remembered) return
179
+ restoredFor.current = pathname
180
+ navigate(remembered)
181
+ }, [remember, asserted, storageKey, navigate, pathname])
182
+
183
+ const withScope = React.useCallback(
184
+ (href: string) => {
185
+ // The URL's ASSERTION, not the resolved scope — so `all` propagates through
186
+ // a link as `all` and keeps meaning "everything" on the next page.
187
+ if (!value) return href
188
+ const [base, existing = ''] = String(href).split('#')[0].split('?')
189
+ const hash = String(href).includes('#') ? `#${String(href).split('#')[1]}` : ''
190
+ const params = new URLSearchParams(existing)
191
+ // A link that already names a scope means it, and is left alone.
192
+ if (!params.get(param)) params.set(param, value)
193
+ const query = params.toString()
194
+ return `${base}${query ? `?${query}` : ''}${hash}`
195
+ },
196
+ [value, param],
197
+ )
198
+
199
+ return { scope, asserted, setScope, withScope }
200
+ }