@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.
- package/package.json +3 -3
- package/src/components/collection/DraftReviewWorkspace.tsx +29 -3
- package/src/components/collection/ReviewDrawer.tsx +24 -2
- package/src/components/collection/TopicReviewWorkspace.tsx +28 -2
- package/src/components/collection/__tests__/approve-unlocks.test.tsx +169 -0
- package/src/components/collection/__tests__/review.test.ts +59 -0
- 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/review.ts +27 -2
- 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
|
@@ -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
|
+
}
|