@startsimpli/ui 0.4.111 → 0.4.113

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.
@@ -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
  }
@@ -199,6 +199,7 @@ export {
199
199
  ScopeSwitcher,
200
200
  ScopeSwitcherControlled,
201
201
  useScopeParam,
202
+ useScopedCollectionClient,
202
203
  createScopeClient,
203
204
  SCOPE_PARAM,
204
205
  SCOPE_ALL,
@@ -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
+ }