@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.110",
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",
@@ -21,6 +21,8 @@
21
21
  "./blog/resolve": "./src/components/blog/resolveArticle.ts",
22
22
  "./activity": "./src/components/activity/index.ts",
23
23
  "./collection": "./src/components/collection/index.ts",
24
+ "./scope": "./src/components/scope/index.ts",
25
+ "./facet-tree": "./src/components/facet-tree/index.ts",
24
26
  "./document-editor": "./src/components/document-editor/index.ts",
25
27
  "./email-editor": "./src/components/email-editor/index.ts",
26
28
  "./workflows": "./src/components/workflows/index.ts",
@@ -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 { CollectionClient, EntityRecord, EntityTypeDef, AttributeDef, Paginated, DataType } from './types';
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,