@startsimpli/ui 0.4.111 → 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 +5 -5
- 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/index.ts +1 -0
- package/src/components/scope/__tests__/use-scoped-client.test.tsx +80 -0
- package/src/components/scope/index.ts +6 -0
- package/src/components/scope/useScopedClient.ts +44 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.112",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
48
48
|
"react": "^18.0.0 || ^19.0.0",
|
|
49
49
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
50
|
-
"@startsimpli/api": "^0.5.58",
|
|
51
50
|
"@startsimpli/auth": "^0.4.35",
|
|
52
|
-
"@startsimpli/i18n": "^0.1.3"
|
|
51
|
+
"@startsimpli/i18n": "^0.1.3",
|
|
52
|
+
"@startsimpli/api": "^0.5.58"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@startsimpli/api": {
|
|
@@ -123,8 +123,8 @@
|
|
|
123
123
|
"tailwindcss": "^3.4.19",
|
|
124
124
|
"tsup": "^8.5.1",
|
|
125
125
|
"typescript": "^6.0.3",
|
|
126
|
-
"@startsimpli/
|
|
127
|
-
"@startsimpli/
|
|
126
|
+
"@startsimpli/i18n": "0.1.3",
|
|
127
|
+
"@startsimpli/realtime": "0.0.2"
|
|
128
128
|
},
|
|
129
129
|
"keywords": [
|
|
130
130
|
"ui",
|
|
@@ -18,6 +18,7 @@ import { notify } from '../toast/use-notify';
|
|
|
18
18
|
import { MarkdownRenderer } from '../blog/MarkdownRenderer';
|
|
19
19
|
import type { CollectionClient, EntityRecord } from './types';
|
|
20
20
|
import { readData, toCamelKey } from './data';
|
|
21
|
+
import { scopeKey } from './scoped-client';
|
|
21
22
|
import {
|
|
22
23
|
groupDrafts,
|
|
23
24
|
parseSources,
|
|
@@ -58,10 +59,14 @@ export interface DraftReviewWorkspaceProps {
|
|
|
58
59
|
|
|
59
60
|
export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
60
61
|
const qc = useQueryClient();
|
|
62
|
+
// The injected client's bound, in the cache key — see the same note on
|
|
63
|
+
// TopicReviewWorkspace: a scope change that does not change the key serves the
|
|
64
|
+
// previous client's drafts under the new client's name.
|
|
65
|
+
const scope = scopeKey(client);
|
|
61
66
|
const typesQ = useQuery({ queryKey: ['schema-types'], queryFn: () => client.listTypes() });
|
|
62
67
|
const draftType = typesQ.data?.results.find((t) => t.key === 'draft');
|
|
63
68
|
const recordsQ = useQuery({
|
|
64
|
-
queryKey: ['entities', 'draft', 'all'],
|
|
69
|
+
queryKey: ['entities', 'draft', 'all', scope],
|
|
65
70
|
queryFn: () => client.listAllEntities('draft'),
|
|
66
71
|
enabled: !!draftType,
|
|
67
72
|
});
|
|
@@ -70,8 +75,12 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
|
70
75
|
// A story whose topic was rejected shouldn't keep cluttering the review — its
|
|
71
76
|
// drafts collapse into the "Rejected" section, mirroring the topic list
|
|
72
77
|
// (startsim-ay9l follow-up). draft.topic_ref is the topic's entity id.
|
|
78
|
+
// SCOPED, unlike the source registry below: a topic is a client's own work,
|
|
79
|
+
// not shared corpus, and this read decides which of THIS board's drafts are
|
|
80
|
+
// collapsed as rejected. Reading it unscoped would let another client's topic
|
|
81
|
+
// ids reach the decision.
|
|
73
82
|
const topicsQ = useQuery({
|
|
74
|
-
queryKey: ['entities', 'topic', 'all'],
|
|
83
|
+
queryKey: ['entities', 'topic', 'all', scope],
|
|
75
84
|
queryFn: () => client.listAllEntities('topic'),
|
|
76
85
|
enabled: !!draftType,
|
|
77
86
|
});
|
|
@@ -83,9 +92,11 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
|
83
92
|
return s;
|
|
84
93
|
}, [topicsQ.data]);
|
|
85
94
|
|
|
95
|
+
// UNSCOPED — the source registry is shared across scopes (epic
|
|
96
|
+
// startsim-ux39t) and this is a domain→tier lookup, not the client's work.
|
|
86
97
|
const sourcesQ = useQuery({
|
|
87
98
|
queryKey: ['entities', 'source', 'all'],
|
|
88
|
-
queryFn: () => client.listAllEntities('source'),
|
|
99
|
+
queryFn: () => client.listAllEntities('source', { scoped: false }),
|
|
89
100
|
enabled: !!draftType,
|
|
90
101
|
});
|
|
91
102
|
const tierByDomain = useMemo(() => {
|
|
@@ -112,6 +123,9 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
|
112
123
|
const [showRejected, setShowRejected] = useState(false);
|
|
113
124
|
|
|
114
125
|
async function writeEntity(record: EntityRecord, changes: Record<string, unknown>) {
|
|
126
|
+
// The FULL blob, spread from the record: PATCH REPLACES `data`, so an
|
|
127
|
+
// attribute this board never touches — the scope path included — has to be
|
|
128
|
+
// sent back to survive. A write is never a move between scopes.
|
|
115
129
|
const data: Record<string, unknown> = { ...record.data };
|
|
116
130
|
for (const [k, v] of Object.entries(changes)) data[toCamelKey(k)] = v;
|
|
117
131
|
return client.updateEntity(record.id, { data });
|
|
@@ -153,6 +167,18 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
|
153
167
|
if (!draftType) {
|
|
154
168
|
return <p className="text-sm text-gray-500">No “draft” type is defined for this app.</p>;
|
|
155
169
|
}
|
|
170
|
+
// See TopicReviewWorkspace: a scoped list can be refused (a type with no
|
|
171
|
+
// declared scope path is a 400), and an unreported refusal renders as an empty
|
|
172
|
+
// board rather than as a problem.
|
|
173
|
+
if (recordsQ.isError) {
|
|
174
|
+
return (
|
|
175
|
+
<p className="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
|
|
176
|
+
Could not load drafts
|
|
177
|
+
{scope === 'all' ? '' : ` for ${scope}`}:{' '}
|
|
178
|
+
{recordsQ.error instanceof Error ? recordsQ.error.message : 'the request was refused.'}
|
|
179
|
+
</p>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
156
182
|
|
|
157
183
|
const pickedCount = activeGroups.filter((g) => g.chosen).length;
|
|
158
184
|
const readyCount = activeGroups.filter((g) => g.ready).length;
|
|
@@ -18,6 +18,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
|
|
|
18
18
|
import { notify } from '../toast/use-notify';
|
|
19
19
|
import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
|
|
20
20
|
import { readData, toCamelKey } from './data';
|
|
21
|
+
import { scopeKey } from './scoped-client';
|
|
21
22
|
|
|
22
23
|
const STATUSES = ['suggested', 'ready', 'rejected', 'written'] as const;
|
|
23
24
|
const VERDICTS = [
|
|
@@ -52,18 +53,26 @@ export interface TopicReviewWorkspaceProps {
|
|
|
52
53
|
|
|
53
54
|
export function TopicReviewWorkspace({ client, markets = [], renderDetail }: TopicReviewWorkspaceProps) {
|
|
54
55
|
const qc = useQueryClient();
|
|
56
|
+
// The bound the injected client is carrying. IT IS PART OF THE CACHE KEY, and
|
|
57
|
+
// that is not bookkeeping: without it a scope change reuses the previous
|
|
58
|
+
// scope's rows — the request would carry the new scope and the board would
|
|
59
|
+
// still show the old client's topics, which is worse than not scoping at all.
|
|
60
|
+
const scope = scopeKey(client);
|
|
55
61
|
const typesQ = useQuery({ queryKey: ['schema-types'], queryFn: () => client.listTypes() });
|
|
56
62
|
const topicType = typesQ.data?.results.find((t) => t.key === 'topic');
|
|
57
63
|
const recordsQ = useQuery({
|
|
58
|
-
queryKey: ['entities', 'topic', 'all'],
|
|
64
|
+
queryKey: ['entities', 'topic', 'all', scope],
|
|
59
65
|
queryFn: () => client.listAllEntities('topic'),
|
|
60
66
|
enabled: !!topicType,
|
|
61
67
|
});
|
|
62
68
|
const records = recordsQ.data ?? [];
|
|
63
69
|
|
|
70
|
+
// The source registry is SHARED across scopes (epic startsim-ux39t) and this
|
|
71
|
+
// is a tier lookup, not a list of the client's work — so it is read unscoped
|
|
72
|
+
// and keyed without the scope, one fetch for every bound rather than one each.
|
|
64
73
|
const sourcesQ = useQuery({
|
|
65
74
|
queryKey: ['entities', 'source', 'all'],
|
|
66
|
-
queryFn: () => client.listAllEntities('source'),
|
|
75
|
+
queryFn: () => client.listAllEntities('source', { scoped: false }),
|
|
67
76
|
enabled: !!topicType,
|
|
68
77
|
});
|
|
69
78
|
const tierByDomain = useMemo(() => {
|
|
@@ -98,6 +107,9 @@ export function TopicReviewWorkspace({ client, markets = [], renderDetail }: Top
|
|
|
98
107
|
const data: Record<string, unknown> = { ...record.data };
|
|
99
108
|
for (const [k, v] of Object.entries(changes)) data[toCamelKey(k)] = v;
|
|
100
109
|
try {
|
|
110
|
+
// The FULL blob, spread from the record — so an attribute this workspace
|
|
111
|
+
// never touches (the scope path among them) survives a PATCH that
|
|
112
|
+
// REPLACES the blob rather than merging it. A write is never a move.
|
|
101
113
|
await client.updateEntity(record.id, { data });
|
|
102
114
|
await qc.invalidateQueries({ queryKey: ['entities', 'topic'] });
|
|
103
115
|
} catch (err) {
|
|
@@ -111,6 +123,20 @@ export function TopicReviewWorkspace({ client, markets = [], renderDetail }: Top
|
|
|
111
123
|
if (!topicType) {
|
|
112
124
|
return <p className="text-sm text-gray-500">No “topic” type is defined for this app.</p>;
|
|
113
125
|
}
|
|
126
|
+
// A SCOPED LIST CAN NOW FAIL, so it has to be able to say so. `?scope=` on a
|
|
127
|
+
// type that declares no scope path is a 400 (bd startsim-mj609) — and an
|
|
128
|
+
// unreported error renders as an empty board, which reads as "this client has
|
|
129
|
+
// no topics". Silently empty under an active bound is the same class of lie
|
|
130
|
+
// as silently widening.
|
|
131
|
+
if (recordsQ.isError) {
|
|
132
|
+
return (
|
|
133
|
+
<p className="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
|
|
134
|
+
Could not load topics
|
|
135
|
+
{scope === 'all' ? '' : ` for ${scope}`}:{' '}
|
|
136
|
+
{recordsQ.error instanceof Error ? recordsQ.error.message : 'the request was refused.'}
|
|
137
|
+
</p>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
114
140
|
|
|
115
141
|
return (
|
|
116
142
|
<div className="space-y-6">
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `withScope` — the one seam (bd startsim-nq39j, epic startsim-ux39t).
|
|
3
|
+
*
|
|
4
|
+
* These pin the four things the bead asked to be TESTED rather than inspected:
|
|
5
|
+
* a list read carries the active scope, a blank one never reaches the wire, a
|
|
6
|
+
* request with no type never carries a scope at all, and a write cannot move a
|
|
7
|
+
* row between scopes.
|
|
8
|
+
*
|
|
9
|
+
* WHY THE ASSERTIONS LOOK AT THE ABSENCE OF A KEY. `?scope=` (blank) and
|
|
10
|
+
* `?scope=all` are both 400s server-side, and the blank one is the dangerous
|
|
11
|
+
* one — `normalize_container("")` returns `/`, so an implementation that sends
|
|
12
|
+
* an empty parameter is asking for EVERY scoped row while believing it asked
|
|
13
|
+
* for none (bd startsim-mj609). `toHaveProperty` is therefore the right
|
|
14
|
+
* assertion and `toBe('')` is not.
|
|
15
|
+
*/
|
|
16
|
+
import { resolveScope, scopeFilters, scopeKey, withScope, SCOPE_FILTER } from '../scoped-client';
|
|
17
|
+
import type { CollectionClient, EntityRecord, EntityTypeDef, ListEntitiesOptions, Paginated } from '../types';
|
|
18
|
+
|
|
19
|
+
function page<T>(results: T[]): Paginated<T> {
|
|
20
|
+
return { count: results.length, next: null, previous: null, results };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function record(over: Partial<EntityRecord> = {}): EntityRecord {
|
|
24
|
+
return {
|
|
25
|
+
id: 1,
|
|
26
|
+
entityType: 'topic',
|
|
27
|
+
externalId: null,
|
|
28
|
+
name: 'A topic',
|
|
29
|
+
data: {},
|
|
30
|
+
createdAt: '2026-08-27T00:00:00Z',
|
|
31
|
+
...over,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stub() {
|
|
36
|
+
const listTypes = jest.fn(async () => page<EntityTypeDef>([]));
|
|
37
|
+
const listAllEntities = jest.fn(async (_type: string, _options?: ListEntitiesOptions) => [record()]);
|
|
38
|
+
const updateEntity = jest.fn(async () => record());
|
|
39
|
+
const client: CollectionClient = { listTypes, listAllEntities, updateEntity };
|
|
40
|
+
return { client, listTypes, listAllEntities, updateEntity };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The filters bag as it was actually handed down, whatever the call shape. */
|
|
44
|
+
function filtersOf(mock: jest.Mock, call = 0): Record<string, string> {
|
|
45
|
+
const options = mock.mock.calls[call]?.[1] as ListEntitiesOptions | undefined;
|
|
46
|
+
return options?.filters ?? {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --------------------------------------------------------------------------
|
|
50
|
+
// resolveScope / scopeFilters — the wire spelling of "everything"
|
|
51
|
+
// --------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
describe('resolveScope — every spelling of "no bound" is one value', () => {
|
|
54
|
+
it.each([
|
|
55
|
+
['null', null],
|
|
56
|
+
['undefined', undefined],
|
|
57
|
+
['empty', ''],
|
|
58
|
+
['whitespace', ' '],
|
|
59
|
+
// The BROWSER URL's reserved word. A caller can reach the raw parameter
|
|
60
|
+
// without going through useScopeParam — a server component reading
|
|
61
|
+
// searchParams, a test, an app keeping the scope elsewhere — so `all` has to
|
|
62
|
+
// mean the same thing here as it does there.
|
|
63
|
+
['the reserved word "all"', 'all'],
|
|
64
|
+
])('resolves %s to null', (_label, input) => {
|
|
65
|
+
expect(resolveScope(input as string | null | undefined)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps a real path, trimmed, verbatim', () => {
|
|
69
|
+
expect(resolveScope(' /clients/acme ')).toBe('/clients/acme');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('passes a malformed path THROUGH rather than dropping it', () => {
|
|
73
|
+
// The server refuses a value with no leading separator, and that refusal is
|
|
74
|
+
// the right answer: dropping it would silently widen to the whole corpus
|
|
75
|
+
// under a filter the reader believes is active.
|
|
76
|
+
expect(resolveScope('clients/acme')).toBe('clients/acme');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('scopeFilters — a blank scope is never on the wire', () => {
|
|
81
|
+
it.each([[null], [undefined], [''], [' '], ['all']])(
|
|
82
|
+
'emits NO scope key at all for %p',
|
|
83
|
+
(input) => {
|
|
84
|
+
const filters = scopeFilters(input as string | null | undefined);
|
|
85
|
+
expect(filters).not.toHaveProperty(SCOPE_FILTER);
|
|
86
|
+
expect(Object.keys(filters)).toHaveLength(0);
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
it('emits the path when there is one', () => {
|
|
91
|
+
expect(scopeFilters('/clients/acme')).toEqual({ scope: '/clients/acme' });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// --------------------------------------------------------------------------
|
|
96
|
+
// list reads
|
|
97
|
+
// --------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe('withScope — with a scope active, every list request carries it', () => {
|
|
100
|
+
it('puts the scope in the FILTERS bag, which the app puts in the URL string', async () => {
|
|
101
|
+
const { client, listAllEntities } = stub();
|
|
102
|
+
await withScope(client, '/clients/acme').listAllEntities('topic');
|
|
103
|
+
expect(filtersOf(listAllEntities)).toEqual({ scope: '/clients/acme' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('carries it on every type a workspace reads, not just the first', async () => {
|
|
107
|
+
const { client, listAllEntities } = stub();
|
|
108
|
+
const scoped = withScope(client, '/clients/acme');
|
|
109
|
+
await scoped.listAllEntities('topic');
|
|
110
|
+
await scoped.listAllEntities('draft');
|
|
111
|
+
expect(filtersOf(listAllEntities, 0)).toEqual({ scope: '/clients/acme' });
|
|
112
|
+
expect(filtersOf(listAllEntities, 1)).toEqual({ scope: '/clients/acme' });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('keeps the caller’s own filters alongside it', async () => {
|
|
116
|
+
const { client, listAllEntities } = stub();
|
|
117
|
+
await withScope(client, '/clients/acme').listAllEntities('draft', {
|
|
118
|
+
filters: { 'attr.published_at__gte': '2026-08-10' },
|
|
119
|
+
});
|
|
120
|
+
expect(filtersOf(listAllEntities)).toEqual({
|
|
121
|
+
scope: '/clients/acme',
|
|
122
|
+
'attr.published_at__gte': '2026-08-10',
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('does not overrule a caller that sent its own scope', async () => {
|
|
127
|
+
const { client, listAllEntities } = stub();
|
|
128
|
+
await withScope(client, '/clients/acme').listAllEntities('draft', {
|
|
129
|
+
filters: { scope: '/clients/globex' },
|
|
130
|
+
});
|
|
131
|
+
expect(filtersOf(listAllEntities).scope).toBe('/clients/globex');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('sends no scope key when nothing is in scope', async () => {
|
|
135
|
+
const { client, listAllEntities } = stub();
|
|
136
|
+
await withScope(client, null).listAllEntities('topic', { filters: { page_size: '200' } });
|
|
137
|
+
expect(filtersOf(listAllEntities)).not.toHaveProperty('scope');
|
|
138
|
+
expect(filtersOf(listAllEntities)).toEqual({ page_size: '200' });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('calls an unscoped read exactly the way an unwrapped client is called', async () => {
|
|
142
|
+
// A client that only ever took `(type)` must see what it always saw, so
|
|
143
|
+
// wrapping an existing app in `withScope` is not a behaviour change.
|
|
144
|
+
const { client, listAllEntities } = stub();
|
|
145
|
+
await withScope(client, null).listAllEntities('topic');
|
|
146
|
+
expect(listAllEntities).toHaveBeenCalledWith('topic');
|
|
147
|
+
expect(listAllEntities.mock.calls[0]).toHaveLength(1);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('withScope — a request with no type never carries a scope', () => {
|
|
152
|
+
it('hands listTypes on with NO arguments', async () => {
|
|
153
|
+
// `?scope=` REQUIRES `?type=` — a 400 without it (bd startsim-mj609),
|
|
154
|
+
// because the parameter resolves through ONE type's declaration and the
|
|
155
|
+
// schema read spans every type.
|
|
156
|
+
const { client, listTypes } = stub();
|
|
157
|
+
await withScope(client, '/clients/acme').listTypes();
|
|
158
|
+
expect(listTypes).toHaveBeenCalledWith();
|
|
159
|
+
expect(listTypes.mock.calls[0]).toHaveLength(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it.each([[''], [' ']])('adds no scope to a list read whose type is %p', async (type) => {
|
|
163
|
+
const { client, listAllEntities } = stub();
|
|
164
|
+
await withScope(client, '/clients/acme').listAllEntities(type);
|
|
165
|
+
expect(filtersOf(listAllEntities)).not.toHaveProperty('scope');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('withScope — the shared corpus opts out', () => {
|
|
170
|
+
it('leaves a `scoped: false` read unbounded', async () => {
|
|
171
|
+
// One source registry, one news_item corpus (epic startsim-ux39t decision
|
|
172
|
+
// 2). Narrowing a lookup of a shared type either 400s or silently loses
|
|
173
|
+
// every row outside the bound.
|
|
174
|
+
const { client, listAllEntities } = stub();
|
|
175
|
+
await withScope(client, '/clients/acme').listAllEntities('source', { scoped: false });
|
|
176
|
+
expect(filtersOf(listAllEntities)).not.toHaveProperty('scope');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('scopes by DEFAULT, so forgetting narrows rather than widens', async () => {
|
|
180
|
+
const { client, listAllEntities } = stub();
|
|
181
|
+
await withScope(client, '/clients/acme').listAllEntities('source', {});
|
|
182
|
+
expect(filtersOf(listAllEntities)).toEqual({ scope: '/clients/acme' });
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// --------------------------------------------------------------------------
|
|
187
|
+
// writes
|
|
188
|
+
// --------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
describe('withScope — a write cannot cross scopes', () => {
|
|
191
|
+
it('hands the payload on BYTE-FOR-BYTE', async () => {
|
|
192
|
+
const { client, updateEntity } = stub();
|
|
193
|
+
const input = { data: { status: 'ready', path: '/clients/globex' } };
|
|
194
|
+
await withScope(client, '/clients/acme').updateEntity(7, input);
|
|
195
|
+
expect(updateEntity).toHaveBeenCalledWith(7, input);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('never rewrites a row’s own scope to the active one', async () => {
|
|
199
|
+
// Patching a row while /clients/acme is selected must NOT move it out of
|
|
200
|
+
// /clients/globex. Changing a row's scope is an explicit act.
|
|
201
|
+
const { client, updateEntity } = stub();
|
|
202
|
+
await withScope(client, '/clients/acme').updateEntity(7, {
|
|
203
|
+
data: { path: '/clients/globex' },
|
|
204
|
+
});
|
|
205
|
+
const [, input] = updateEntity.mock.calls[0] as [unknown, { data: Record<string, unknown> }];
|
|
206
|
+
expect(input.data.path).toBe('/clients/globex');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('never STAMPS a scope onto a write that carried none', async () => {
|
|
210
|
+
// The bead's "writes stamp it" belongs to CREATE, on the upsert path. There
|
|
211
|
+
// is no create on this interface, and stamping an UPDATE is exactly the
|
|
212
|
+
// cross-scope move the same bead forbids.
|
|
213
|
+
const { client, updateEntity } = stub();
|
|
214
|
+
await withScope(client, '/clients/acme').updateEntity(7, { data: { status: 'ready' } });
|
|
215
|
+
const [, input] = updateEntity.mock.calls[0] as [unknown, { data: Record<string, unknown> }];
|
|
216
|
+
expect(input.data).toEqual({ status: 'ready' });
|
|
217
|
+
expect(input).not.toHaveProperty('scope');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// --------------------------------------------------------------------------
|
|
222
|
+
// the cache key
|
|
223
|
+
// --------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
describe('scopeKey — a cached read is only valid for its own scope', () => {
|
|
226
|
+
it('is the path for a bound client', () => {
|
|
227
|
+
expect(scopeKey(withScope(stub().client, '/clients/acme'))).toBe('/clients/acme');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('is "all" for an unbounded one', () => {
|
|
231
|
+
expect(scopeKey(withScope(stub().client, null))).toBe('all');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('is "all" for a plain, never-wrapped client, so an existing cache does not split', () => {
|
|
235
|
+
expect(scopeKey(stub().client)).toBe('all');
|
|
236
|
+
expect(scopeKey(undefined)).toBe('all');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('differs between two scopes — the whole point of putting it in a key', () => {
|
|
240
|
+
const { client } = stub();
|
|
241
|
+
expect(scopeKey(withScope(client, '/a'))).not.toBe(scopeKey(withScope(client, '/b')));
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam AS THE REVIEW SURFACE SEES IT (bd startsim-nq39j).
|
|
3
|
+
*
|
|
4
|
+
* `scoped-client.test.ts` pins the decorator in isolation. This pins the thing
|
|
5
|
+
* the decorator exists for: a workspace handed a scoped client asks the server
|
|
6
|
+
* for that scope, SHOWS that scope, and stops showing it the moment the scope
|
|
7
|
+
* changes.
|
|
8
|
+
*
|
|
9
|
+
* THE SECOND TEST HERE IS THE ONE THAT MATTERS, and it is a defect this bead
|
|
10
|
+
* introduced and had to fix. react-query keys the two list reads
|
|
11
|
+
* `['entities','topic','all']` — a key with no scope in it. Wrap the client and
|
|
12
|
+
* the REQUEST carries the new scope while the CACHE serves the old scope's rows:
|
|
13
|
+
* the "every list request carries it" acceptance criterion goes green while the
|
|
14
|
+
* board shows another client's work. A scope in the key is the fix and this is
|
|
15
|
+
* what stops it being tidied away.
|
|
16
|
+
*/
|
|
17
|
+
import * as React from 'react';
|
|
18
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
19
|
+
import userEvent from '@testing-library/user-event';
|
|
20
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
21
|
+
|
|
22
|
+
import { TopicReviewWorkspace } from '../TopicReviewWorkspace';
|
|
23
|
+
import { withScope } from '../scoped-client';
|
|
24
|
+
import type {
|
|
25
|
+
CollectionClient,
|
|
26
|
+
EntityRecord,
|
|
27
|
+
EntityTypeDef,
|
|
28
|
+
ListEntitiesOptions,
|
|
29
|
+
Paginated,
|
|
30
|
+
} from '../types';
|
|
31
|
+
|
|
32
|
+
const TOPIC_TYPE: EntityTypeDef = { id: 1, key: 'topic', label: 'Topic', attributes: [] };
|
|
33
|
+
|
|
34
|
+
function page<T>(results: T[]): Paginated<T> {
|
|
35
|
+
return { count: results.length, next: null, previous: null, results };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function topic(id: number, title: string, path: string): EntityRecord {
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
entityType: 'topic',
|
|
42
|
+
externalId: null,
|
|
43
|
+
name: title,
|
|
44
|
+
data: { title, status: 'suggested', path },
|
|
45
|
+
createdAt: '2026-08-27T00:00:00Z',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Rows per scope, so "which scope did the board actually render" is answerable. */
|
|
50
|
+
const BY_SCOPE: Record<string, EntityRecord[]> = {
|
|
51
|
+
'/clients/acme': [topic(1, 'Acme desalination', '/clients/acme')],
|
|
52
|
+
'/clients/globex': [topic(2, 'Globex logistics', '/clients/globex')],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function stub(overrides: Partial<CollectionClient> = {}) {
|
|
56
|
+
const listTypes = jest.fn(async () => page([TOPIC_TYPE]));
|
|
57
|
+
const listAllEntities = jest.fn(async (type: string, options?: ListEntitiesOptions) => {
|
|
58
|
+
if (type !== 'topic') return [];
|
|
59
|
+
const scope = options?.filters?.scope;
|
|
60
|
+
return scope ? (BY_SCOPE[scope] ?? []) : Object.values(BY_SCOPE).flat();
|
|
61
|
+
});
|
|
62
|
+
const updateEntity = jest.fn(async () => topic(1, 'Acme desalination', '/clients/acme'));
|
|
63
|
+
const client: CollectionClient = { listTypes, listAllEntities, updateEntity, ...overrides };
|
|
64
|
+
return { client, listTypes, listAllEntities, updateEntity };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function Harness({ client }: { client: CollectionClient }) {
|
|
68
|
+
// ONE QueryClient across re-renders — otherwise a fresh cache per render would
|
|
69
|
+
// hide exactly the staleness the scope-change test is looking for.
|
|
70
|
+
const [qc] = React.useState(
|
|
71
|
+
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
|
|
72
|
+
);
|
|
73
|
+
return (
|
|
74
|
+
<QueryClientProvider client={qc}>
|
|
75
|
+
<TopicReviewWorkspace client={client} />
|
|
76
|
+
</QueryClientProvider>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every `topic` list read the client saw, as its scope filter (or `none`). */
|
|
81
|
+
function topicScopes(mock: jest.Mock): string[] {
|
|
82
|
+
return mock.mock.calls
|
|
83
|
+
.filter((call) => call[0] === 'topic')
|
|
84
|
+
.map((call) => (call[1] as ListEntitiesOptions | undefined)?.filters?.scope ?? 'none');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('a scoped client scopes the whole review surface', () => {
|
|
88
|
+
it('asks the server for the active scope, and renders what came back', async () => {
|
|
89
|
+
const { client, listAllEntities } = stub();
|
|
90
|
+
render(<Harness client={withScope(client, '/clients/acme')} />);
|
|
91
|
+
|
|
92
|
+
expect(await screen.findByText('Acme desalination')).toBeTruthy();
|
|
93
|
+
expect(topicScopes(listAllEntities)).toEqual(['/clients/acme']);
|
|
94
|
+
// NO CLIENT-SIDE FILTERING: the board shows exactly the rows the request
|
|
95
|
+
// returned, so the other scope's row was never fetched to be hidden.
|
|
96
|
+
expect(screen.queryByText('Globex logistics')).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('REFETCHES when the scope changes, instead of serving the previous scope’s rows', async () => {
|
|
100
|
+
const { client, listAllEntities } = stub();
|
|
101
|
+
const { rerender } = render(<Harness client={withScope(client, '/clients/acme')} />);
|
|
102
|
+
expect(await screen.findByText('Acme desalination')).toBeTruthy();
|
|
103
|
+
|
|
104
|
+
rerender(<Harness client={withScope(client, '/clients/globex')} />);
|
|
105
|
+
|
|
106
|
+
expect(await screen.findByText('Globex logistics')).toBeTruthy();
|
|
107
|
+
await waitFor(() => expect(screen.queryByText('Acme desalination')).toBeNull());
|
|
108
|
+
expect(topicScopes(listAllEntities)).toEqual(['/clients/acme', '/clients/globex']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('reads the SHARED source registry unscoped, and reads it once for both scopes', async () => {
|
|
112
|
+
// One source registry across scopes (epic startsim-ux39t decision 2). It is
|
|
113
|
+
// also keyed without the scope, so switching client does not refetch it.
|
|
114
|
+
const { client, listAllEntities } = stub();
|
|
115
|
+
const { rerender } = render(<Harness client={withScope(client, '/clients/acme')} />);
|
|
116
|
+
expect(await screen.findByText('Acme desalination')).toBeTruthy();
|
|
117
|
+
rerender(<Harness client={withScope(client, '/clients/globex')} />);
|
|
118
|
+
expect(await screen.findByText('Globex logistics')).toBeTruthy();
|
|
119
|
+
|
|
120
|
+
const sourceCalls = listAllEntities.mock.calls.filter((call) => call[0] === 'source');
|
|
121
|
+
expect(sourceCalls).toHaveLength(1);
|
|
122
|
+
expect((sourceCalls[0][1] as ListEntitiesOptions).filters).not.toHaveProperty('scope');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('never puts a scope on the schema read', async () => {
|
|
126
|
+
const { client, listTypes } = stub();
|
|
127
|
+
render(<Harness client={withScope(client, '/clients/acme')} />);
|
|
128
|
+
expect(await screen.findByText('Acme desalination')).toBeTruthy();
|
|
129
|
+
// `?scope=` without `?type=` is a 400 (bd startsim-mj609).
|
|
130
|
+
expect(listTypes).toHaveBeenCalledWith();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('an update preserves the row’s scope', () => {
|
|
135
|
+
it('PATCHes the row’s own path back, not the active one', async () => {
|
|
136
|
+
// The row lives in /clients/globex; the board is bounded to /clients/acme
|
|
137
|
+
// (a staff view that reached it, a stale cache, a hand-edited URL). Rating
|
|
138
|
+
// it must not MOVE it. PATCH replaces the whole blob, so the path has to be
|
|
139
|
+
// in the payload — with the row's value, never the client's.
|
|
140
|
+
const { client, updateEntity } = stub();
|
|
141
|
+
const scoped = withScope(client, '/clients/acme');
|
|
142
|
+
const stray = topic(9, 'Stray topic', '/clients/globex');
|
|
143
|
+
jest
|
|
144
|
+
.spyOn(client, 'listAllEntities')
|
|
145
|
+
.mockImplementation(async (type: string) => (type === 'topic' ? [stray] : []));
|
|
146
|
+
|
|
147
|
+
render(<Harness client={scoped} />);
|
|
148
|
+
expect(await screen.findByText('Stray topic')).toBeTruthy();
|
|
149
|
+
|
|
150
|
+
await userEvent.click(screen.getByRole('button', { name: /Good/ }));
|
|
151
|
+
|
|
152
|
+
await waitFor(() => expect(updateEntity).toHaveBeenCalled());
|
|
153
|
+
const [id, input] = updateEntity.mock.calls[0] as [
|
|
154
|
+
number,
|
|
155
|
+
{ data: Record<string, unknown> },
|
|
156
|
+
];
|
|
157
|
+
expect(id).toBe(9);
|
|
158
|
+
expect(input.data.path).toBe('/clients/globex');
|
|
159
|
+
expect(input.data.teamVerdict).toBe('good');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('a refused scope is reported, not rendered as an empty board', () => {
|
|
164
|
+
it('shows the server’s refusal instead of “Nothing to review right now.”', async () => {
|
|
165
|
+
// `?scope=` on a type declaring no scope path is a 400 (bd startsim-mj609).
|
|
166
|
+
// Swallowed, it renders as "this client has no topics" — a confident wrong
|
|
167
|
+
// answer of the same family as silently widening.
|
|
168
|
+
const { client } = stub();
|
|
169
|
+
jest.spyOn(client, 'listAllEntities').mockImplementation(async (type: string) => {
|
|
170
|
+
if (type === 'topic') throw new Error("type 'topic' declares no scope path");
|
|
171
|
+
return [];
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
render(<Harness client={withScope(client, '/clients/acme')} />);
|
|
175
|
+
|
|
176
|
+
expect(await screen.findByText(/declares no scope path/)).toBeTruthy();
|
|
177
|
+
expect(screen.queryByText('Nothing to review right now.')).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -28,7 +28,28 @@ export {
|
|
|
28
28
|
type VerdictOption,
|
|
29
29
|
type VerdictTone,
|
|
30
30
|
} from './review';
|
|
31
|
-
export type {
|
|
31
|
+
export type {
|
|
32
|
+
CollectionClient,
|
|
33
|
+
EntityRecord,
|
|
34
|
+
EntityTypeDef,
|
|
35
|
+
AttributeDef,
|
|
36
|
+
Paginated,
|
|
37
|
+
DataType,
|
|
38
|
+
EntityFilters,
|
|
39
|
+
ListEntitiesOptions,
|
|
40
|
+
} from './types';
|
|
41
|
+
// The scope seam (bd startsim-nq39j): wrap the injected client once and every
|
|
42
|
+
// review surface in this package is scoped, because every one of them is
|
|
43
|
+
// client-injected. `useScopedCollectionClient` in '@startsimpli/ui/scope' binds
|
|
44
|
+
// it to the URL for a Next host.
|
|
45
|
+
export {
|
|
46
|
+
withScope,
|
|
47
|
+
scopeFilters,
|
|
48
|
+
resolveScope,
|
|
49
|
+
scopeKey,
|
|
50
|
+
SCOPE_FILTER,
|
|
51
|
+
type ScopedCollectionClient,
|
|
52
|
+
} from './scoped-client';
|
|
32
53
|
export { readData, toCamelKey } from './data';
|
|
33
54
|
export {
|
|
34
55
|
parseSources,
|
|
@@ -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
|
}
|
package/src/components/index.ts
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useScopedCollectionClient` (bd startsim-nq39j) — the URL's scope bound onto
|
|
3
|
+
* the app's injected tenant client, which is the whole of a fork's adoption.
|
|
4
|
+
*
|
|
5
|
+
* Two properties, and the second is the one that would otherwise be discovered
|
|
6
|
+
* in production: the hook reads the SAME `?scope=` the switcher writes (so a
|
|
7
|
+
* shared link scopes the data, not just the picker's label), and the client it
|
|
8
|
+
* returns is REFERENTIALLY STABLE — it is a react-query input, and a fresh
|
|
9
|
+
* object per render refetches every list on every keystroke elsewhere on the
|
|
10
|
+
* page.
|
|
11
|
+
*/
|
|
12
|
+
import * as React from 'react'
|
|
13
|
+
import { render, screen } from '@testing-library/react'
|
|
14
|
+
import { useSearchParams } from 'next/navigation'
|
|
15
|
+
|
|
16
|
+
import { useScopedCollectionClient } from '../useScopedClient'
|
|
17
|
+
import type { CollectionClient, EntityRecord, EntityTypeDef, Paginated } from '../../collection/types'
|
|
18
|
+
|
|
19
|
+
const mockedSearchParams = useSearchParams as unknown as jest.Mock
|
|
20
|
+
|
|
21
|
+
function page<T>(results: T[]): Paginated<T> {
|
|
22
|
+
return { count: results.length, next: null, previous: null, results }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const client: CollectionClient = {
|
|
26
|
+
listTypes: async () => page<EntityTypeDef>([]),
|
|
27
|
+
listAllEntities: async () => [] as EntityRecord[],
|
|
28
|
+
updateEntity: async () => ({}) as EntityRecord,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Renders the bound scope and hands every client instance it saw to the test. */
|
|
32
|
+
function Probe({ seen }: { seen: unknown[] }) {
|
|
33
|
+
const scoped = useScopedCollectionClient(client, { remember: false })
|
|
34
|
+
seen.push(scoped)
|
|
35
|
+
return <span data-testid="scope">{String(scoped.scope)}</span>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
mockedSearchParams.mockReturnValue(new URLSearchParams(''))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('useScopedCollectionClient', () => {
|
|
43
|
+
it('binds the client to the scope the URL is asserting', () => {
|
|
44
|
+
mockedSearchParams.mockReturnValue(new URLSearchParams('scope=/clients/acme'))
|
|
45
|
+
render(<Probe seen={[]} />)
|
|
46
|
+
expect(screen.getByTestId('scope').textContent).toBe('/clients/acme')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('reads the URL’s reserved "all" as no bound', () => {
|
|
50
|
+
// `?scope=all` is the browser spelling of "everything"; on the wire that is
|
|
51
|
+
// the ABSENCE of the parameter, which is what `scope: null` produces.
|
|
52
|
+
mockedSearchParams.mockReturnValue(new URLSearchParams('scope=all'))
|
|
53
|
+
render(<Probe seen={[]} />)
|
|
54
|
+
expect(screen.getByTestId('scope').textContent).toBe('null')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('is unbounded on a bare URL', () => {
|
|
58
|
+
render(<Probe seen={[]} />)
|
|
59
|
+
expect(screen.getByTestId('scope').textContent).toBe('null')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns the SAME client across re-renders, so react-query does not thrash', () => {
|
|
63
|
+
mockedSearchParams.mockReturnValue(new URLSearchParams('scope=/clients/acme'))
|
|
64
|
+
const seen: unknown[] = []
|
|
65
|
+
const { rerender } = render(<Probe seen={seen} />)
|
|
66
|
+
rerender(<Probe seen={seen} />)
|
|
67
|
+
expect(seen.length).toBeGreaterThan(1)
|
|
68
|
+
expect(new Set(seen).size).toBe(1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns a NEW client when the scope changes, so the cache key moves with it', () => {
|
|
72
|
+
mockedSearchParams.mockReturnValue(new URLSearchParams('scope=/clients/acme'))
|
|
73
|
+
const seen: unknown[] = []
|
|
74
|
+
const { rerender } = render(<Probe seen={seen} />)
|
|
75
|
+
mockedSearchParams.mockReturnValue(new URLSearchParams('scope=/clients/globex'))
|
|
76
|
+
rerender(<Probe seen={seen} />)
|
|
77
|
+
expect(screen.getByTestId('scope').textContent).toBe('/clients/globex')
|
|
78
|
+
expect(new Set(seen).size).toBe(2)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -26,6 +26,12 @@ export {
|
|
|
26
26
|
type ScopeHttp,
|
|
27
27
|
type CreateScopeClientOptions,
|
|
28
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'
|
|
29
35
|
export type {
|
|
30
36
|
ScopeClient,
|
|
31
37
|
ScopeNode,
|
|
@@ -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
|
+
}
|