@cosmicdrift/kumiko-renderer 0.241.0 → 0.243.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.
|
|
3
|
+
"version": "0.243.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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.243.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.243.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@types/react-dom": "^19.2.3",
|
|
28
28
|
"jsdom": "^29.1.1",
|
|
29
29
|
"react-dom": "^19.2.6",
|
|
30
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
30
|
+
"@cosmicdrift/kumiko-locale-de": "0.243.0"
|
|
31
31
|
},
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
} from "@cosmicdrift/kumiko-headless";
|
|
26
26
|
import { fieldLabelKey, fieldOptionLabelKey } from "@cosmicdrift/kumiko-headless";
|
|
27
27
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
28
|
-
import { extractCreatedId } from "../components/reference-create-dialog";
|
|
28
|
+
import { extractCreatedId, extractIdField } from "../components/reference-create-dialog";
|
|
29
29
|
import { RenderEdit, type RenderEditAction } from "../components/render-edit";
|
|
30
30
|
import { RenderList, type ToolbarActionButton } from "../components/render-list";
|
|
31
31
|
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
|
|
@@ -2425,6 +2425,12 @@ function ProjectionDetailBody({
|
|
|
2425
2425
|
}
|
|
2426
2426
|
// ---- actionForm (Tier 2.7d) ----
|
|
2427
2427
|
|
|
2428
|
+
function redirectScreenTarget(
|
|
2429
|
+
redirect: NonNullable<ActionFormScreenDefinition["redirect"]>,
|
|
2430
|
+
): string {
|
|
2431
|
+
return typeof redirect === "string" ? redirect : redirect.screen;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2428
2434
|
// Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
|
|
2429
2435
|
// RenderEdit über synthetisierte EntityDefinition + EntityEditScreen-
|
|
2430
2436
|
// Definition (siehe action-form-shim.ts für die Schulden-Doku). Die
|
|
@@ -2476,8 +2482,10 @@ function ActionFormBody({
|
|
|
2476
2482
|
// Author entscheidet bewusst ob "stay on form" (default) oder
|
|
2477
2483
|
// "back to list" (typisch bei Create-style Aktionen).
|
|
2478
2484
|
if (screen.redirect !== undefined) {
|
|
2479
|
-
const
|
|
2480
|
-
const
|
|
2485
|
+
const redirectScreen = redirectScreenTarget(screen.redirect);
|
|
2486
|
+
const idField = typeof screen.redirect === "string" ? "id" : screen.redirect.idFrom;
|
|
2487
|
+
const targetId = lastSegment(redirectScreen);
|
|
2488
|
+
const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
|
|
2481
2489
|
// A qualified redirect target may live in a different feature than
|
|
2482
2490
|
// this screen's own schema (fw#2485) — resolve over all mounted
|
|
2483
2491
|
// features (own schema first, cheap and provider-independent) same
|
|
@@ -2496,7 +2504,7 @@ function ActionFormBody({
|
|
|
2496
2504
|
// pick by — fall back to the pre-fw#2485 best-effort match by
|
|
2497
2505
|
// short id across all mounted features.
|
|
2498
2506
|
appFeatures.flatMap((f) => f.screens).find((s) => lastSegment(s.id) === targetId));
|
|
2499
|
-
const entityId =
|
|
2507
|
+
const entityId = extractIdField(result.data, idField);
|
|
2500
2508
|
const carriesId =
|
|
2501
2509
|
target !== undefined &&
|
|
2502
2510
|
(target.type === "entityEdit" || target.type === "projectionDetail");
|
|
@@ -2514,7 +2522,9 @@ function ActionFormBody({
|
|
|
2514
2522
|
// (Single-Action-Screens, wo Cancel nur Submit-ohne-Senden wäre).
|
|
2515
2523
|
const handleCancel = useMemo<(() => void) | undefined>(() => {
|
|
2516
2524
|
if (onCancelOverride !== undefined) return onCancelOverride;
|
|
2517
|
-
const target =
|
|
2525
|
+
const target =
|
|
2526
|
+
screen.cancelTarget ??
|
|
2527
|
+
(screen.redirect !== undefined ? redirectScreenTarget(screen.redirect) : undefined);
|
|
2518
2528
|
if (target === undefined || target === false) return undefined;
|
|
2519
2529
|
return () => nav.navigate({ screenId: lastSegment(target) });
|
|
2520
2530
|
}, [nav, screen.redirect, screen.cancelTarget, onCancelOverride]);
|
|
@@ -3,7 +3,7 @@ import type { RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
|
3
3
|
import type { Dispatcher, EditRelatedListSectionViewModel } from "@cosmicdrift/kumiko-headless";
|
|
4
4
|
import { render, screen as rtlScreen, waitFor } from "@testing-library/react";
|
|
5
5
|
import type { ComponentType, ReactNode } from "react";
|
|
6
|
-
import { NavProvider } from "../../app/nav";
|
|
6
|
+
import { type NavApi, NavProvider } from "../../app/nav";
|
|
7
7
|
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
8
8
|
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
9
9
|
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
@@ -26,22 +26,27 @@ const testSection: ComponentType<SectionProps> = ({ testId, children }) => (
|
|
|
26
26
|
// wired straight to the action's onTrigger — enough to prove
|
|
27
27
|
// RelatedListSection wires rowActions through to a real dispatch, without
|
|
28
28
|
// needing the production DataTable's sorting/paging/kebab-menu chrome.
|
|
29
|
+
// The isVisible filter mirrors the production DataTable's own row-action
|
|
30
|
+
// filter (renderer-web primitives/index.tsx) so per-row gating is exercised
|
|
31
|
+
// here rather than assumed.
|
|
29
32
|
const testDataTable: ComponentType<DataTableProps> = ({ rows, rowActions }) => (
|
|
30
33
|
<table>
|
|
31
34
|
<tbody>
|
|
32
35
|
{rows.map((row) => (
|
|
33
36
|
<tr key={row.id} data-testid={`row-${row.id}`}>
|
|
34
37
|
<td>
|
|
35
|
-
{(rowActions ?? [])
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
{(rowActions ?? [])
|
|
39
|
+
.filter((action) => action.isVisible === undefined || action.isVisible(row))
|
|
40
|
+
.map((action) => (
|
|
41
|
+
<button
|
|
42
|
+
key={action.id}
|
|
43
|
+
type="button"
|
|
44
|
+
data-testid={`action-${action.id}-${row.id}`}
|
|
45
|
+
onClick={() => void action.onTrigger(row)}
|
|
46
|
+
>
|
|
47
|
+
{action.label}
|
|
48
|
+
</button>
|
|
49
|
+
))}
|
|
45
50
|
</td>
|
|
46
51
|
</tr>
|
|
47
52
|
))}
|
|
@@ -74,7 +79,7 @@ function testPrimitives(): CorePrimitives {
|
|
|
74
79
|
} as unknown as CorePrimitives;
|
|
75
80
|
}
|
|
76
81
|
|
|
77
|
-
function stubDispatcher(): {
|
|
82
|
+
function stubDispatcher(rows: readonly Record<string, unknown>[] = [{ id: "r1", name: "Alice" }]): {
|
|
78
83
|
dispatcher: Dispatcher;
|
|
79
84
|
writes: Array<{ type: string; payload: unknown }>;
|
|
80
85
|
queryCount: () => number;
|
|
@@ -88,10 +93,7 @@ function stubDispatcher(): {
|
|
|
88
93
|
}) as Dispatcher["write"],
|
|
89
94
|
query: (async () => {
|
|
90
95
|
queryCalls += 1;
|
|
91
|
-
return {
|
|
92
|
-
isSuccess: true,
|
|
93
|
-
data: { rows: [{ id: "r1", name: "Alice" }], nextCursor: null },
|
|
94
|
-
};
|
|
96
|
+
return { isSuccess: true, data: { rows, nextCursor: null } };
|
|
95
97
|
}) as Dispatcher["query"],
|
|
96
98
|
batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
|
|
97
99
|
statusStore: {
|
|
@@ -105,6 +107,28 @@ function stubDispatcher(): {
|
|
|
105
107
|
return { dispatcher, writes, queryCount: () => queryCalls };
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
function stubNav(): {
|
|
111
|
+
nav: NavApi;
|
|
112
|
+
navigations: unknown[];
|
|
113
|
+
searchParams: Array<Record<string, string | null>>;
|
|
114
|
+
} {
|
|
115
|
+
const navigations: unknown[] = [];
|
|
116
|
+
const searchParams: Array<Record<string, string | null>> = [];
|
|
117
|
+
const nav: NavApi = {
|
|
118
|
+
route: undefined,
|
|
119
|
+
navigate: (target: unknown) => {
|
|
120
|
+
navigations.push(target);
|
|
121
|
+
},
|
|
122
|
+
replace: noop,
|
|
123
|
+
hrefFor: () => "#",
|
|
124
|
+
searchParams: {},
|
|
125
|
+
setSearchParams: (params: Record<string, string | null>) => {
|
|
126
|
+
searchParams.push(params);
|
|
127
|
+
},
|
|
128
|
+
} as unknown as NavApi;
|
|
129
|
+
return { nav, navigations, searchParams };
|
|
130
|
+
}
|
|
131
|
+
|
|
108
132
|
const rowActions: readonly RowAction[] = [
|
|
109
133
|
{
|
|
110
134
|
id: "resend",
|
|
@@ -122,7 +146,11 @@ const historySection: EditRelatedListSectionViewModel = {
|
|
|
122
146
|
rowActions,
|
|
123
147
|
};
|
|
124
148
|
|
|
125
|
-
function renderRelatedList(
|
|
149
|
+
function renderRelatedList(
|
|
150
|
+
dispatcher: Dispatcher,
|
|
151
|
+
section: EditRelatedListSectionViewModel = historySection,
|
|
152
|
+
nav: NavApi = stubNav().nav,
|
|
153
|
+
) {
|
|
126
154
|
return render(
|
|
127
155
|
<LocaleProvider
|
|
128
156
|
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
@@ -130,17 +158,8 @@ function renderRelatedList(dispatcher: Dispatcher) {
|
|
|
130
158
|
>
|
|
131
159
|
<DispatcherProvider dispatcher={dispatcher}>
|
|
132
160
|
<PrimitivesProvider value={testPrimitives()}>
|
|
133
|
-
<NavProvider
|
|
134
|
-
|
|
135
|
-
route: undefined,
|
|
136
|
-
navigate: noop,
|
|
137
|
-
replace: noop,
|
|
138
|
-
hrefFor: () => "#",
|
|
139
|
-
searchParams: {},
|
|
140
|
-
setSearchParams: noop,
|
|
141
|
-
}}
|
|
142
|
-
>
|
|
143
|
-
<RelatedListSection section={historySection} parentId="order-1" featureName="orders" />
|
|
161
|
+
<NavProvider value={nav}>
|
|
162
|
+
<RelatedListSection section={section} parentId="order-1" featureName="orders" />
|
|
144
163
|
</NavProvider>
|
|
145
164
|
</PrimitivesProvider>
|
|
146
165
|
</DispatcherProvider>
|
|
@@ -160,4 +179,61 @@ describe("RelatedListSection — rowActions", () => {
|
|
|
160
179
|
expect(writes[0]).toEqual({ type: "orders:write:resend", payload: { id: "r1" } });
|
|
161
180
|
await waitFor(() => expect(queryCount()).toBe(2));
|
|
162
181
|
});
|
|
182
|
+
|
|
183
|
+
test("a navigate row action targets its screen and carries the clicked row's own values as search params", async () => {
|
|
184
|
+
const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
|
|
185
|
+
const { nav, navigations, searchParams } = stubNav();
|
|
186
|
+
renderRelatedList(
|
|
187
|
+
dispatcher,
|
|
188
|
+
{
|
|
189
|
+
kind: "relatedList",
|
|
190
|
+
title: "Positions",
|
|
191
|
+
query: "lease:query:items:list",
|
|
192
|
+
columns: [{ field: "name" }],
|
|
193
|
+
rowActions: [
|
|
194
|
+
{
|
|
195
|
+
kind: "navigate",
|
|
196
|
+
id: "adjust-rent",
|
|
197
|
+
label: "actions.adjustRent",
|
|
198
|
+
screen: "adjust-rent-form",
|
|
199
|
+
params: { map: { itemId: "id", currentAmount: "amount" } },
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
nav,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
|
|
207
|
+
rtlScreen.getByTestId("action-adjust-rent-item-7").click();
|
|
208
|
+
|
|
209
|
+
await waitFor(() => expect(navigations).toHaveLength(1));
|
|
210
|
+
expect(navigations[0]).toEqual({ screenId: "adjust-rent-form" });
|
|
211
|
+
expect(searchParams).toEqual([{ itemId: "item-7", currentAmount: "1200" }]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a row action with a visible condition renders only on the rows that satisfy it", async () => {
|
|
215
|
+
const { dispatcher } = stubDispatcher([
|
|
216
|
+
{ id: "active-1", name: "Running", status: "active" },
|
|
217
|
+
{ id: "ended-1", name: "Closed", status: "ended" },
|
|
218
|
+
]);
|
|
219
|
+
renderRelatedList(dispatcher, {
|
|
220
|
+
kind: "relatedList",
|
|
221
|
+
title: "Positions",
|
|
222
|
+
query: "lease:query:items:list",
|
|
223
|
+
columns: [{ field: "name" }],
|
|
224
|
+
rowActions: [
|
|
225
|
+
{
|
|
226
|
+
id: "end-item",
|
|
227
|
+
label: "actions.endItem",
|
|
228
|
+
handler: "lease:write:end-item",
|
|
229
|
+
payload: { pick: ["id"] },
|
|
230
|
+
visible: { field: "status", eq: "active" },
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-ended-1")).toBeTruthy());
|
|
236
|
+
expect(rtlScreen.getByTestId("action-end-item-active-1")).toBeTruthy();
|
|
237
|
+
expect(rtlScreen.queryByTestId("action-end-item-ended-1")).toBeNull();
|
|
238
|
+
});
|
|
163
239
|
});
|
|
@@ -23,10 +23,18 @@ function entityWriteCommand(featureName: string, entity: string): string {
|
|
|
23
23
|
// The create handler's success payload is `{ kind: "save", id, ... }`
|
|
24
24
|
// (see event-store-executor-write.ts) but RenderEdit's onSubmit only
|
|
25
25
|
// types it as `unknown` — narrow defensively instead of casting through it.
|
|
26
|
-
|
|
26
|
+
// `field` is author-declared (ActionFormRedirect.idFrom, fw#2670), never
|
|
27
|
+
// user input; the own-property check keeps a prototype key like
|
|
28
|
+
// "constructor" from resolving to anything.
|
|
29
|
+
export function extractIdField(data: unknown, field: string): string | undefined {
|
|
27
30
|
if (typeof data !== "object" || data === null) return undefined;
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
if (!Object.hasOwn(data, field)) return undefined;
|
|
32
|
+
const value = (data as Record<string, unknown>)[field];
|
|
33
|
+
return typeof value === "string" ? value : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function extractCreatedId(data: unknown): string | undefined {
|
|
37
|
+
return extractIdField(data, "id");
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
export type ReferenceCreateDialogProps = {
|
package/src/primitives.tsx
CHANGED
|
@@ -448,8 +448,9 @@ export type InputProps =
|
|
|
448
448
|
readonly name: string;
|
|
449
449
|
readonly value: string;
|
|
450
450
|
readonly onChange: (v: string) => void;
|
|
451
|
-
/**
|
|
452
|
-
*
|
|
451
|
+
/** Visible rows. Acts as a minimum height — the field starts this tall
|
|
452
|
+
* and still grows with its content. Omitted, the default primitive
|
|
453
|
+
* falls back to its own floor (#2677). */
|
|
453
454
|
readonly rows?: number;
|
|
454
455
|
readonly disabled?: boolean;
|
|
455
456
|
readonly required?: boolean;
|