@cosmicdrift/kumiko-renderer 0.281.0 → 0.283.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.281.0",
3
+ "version": "0.283.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.281.0",
19
- "@cosmicdrift/kumiko-headless": "0.281.0",
20
- "@cosmicdrift/kumiko-types": "0.281.0",
18
+ "@cosmicdrift/kumiko-framework": "0.283.0",
19
+ "@cosmicdrift/kumiko-headless": "0.283.0",
20
+ "@cosmicdrift/kumiko-types": "0.283.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.281.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.283.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { synthesizeProjectionDetailScreen } from "../projection-detail-shim";
3
+ import {
4
+ synthesizeProjectionDetailEntity,
5
+ synthesizeProjectionDetailScreen,
6
+ } from "../projection-detail-shim";
4
7
 
5
8
  // Regression for the writeForm extension (fw editable-detail-screens): the
6
9
  // hard readOnly:true enforcement in synthesizeProjectionDetailScreen must
@@ -60,3 +63,126 @@ describe("synthesizeProjectionDetailScreen", () => {
60
63
  expect("description" in withoutDescription).toBe(false);
61
64
  });
62
65
  });
66
+
67
+ // Regression for fw#2982: a section that puts its fields in `groups`
68
+ // instead of `fields` used to vanish entirely — synthesizeProjectionDetailEntity
69
+ // never registered the field names, so computeEditViewModel threw "references
70
+ // unknown field" for every one of them, and synthesizeProjectionDetailScreen
71
+ // let a group field's own readOnly value pass through unforced.
72
+ describe("groups-sections (fw#2982)", () => {
73
+ const groupsOnlyScreen: ProjectionDetailScreenDefinition = {
74
+ id: "contact-detail",
75
+ type: "projectionDetail",
76
+ query: "contacts:query:contact:detail",
77
+ layout: {
78
+ sections: [
79
+ {
80
+ title: "Stammdaten",
81
+ fields: [],
82
+ groups: [
83
+ {
84
+ title: "Kontakt",
85
+ fields: [{ field: "contactKind", readOnly: false }, "email"],
86
+ },
87
+ ],
88
+ },
89
+ ],
90
+ },
91
+ };
92
+
93
+ test("synthesizeProjectionDetailEntity registers every field declared only through groups", () => {
94
+ const entity = synthesizeProjectionDetailEntity(groupsOnlyScreen.layout);
95
+ expect(entity.fields["contactKind"]).toEqual({ type: "text" });
96
+ expect(entity.fields["email"]).toEqual({ type: "text" });
97
+ });
98
+
99
+ test("synthesizeProjectionDetailScreen forces readOnly:true on a group field even when the author set readOnly:false", () => {
100
+ const result = synthesizeProjectionDetailScreen(groupsOnlyScreen);
101
+ const [section] = result.layout.sections;
102
+ if (section === undefined || !("fields" in section) || "kind" in section) {
103
+ throw new Error("expected the section to stay a plain fields section");
104
+ }
105
+ if (!("groups" in section) || section.groups === undefined) {
106
+ throw new Error("expected the section to keep its groups");
107
+ }
108
+ expect(section.groups[0]?.fields).toEqual([
109
+ { field: "contactKind", readOnly: true },
110
+ { field: "email", readOnly: true },
111
+ ]);
112
+ });
113
+
114
+ test("a screen mixing a plain fields section and a groups section resolves both, for both functions", () => {
115
+ const mixedScreen: ProjectionDetailScreenDefinition = {
116
+ id: "deposit-detail",
117
+ type: "projectionDetail",
118
+ query: "deposits:query:deposit:detail",
119
+ layout: {
120
+ sections: [
121
+ { title: "Basics", fields: [{ field: "amount", readOnly: false }] },
122
+ {
123
+ title: "Parties",
124
+ fields: [],
125
+ groups: [{ title: "Tenant", fields: ["tenantName"] }],
126
+ },
127
+ ],
128
+ },
129
+ };
130
+
131
+ const entity = synthesizeProjectionDetailEntity(mixedScreen.layout);
132
+ expect(entity.fields["amount"]).toEqual({ type: "text" });
133
+ expect(entity.fields["tenantName"]).toEqual({ type: "text" });
134
+
135
+ const result = synthesizeProjectionDetailScreen(mixedScreen);
136
+ const [fieldsSection, groupsSection] = result.layout.sections;
137
+ if (fieldsSection === undefined || !("fields" in fieldsSection) || "kind" in fieldsSection) {
138
+ throw new Error("expected the first section to stay a plain fields section");
139
+ }
140
+ expect(fieldsSection.fields[0]).toMatchObject({ field: "amount", readOnly: true });
141
+
142
+ if (
143
+ groupsSection === undefined ||
144
+ !("groups" in groupsSection) ||
145
+ groupsSection.groups === undefined
146
+ ) {
147
+ throw new Error("expected the second section to keep its groups");
148
+ }
149
+ expect(groupsSection.groups[0]?.fields).toEqual([{ field: "tenantName", readOnly: true }]);
150
+ });
151
+
152
+ // The boot-validator rejects fields+groups both non-empty on one section at
153
+ // boot time, so an author can never actually ship this shape — but the shim
154
+ // is not itself a validation pass, and this pins the union, not the
155
+ // either/or a naive `groups !== undefined ? grouped : fields` would take.
156
+ test("a single section carrying both fields and groups resolves both, for both functions", () => {
157
+ const bothScreen: ProjectionDetailScreenDefinition = {
158
+ id: "invoice-detail",
159
+ type: "projectionDetail",
160
+ query: "invoices:query:invoice:detail",
161
+ layout: {
162
+ sections: [
163
+ {
164
+ title: "Mixed",
165
+ fields: [{ field: "amount", readOnly: false }],
166
+ groups: [{ title: "Tenant", fields: ["tenantName"] }],
167
+ },
168
+ ],
169
+ },
170
+ };
171
+
172
+ const entity = synthesizeProjectionDetailEntity(bothScreen.layout);
173
+ expect(entity.fields["amount"]).toEqual({ type: "text" });
174
+ expect(entity.fields["tenantName"]).toEqual({ type: "text" });
175
+
176
+ const result = synthesizeProjectionDetailScreen(bothScreen);
177
+ const [section] = result.layout.sections;
178
+ if (section === undefined || !("fields" in section) || "kind" in section) {
179
+ throw new Error("expected the section to stay a plain fields section");
180
+ }
181
+ expect(section.fields[0]).toMatchObject({ field: "amount", readOnly: true });
182
+
183
+ if (!("groups" in section) || section.groups === undefined) {
184
+ throw new Error("expected the section to keep its groups");
185
+ }
186
+ expect(section.groups[0]?.fields).toEqual([{ field: "tenantName", readOnly: true }]);
187
+ });
188
+ });
@@ -21,6 +21,8 @@
21
21
  // Komponente.
