@cosmicdrift/kumiko-renderer 0.325.0 → 0.325.2
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 +5 -5
- package/src/app/kumiko-screen.tsx +5 -1
- package/src/changes.json +12 -0
- package/src/components/__tests__/render-edit-logic.test.ts +64 -0
- package/src/components/render-edit-logic.ts +16 -0
- package/src/components/render-edit-types.ts +4 -0
- package/src/components/render-edit.tsx +12 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.325.
|
|
3
|
+
"version": "0.325.2",
|
|
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,9 +15,9 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.325.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.325.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "0.325.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.325.2",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.325.2",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.325.2",
|
|
21
21
|
"react": "^19.2.6",
|
|
22
22
|
"temporal-polyfill": "^0.3.2",
|
|
23
23
|
"zod": "^4.4.3"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@types/react-dom": "^19.2.3",
|
|
29
29
|
"jsdom": "^29.1.1",
|
|
30
30
|
"react-dom": "^19.2.6",
|
|
31
|
-
"@cosmicdrift/kumiko-locale-de": "0.325.
|
|
31
|
+
"@cosmicdrift/kumiko-locale-de": "0.325.2"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
|
@@ -500,6 +500,9 @@ function resolveTenantCurrency(
|
|
|
500
500
|
// lands here — same last resort as the entityEdit path's `?? "EUR"`.
|
|
501
501
|
const ACTION_FORM_CURRENCY_FALLBACK = "EUR";
|
|
502
502
|
|
|
503
|
+
// Shared by the update payload shape and the server-error path prefix so both can't drift.
|
|
504
|
+
const UPDATE_CHANGES_KEY = "changes";
|
|
505
|
+
|
|
503
506
|
// A money field naming a fixed ISO code (`currency: { kind: "literal", code }`,
|
|
504
507
|
// fw#2839) resolves with no query at all. Read structurally, same idiom as
|
|
505
508
|
// tenantCurrencyMoneyFieldNames above.
|
|
@@ -1196,7 +1199,7 @@ function EntityEditUpdateForm({
|
|
|
1196
1199
|
(snap: { readonly changes: Readonly<Record<string, unknown>> }): unknown => ({
|
|
1197
1200
|
id: entityId,
|
|
1198
1201
|
version: recordVersion,
|
|
1199
|
-
|
|
1202
|
+
[UPDATE_CHANGES_KEY]: snap.changes,
|
|
1200
1203
|
}),
|
|
1201
1204
|
[entityId, recordVersion],
|
|
1202
1205
|
);
|
|
@@ -1317,6 +1320,7 @@ function EntityEditUpdateForm({
|
|
|
1317
1320
|
writeCommand={writeCommand}
|
|
1318
1321
|
payloadMode="changes"
|
|
1319
1322
|
buildPayload={buildPayload}
|
|
1323
|
+
serverFieldPathPrefix={`${UPDATE_CHANGES_KEY}.`}
|
|
1320
1324
|
onSubmit={handleSubmitted}
|
|
1321
1325
|
// allowDelete:false marks an entity without a CRUD delete (history is
|
|
1322
1326
|
// kept) — without this gate the button dispatched against an
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.325.2",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Server validation issues without a rendered field keep the error banner",
|
|
6
|
+
"detail": "RenderEdit suppressed the banner as soon as the server returned any field issue, even one\nthat no rendered field can display (`version`, `id`, a root-level refine, a condition-hidden\nfield), so the save failed silently. The banner now stays whenever at least one issue has no\nvisible field; tabs/wizard layouts still jump to the step holding the first matched field."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.325.1",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Server validation errors on entityEdit update forms reach their fields again",
|
|
12
|
+
"detail": "The update form sends `{ id, version, changes }`, so server validation issues arrive as\n`changes.<field>`. The form controller stored them under that key, so no field showed the\nerror, tabs/wizard layouts never jumped to the erroring tab (only the generic banner), and\nsingle-section forms failed silently. A new submit option `serverFieldPathPrefix` strips the\nnesting key; KumikoScreen's update form sets it to `changes.`. Paths without the prefix are unchanged."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.320.0",
|
|
4
16
|
"type": "improvement",
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
import {
|
|
9
9
|
filterEditSections,
|
|
10
10
|
hasEditableSection,
|
|
11
|
+
hasIssueWithoutRenderedField,
|
|
11
12
|
resolveExtensionEntityId,
|
|
12
13
|
shouldNotifyCaller,
|
|
13
14
|
} from "../render-edit-logic";
|
|
@@ -248,3 +249,66 @@ describe("filterEditSections", () => {
|
|
|
248
249
|
expect(result).toEqual([extensionSection]);
|
|
249
250
|
});
|
|
250
251
|
});
|
|
252
|
+
|
|
253
|
+
describe("hasIssueWithoutRenderedField", () => {
|
|
254
|
+
test("issue path matches a rendered field → false", () => {
|
|
255
|
+
expect(hasIssueWithoutRenderedField(["title"], [namedFieldsSection("title", "body")])).toBe(
|
|
256
|
+
false,
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Root-level refine issues (`version`/`id`/`changes`) never match a rendered
|
|
261
|
+
// field name — the banner is the only place that can show them.
|
|
262
|
+
test("root-level refine path (version) → true", () => {
|
|
263
|
+
expect(hasIssueWithoutRenderedField(["version"], [namedFieldsSection("title")])).toBe(true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("(root) path → true", () => {
|
|
267
|
+
expect(hasIssueWithoutRenderedField(["(root)"], [namedFieldsSection("title")])).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("path matches a field hidden by its own condition → true", () => {
|
|
271
|
+
const section: EditSectionViewModel = {
|
|
272
|
+
kind: "fields",
|
|
273
|
+
columns: 1,
|
|
274
|
+
visible: true,
|
|
275
|
+
fields: [
|
|
276
|
+
{
|
|
277
|
+
field: "a",
|
|
278
|
+
label: "A",
|
|
279
|
+
type: "text",
|
|
280
|
+
value: "",
|
|
281
|
+
visible: true,
|
|
282
|
+
readOnly: false,
|
|
283
|
+
required: false,
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
field: "b",
|
|
287
|
+
label: "B",
|
|
288
|
+
type: "text",
|
|
289
|
+
value: "",
|
|
290
|
+
visible: false,
|
|
291
|
+
readOnly: false,
|
|
292
|
+
required: false,
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
expect(hasIssueWithoutRenderedField(["b"], [section])).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("dotted path (tasks.2.title) matches on its first segment → false", () => {
|
|
300
|
+
expect(hasIssueWithoutRenderedField(["tasks.2.title"], [namedFieldsSection("tasks")])).toBe(
|
|
301
|
+
false,
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("mixed: one matched, one unmatched → true", () => {
|
|
306
|
+
expect(hasIssueWithoutRenderedField(["title", "version"], [namedFieldsSection("title")])).toBe(
|
|
307
|
+
true,
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("empty issue list → true (single-section path always shows the banner then)", () => {
|
|
312
|
+
expect(hasIssueWithoutRenderedField([], [namedFieldsSection("title")])).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -41,6 +41,22 @@ export function shouldNotifyCaller(
|
|
|
41
41
|
return !(result.isSuccess && !extensionsPersisted);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Matched on the first path segment (embedded-list rows are dotted, e.g. `tasks.2.title`).
|
|
45
|
+
// No issues at all counts as unmatched, so the caller keeps the banner.
|
|
46
|
+
export function hasIssueWithoutRenderedField(
|
|
47
|
+
issuePaths: readonly string[],
|
|
48
|
+
sections: readonly EditSectionViewModel[],
|
|
49
|
+
): boolean {
|
|
50
|
+
if (issuePaths.length === 0) return true;
|
|
51
|
+
const renderedFields = new Set(
|
|
52
|
+
sections
|
|
53
|
+
.filter((s) => s.kind === "fields" && (s.visible || s.fields.length === 0))
|
|
54
|
+
.flatMap((s) => (s.kind === "fields" ? s.fields.filter((f) => f.visible) : []))
|
|
55
|
+
.map((f) => f.field),
|
|
56
|
+
);
|
|
57
|
+
return issuePaths.some((path) => !renderedFields.has(path.split(".")[0] ?? path));
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
// Extension, relatedList and writeForm sections skip the `fields` filter
|
|
45
61
|
// (writeForm's fields belong to its own independent form, not the host's);
|
|
46
62
|
// a `fields` section left with zero fields after filtering is dropped, not
|
|
@@ -46,6 +46,10 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
46
46
|
readonly onSubmit?: (result: SubmitResult<unknown>) => void;
|
|
47
47
|
readonly payloadMode?: "values" | "changes";
|
|
48
48
|
readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
|
|
49
|
+
/** Prefix to strip from server validation issue paths before mapping them
|
|
50
|
+
* onto form fields — see SubmitConfig.serverFieldPathPrefix. Only needed
|
|
51
|
+
* when buildPayload nests the form values under a key. */
|
|
52
|
+
readonly serverFieldPathPrefix?: string;
|
|
49
53
|
readonly onDelete?: () => Promise<void> | void;
|
|
50
54
|
readonly onCancel?: () => void;
|
|
51
55
|
readonly onReload?: () => void;
|
|
@@ -48,6 +48,7 @@ import { RelatedListSection } from "./related-list-section";
|
|
|
48
48
|
import {
|
|
49
49
|
filterEditSections,
|
|
50
50
|
hasEditableSection,
|
|
51
|
+
hasIssueWithoutRenderedField,
|
|
51
52
|
resolveExtensionEntityId,
|
|
52
53
|
shouldNotifyCaller,
|
|
53
54
|
} from "./render-edit-logic";
|
|
@@ -296,6 +297,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
296
297
|
onSubmit,
|
|
297
298
|
payloadMode = "values",
|
|
298
299
|
buildPayload,
|
|
300
|
+
serverFieldPathPrefix,
|
|
299
301
|
onDelete,
|
|
300
302
|
onCancel,
|
|
301
303
|
onReload,
|
|
@@ -434,6 +436,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
434
436
|
type: writeCommand,
|
|
435
437
|
payloadMode,
|
|
436
438
|
...(buildPayload !== undefined && { buildPayload }),
|
|
439
|
+
...(serverFieldPathPrefix !== undefined && { serverFieldPathPrefix }),
|
|
437
440
|
...(scopeFieldNames !== undefined && { validateScope: scopeFieldNames }),
|
|
438
441
|
}
|
|
439
442
|
: undefined;
|
|
@@ -1027,29 +1030,21 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1027
1030
|
}
|
|
1028
1031
|
} else {
|
|
1029
1032
|
const fieldIssues = result.error.details?.fields ?? [];
|
|
1033
|
+
const issuePaths = fieldIssues.map((i) => i.path);
|
|
1030
1034
|
// A server field error on a step the form isn't currently showing is
|
|
1031
1035
|
// otherwise invisible — jump to the first step that contains one of
|
|
1032
1036
|
// the errored fields. For tabs this is what keeps the submit from
|
|
1033
1037
|
// blocking in silence, the failure mode the boot-validator used to
|
|
1034
|
-
// prevent by rejecting the layout outright (fw#3134).
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
// field itself already shows the error inline).
|
|
1038
|
+
// prevent by rejecting the layout outright (fw#3134). An issue no
|
|
1039
|
+
// rendered field can show (`version`, `id`, a root-level refine, a
|
|
1040
|
+
// hidden field) keeps the banner, jump or not.
|
|
1038
1041
|
if (isStepped) {
|
|
1039
|
-
const step = findFirstErroringStep(
|
|
1040
|
-
if (step !== undefined)
|
|
1041
|
-
setRawStep(step);
|
|
1042
|
-
setFormError(null);
|
|
1043
|
-
} else {
|
|
1044
|
-
// No rendered step contains any of the errored fields (a
|
|
1045
|
-
// root-level issue, or a field outside every step) — nothing
|
|
1046
|
-
// will show this error inline, so the banner must not be
|
|
1047
|
-
// suppressed.
|
|
1048
|
-
setFormError(result.error);
|
|
1049
|
-
}
|
|
1050
|
-
} else {
|
|
1051
|
-
setFormError(fieldIssues.length === 0 ? result.error : null);
|
|
1042
|
+
const step = findFirstErroringStep(issuePaths);
|
|
1043
|
+
if (step !== undefined) setRawStep(step);
|
|
1052
1044
|
}
|
|
1045
|
+
setFormError(
|
|
1046
|
+
hasIssueWithoutRenderedField(issuePaths, filteredSections) ? result.error : null,
|
|
1047
|
+
);
|
|
1053
1048
|
}
|
|
1054
1049
|
// skip: on entity-success-but-extension-failure the extension-error
|
|
1055
1050
|
// banner is showing — don't notify (the caller navigates away on success
|