@cosmicdrift/kumiko-renderer 0.202.0 → 0.204.0

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": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.202.0",
3
+ "version": "0.204.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.202.0",
19
- "@cosmicdrift/kumiko-headless": "0.202.0",
18
+ "@cosmicdrift/kumiko-framework": "0.204.0",
19
+ "@cosmicdrift/kumiko-headless": "0.204.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -0,0 +1,107 @@
1
+ // fw#2165: buildListQueryPayload was extracted out of EntityListBody's
2
+ // queryPayload useMemo so ProjectionListBody could reuse it without
3
+ // duplicating the branch logic. This pins the extracted function against
4
+ // exactly what EntityListBody built before the extraction — the regression
5
+ // floor both list types now sit on.
6
+ import { describe, expect, test } from "bun:test";
7
+ import { buildListQueryPayload } from "../kumiko-screen";
8
+
9
+ const base = {
10
+ limit: 50,
11
+ search: "",
12
+ sort: null,
13
+ usePager: false,
14
+ page: 1,
15
+ useInfinite: false,
16
+ cursor: undefined,
17
+ } as const;
18
+
19
+ describe("buildListQueryPayload", () => {
20
+ test("bare state: only limit, no search/sort/pager/infinite keys", () => {
21
+ expect(buildListQueryPayload(base)).toEqual({ limit: 50 });
22
+ });
23
+
24
+ test("empty search term is omitted, not sent as an empty string", () => {
25
+ expect(buildListQueryPayload({ ...base, search: "" })).toEqual({ limit: 50 });
26
+ });
27
+
28
+ test("non-empty search lands in the payload", () => {
29
+ expect(buildListQueryPayload({ ...base, search: "acme" })).toEqual({
30
+ limit: 50,
31
+ search: "acme",
32
+ });
33
+ });
34
+
35
+ test("no sort: neither sort nor sortDirection appear", () => {
36
+ expect(buildListQueryPayload({ ...base, sort: null })).toEqual({ limit: 50 });
37
+ });
38
+
39
+ test("sort carries both field and direction", () => {
40
+ expect(buildListQueryPayload({ ...base, sort: { field: "createdAt", dir: "desc" } })).toEqual({
41
+ limit: 50,
42
+ sort: "createdAt",
43
+ sortDirection: "desc",
44
+ });
45
+ });
46
+
47
+ test("pager mode, page 1: totalCount is sent, offset is omitted (not offset: 0)", () => {
48
+ expect(buildListQueryPayload({ ...base, usePager: true, page: 1 })).toEqual({
49
+ limit: 50,
50
+ totalCount: true,
51
+ });
52
+ });
53
+
54
+ test("pager mode, page 3: offset is (page - 1) * limit", () => {
55
+ expect(buildListQueryPayload({ ...base, usePager: true, page: 3 })).toEqual({
56
+ limit: 50,
57
+ offset: 100,
58
+ totalCount: true,
59
+ });
60
+ });
61
+
62
+ test("infinite mode without a cursor yet: no cursor key", () => {
63
+ expect(buildListQueryPayload({ ...base, useInfinite: true, cursor: undefined })).toEqual({
64
+ limit: 50,
65
+ });
66
+ });
67
+
68
+ test("infinite mode with a cursor: cursor lands in the payload", () => {
69
+ expect(buildListQueryPayload({ ...base, useInfinite: true, cursor: "row-42" })).toEqual({
70
+ limit: 50,
71
+ cursor: "row-42",
72
+ });
73
+ });
74
+
75
+ test("pager wins over infinite when both flags are true (usePager gates first)", () => {
76
+ expect(
77
+ buildListQueryPayload({ ...base, usePager: true, useInfinite: true, cursor: "row-42" }),
78
+ ).toEqual({ limit: 50, totalCount: true });
79
+ });
80
+
81
+ test("pagination=false (neither pager nor infinite): no pagination keys at all", () => {
82
+ expect(
83
+ buildListQueryPayload({ ...base, usePager: false, useInfinite: false, cursor: "row-42" }),
84
+ ).toEqual({ limit: 50 });
85
+ });
86
+
87
+ test("full combination: search + sort + pager", () => {
88
+ expect(
89
+ buildListQueryPayload({
90
+ limit: 25,
91
+ search: "acme",
92
+ sort: { field: "name", dir: "asc" },
93
+ usePager: true,
94
+ page: 2,
95
+ useInfinite: false,
96
+ cursor: undefined,
97
+ }),
98
+ ).toEqual({
99
+ limit: 25,
100
+ search: "acme",
101
+ sort: "name",
102
+ sortDirection: "asc",
103
+ offset: 25,
104
+ totalCount: true,
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,449 @@
1
+ // fw#2166: projectionDetail screens can declare header `actions`, and get a
2
+ // default "edit" action for free when `detailFor` names an entity that has a
3
+ // visible entityEdit screen somewhere in the app (not just this feature —
4
+ // the motivating case is a projectionDetail whose query belongs to one
5
+ // feature while the entity's entityEdit screen lives in another). This
6
+ // renders the real path (KumikoScreen → ProjectionDetailBody → RenderEdit)
7
+ // under a stub dispatcher + AppFeaturesProvider, and proves the default-edit
8
+ // resolution rules from the boot-validator's detailFor doc (cross-feature
9
+ // lookup, access-gating, id: "edit" declared-action suppression).
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import type {
13
+ AccessRule,
14
+ EntityEditScreenDefinition,
15
+ ProjectionDetailScreenDefinition,
16
+ } from "@cosmicdrift/kumiko-framework/ui-types";
17
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
18
+ import { act, fireEvent, render, waitFor } from "@testing-library/react";
19
+ import type { ComponentType, ReactNode } from "react";
20
+ import { DispatcherProvider } from "../../context/dispatcher-context";
21
+ import { UserRolesProvider } from "../../context/user-roles-context";
22
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
23
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
24
+ import {
25
+ type BannerProps,
26
+ type ButtonProps,
27
+ type CorePrimitives,
28
+ type FormProps,
29
+ PrimitivesProvider,
30
+ } from "../../primitives";
31
+ import { AppFeaturesProvider } from "../app-features-context";
32
+ import type { FeatureSchema } from "../feature-schema";
33
+ import { KumikoScreen } from "../kumiko-screen";
34
+ import type { NavApi, ScreenTarget } from "../nav";
35
+ import { NavProvider } from "../nav";
36
+
37
+ const noop = (): ReactNode => null;
38
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
39
+
40
+ const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId }) => (
41
+ <button type="button" data-testid={testId} onClick={() => void onClick?.()}>
42
+ {children}
43
+ </button>
44
+ );
45
+
46
+ // Form's `actions` slot carries the header buttons under test — passChildren
47
+ // alone would drop it. Wrapped in distinct testid'd containers so a test can
48
+ // assert a banner rendered as a Form CHILD (formError/actionError region)
49
+ // rather than inside the actions row — the review finding this proves.
50
+ const FormWithActions: ComponentType<FormProps> = ({ children, actions }) => (
51
+ <>
52
+ <div data-testid="form-body">{children}</div>
53
+ <div data-testid="form-actions">{actions}</div>
54
+ </>
55
+ );
56
+
57
+ // passChildren would drop testId — this test asserts on Banner structure/
58
+ // placement, so it needs a real (if minimal) wrapper element.
59
+ const TestBanner: ComponentType<BannerProps> = ({ children, testId }) => (
60
+ <div data-testid={testId}>{children}</div>
61
+ );
62
+
63
+ const testPrimitives: CorePrimitives = {
64
+ Button: TestButton,
65
+ Banner: TestBanner,
66
+ Field: passChildren,
67
+ Input: noop,
68
+ DataTable: noop,
69
+ Form: FormWithActions,
70
+ Section: passChildren,
71
+ Card: passChildren,
72
+ Grid: passChildren,
73
+ GridCell: passChildren,
74
+ Text: passChildren,
75
+ Heading: noop,
76
+ Dialog: noop,
77
+ Modal: noop,
78
+ Lightbox: noop,
79
+ ConfigSourceBadge: noop,
80
+ ConfigCascadeView: noop,
81
+ Link: noop,
82
+ };
83
+
84
+ function stubDispatcher(
85
+ record: Readonly<Record<string, unknown>>,
86
+ writeErrorMessage?: string,
87
+ ): Dispatcher {
88
+ const writeResult =
89
+ writeErrorMessage !== undefined
90
+ ? {
91
+ isSuccess: false,
92
+ error: {
93
+ code: "write-failed",
94
+ httpStatus: 500,
95
+ i18nKey: "kumiko.errors.does-not-exist-in-any-bundle",
96
+ message: writeErrorMessage,
97
+ },
98
+ }
99
+ : { isSuccess: true, data: {} };
100
+ return {
101
+ write: (async () => writeResult) as unknown as Dispatcher["write"],
102
+ query: (async () => ({ isSuccess: true, data: record })) as unknown as Dispatcher["query"],
103
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
104
+ statusStore: {
105
+ getState: () => "online",
106
+ subscribe: () => () => {},
107
+ } as unknown as Dispatcher["statusStore"],
108
+ async *stream() {},
109
+ pendingWrites: () => [],
110
+ pendingFiles: () => [],
111
+ };
112
+ }
113
+
114
+ function detailScreen(
115
+ overrides?: Partial<ProjectionDetailScreenDefinition & { readonly detailFor?: string }>,
116
+ ): FeatureSchema["screens"][number] {
117
+ return {
118
+ id: "rent-detail",
119
+ type: "projectionDetail",
120
+ query: "app:query:rent:detail",
121
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
122
+ detailFor: "rent",
123
+ ...overrides,
124
+ } as FeatureSchema["screens"][number];
125
+ }
126
+
127
+ // Registry-qualified id ("<feature>:screen:<short>"), matching real schema
128
+ // output — a bare short id here would hide the QN-vs-short-form bug this
129
+ // test suite exists to catch (fw#2166 review finding 1/4).
130
+ function editScreen(
131
+ entity: string,
132
+ access?: AccessRule,
133
+ featureName = "app",
134
+ ): EntityEditScreenDefinition {
135
+ return {
136
+ id: `${featureName}:screen:rent-edit`,
137
+ type: "entityEdit",
138
+ entity,
139
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
140
+ ...(access !== undefined && { access }),
141
+ };
142
+ }
143
+
144
+ function renderDetail(opts: {
145
+ readonly primarySchema: FeatureSchema;
146
+ readonly features: readonly FeatureSchema[];
147
+ readonly userRoles?: readonly string[];
148
+ readonly record?: Readonly<Record<string, unknown>>;
149
+ readonly onNavigate?: (target: ScreenTarget) => void;
150
+ readonly writeErrorMessage?: string;
151
+ }): ReturnType<typeof render> {
152
+ const navApi: NavApi = {
153
+ route: { screenId: "app:screen:rent-detail" },
154
+ // Header actions only ever navigate with a ScreenTarget (screenId +
155
+ // entityId) — same narrowing idiom as nav.tsx's resolveTarget.
156
+ navigate: (target) => {
157
+ if ("screenId" in target) opts.onNavigate?.(target);
158
+ },
159
+ replace: () => {},
160
+ hrefFor: () => "",
161
+ searchParams: {},
162
+ setSearchParams: () => {},
163
+ };
164
+ return render(
165
+ <LocaleProvider
166
+ resolver={createStaticLocaleResolver({ locale: "de-DE" })}
167
+ fallbackBundles={[kumikoDefaultTranslations]}
168
+ >
169
+ <DispatcherProvider
170
+ dispatcher={stubDispatcher(
171
+ opts.record ?? { id: "rent-1", description: "Rent for April" },
172
+ opts.writeErrorMessage,
173
+ )}
174
+ >
175
+ <AppFeaturesProvider features={opts.features}>
176
+ <UserRolesProvider roles={opts.userRoles}>
177
+ <NavProvider value={navApi}>
178
+ <PrimitivesProvider value={testPrimitives}>
179
+ <KumikoScreen
180
+ schema={opts.primarySchema}
181
+ qn="app:screen:rent-detail"
182
+ entityId="rent-1"
183
+ />
184
+ </PrimitivesProvider>
185
+ </NavProvider>
186
+ </UserRolesProvider>
187
+ </AppFeaturesProvider>
188
+ </DispatcherProvider>
189
+ </LocaleProvider>,
190
+ );
191
+ }
192
+
193
+ describe("projectionDetail default edit action (fw#2166)", () => {
194
+ test("detailFor + matching entityEdit in the SAME feature → edit button navigates with the record id", async () => {
195
+ const schema: FeatureSchema = {
196
+ featureName: "app",
197
+ entities: {},
198
+ screens: [detailScreen(), editScreen("rent")],
199
+ };
200
+ let navigated: ScreenTarget | undefined;
201
+ const { getByTestId, queryByText } = renderDetail({
202
+ primarySchema: schema,
203
+ features: [schema],
204
+ userRoles: [],
205
+ onNavigate: (target) => {
206
+ navigated = target;
207
+ },
208
+ });
209
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
210
+
211
+ // trigger() is async (busy-state bookkeeping around onPress), so the
212
+ // click's state updates land after this tick — act() flushes them.
213
+ await act(async () => {
214
+ fireEvent.click(getByTestId("render-edit-action-edit"));
215
+ });
216
+ expect(navigated).toEqual({ screenId: "rent-edit", entityId: "rent-1" });
217
+ });
218
+
219
+ test("edit screen access denied → no edit button", async () => {
220
+ const schema: FeatureSchema = {
221
+ featureName: "app",
222
+ entities: {},
223
+ screens: [detailScreen(), editScreen("rent", { roles: ["admin"] })],
224
+ };
225
+ const { queryByTestId, queryByText } = renderDetail({
226
+ primarySchema: schema,
227
+ features: [schema],
228
+ userRoles: [],
229
+ });
230
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
231
+
232
+ expect(queryByTestId("render-edit-action-edit")).toBeNull();
233
+ });
234
+
235
+ test("no entityEdit screen for the detailFor entity → no edit button", async () => {
236
+ const schema: FeatureSchema = {
237
+ featureName: "app",
238
+ entities: {},
239
+ screens: [detailScreen()],
240
+ };
241
+ const { queryByTestId, queryByText } = renderDetail({
242
+ primarySchema: schema,
243
+ features: [schema],
244
+ userRoles: [],
245
+ });
246
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
247
+
248
+ expect(queryByTestId("render-edit-action-edit")).toBeNull();
249
+ });
250
+
251
+ test('a declared actions entry with id: "edit" wins — exactly one edit button, the declared one', async () => {
252
+ const schema: FeatureSchema = {
253
+ featureName: "app",
254
+ entities: {},
255
+ screens: [
256
+ detailScreen({
257
+ actions: [
258
+ { kind: "navigate", id: "edit", label: "custom-edit-label", screen: "rent-edit" },
259
+ ],
260
+ }),
261
+ editScreen("rent"),
262
+ ],
263
+ };
264
+ const { getAllByTestId, queryByText } = renderDetail({
265
+ primarySchema: schema,
266
+ features: [schema],
267
+ userRoles: [],
268
+ });
269
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
270
+
271
+ const editButtons = getAllByTestId("render-edit-action-edit");
272
+ expect(editButtons).toHaveLength(1);
273
+ // "custom-edit-label" has no translation registered, so translate()
274
+ // returns the raw key — proving this is the declared action, not the
275
+ // default (which renders the translated "kumiko.actions.edit").
276
+ expect(editButtons[0]?.textContent).toBe("custom-edit-label");
277
+ });
278
+
279
+ test("two entityEdit screens for the same entity, only the second accessible → button still renders, targeting the second (review finding 2)", async () => {
280
+ const lockedEdit: EntityEditScreenDefinition = {
281
+ id: "app:screen:rent-edit-locked",
282
+ type: "entityEdit",
283
+ entity: "rent",
284
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
285
+ access: { roles: ["admin"] },
286
+ };
287
+ const openEdit: EntityEditScreenDefinition = {
288
+ id: "app:screen:rent-edit-open",
289
+ type: "entityEdit",
290
+ entity: "rent",
291
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
292
+ };
293
+ const schema: FeatureSchema = {
294
+ featureName: "app",
295
+ entities: {},
296
+ screens: [detailScreen(), lockedEdit, openEdit],
297
+ };
298
+ let navigated: ScreenTarget | undefined;
299
+ const { getByTestId, queryByText } = renderDetail({
300
+ primarySchema: schema,
301
+ features: [schema],
302
+ userRoles: [],
303
+ onNavigate: (target) => {
304
+ navigated = target;
305
+ },
306
+ });
307
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
308
+
309
+ await act(async () => {
310
+ fireEvent.click(getByTestId("render-edit-action-edit"));
311
+ });
312
+ expect(navigated).toEqual({ screenId: "rent-edit-open", entityId: "rent-1" });
313
+ });
314
+
315
+ test("declared navigate action without entityId, targeting an entityEdit of the SAME entity → auto-fills the record id (review finding 3)", async () => {
316
+ const schema: FeatureSchema = {
317
+ featureName: "app",
318
+ entities: {},
319
+ screens: [
320
+ detailScreen({
321
+ actions: [
322
+ {
323
+ kind: "navigate",
324
+ id: "open-record",
325
+ label: "actions.openRecord",
326
+ screen: "rent-edit",
327
+ },
328
+ ],
329
+ }),
330
+ editScreen("rent"),
331
+ ],
332
+ };
333
+ let navigated: ScreenTarget | undefined;
334
+ const { getByTestId, queryByText } = renderDetail({
335
+ primarySchema: schema,
336
+ features: [schema],
337
+ userRoles: [],
338
+ onNavigate: (target) => {
339
+ navigated = target;
340
+ },
341
+ });
342
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
343
+
344
+ await act(async () => {
345
+ fireEvent.click(getByTestId("render-edit-action-open-record"));
346
+ });
347
+ expect(navigated).toEqual({ screenId: "rent-edit", entityId: "rent-1" });
348
+ });
349
+
350
+ test("declared action with a non-matching visible condition → no button rendered", async () => {
351
+ const schema: FeatureSchema = {
352
+ featureName: "app",
353
+ entities: {},
354
+ screens: [
355
+ detailScreen({
356
+ actions: [
357
+ {
358
+ kind: "navigate",
359
+ id: "archive",
360
+ label: "actions.archive",
361
+ screen: "rent-edit",
362
+ visible: { field: "status", eq: "closed" },
363
+ },
364
+ ],
365
+ }),
366
+ ],
367
+ };
368
+ const { queryByTestId, queryByText } = renderDetail({
369
+ primarySchema: schema,
370
+ features: [schema],
371
+ userRoles: [],
372
+ record: { id: "rent-1", description: "Rent for April", status: "open" },
373
+ });
374
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
375
+
376
+ expect(queryByTestId("render-edit-action-archive")).toBeNull();
377
+ });
378
+
379
+ test("entityEdit screen lives in ANOTHER feature → edit button is still there (cross-feature resolution)", async () => {
380
+ const appSchema: FeatureSchema = {
381
+ featureName: "app",
382
+ entities: {},
383
+ screens: [detailScreen()],
384
+ };
385
+ const billingSchema: FeatureSchema = {
386
+ featureName: "billing",
387
+ entities: {},
388
+ screens: [editScreen("rent", undefined, "billing")],
389
+ };
390
+ let navigated: ScreenTarget | undefined;
391
+ const { getByTestId, queryByText } = renderDetail({
392
+ primarySchema: appSchema,
393
+ features: [appSchema, billingSchema],
394
+ userRoles: [],
395
+ onNavigate: (target) => {
396
+ navigated = target;
397
+ },
398
+ });
399
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
400
+
401
+ // trigger() is async (busy-state bookkeeping around onPress), so the
402
+ // click's state updates land after this tick — act() flushes them.
403
+ await act(async () => {
404
+ fireEvent.click(getByTestId("render-edit-action-edit"));
405
+ });
406
+ expect(navigated).toEqual({ screenId: "rent-edit", entityId: "rent-1" });
407
+ });
408
+
409
+ test("a failed writeHandler action shows its error in the shared error region, NOT inside the action button row", async () => {
410
+ const schema: FeatureSchema = {
411
+ featureName: "app",
412
+ entities: {},
413
+ screens: [
414
+ detailScreen({
415
+ actions: [
416
+ {
417
+ kind: "writeHandler",
418
+ id: "archive",
419
+ label: "actions.archive",
420
+ handler: "app:write:archive",
421
+ },
422
+ ],
423
+ }),
424
+ ],
425
+ };
426
+ const { getByTestId, queryByText } = renderDetail({
427
+ primarySchema: schema,
428
+ features: [schema],
429
+ userRoles: [],
430
+ writeErrorMessage: "archive failed: rent is still active",
431
+ });
432
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
433
+
434
+ await act(async () => {
435
+ fireEvent.click(getByTestId("render-edit-action-archive"));
436
+ });
437
+
438
+ const errorBanner = await waitFor(() => getByTestId("render-edit-action-error"));
439
+ expect(errorBanner.textContent).toBe("archive failed: rent is still active");
440
+ // The banner must be a Form child (formError-adjacent region), not a
441
+ // descendant of the actions row — a full-width Banner inside the
442
+ // `justify-end` button row breaks its layout (fw#2166 review finding 5).
443
+ expect(errorBanner.closest('[data-testid="form-body"]')).not.toBeNull();
444
+ expect(errorBanner.closest('[data-testid="form-actions"]')).toBeNull();
445
+ expect(
446
+ getByTestId("render-edit-action-archive").closest('[data-testid="form-actions"]'),
447
+ ).not.toBeNull();
448
+ });
449
+ });