22
22
 
23
23
  import type {
24
+ EditFieldSpec,
25
+ EditFieldsSection,
24
26
  EditLayout,
25
27
  EntityDefinition,
26
28
  EntityEditScreenDefinition,
@@ -32,6 +34,16 @@ import {
32
34
  PROJECTION_DETAIL_ENTITY as PROJECTION_DETAIL_PSEUDO_ENTITY,
33
35
  } from "@cosmicdrift/kumiko-framework/ui-types";
34
36
 
37
+ // The boot-validator treats `fields`/`groups` as mutually exclusive per
38
+ // section, but this reads both anyway: an extra name the current section
39
+ // shape can never carry is harmless, while a name computeEditViewModel does
40
+ // reference and this shim omitted throws downstream ("references unknown
41
+ // field ... on entity"). Union is strictly safer than picking one branch.
42
+ function allFieldSpecs(section: EditFieldsSection): readonly EditFieldSpec[] {
43
+ const grouped = section.groups?.flatMap((group) => group.fields) ?? [];
44
+ return [...section.fields, ...grouped];
45
+ }
46
+
35
47
  /** Minimale EntityDefinition aus den Layout-Feldern: jedes Feld ein Text-
36
48
  * Feld — computeEditViewModel liest nur `fields[<f>].type`, Text reicht für
37
49
  * eine reine Anzeige (kein Select/Number-spezifisches Rendering nötig). */
@@ -40,7 +52,7 @@ export function synthesizeProjectionDetailEntity(layout: EditLayout): EntityDefi
40
52
  for (const section of layout.sections) {
41
53
  // relatedList and extension sections carry no `fields` to synthesize.
42
54
  if (!isFieldsEditSection(section)) continue;
43
- for (const spec of section.fields) {
55
+ for (const spec of allFieldSpecs(section)) {
44
56
  fields[normalizeEditField(spec).field] = { type: "text" };
45
57
  }
46
58
  }
@@ -52,12 +64,19 @@ export function synthesizeProjectionDetailEntity(layout: EditLayout): EntityDefi
52
64
  export function synthesizeProjectionDetailScreen(
53
65
  screen: ProjectionDetailScreenDefinition,
54
66
  ): EntityEditScreenDefinition {
67
+ const forceReadOnly = (spec: EditFieldSpec) => ({ ...normalizeEditField(spec), readOnly: true });
55
68
  const sections = screen.layout.sections.map((section) => {
56
69
  // relatedList and extension sections pass through unchanged.
57
70
  if (!isFieldsEditSection(section)) return section;
58
71
  return {
59
72
  ...section,
60
- fields: section.fields.map((spec) => ({ ...normalizeEditField(spec), readOnly: true })),
73
+ fields: section.fields.map(forceReadOnly),
74
+ ...(section.groups !== undefined && {
75
+ groups: section.groups.map((group) => ({
76
+ ...group,
77
+ fields: group.fields.map(forceReadOnly),
78
+ })),
79
+ }),
61
80
  };
62
81
  });
63
82
  return {
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.283.0",
4
+ "type": "fix",
5
+ "title": "Fix projectionDetail detail screens dropping fields declared through groups",
6
+ "detail": "synthesizeProjectionDetailEntity/synthesizeProjectionDetailScreen only read section.fields, so a section that put its fields in groups instead lost them entirely, and any group field's own readOnly:false passed through unforced. Both helpers now read fields and groups together, and force readOnly:true on both."
7
+ }
8
+ ]