@cosmicdrift/kumiko-renderer 0.215.6 → 0.215.7
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 +4 -4
- package/src/app/__tests__/list-filter-facets.test.tsx +96 -0
- package/src/app/kumiko-screen.tsx +23 -10
- package/src/components/__tests__/render-edit-action-button.test.tsx +168 -0
- package/src/components/__tests__/render-edit-submit-actions.test.tsx +300 -0
- package/src/components/render-edit-action-button.tsx +66 -0
- package/src/components/render-edit-types.ts +164 -0
- package/src/components/render-edit.tsx +10 -220
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.215.
|
|
3
|
+
"version": "0.215.7",
|
|
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.215.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.215.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.215.7",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.215.7",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@testing-library/react": "^16.3.2",
|
|
26
26
|
"@types/react": "^19.2.14",
|
|
27
27
|
"jsdom": "^29.1.1",
|
|
28
|
-
"@cosmicdrift/kumiko-locale-de": "0.215.
|
|
28
|
+
"@cosmicdrift/kumiko-locale-de": "0.215.7"
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
@@ -238,4 +238,100 @@ describe("projectionList filter + facets (fw#2224)", () => {
|
|
|
238
238
|
filters: [{ field: "active", op: "in", value: [true] }],
|
|
239
239
|
});
|
|
240
240
|
});
|
|
241
|
+
|
|
242
|
+
// fw#2373: labels without a translation key must pass through unchanged —
|
|
243
|
+
// otherwise shipping translate() alone regresses Members filters to raw keys
|
|
244
|
+
// when locale packs are missing.
|
|
245
|
+
test("projection facet labels without a translation key pass through unchanged", async () => {
|
|
246
|
+
queryCalls = [];
|
|
247
|
+
capturedProps = undefined;
|
|
248
|
+
const screen: ProjectionListScreenDefinition = {
|
|
249
|
+
id: "member-list",
|
|
250
|
+
type: "projectionList",
|
|
251
|
+
query: "ledger:query:member:list",
|
|
252
|
+
columns: ["tier", "active"],
|
|
253
|
+
facets: [
|
|
254
|
+
{
|
|
255
|
+
field: "tier",
|
|
256
|
+
type: "select",
|
|
257
|
+
label: "ledger.member.tier",
|
|
258
|
+
options: [
|
|
259
|
+
{ value: "gold", label: "ledger.member.tier.gold" },
|
|
260
|
+
{ value: "silver", label: "ledger.member.tier.silver" },
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
field: "active",
|
|
265
|
+
type: "boolean",
|
|
266
|
+
label: "ledger.member.active",
|
|
267
|
+
trueLabel: "ledger.member.active.true",
|
|
268
|
+
falseLabel: "ledger.member.active.false",
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
const schema: FeatureSchema = {
|
|
273
|
+
featureName: "ledger",
|
|
274
|
+
entities: {},
|
|
275
|
+
screens: [screen],
|
|
276
|
+
} as FeatureSchema;
|
|
277
|
+
|
|
278
|
+
renderScreen(schema, "ledger:screen:member-list");
|
|
279
|
+
|
|
280
|
+
await waitFor(() => expect(capturedProps).toBeDefined());
|
|
281
|
+
const props = getCapturedProps();
|
|
282
|
+
if (props === undefined) throw new Error("DataTable was not rendered");
|
|
283
|
+
expect(props.filterFacets).toEqual([
|
|
284
|
+
{
|
|
285
|
+
field: "tier",
|
|
286
|
+
label: "ledger.member.tier",
|
|
287
|
+
options: [
|
|
288
|
+
{ value: "gold", label: "ledger.member.tier.gold" },
|
|
289
|
+
{ value: "silver", label: "ledger.member.tier.silver" },
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
field: "active",
|
|
294
|
+
label: "ledger.member.active",
|
|
295
|
+
options: [
|
|
296
|
+
{ value: "true", label: "ledger.member.active.true" },
|
|
297
|
+
{ value: "false", label: "ledger.member.active.false" },
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("projection facet labels resolve when a translation key exists", async () => {
|
|
304
|
+
queryCalls = [];
|
|
305
|
+
capturedProps = undefined;
|
|
306
|
+
const screen: ProjectionListScreenDefinition = {
|
|
307
|
+
id: "member-list",
|
|
308
|
+
type: "projectionList",
|
|
309
|
+
query: "ledger:query:member:list",
|
|
310
|
+
columns: ["tier"],
|
|
311
|
+
facets: [
|
|
312
|
+
{
|
|
313
|
+
field: "tier",
|
|
314
|
+
type: "select",
|
|
315
|
+
label: "kumiko.actions.save",
|
|
316
|
+
options: [{ value: "gold", label: "kumiko.actions.cancel" }],
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
};
|
|
320
|
+
const schema: FeatureSchema = {
|
|
321
|
+
featureName: "ledger",
|
|
322
|
+
entities: {},
|
|
323
|
+
screens: [screen],
|
|
324
|
+
} as FeatureSchema;
|
|
325
|
+
|
|
326
|
+
renderScreen(schema, "ledger:screen:member-list");
|
|
327
|
+
|
|
328
|
+
await waitFor(() => expect(capturedProps).toBeDefined());
|
|
329
|
+
const props = getCapturedProps();
|
|
330
|
+
if (props === undefined) throw new Error("DataTable was not rendered");
|
|
331
|
+
const facet = props.filterFacets?.[0];
|
|
332
|
+
expect(facet?.label).not.toBe("kumiko.actions.save");
|
|
333
|
+
expect(facet?.options?.[0]?.label).not.toBe("kumiko.actions.cancel");
|
|
334
|
+
expect(typeof facet?.label).toBe("string");
|
|
335
|
+
expect(facet?.label.length).toBeGreaterThan(0);
|
|
336
|
+
});
|
|
241
337
|
});
|
|
@@ -954,22 +954,31 @@ function resolveEntityFacetSpecs(
|
|
|
954
954
|
|
|
955
955
|
// projectionList adapter — a projectionList has no entity/i18n convention to
|
|
956
956
|
// derive labels from, so ListFacetSpec carries every label explicitly
|
|
957
|
-
// (fw#2224)
|
|
958
|
-
//
|
|
957
|
+
// (fw#2224). Labels may be raw display strings or i18n keys; run them
|
|
958
|
+
// through translate like entity facets (passthrough when the key is missing).
|
|
959
959
|
function resolveProjectionFacetSpecs(
|
|
960
960
|
facets: readonly ListFacetSpec[] | undefined,
|
|
961
|
+
translate: Translate,
|
|
961
962
|
): ResolvedFacetSpec[] {
|
|
962
963
|
if (facets === undefined) return [];
|
|
963
964
|
return facets.map((facet) =>
|
|
964
965
|
facet.type === "select"
|
|
965
|
-
? {
|
|
966
|
+
? {
|
|
967
|
+
field: facet.field,
|
|
968
|
+
type: "select",
|
|
969
|
+
label: translate(facet.label),
|
|
970
|
+
options: facet.options.map((opt) => ({
|
|
971
|
+
value: opt.value,
|
|
972
|
+
label: translate(opt.label),
|
|
973
|
+
})),
|
|
974
|
+
}
|
|
966
975
|
: {
|
|
967
976
|
field: facet.field,
|
|
968
977
|
type: "boolean",
|
|
969
|
-
label: facet.label,
|
|
978
|
+
label: translate(facet.label),
|
|
970
979
|
options: [
|
|
971
|
-
{ value: "true", label: facet.trueLabel },
|
|
972
|
-
{ value: "false", label: facet.falseLabel },
|
|
980
|
+
{ value: "true", label: translate(facet.trueLabel) },
|
|
981
|
+
{ value: "false", label: translate(facet.falseLabel) },
|
|
973
982
|
],
|
|
974
983
|
},
|
|
975
984
|
);
|
|
@@ -1618,10 +1627,14 @@ function ProjectionListBody({
|
|
|
1618
1627
|
const usePager = paginated && (screen.pagination ?? "pages") === "pages";
|
|
1619
1628
|
|
|
1620
1629
|
// Facets (fw#2224) — a projectionList has no entity, so screen.facets is
|
|
1621
|
-
// the only field inventory; resolveProjectionFacetSpecs reshapes
|
|
1622
|
-
// explicit labels into the same ResolvedFacetSpec the
|
|
1623
|
-
// produces, so both feed the same
|
|
1624
|
-
|
|
1630
|
+
// the only field inventory; resolveProjectionFacetSpecs reshapes +
|
|
1631
|
+
// translates its explicit labels into the same ResolvedFacetSpec the
|
|
1632
|
+
// entityList adapter produces, so both feed the same
|
|
1633
|
+
// buildFilterFacets/buildFilterPayload.
|
|
1634
|
+
const facetSpecs = useMemo(
|
|
1635
|
+
() => resolveProjectionFacetSpecs(screen.facets, effectiveTranslate),
|
|
1636
|
+
[screen.facets, effectiveTranslate],
|
|
1637
|
+
);
|
|
1625
1638
|
const filterPayload = useMemo(
|
|
1626
1639
|
() =>
|
|
1627
1640
|
buildFilterPayload(
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
|
|
3
|
+
import type { ComponentType } from "react";
|
|
4
|
+
import type { ButtonProps, DialogProps } from "../../primitives";
|
|
5
|
+
import { RenderEditActionButton } from "../render-edit-action-button";
|
|
6
|
+
import type { RenderEditAction } from "../render-edit-types";
|
|
7
|
+
|
|
8
|
+
const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId, type, loading }) => (
|
|
9
|
+
<button
|
|
10
|
+
type={type ?? "button"}
|
|
11
|
+
data-testid={testId}
|
|
12
|
+
data-loading={loading ? "1" : "0"}
|
|
13
|
+
onClick={() => {
|
|
14
|
+
void onClick?.();
|
|
15
|
+
}}
|
|
16
|
+
>
|
|
17
|
+
{children}
|
|
18
|
+
</button>
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const TestDialog: ComponentType<DialogProps> = ({
|
|
22
|
+
open,
|
|
23
|
+
onOpenChange,
|
|
24
|
+
title,
|
|
25
|
+
description,
|
|
26
|
+
confirmLabel,
|
|
27
|
+
variant,
|
|
28
|
+
onConfirm,
|
|
29
|
+
testId,
|
|
30
|
+
}) =>
|
|
31
|
+
open ? (
|
|
32
|
+
<div data-testid={testId} data-variant={variant ?? "default"}>
|
|
33
|
+
<span data-testid={`${testId}-title`}>{title}</span>
|
|
34
|
+
{description !== undefined && (
|
|
35
|
+
<span data-testid={`${testId}-description`}>{description}</span>
|
|
36
|
+
)}
|
|
37
|
+
<button type="button" data-testid={`${testId}-confirm`} onClick={() => void onConfirm()}>
|
|
38
|
+
{confirmLabel ?? "Confirm"}
|
|
39
|
+
</button>
|
|
40
|
+
<button type="button" data-testid={`${testId}-cancel`} onClick={() => onOpenChange(false)}>
|
|
41
|
+
Cancel
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
) : null;
|
|
45
|
+
|
|
46
|
+
function renderAction(action: RenderEditAction, onError: (text: string | null) => void = () => {}) {
|
|
47
|
+
return render(
|
|
48
|
+
<RenderEditActionButton
|
|
49
|
+
action={action}
|
|
50
|
+
Button={TestButton}
|
|
51
|
+
Dialog={TestDialog}
|
|
52
|
+
onError={onError}
|
|
53
|
+
/>,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("RenderEditActionButton", () => {
|
|
58
|
+
test("secondary action without confirm runs onPress immediately", async () => {
|
|
59
|
+
let pressed = 0;
|
|
60
|
+
renderAction({
|
|
61
|
+
id: "ping",
|
|
62
|
+
label: "Ping",
|
|
63
|
+
onPress: async () => {
|
|
64
|
+
pressed += 1;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(rtlScreen.queryByTestId("render-edit-action-ping-dialog")).toBeNull();
|
|
69
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-action-ping"));
|
|
70
|
+
await waitFor(() => expect(pressed).toBe(1));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("explicit confirm text opens dialog; confirm runs onPress, cancel does not", async () => {
|
|
74
|
+
let pressed = 0;
|
|
75
|
+
renderAction({
|
|
76
|
+
id: "archive",
|
|
77
|
+
label: "Archive",
|
|
78
|
+
confirm: "Really archive?",
|
|
79
|
+
confirmLabel: "Yes, archive",
|
|
80
|
+
onPress: async () => {
|
|
81
|
+
pressed += 1;
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-action-archive"));
|
|
86
|
+
expect(rtlScreen.getByTestId("render-edit-action-archive-dialog")).toBeTruthy();
|
|
87
|
+
expect(rtlScreen.getByTestId("render-edit-action-archive-dialog-description").textContent).toBe(
|
|
88
|
+
"Really archive?",
|
|
89
|
+
);
|
|
90
|
+
expect(rtlScreen.getByTestId("render-edit-action-archive-dialog-confirm").textContent).toBe(
|
|
91
|
+
"Yes, archive",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-action-archive-dialog-cancel"));
|
|
95
|
+
expect(rtlScreen.queryByTestId("render-edit-action-archive-dialog")).toBeNull();
|
|
96
|
+
expect(pressed).toBe(0);
|
|
97
|
+
|
|
98
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-action-archive"));
|
|
99
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-action-archive-dialog-confirm"));
|
|
100
|
+
await waitFor(() => expect(pressed).toBe(1));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("danger style forces confirm dialog even without confirm text", async () => {
|
|
104
|
+
let pressed = 0;
|
|
105
|
+
renderAction({
|
|
106
|
+
id: "delete",
|
|
107
|
+
label: "Delete",
|
|
108
|
+
style: "danger",
|
|
109
|
+
onPress: async () => {
|
|
110
|
+
pressed += 1;
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-action-delete"));
|
|
115
|
+
const dialog = rtlScreen.getByTestId("render-edit-action-delete-dialog");
|
|
116
|
+
expect(dialog.getAttribute("data-variant")).toBe("danger");
|
|
117
|
+
expect(rtlScreen.queryByTestId("render-edit-action-delete-dialog-description")).toBeNull();
|
|
118
|
+
expect(pressed).toBe(0);
|
|
119
|
+
|
|
120
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-action-delete-dialog-confirm"));
|
|
121
|
+
await waitFor(() => expect(pressed).toBe(1));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("onPress failure reports via onError", async () => {
|
|
125
|
+
const errors: Array<string | null> = [];
|
|
126
|
+
renderAction(
|
|
127
|
+
{
|
|
128
|
+
id: "boom",
|
|
129
|
+
label: "Boom",
|
|
130
|
+
onPress: async () => {
|
|
131
|
+
throw new Error("action exploded");
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
(text) => {
|
|
135
|
+
errors.push(text);
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-action-boom"));
|
|
140
|
+
await waitFor(() => expect(errors).toContain("action exploded"));
|
|
141
|
+
expect(errors[0]).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("sets loading while onPress is in flight", async () => {
|
|
145
|
+
let resolvePress!: () => void;
|
|
146
|
+
const pressPromise = new Promise<void>((resolve) => {
|
|
147
|
+
resolvePress = resolve;
|
|
148
|
+
});
|
|
149
|
+
renderAction({
|
|
150
|
+
id: "slow",
|
|
151
|
+
label: "Slow",
|
|
152
|
+
onPress: () => pressPromise,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-action-slow"));
|
|
156
|
+
await waitFor(() =>
|
|
157
|
+
expect(rtlScreen.getByTestId("render-edit-action-slow").getAttribute("data-loading")).toBe(
|
|
158
|
+
"1",
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
resolvePress();
|
|
162
|
+
await waitFor(() =>
|
|
163
|
+
expect(rtlScreen.getByTestId("render-edit-action-slow").getAttribute("data-loading")).toBe(
|
|
164
|
+
"0",
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
EntityDefinition,
|
|
4
|
+
EntityEditScreenDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
+
import type { DispatcherError, SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
7
|
+
import { fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
|
|
8
|
+
import type { ComponentType, ReactNode } from "react";
|
|
9
|
+
import { buildFormSchema } from "../../app/form-schema";
|
|
10
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
11
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
12
|
+
import {
|
|
13
|
+
type BannerProps,
|
|
14
|
+
type CorePrimitives,
|
|
15
|
+
PrimitivesProvider,
|
|
16
|
+
type SectionProps,
|
|
17
|
+
type TextProps,
|
|
18
|
+
} from "../../primitives";
|
|
19
|
+
import { RenderEdit, type RenderEditAction, type RenderEditProps } from "../render-edit";
|
|
20
|
+
|
|
21
|
+
type Values = { name: string };
|
|
22
|
+
|
|
23
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
24
|
+
|
|
25
|
+
const noop = () => {};
|
|
26
|
+
|
|
27
|
+
const renderSection: ComponentType<SectionProps> = ({ testId, children }) => (
|
|
28
|
+
<div data-testid={testId}>{children}</div>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const testButton: ComponentType<{
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
onClick?: () => void;
|
|
34
|
+
testId?: string;
|
|
35
|
+
type?: "button" | "submit";
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
}> = ({ children, onClick, testId, type, disabled }) => (
|
|
38
|
+
<button type={type ?? "button"} data-testid={testId} onClick={onClick} disabled={disabled}>
|
|
39
|
+
{children}
|
|
40
|
+
</button>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const testForm: ComponentType<{
|
|
44
|
+
children?: ReactNode;
|
|
45
|
+
actions?: ReactNode;
|
|
46
|
+
onSubmit?: () => void;
|
|
47
|
+
}> = ({ children, actions, onSubmit }) => (
|
|
48
|
+
<form
|
|
49
|
+
onSubmit={(e) => {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
onSubmit?.();
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{children}
|
|
55
|
+
{actions}
|
|
56
|
+
</form>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const testInput: ComponentType<{
|
|
60
|
+
name?: string;
|
|
61
|
+
value?: unknown;
|
|
62
|
+
onChange?: (v: unknown) => void;
|
|
63
|
+
}> = ({ name = "field", value, onChange }) => (
|
|
64
|
+
<input
|
|
65
|
+
aria-label={name}
|
|
66
|
+
data-testid={`input-${name}`}
|
|
67
|
+
value={typeof value === "string" ? value : ""}
|
|
68
|
+
onChange={(e) => onChange?.(e.target.value)}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const testBanner: ComponentType<BannerProps> = ({ children, testId }) => (
|
|
73
|
+
<div data-testid={testId}>{children}</div>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const testText: ComponentType<TextProps> = ({ children, testId }) => (
|
|
77
|
+
<span data-testid={testId}>{children}</span>
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
function testPrimitives(): CorePrimitives {
|
|
81
|
+
return {
|
|
82
|
+
Button: testButton,
|
|
83
|
+
Banner: testBanner,
|
|
84
|
+
Field: passChildren,
|
|
85
|
+
Input: testInput,
|
|
86
|
+
DataTable: noop,
|
|
87
|
+
Form: testForm,
|
|
88
|
+
Section: renderSection,
|
|
89
|
+
Card: passChildren,
|
|
90
|
+
Grid: passChildren,
|
|
91
|
+
GridCell: passChildren,
|
|
92
|
+
Text: testText,
|
|
93
|
+
Heading: noop,
|
|
94
|
+
Dialog: noop,
|
|
95
|
+
Modal: noop,
|
|
96
|
+
Lightbox: noop,
|
|
97
|
+
ConfigSourceBadge: noop,
|
|
98
|
+
ConfigCascadeView: noop,
|
|
99
|
+
Link: noop,
|
|
100
|
+
} as unknown as CorePrimitives;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildEntity(required = false): EntityDefinition {
|
|
104
|
+
return {
|
|
105
|
+
fields: {
|
|
106
|
+
name: {
|
|
107
|
+
type: "text",
|
|
108
|
+
maxLength: 200,
|
|
109
|
+
required,
|
|
110
|
+
searchable: false,
|
|
111
|
+
sortable: false,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderEdit(
|
|
118
|
+
screen: EntityEditScreenDefinition,
|
|
119
|
+
overrides: Partial<RenderEditProps<Values>> = {},
|
|
120
|
+
entity: EntityDefinition = buildEntity(),
|
|
121
|
+
) {
|
|
122
|
+
return render(
|
|
123
|
+
<LocaleProvider
|
|
124
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
125
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
126
|
+
>
|
|
127
|
+
<PrimitivesProvider value={testPrimitives()}>
|
|
128
|
+
<RenderEdit
|
|
129
|
+
screen={screen}
|
|
130
|
+
entity={entity}
|
|
131
|
+
featureName="contacts"
|
|
132
|
+
initial={{ name: "" }}
|
|
133
|
+
{...overrides}
|
|
134
|
+
/>
|
|
135
|
+
</PrimitivesProvider>
|
|
136
|
+
</LocaleProvider>,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const oneFieldScreen: EntityEditScreenDefinition = {
|
|
141
|
+
id: "contact-edit",
|
|
142
|
+
type: "entityEdit",
|
|
143
|
+
entity: "contact",
|
|
144
|
+
layout: { sections: [{ title: "Main", fields: ["name"] }] },
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const writeFailure: DispatcherError = {
|
|
148
|
+
code: "conflict",
|
|
149
|
+
httpStatus: 409,
|
|
150
|
+
i18nKey: "errors.conflict",
|
|
151
|
+
message: "conflict",
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
describe("RenderEdit — submit path", () => {
|
|
155
|
+
test("changing a field and saving calls onSubmit with a successful result", async () => {
|
|
156
|
+
let submitted: SubmitResult<unknown> | undefined;
|
|
157
|
+
renderEdit(oneFieldScreen, {
|
|
158
|
+
customSubmit: async () => ({
|
|
159
|
+
validationBlocked: false,
|
|
160
|
+
isSuccess: true,
|
|
161
|
+
data: { id: "n1" },
|
|
162
|
+
}),
|
|
163
|
+
onSubmit: (result) => {
|
|
164
|
+
submitted = result;
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
fireEvent.change(rtlScreen.getByLabelText(/name/i), { target: { value: "Ferdinand" } });
|
|
169
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
|
|
170
|
+
|
|
171
|
+
await waitFor(() => expect(submitted).toBeDefined());
|
|
172
|
+
expect(submitted?.isSuccess).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("submit stays disabled while the form is unchanged and enables after edits", async () => {
|
|
176
|
+
const view = renderEdit(oneFieldScreen, {
|
|
177
|
+
customSubmit: async () => ({ validationBlocked: false, isSuccess: true, data: {} }),
|
|
178
|
+
onSubmit: () => {
|
|
179
|
+
throw new Error("must not fire while disabled");
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const save = rtlScreen.getByTestId("render-edit-submit") as HTMLButtonElement;
|
|
184
|
+
expect(save.disabled).toBe(true);
|
|
185
|
+
fireEvent.change(rtlScreen.getByLabelText(/name/i), { target: { value: "X" } });
|
|
186
|
+
await waitFor(() => expect(save.disabled).toBe(false));
|
|
187
|
+
view.unmount();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("failed customSubmit surfaces the form-error banner and notifies onSubmit", async () => {
|
|
191
|
+
let submitted: SubmitResult<unknown> | undefined;
|
|
192
|
+
let customCalls = 0;
|
|
193
|
+
renderEdit(oneFieldScreen, {
|
|
194
|
+
customSubmit: async () => {
|
|
195
|
+
customCalls += 1;
|
|
196
|
+
return { validationBlocked: false, isSuccess: false, error: writeFailure };
|
|
197
|
+
},
|
|
198
|
+
onSubmit: (result) => {
|
|
199
|
+
submitted = result;
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
fireEvent.change(rtlScreen.getByLabelText(/name/i), { target: { value: "Ferdinand" } });
|
|
204
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
|
|
205
|
+
|
|
206
|
+
await waitFor(() => expect(rtlScreen.getByTestId("render-edit-form-error")).toBeTruthy());
|
|
207
|
+
// LocaleProvider resolves i18nKey → human copy; pin the error code via onSubmit.
|
|
208
|
+
expect(rtlScreen.getByTestId("render-edit-form-error-key").textContent?.length).toBeGreaterThan(
|
|
209
|
+
0,
|
|
210
|
+
);
|
|
211
|
+
expect(customCalls).toBe(1);
|
|
212
|
+
expect(submitted).toEqual({
|
|
213
|
+
validationBlocked: false,
|
|
214
|
+
isSuccess: false,
|
|
215
|
+
error: writeFailure,
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("schema validation blocks customSubmit and notifies validationBlocked", async () => {
|
|
220
|
+
let submitted: SubmitResult<unknown> | undefined;
|
|
221
|
+
let customCalls = 0;
|
|
222
|
+
const entity = buildEntity(true);
|
|
223
|
+
// RenderEdit only validates when a schema is passed (kumiko-screen builds it
|
|
224
|
+
// via buildFormSchema). Without it, entity.required is display-only here.
|
|
225
|
+
render(
|
|
226
|
+
<LocaleProvider
|
|
227
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
228
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
229
|
+
>
|
|
230
|
+
<PrimitivesProvider value={testPrimitives()}>
|
|
231
|
+
<RenderEdit
|
|
232
|
+
screen={oneFieldScreen}
|
|
233
|
+
entity={entity}
|
|
234
|
+
featureName="contacts"
|
|
235
|
+
initial={{ name: "seed" }}
|
|
236
|
+
schema={buildFormSchema(entity, oneFieldScreen)}
|
|
237
|
+
customSubmit={async () => {
|
|
238
|
+
customCalls += 1;
|
|
239
|
+
return { validationBlocked: false, isSuccess: true, data: {} };
|
|
240
|
+
}}
|
|
241
|
+
onSubmit={(result) => {
|
|
242
|
+
submitted = result;
|
|
243
|
+
}}
|
|
244
|
+
/>
|
|
245
|
+
</PrimitivesProvider>
|
|
246
|
+
</LocaleProvider>,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// Clear the required field; form stays dirty vs initial "seed".
|
|
250
|
+
fireEvent.change(rtlScreen.getByLabelText(/name/i), { target: { value: "" } });
|
|
251
|
+
await waitFor(() =>
|
|
252
|
+
expect((rtlScreen.getByTestId("render-edit-submit") as HTMLButtonElement).disabled).toBe(
|
|
253
|
+
false,
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
|
|
257
|
+
|
|
258
|
+
await waitFor(() => expect(submitted).toBeDefined());
|
|
259
|
+
expect(submitted).toEqual({ validationBlocked: true, isSuccess: false });
|
|
260
|
+
expect(customCalls).toBe(0);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("RenderEdit — custom actions", () => {
|
|
265
|
+
test("renders an action button and runs its handler on click", async () => {
|
|
266
|
+
let pressed = 0;
|
|
267
|
+
const actions: readonly RenderEditAction[] = [
|
|
268
|
+
{
|
|
269
|
+
id: "ping",
|
|
270
|
+
label: "Ping",
|
|
271
|
+
onPress: async () => {
|
|
272
|
+
pressed += 1;
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
renderEdit(oneFieldScreen, { actions });
|
|
277
|
+
|
|
278
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-action-ping"));
|
|
279
|
+
await waitFor(() => expect(pressed).toBe(1));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("action handler failure shows the action-error banner", async () => {
|
|
283
|
+
const actions: readonly RenderEditAction[] = [
|
|
284
|
+
{
|
|
285
|
+
id: "boom",
|
|
286
|
+
label: "Boom",
|
|
287
|
+
onPress: async () => {
|
|
288
|
+
throw new Error("action exploded");
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
];
|
|
292
|
+
renderEdit(oneFieldScreen, { actions });
|
|
293
|
+
|
|
294
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-action-boom"));
|
|
295
|
+
await waitFor(() => expect(rtlScreen.getByTestId("render-edit-action-error")).toBeTruthy());
|
|
296
|
+
expect(rtlScreen.getByTestId("render-edit-action-error").textContent).toContain(
|
|
297
|
+
"action exploded",
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type { usePrimitives } from "../primitives";
|
|
4
|
+
import type { RenderEditAction } from "./render-edit-types";
|
|
5
|
+
|
|
6
|
+
export function RenderEditActionButton({
|
|
7
|
+
action,
|
|
8
|
+
Button,
|
|
9
|
+
Dialog,
|
|
10
|
+
onError,
|
|
11
|
+
}: {
|
|
12
|
+
readonly action: RenderEditAction;
|
|
13
|
+
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
14
|
+
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
15
|
+
readonly onError: (text: string | null) => void;
|
|
16
|
+
}): ReactNode {
|
|
17
|
+
const [busy, setBusy] = useState(false);
|
|
18
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
19
|
+
|
|
20
|
+
const trigger = async (): Promise<void> => {
|
|
21
|
+
setBusy(true);
|
|
22
|
+
onError(null);
|
|
23
|
+
try {
|
|
24
|
+
await action.onPress();
|
|
25
|
+
} catch (e) {
|
|
26
|
+
onError(e instanceof Error ? e.message : String(e));
|
|
27
|
+
} finally {
|
|
28
|
+
setBusy(false);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const variant = action.style ?? "secondary";
|
|
33
|
+
// Same rule as RowActionWriteHandler: "danger" forces a confirm even
|
|
34
|
+
// without an explicit confirm key.
|
|
35
|
+
const needsConfirm = action.confirm !== undefined || action.style === "danger";
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<Button
|
|
40
|
+
type="button"
|
|
41
|
+
variant={variant}
|
|
42
|
+
loading={busy}
|
|
43
|
+
onClick={() => {
|
|
44
|
+
if (needsConfirm) {
|
|
45
|
+
setConfirmOpen(true);
|
|
46
|
+
} else {
|
|
47
|
+
void trigger();
|
|
48
|
+
}
|
|
49
|
+
}}
|
|
50
|
+
testId={`render-edit-action-${action.id}`}
|
|
51
|
+
>
|
|
52
|
+
{action.label}
|
|
53
|
+
</Button>
|
|
54
|
+
<Dialog
|
|
55
|
+
open={confirmOpen}
|
|
56
|
+
onOpenChange={setConfirmOpen}
|
|
57
|
+
title={action.label}
|
|
58
|
+
{...(action.confirm !== undefined && { description: action.confirm })}
|
|
59
|
+
confirmLabel={action.confirmLabel ?? action.label}
|
|
60
|
+
{...(action.style === "danger" && { variant: "danger" as const })}
|
|
61
|
+
onConfirm={trigger}
|
|
62
|
+
testId={`render-edit-action-${action.id}-dialog`}
|
|
63
|
+
/>
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntityDefinition,
|
|
3
|
+
EntityEditScreenDefinition,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
5
|
+
import type {
|
|
6
|
+
FormSnapshot,
|
|
7
|
+
FormValues,
|
|
8
|
+
SubmitResult,
|
|
9
|
+
Translate,
|
|
10
|
+
} from "@cosmicdrift/kumiko-headless";
|
|
11
|
+
import type { ReactNode } from "react";
|
|
12
|
+
import type { z } from "zod";
|
|
13
|
+
|
|
14
|
+
export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
15
|
+
readonly screen: EntityEditScreenDefinition;
|
|
16
|
+
readonly entity: EntityDefinition;
|
|
17
|
+
readonly featureName: string;
|
|
18
|
+
readonly initial: TValues;
|
|
19
|
+
/** Real entity id for extension-section mounts (set-value UI). Mount AND
|
|
20
|
+
* persistExtensions resolve it via `resolveExtensionEntityId(entityIdProp,
|
|
21
|
+
* vm.id)` — the same value, so the section doesn't mount editable against
|
|
22
|
+
* one id while persist writes against another (or none at all).
|
|
23
|
+
* Omitting (undefined) = fallback to `vm.id` (= values["id"]), which the
|
|
24
|
+
* update form carries for the existing row. Explicit `null` = "no entity"
|
|
25
|
+
* (create mode / no extension persistence). */
|
|
26
|
+
readonly entityId?: string | null;
|
|
27
|
+
/** Already-persisted extension values (e.g. `record.customFields`) for
|
|
28
|
+
* extension-section mounts. Lets the section display its current stock
|
|
29
|
+
* during edit. Only the update body provides this. */
|
|
30
|
+
readonly extensionInitialValues?: Readonly<Record<string, unknown>>;
|
|
31
|
+
/** Standard single-write submit path. Ignored when `customSubmit` is set
|
|
32
|
+
* (configEdit screens dispatch multiple writes per submit, where a single
|
|
33
|
+
* writeCommand makes no sense). */
|
|
34
|
+
readonly writeCommand?: string;
|
|
35
|
+
/** Override for the submit pipeline. When set, runs controller.validate()
|
|
36
|
+
* and then customSubmit(snapshot) instead of controller.submit(). On
|
|
37
|
+
* success the form state rebases so that isUnchanged/isDirty become false
|
|
38
|
+
* again — without that, save button and banner stay stale. */
|
|
39
|
+
readonly customSubmit?: (snapshot: FormSnapshot<TValues>) => Promise<SubmitResult<unknown>>;
|
|
40
|
+
readonly translate?: Translate;
|
|
41
|
+
readonly ctx?: TCtx;
|
|
42
|
+
readonly schema?: z.ZodType;
|
|
43
|
+
readonly onSubmit?: (result: SubmitResult<unknown>) => void;
|
|
44
|
+
readonly payloadMode?: "values" | "changes";
|
|
45
|
+
readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
|
|
46
|
+
readonly onDelete?: () => Promise<void> | void;
|
|
47
|
+
readonly onCancel?: () => void;
|
|
48
|
+
readonly onReload?: () => void;
|
|
49
|
+
/** Copy-link action (issue #912) — only set in update mode (create mode
|
|
50
|
+
* has no entity id yet, hence no permalink). The callback is already fully
|
|
51
|
+
* bound (URL building + clipboard happen outside, in
|
|
52
|
+
* `@cosmicdrift/kumiko-renderer-web`'s RoutedScreen — this platform-neutral
|
|
53
|
+
* package must not touch `navigator`/`window`, see
|
|
54
|
+
* guard-renderer-boundaries). undefined = no button. */
|
|
55
|
+
readonly onCopyLink?: () => Promise<void> | void;
|
|
56
|
+
/** Header action buttons, rendered before the built-in copy-link/
|
|
57
|
+
* delete/cancel/save controls. Each callback is already fully
|
|
58
|
+
* bound (screen-type/nav/dispatcher resolution happens in the caller,
|
|
59
|
+
* same split as `onCopyLink`) — RenderEdit only wires the button, its
|
|
60
|
+
* busy state and its confirm dialog. */
|
|
61
|
+
readonly actions?: readonly RenderEditAction[];
|
|
62
|
+
/** i18n key for the submit button. Default: "kumiko.actions.save".
|
|
63
|
+
* Action forms (tier 2.7d) pass their screen.submitLabel here so that
|
|
64
|
+
* "Save" can be replaced by domain-specific strings ("Approve" /
|
|
65
|
+
* "Dispatch" / etc.). */
|
|
66
|
+
readonly submitLabel?: string;
|
|
67
|
+
/** Per-field extra content inline after the label (e.g.
|
|
68
|
+
* ConfigSourceBadge). Called with the field name, returns a ReactNode or
|
|
69
|
+
* undefined. */
|
|
70
|
+
readonly labelAppendix?: (fieldName: string) => ReactNode | undefined;
|
|
71
|
+
/** Per-field extra content below the input (e.g. ConfigCascadeView).
|
|
72
|
+
* Called with the field name, returns a ReactNode or undefined. */
|
|
73
|
+
readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
|
|
74
|
+
/** Controlled mode (issue #1887): fires on every values-snapshot change
|
|
75
|
+
* (typing, `patch(...)` from outside) with the current values. `changes`
|
|
76
|
+
* is the delta against the initial values — same semantics as
|
|
77
|
+
* `payloadMode: "changes"` — so a caller never overwrites unseen fields.
|
|
78
|
+
* `valid` is a pure dry-run parse against `schema` (not a
|
|
79
|
+
* `controller.validate()` call), so it does not paint field errors into
|
|
80
|
+
* the UI and can diverge from the currently rendered `snapshot.errors` —
|
|
81
|
+
* always `true` without `schema`. A caller that patches a fresh object
|
|
82
|
+
* reference on every call must not do so unconditionally: `setValues` is
|
|
83
|
+
* a no-op when the merged value is reference-equal to the current one,
|
|
84
|
+
* so only a converging patch settles instead of looping. Without this
|
|
85
|
+
* prop, existing behavior is unchanged. `onControlsReady` is guaranteed
|
|
86
|
+
* to have already fired by the time the mount-time `onChange` call
|
|
87
|
+
* happens, so a caller patching dependent fields from inside `onChange`
|
|
88
|
+
* never has to guard against `controls` being undefined. */
|
|
89
|
+
readonly onChange?: (state: RenderEditChangeState<TValues>) => void;
|
|
90
|
+
/** Controlled mode (issue #1887): called once after mount, hands the
|
|
91
|
+
* caller `patch`/`validate`/`getValues` bound to this RenderEdit
|
|
92
|
+
* instance — addressable from outside without a remount. `patch` merges
|
|
93
|
+
* only the given keys (existing `controller.setValues` semantics),
|
|
94
|
+
* values on unmentioned fields stay untouched. `validate` runs without a
|
|
95
|
+
* write and reports field issues via `snapshot.errors` on the field
|
|
96
|
+
* itself rather than as a summary banner. Without this prop, existing
|
|
97
|
+
* behavior is unchanged. */
|
|
98
|
+
readonly onControlsReady?: (controls: RenderEditControls<TValues>) => void;
|
|
99
|
+
/** Renders only these fields (by `field` name from the layout) — section
|
|
100
|
+
* order, title, and visibility still come from the layout, so the caller
|
|
101
|
+
* doesn't duplicate its shape. A section with no fields left after
|
|
102
|
+
* filtering is dropped entirely (not rendered empty). Submit validation
|
|
103
|
+
* is scoped to the actually-rendered fields the same way — a required
|
|
104
|
+
* field outside this list doesn't block submit. Omitting this prop keeps
|
|
105
|
+
* unchanged behavior. Read once at mount for `controller.submit()`'s
|
|
106
|
+
* validation scope (the underlying `useForm` controller is mount-lived);
|
|
107
|
+
* rendering and `controls.validate()` do stay reactive to later changes. */
|
|
108
|
+
readonly fields?: readonly string[];
|
|
109
|
+
/** Locked state (issue #1896): every rendered field and the submit button
|
|
110
|
+
* go visibly inactive, no write possible. For cases where input becomes
|
|
111
|
+
* moot — e.g. Solon's editor pointing at an existing record instead of
|
|
112
|
+
* creating a new one. Extension sections are out of scope: RenderEdit has
|
|
113
|
+
* no way to force-disable an arbitrary registered component. Omitting
|
|
114
|
+
* this prop keeps unchanged behavior.
|
|
115
|
+
*
|
|
116
|
+
* ponytail: direct-consumer only — kumiko-screen.tsx's RenderEdit call
|
|
117
|
+
* sites pass explicit prop lists without a spread and never forward
|
|
118
|
+
* `disabled`, so a screen-driven app can't set the locked state today.
|
|
119
|
+
* Upgrade path if that's needed: thread a screen-spec flag through to
|
|
120
|
+
* `EntityEditCreateBody`/`EntityEditEditBody`. */
|
|
121
|
+
readonly disabled?: boolean;
|
|
122
|
+
/** Renders the fields without RenderEdit's own action bar (save, cancel,
|
|
123
|
+
* delete, copy-link). For hosts that put those controls into their own
|
|
124
|
+
* chrome — a drawer footer, a wizard shell — and drive the write through
|
|
125
|
+
* `onControlsReady`'s `submit`. Omitting this prop keeps unchanged
|
|
126
|
+
* behavior. */
|
|
127
|
+
readonly hideActions?: boolean;
|
|
128
|
+
/** "form" (default) — every field renders as its Input widget, disabled
|
|
129
|
+
* when `field.readOnly`, unchanged behavior. "text" renders a
|
|
130
|
+
* `field.readOnly` field as plain text instead of a disabled Input
|
|
131
|
+
* (ProjectionDetailBody's read view, fw#2245) — editable fields are
|
|
132
|
+
* unaffected either way, so this only changes forms that already have
|
|
133
|
+
* readOnly fields. */
|
|
134
|
+
readonly valueDisplay?: "form" | "text";
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export type RenderEditAction = {
|
|
138
|
+
readonly id: string;
|
|
139
|
+
readonly label: string;
|
|
140
|
+
readonly onPress: () => void | Promise<void>;
|
|
141
|
+
readonly style?: "primary" | "secondary" | "danger";
|
|
142
|
+
readonly confirm?: string;
|
|
143
|
+
readonly confirmLabel?: string;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export type RenderEditChangeState<TValues extends FormValues> = {
|
|
147
|
+
readonly values: TValues;
|
|
148
|
+
readonly changes: Partial<TValues>;
|
|
149
|
+
readonly dirty: boolean;
|
|
150
|
+
readonly valid: boolean;
|
|
151
|
+
readonly submitting: boolean;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export type RenderEditControls<TValues extends FormValues> = {
|
|
155
|
+
readonly patch: (partial: Partial<TValues>) => void;
|
|
156
|
+
readonly validate: () => boolean;
|
|
157
|
+
readonly getValues: () => TValues;
|
|
158
|
+
/** Runs the same pipeline the built-in save button runs: validation,
|
|
159
|
+
* `customSubmit`/`writeCommand`, extension-section persistence, draft
|
|
160
|
+
* discard, state rebase. Unlike the button it carries no unchanged-form
|
|
161
|
+
* guard — a host showing a pre-filled proposal must be able to accept it
|
|
162
|
+
* untouched. */
|
|
163
|
+
readonly submit: () => Promise<void>;
|
|
164
|
+
};
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
EntityDefinition,
|
|
3
2
|
EntityEditScreenDefinition,
|
|
4
3
|
FieldCondition,
|
|
5
4
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
@@ -15,14 +14,21 @@ import type {
|
|
|
15
14
|
EditSectionViewModel,
|
|
16
15
|
FieldConditions,
|
|
17
16
|
FieldIssue,
|
|
18
|
-
FormSnapshot,
|
|
19
17
|
FormValues,
|
|
20
18
|
SubmitResult,
|
|
21
|
-
Translate,
|
|
22
19
|
} from "@cosmicdrift/kumiko-headless";
|
|
23
20
|
import { computeEditViewModel } from "@cosmicdrift/kumiko-headless";
|
|
21
|
+
import { RenderEditActionButton } from "./render-edit-action-button";
|
|
22
|
+
import type { RenderEditProps } from "./render-edit-types";
|
|
23
|
+
|
|
24
|
+
export type {
|
|
25
|
+
RenderEditAction,
|
|
26
|
+
RenderEditChangeState,
|
|
27
|
+
RenderEditControls,
|
|
28
|
+
RenderEditProps,
|
|
29
|
+
} from "./render-edit-types";
|
|
30
|
+
|
|
24
31
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
25
|
-
import type { z } from "zod";
|
|
26
32
|
import { ExtensionFormRegistryProvider, useExtensionFormHost } from "../app/extension-form-submit";
|
|
27
33
|
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections";
|
|
28
34
|
import { useOptionalDispatcher } from "../context/dispatcher-context";
|
|
@@ -94,160 +100,6 @@ function mintDraftId(): string {
|
|
|
94
100
|
// der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
|
|
95
101
|
// Änderungen.
|
|
96
102
|
|
|
97
|
-
export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
98
|
-
readonly screen: EntityEditScreenDefinition;
|
|
99
|
-
readonly entity: EntityDefinition;
|
|
100
|
-
readonly featureName: string;
|
|
101
|
-
readonly initial: TValues;
|
|
102
|
-
/** Echte entity-id für extension-section-Mounts (Set-Value-UI). Mount UND
|
|
103
|
-
* persistExtensions lösen sie über `resolveExtensionEntityId(entityIdProp,
|
|
104
|
-
* vm.id)` auf — denselben Wert, damit die Section nicht editierbar gegen eine
|
|
105
|
-
* id mountet während Persist gegen eine andere (oder gar nicht) schreibt.
|
|
106
|
-
* Weglassen (undefined) = Fallback auf `vm.id` (= values["id"]), das das
|
|
107
|
-
* Update-Form für die bestehende Row trägt. Explizites `null` = "keine
|
|
108
|
-
* entity" (create-mode / keine extension-Persistenz). */
|
|
109
|
-
readonly entityId?: string | null;
|
|
110
|
-
/** Bereits gespeicherte Extension-Werte (z.B. `record.customFields`) für
|
|
111
|
-
* extension-section-Mounts. Erlaubt der Section, den Bestand beim Edit
|
|
112
|
-
* anzuzeigen. Nur der Update-Body liefert das. */
|
|
113
|
-
readonly extensionInitialValues?: Readonly<Record<string, unknown>>;
|
|
114
|
-
/** Standard single-write Submit-Pfad. Ignoriert wenn `customSubmit`
|
|
115
|
-
* gesetzt ist (configEdit-Screens dispatchen mehrere Writes pro
|
|
116
|
-
* Submit, da macht writeCommand keinen Sinn). */
|
|
117
|
-
readonly writeCommand?: string;
|
|
118
|
-
/** Override für die Submit-Pipeline. Wenn gesetzt, läuft erst
|
|
119
|
-
* controller.validate() und dann customSubmit(snapshot) statt
|
|
120
|
-
* controller.submit(). On-success rebased der Form-State so dass
|
|
121
|
-
* isUnchanged/isDirty wieder false werden — ohne das blieben
|
|
122
|
-
* Save-Button und Banner stale. */
|
|
123
|
-
readonly customSubmit?: (snapshot: FormSnapshot<TValues>) => Promise<SubmitResult<unknown>>;
|
|
124
|
-
readonly translate?: Translate;
|
|
125
|
-
readonly ctx?: TCtx;
|
|
126
|
-
readonly schema?: z.ZodType;
|
|
127
|
-
readonly onSubmit?: (result: SubmitResult<unknown>) => void;
|
|
128
|
-
readonly payloadMode?: "values" | "changes";
|
|
129
|
-
readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
|
|
130
|
-
readonly onDelete?: () => Promise<void> | void;
|
|
131
|
-
readonly onCancel?: () => void;
|
|
132
|
-
readonly onReload?: () => void;
|
|
133
|
-
/** Copy-Link-Action (Issue #912) — nur in update-mode gesetzt (create-mode
|
|
134
|
-
* hat noch keine entity-id, also keinen Permalink). Der Callback ist
|
|
135
|
-
* bereits vollständig gebunden (URL-Bau + Clipboard passiert außerhalb,
|
|
136
|
-
* in `@cosmicdrift/kumiko-renderer-web`'s RoutedScreen — dieses
|
|
137
|
-
* platform-neutrale Package darf kein `navigator`/`window` anfassen,
|
|
138
|
-
* siehe guard-renderer-boundaries). undefined = kein Button. */
|
|
139
|
-
readonly onCopyLink?: () => Promise<void> | void;
|
|
140
|
-
/** Header action buttons, rendered before the built-in copy-link/
|
|
141
|
-
* delete/cancel/save controls. Each callback is already fully
|
|
142
|
-
* bound (screen-type/nav/dispatcher resolution happens in the caller,
|
|
143
|
-
* same split as `onCopyLink`) — RenderEdit only wires the button, its
|
|
144
|
-
* busy state and its confirm dialog. */
|
|
145
|
-
readonly actions?: readonly RenderEditAction[];
|
|
146
|
-
/** i18n-key für den Submit-Button. Default: "kumiko.actions.save".
|
|
147
|
-
* Action-Forms (Tier 2.7d) übergeben hier ihren screen.submitLabel,
|
|
148
|
-
* damit "Speichern" durch domain-spezifischere Strings ersetzt
|
|
149
|
-
* werden kann ("Genehmigen" / "Versenden" / etc.). */
|
|
150
|
-
readonly submitLabel?: string;
|
|
151
|
-
/** Pro-Field-Zusatz-Inhalt inline nach dem Label (z.B. ConfigSourceBadge).
|
|
152
|
-
* Wird mit dem Field-Namen aufgerufen, returnt ReactNode oder
|
|
153
|
-
* undefined. */
|
|
154
|
-
readonly labelAppendix?: (fieldName: string) => ReactNode | undefined;
|
|
155
|
-
/** Pro-Field-Zusatz-Inhalt unter dem Input (z.B. ConfigCascadeView).
|
|
156
|
-
* Wird mit dem Field-Namen aufgerufen, returnt ReactNode oder
|
|
157
|
-
* undefined. */
|
|
158
|
-
readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
|
|
159
|
-
/** Controlled mode (issue #1887): fires on every values-snapshot change
|
|
160
|
-
* (typing, `patch(...)` from outside) with the current values. `changes`
|
|
161
|
-
* is the delta against the initial values — same semantics as
|
|
162
|
-
* `payloadMode: "changes"` — so a caller never overwrites unseen fields.
|
|
163
|
-
* `valid` is a pure dry-run parse against `schema` (not a
|
|
164
|
-
* `controller.validate()` call), so it does not paint field errors into
|
|
165
|
-
* the UI and can diverge from the currently rendered `snapshot.errors` —
|
|
166
|
-
* always `true` without `schema`. A caller that patches a fresh object
|
|
167
|
-
* reference on every call must not do so unconditionally: `setValues` is
|
|
168
|
-
* a no-op when the merged value is reference-equal to the current one,
|
|
169
|
-
* so only a converging patch settles instead of looping. Without this
|
|
170
|
-
* prop, existing behavior is unchanged. `onControlsReady` is guaranteed
|
|
171
|
-
* to have already fired by the time the mount-time `onChange` call
|
|
172
|
-
* happens, so a caller patching dependent fields from inside `onChange`
|
|
173
|
-
* never has to guard against `controls` being undefined. */
|
|
174
|
-
readonly onChange?: (state: RenderEditChangeState<TValues>) => void;
|
|
175
|
-
/** Controlled mode (issue #1887): called once after mount, hands the
|
|
176
|
-
* caller `patch`/`validate`/`getValues` bound to this RenderEdit
|
|
177
|
-
* instance — addressable from outside without a remount. `patch` merges
|
|
178
|
-
* only the given keys (existing `controller.setValues` semantics),
|
|
179
|
-
* values on unmentioned fields stay untouched. `validate` runs without a
|
|
180
|
-
* write and reports field issues via `snapshot.errors` on the field
|
|
181
|
-
* itself rather than as a summary banner. Without this prop, existing
|
|
182
|
-
* behavior is unchanged. */
|
|
183
|
-
readonly onControlsReady?: (controls: RenderEditControls<TValues>) => void;
|
|
184
|
-
/** Renders only these fields (by `field` name from the layout) — section
|
|
185
|
-
* order, title, and visibility still come from the layout, so the caller
|
|
186
|
-
* doesn't duplicate its shape. A section with no fields left after
|
|
187
|
-
* filtering is dropped entirely (not rendered empty). Submit validation
|
|
188
|
-
* is scoped to the actually-rendered fields the same way — a required
|
|
189
|
-
* field outside this list doesn't block submit. Omitting this prop keeps
|
|
190
|
-
* unchanged behavior. Read once at mount for `controller.submit()`'s
|
|
191
|
-
* validation scope (the underlying `useForm` controller is mount-lived);
|
|
192
|
-
* rendering and `controls.validate()` do stay reactive to later changes. */
|
|
193
|
-
readonly fields?: readonly string[];
|
|
194
|
-
/** Locked state (issue #1896): every rendered field and the submit button
|
|
195
|
-
* go visibly inactive, no write possible. For cases where input becomes
|
|
196
|
-
* moot — e.g. Solon's editor pointing at an existing record instead of
|
|
197
|
-
* creating a new one. Extension sections are out of scope: RenderEdit has
|
|
198
|
-
* no way to force-disable an arbitrary registered component. Omitting
|
|
199
|
-
* this prop keeps unchanged behavior.
|
|
200
|
-
*
|
|
201
|
-
* ponytail: direct-consumer only — kumiko-screen.tsx's RenderEdit call
|
|
202
|
-
* sites pass explicit prop lists without a spread and never forward
|
|
203
|
-
* `disabled`, so a screen-driven app can't set the locked state today.
|
|
204
|
-
* Upgrade path if that's needed: thread a screen-spec flag through to
|
|
205
|
-
* `EntityEditCreateBody`/`EntityEditEditBody`. */
|
|
206
|
-
readonly disabled?: boolean;
|
|
207
|
-
/** Renders the fields without RenderEdit's own action bar (save, cancel,
|
|
208
|
-
* delete, copy-link). For hosts that put those controls into their own
|
|
209
|
-
* chrome — a drawer footer, a wizard shell — and drive the write through
|
|
210
|
-
* `onControlsReady`'s `submit`. Omitting this prop keeps unchanged
|
|
211
|
-
* behavior. */
|
|
212
|
-
readonly hideActions?: boolean;
|
|
213
|
-
/** "form" (default) — every field renders as its Input widget, disabled
|
|
214
|
-
* when `field.readOnly`, unchanged behavior. "text" renders a
|
|
215
|
-
* `field.readOnly` field as plain text instead of a disabled Input
|
|
216
|
-
* (ProjectionDetailBody's read view, fw#2245) — editable fields are
|
|
217
|
-
* unaffected either way, so this only changes forms that already have
|
|
218
|
-
* readOnly fields. */
|
|
219
|
-
readonly valueDisplay?: "form" | "text";
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
export type RenderEditAction = {
|
|
223
|
-
readonly id: string;
|
|
224
|
-
readonly label: string;
|
|
225
|
-
readonly onPress: () => void | Promise<void>;
|
|
226
|
-
readonly style?: "primary" | "secondary" | "danger";
|
|
227
|
-
readonly confirm?: string;
|
|
228
|
-
readonly confirmLabel?: string;
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
export type RenderEditChangeState<TValues extends FormValues> = {
|
|
232
|
-
readonly values: TValues;
|
|
233
|
-
readonly changes: Partial<TValues>;
|
|
234
|
-
readonly dirty: boolean;
|
|
235
|
-
readonly valid: boolean;
|
|
236
|
-
readonly submitting: boolean;
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
export type RenderEditControls<TValues extends FormValues> = {
|
|
240
|
-
readonly patch: (partial: Partial<TValues>) => void;
|
|
241
|
-
readonly validate: () => boolean;
|
|
242
|
-
readonly getValues: () => TValues;
|
|
243
|
-
/** Runs the same pipeline the built-in save button runs: validation,
|
|
244
|
-
* `customSubmit`/`writeCommand`, extension-section persistence, draft
|
|
245
|
-
* discard, state rebase. Unlike the button it carries no unchanged-form
|
|
246
|
-
* guard — a host showing a pre-filled proposal must be able to accept it
|
|
247
|
-
* untouched. */
|
|
248
|
-
readonly submit: () => Promise<void>;
|
|
249
|
-
};
|
|
250
|
-
|
|
251
103
|
function toConditionValue<TValues extends FormValues, TCtx>(
|
|
252
104
|
cond: FieldCondition,
|
|
253
105
|
): NonNullable<FieldConditions<TValues, TCtx>["visible"]> {
|
|
@@ -342,68 +194,6 @@ function ExtensionSectionMount({
|
|
|
342
194
|
// render-list.tsx's ToolbarActionView (each RenderEditAction is
|
|
343
195
|
// independently bound by the caller, there is no shared trigger pipeline
|
|
344
196
|
// to hook into like the built-in onDelete/onSubmit paths have).
|
|
345
|
-
function RenderEditActionButton({
|
|
346
|
-
action,
|
|
347
|
-
Button,
|
|
348
|
-
Dialog,
|
|
349
|
-
onError,
|
|
350
|
-
}: {
|
|
351
|
-
readonly action: RenderEditAction;
|
|
352
|
-
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
353
|
-
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
354
|
-
readonly onError: (text: string | null) => void;
|
|
355
|
-
}): ReactNode {
|
|
356
|
-
const [busy, setBusy] = useState(false);
|
|
357
|
-
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
358
|
-
|
|
359
|
-
const trigger = async (): Promise<void> => {
|
|
360
|
-
setBusy(true);
|
|
361
|
-
onError(null);
|
|
362
|
-
try {
|
|
363
|
-
await action.onPress();
|
|
364
|
-
} catch (e) {
|
|
365
|
-
onError(e instanceof Error ? e.message : String(e));
|
|
366
|
-
} finally {
|
|
367
|
-
setBusy(false);
|
|
368
|
-
}
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
const variant = action.style ?? "secondary";
|
|
372
|
-
// Same rule as RowActionWriteHandler: "danger" forces a confirm even
|
|
373
|
-
// without an explicit confirm key.
|
|
374
|
-
const needsConfirm = action.confirm !== undefined || action.style === "danger";
|
|
375
|
-
|
|
376
|
-
return (
|
|
377
|
-
<>
|
|
378
|
-
<Button
|
|
379
|
-
type="button"
|
|
380
|
-
variant={variant}
|
|
381
|
-
loading={busy}
|
|
382
|
-
onClick={() => {
|
|
383
|
-
if (needsConfirm) {
|
|
384
|
-
setConfirmOpen(true);
|
|
385
|
-
} else {
|
|
386
|
-
void trigger();
|
|
387
|
-
}
|
|
388
|
-
}}
|
|
389
|
-
testId={`render-edit-action-${action.id}`}
|
|
390
|
-
>
|
|
391
|
-
{action.label}
|
|
392
|
-
</Button>
|
|
393
|
-
<Dialog
|
|
394
|
-
open={confirmOpen}
|
|
395
|
-
onOpenChange={setConfirmOpen}
|
|
396
|
-
title={action.label}
|
|
397
|
-
{...(action.confirm !== undefined && { description: action.confirm })}
|
|
398
|
-
confirmLabel={action.confirmLabel ?? action.label}
|
|
399
|
-
{...(action.style === "danger" && { variant: "danger" as const })}
|
|
400
|
-
onConfirm={trigger}
|
|
401
|
-
testId={`render-edit-action-${action.id}-dialog`}
|
|
402
|
-
/>
|
|
403
|
-
</>
|
|
404
|
-
);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
197
|
export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
408
198
|
props: RenderEditProps<TValues, TCtx>,
|
|
409
199
|
): ReactNode {
|