@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.
- package/package.json +3 -1
- package/src/components/collection/DraftReviewWorkspace.tsx +29 -3
- package/src/components/collection/TopicReviewWorkspace.tsx +28 -2
- package/src/components/collection/__tests__/scoped-client.test.ts +243 -0
- package/src/components/collection/__tests__/scoped-workspace.test.tsx +179 -0
- package/src/components/collection/index.ts +22 -1
- package/src/components/collection/scoped-client.ts +155 -0
- package/src/components/collection/types.ts +52 -1
- 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 +47 -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/__tests__/use-scoped-client.test.tsx +80 -0
- package/src/components/scope/client.ts +145 -0
- package/src/components/scope/index.ts +40 -0
- package/src/components/scope/types.ts +90 -0
- package/src/components/scope/useScopeParam.ts +200 -0
- package/src/components/scope/useScopedClient.ts +44 -0
|
@@ -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,40 @@
|
|
|
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
|
+
/**
|
|
30
|
+
* The URL's scope bound onto the app's `CollectionClient` (bd startsim-nq39j) —
|
|
31
|
+
* the whole of a fork's adoption, because every review surface in
|
|
32
|
+
* '@startsimpli/ui/collection' is client-injected.
|
|
33
|
+
*/
|
|
34
|
+
export { useScopedCollectionClient } from './useScopedClient'
|
|
35
|
+
export type {
|
|
36
|
+
ScopeClient,
|
|
37
|
+
ScopeNode,
|
|
38
|
+
ScopeMatch,
|
|
39
|
+
ScopeSearchQuery,
|
|
40
|
+
} 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
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The URL's scope, bound onto the app's injected tenant client — the two halves
|
|
5
|
+
* of bd startsim-nq39j joined in the one place an app has to touch.
|
|
6
|
+
*
|
|
7
|
+
* A fork wrapper is then the whole adoption:
|
|
8
|
+
*
|
|
9
|
+
* const client = useScopedCollectionClient(collectionClient)
|
|
10
|
+
* return <TopicReviewWorkspace client={client} />
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS AND NOT `withScope(collectionClient, useScopeParam().scope)` INLINE.
|
|
13
|
+
* The returned client is a react-query input: it is the query function's closure
|
|
14
|
+
* and, through `scopeKey`, part of the query key. Built fresh on every render it
|
|
15
|
+
* is a new object every render, which is the same defect `ScopeClient` documents
|
|
16
|
+
* for the switcher's own reset key. The memo here is the point of the hook.
|
|
17
|
+
*
|
|
18
|
+
* THE HOOK LIVES IN `scope/`, NOT IN `collection/`, ON PURPOSE. Reading the URL
|
|
19
|
+
* means `next/navigation`; keeping that import out of `collection/` leaves the
|
|
20
|
+
* review workspaces and `withScope` framework-agnostic, so a host that keeps its
|
|
21
|
+
* scope somewhere else (a native shell, a test, a server component passing a
|
|
22
|
+
* value down) calls `withScope` directly and loses nothing.
|
|
23
|
+
*/
|
|
24
|
+
import * as React from 'react'
|
|
25
|
+
|
|
26
|
+
import { withScope, type ScopedCollectionClient } from '../collection/scoped-client'
|
|
27
|
+
import type { CollectionClient } from '../collection/types'
|
|
28
|
+
import { useScopeParam, type UseScopeParamOptions } from './useScopeParam'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Bind the URL's active scope onto a `CollectionClient`.
|
|
32
|
+
*
|
|
33
|
+
* The result is stable while the scope and the underlying client are — so pass a
|
|
34
|
+
* module-level or memoized `client`, exactly as an unwrapped one would need to
|
|
35
|
+
* be. Options are forwarded to {@link useScopeParam} verbatim.
|
|
36
|
+
*/
|
|
37
|
+
export function useScopedCollectionClient(
|
|
38
|
+
client: CollectionClient,
|
|
39
|
+
options: UseScopeParamOptions = {},
|
|
40
|
+
): ScopedCollectionClient {
|
|
41
|
+
const { param, remember, rememberKey, history } = options
|
|
42
|
+
const { scope } = useScopeParam({ param, remember, rememberKey, history })
|
|
43
|
+
return React.useMemo(() => withScope(client, scope), [client, scope])
|
|
44
|
+
}
|