@cosmicdrift/kumiko-renderer 0.325.1 → 0.326.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.325.1",
3
+ "version": "0.326.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,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.325.1",
19
- "@cosmicdrift/kumiko-headless": "0.325.1",
20
- "@cosmicdrift/kumiko-types": "0.325.1",
18
+ "@cosmicdrift/kumiko-framework": "0.326.0",
19
+ "@cosmicdrift/kumiko-headless": "0.326.0",
20
+ "@cosmicdrift/kumiko-types": "0.326.0",
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.1"
31
+ "@cosmicdrift/kumiko-locale-de": "0.326.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
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
+ },
2
8
  {
3
9
  "version": "0.325.1",
4
10
  "type": "fix",
@@ -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
@@ -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";
@@ -1029,29 +1030,21 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1029
1030
  }
1030
1031
  } else {
1031
1032
  const fieldIssues = result.error.details?.fields ?? [];
1033
+ const issuePaths = fieldIssues.map((i) => i.path);
1032
1034
  // A server field error on a step the form isn't currently showing is
1033
1035
  // otherwise invisible — jump to the first step that contains one of
1034
1036
  // the errored fields. For tabs this is what keeps the submit from
1035
1037
  // blocking in silence, the failure mode the boot-validator used to
1036
- // prevent by rejecting the layout outright (fw#3134). Single-section
1037
- // forms render every field at once, so the original
1038
- // suppress-when-field-issues-exist rule still applies there (the
1039
- // 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.
1040
1041
  if (isStepped) {
1041
- const step = findFirstErroringStep(fieldIssues.map((i) => i.path));
1042
- if (step !== undefined) {
1043
- setRawStep(step);
1044
- setFormError(null);
1045
- } else {
1046
- // No rendered step contains any of the errored fields (a
1047
- // root-level issue, or a field outside every step) — nothing
1048
- // will show this error inline, so the banner must not be
1049
- // suppressed.
1050
- setFormError(result.error);
1051
- }
1052
- } else {
1053
- setFormError(fieldIssues.length === 0 ? result.error : null);
1042
+ const step = findFirstErroringStep(issuePaths);
1043
+ if (step !== undefined) setRawStep(step);
1054
1044
  }
1045
+ setFormError(
1046
+ hasIssueWithoutRenderedField(issuePaths, filteredSections) ? result.error : null,
1047
+ );
1055
1048
  }
1056
1049
  // skip: on entity-success-but-extension-failure the extension-error
1057
1050
  // banner is showing — don't notify (the caller navigates away on success