@cosmicdrift/kumiko-headless 0.239.0 → 0.240.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-headless",
3
- "version": "0.239.0",
3
+ "version": "0.240.0",
4
4
  "description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@cosmicdrift/kumiko-framework": "0.239.0",
39
+ "@cosmicdrift/kumiko-framework": "0.240.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
package/src/index.ts CHANGED
@@ -89,6 +89,7 @@ export type {
89
89
  EditSectionSpec,
90
90
  EditSectionViewModel,
91
91
  EditViewModel,
92
+ EditWriteFormSectionViewModel,
92
93
  EmbeddedDerivedOp,
93
94
  EmbeddedListCellViewModel,
94
95
  EmbeddedListIssueGroups,
@@ -2,9 +2,15 @@ import { describe, expect, test } from "bun:test";
2
2
  import type {
3
3
  EntityDefinition,
4
4
  EntityEditScreenDefinition,
5
+ RowAction,
5
6
  } from "@cosmicdrift/kumiko-framework/ui-types";
6
7
  import { computeEditViewModel } from "../edit";
7
- import type { EditFieldsSectionViewModel, EditSectionViewModel } from "../types";
8
+ import type {
9
+ EditFieldsSectionViewModel,
10
+ EditRelatedListSectionViewModel,
11
+ EditSectionViewModel,
12
+ EditWriteFormSectionViewModel,
13
+ } from "../types";
8
14
 
9
15
  function asFields(s: EditSectionViewModel | undefined): EditFieldsSectionViewModel {
10
16
  if (s === undefined || s.kind !== "fields") {
@@ -730,3 +736,150 @@ describe("computeEditViewModel — declared reference metadata (fw#2662)", () =>
730
736
  expect(field?.refLabelField).toBeUndefined();
731
737
  });
732
738
  });
739
+
740
+ function asRelatedList(s: EditSectionViewModel | undefined): EditRelatedListSectionViewModel {
741
+ if (s === undefined || s.kind !== "relatedList") {
742
+ throw new Error(`expected relatedList-section, got ${s?.kind ?? "undefined"}`);
743
+ }
744
+ return s;
745
+ }
746
+
747
+ function asWriteForm(s: EditSectionViewModel | undefined): EditWriteFormSectionViewModel {
748
+ if (s === undefined || s.kind !== "writeForm") {
749
+ throw new Error(`expected writeForm-section, got ${s?.kind ?? "undefined"}`);
750
+ }
751
+ return s;
752
+ }
753
+
754
+ describe("computeEditViewModel — relatedList rowActions passthrough (fw editable-detail-screens)", () => {
755
+ test("rowActions pass through onto the relatedList section view-model verbatim", () => {
756
+ const rowActions: readonly RowAction[] = [
757
+ { id: "sync", label: "actions.sync", handler: "app:write:sync", payload: { pick: ["id"] } },
758
+ ];
759
+ const vm = computeEditViewModel({
760
+ screen: editScreen({
761
+ sections: [
762
+ {
763
+ kind: "relatedList",
764
+ title: "History",
765
+ query: "app:query:history",
766
+ columns: ["name"],
767
+ rowActions,
768
+ },
769
+ ],
770
+ }),
771
+ entity: orderEntity,
772
+ values: {},
773
+ translate,
774
+ featureName: "orders",
775
+ });
776
+
777
+ expect(asRelatedList(vm.sections[0]).rowActions).toEqual(rowActions);
778
+ });
779
+
780
+ test("rowActions is absent from the view-model when the section spec has none", () => {
781
+ const vm = computeEditViewModel({
782
+ screen: editScreen({
783
+ sections: [
784
+ { kind: "relatedList", title: "History", query: "app:query:history", columns: ["name"] },
785
+ ],
786
+ }),
787
+ entity: orderEntity,
788
+ values: {},
789
+ translate,
790
+ featureName: "orders",
791
+ });
792
+
793
+ expect(asRelatedList(vm.sections[0]).rowActions).toBeUndefined();
794
+ });
795
+ });
796
+
797
+ describe("computeEditViewModel — writeForm sections (fw editable-detail-screens)", () => {
798
+ test("resolves the section's own fieldDefs/fields through the same per-field pipeline as a fields section", () => {
799
+ const vm = computeEditViewModel({
800
+ screen: editScreen({
801
+ sections: [
802
+ {
803
+ kind: "writeForm",
804
+ title: "Add note",
805
+ fieldDefs: { note: { type: "text", required: true } },
806
+ fields: ["note"],
807
+ handler: "orders:write:add-note",
808
+ },
809
+ ],
810
+ }),
811
+ entity: orderEntity,
812
+ values: {},
813
+ translate,
814
+ featureName: "orders",
815
+ });
816
+
817
+ const section = asWriteForm(vm.sections[0]);
818
+ expect(section.handler).toBe("orders:write:add-note");
819
+ expect(section.fields).toEqual([
820
+ {
821
+ field: "note",
822
+ label: "orders:entity:__write-form-section__:field:note",
823
+ type: "text",
824
+ value: undefined,
825
+ visible: true,
826
+ readOnly: false,
827
+ required: true,
828
+ },
829
+ ]);
830
+ });
831
+
832
+ test("readOnly/required on a writeForm field come from the section's own fieldDefs, independent of the host entity", () => {
833
+ const vm = computeEditViewModel({
834
+ screen: editScreen({
835
+ sections: [
836
+ {
837
+ kind: "writeForm",
838
+ title: "Add note",
839
+ // "notes" is optional on orderEntity — the writeForm section's own
840
+ // fieldDefs declares its own "notes" field as required, and that
841
+ // (not the host entity's) must win.
842
+ fieldDefs: { notes: { type: "text", required: true } },
843
+ fields: ["notes"],
844
+ handler: "orders:write:add-note",
845
+ },
846
+ ],
847
+ }),
848
+ entity: orderEntity,
849
+ values: {},
850
+ translate,
851
+ featureName: "orders",
852
+ });
853
+
854
+ expect(asWriteForm(vm.sections[0]).fields[0]?.required).toBe(true);
855
+ });
856
+
857
+ test("columns defaults to 1; icon/description/submitLabel are translated and passed through when set", () => {
858
+ const vm = computeEditViewModel({
859
+ screen: editScreen({
860
+ sections: [
861
+ {
862
+ kind: "writeForm",
863
+ title: "Add note",
864
+ description: "orders:write-form.description",
865
+ icon: "plus",
866
+ fieldDefs: { note: { type: "text" } },
867
+ fields: ["note"],
868
+ handler: "orders:write:add-note",
869
+ submitLabel: "orders:write-form.submit",
870
+ },
871
+ ],
872
+ }),
873
+ entity: orderEntity,
874
+ values: {},
875
+ translate,
876
+ featureName: "orders",
877
+ });
878
+
879
+ const section = asWriteForm(vm.sections[0]);
880
+ expect(section.columns).toBe(1);
881
+ expect(section.icon).toBe("plus");
882
+ expect(section.description).toBe("orders:write-form.description");
883
+ expect(section.submitLabel).toBe("orders:write-form.submit");
884
+ });
885
+ });
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ EditWriteFormSection,
2
3
  EntityDefinition,
3
4
  EntityEditScreenDefinition,
4
5
  FieldCondition,
@@ -7,8 +8,10 @@ import {
7
8
  evalFieldCondition,
8
9
  isExtensionEditSection,
9
10
  isFieldsEditSection,
11
+ isWriteFormEditSection,
10
12
  normalizeEditField,
11
13
  parseRefTarget,
14
+ WRITE_FORM_SECTION_ENTITY,
12
15
  } from "@cosmicdrift/kumiko-framework/ui-types";
13
16
  import {
14
17
  buildOptionLabels,
@@ -21,6 +24,7 @@ import type {
21
24
  EditFieldViewModel,
22
25
  EditSectionViewModel,
23
26
  EditViewModel,
27
+ EditWriteFormSectionViewModel,
24
28
  EmbeddedListCellViewModel,
25
29
  Translate,
26
30
  } from "./types";
@@ -58,6 +62,54 @@ export type ComputeEditViewModelInput<
58
62
  readonly featureName: string;
59
63
  };
60
64
 
65
+ // Resolves a writeForm section's own fieldDefs/fields through the same
66
+ // per-field pipeline as a plain fields section, by reusing the pipeline
67
+ // itself rather than duplicating it: wrap fieldDefs/fields as a throwaway
68
+ // entityEdit screen (same "synthesize a pseudo-entity" idiom as the shims in
69
+ // packages/renderer/src/app/*-shim.ts) and recurse into computeEditViewModel
70
+ // once. Split out of computeEditViewModel's section-map to keep that
71
+ // function's own complexity from absorbing this branch's decision points.
72
+ function computeWriteFormSectionViewModel<TValues extends Readonly<Record<string, unknown>>>(
73
+ sectionSpec: EditWriteFormSection,
74
+ screenId: string,
75
+ values: TValues,
76
+ translate: Translate,
77
+ featureName: string,
78
+ ): EditWriteFormSectionViewModel {
79
+ const innerEntity: EntityDefinition = { fields: sectionSpec.fieldDefs } as EntityDefinition;
80
+ const innerScreen: EntityEditScreenDefinition = {
81
+ id: `${screenId}:${sectionSpec.id ?? "write-form"}`,
82
+ type: "entityEdit",
83
+ entity: WRITE_FORM_SECTION_ENTITY,
84
+ layout: {
85
+ sections: [{ kind: "fields", fields: sectionSpec.fields, columns: sectionSpec.columns }],
86
+ },
87
+ };
88
+ const inner = computeEditViewModel({
89
+ screen: innerScreen,
90
+ entity: innerEntity,
91
+ values,
92
+ translate,
93
+ featureName,
94
+ });
95
+ const innerSection = inner.sections[0];
96
+ const fields = innerSection?.kind === "fields" ? innerSection.fields : [];
97
+ return {
98
+ kind: "writeForm" as const,
99
+ ...(sectionSpec.title !== undefined && { title: translate(sectionSpec.title) }),
100
+ ...(sectionSpec.description !== undefined && {
101
+ description: translate(sectionSpec.description),
102
+ }),
103
+ columns: sectionSpec.columns ?? 1,
104
+ fields,
105
+ ...(sectionSpec.icon !== undefined && { icon: sectionSpec.icon }),
106
+ handler: sectionSpec.handler,
107
+ ...(sectionSpec.submitLabel !== undefined && {
108
+ submitLabel: translate(sectionSpec.submitLabel),
109
+ }),
110
+ };
111
+ }
112
+
61
113
  // Pure transform from screen-def + entity-def + row-values to the flat
62
114
  // section/field tree the renderer draws. FieldConditions are evaluated here
63
115
  // so the renderer never re-runs them during React render.
@@ -76,6 +128,15 @@ export function computeEditViewModel<
76
128
  ...(sectionSpec.entityName !== undefined && { entityName: sectionSpec.entityName }),
77
129
  };
78
130
  }
131
+ if (isWriteFormEditSection(sectionSpec)) {
132
+ return computeWriteFormSectionViewModel(
133
+ sectionSpec,
134
+ screen.id,
135
+ values,
136
+ translate,
137
+ featureName,
138
+ );
139
+ }
79
140
  if (!isFieldsEditSection(sectionSpec)) {
80
141
  // relatedList runs its own query — nothing here to resolve against
81
142
  // entity/values, the spec passes through verbatim (fw#2166).
@@ -87,6 +148,7 @@ export function computeEditViewModel<
87
148
  columns: sectionSpec.columns,
88
149
  ...(sectionSpec.pageSize !== undefined && { pageSize: sectionSpec.pageSize }),
89
150
  ...(sectionSpec.rowClick !== undefined && { rowClick: sectionSpec.rowClick }),
151
+ ...(sectionSpec.rowActions !== undefined && { rowActions: sectionSpec.rowActions }),
90
152
  };
91
153
  }
92
154
  const fields: EditFieldViewModel[] = sectionSpec.fields.map((fieldSpec) => {
@@ -29,6 +29,7 @@ export type {
29
29
  EditSectionSpec,
30
30
  EditSectionViewModel,
31
31
  EditViewModel,
32
+ EditWriteFormSectionViewModel,
32
33
  EmbeddedListCellViewModel,
33
34
  FieldConditionCtx,
34
35
  FieldRenderer,
@@ -6,6 +6,7 @@ import type {
6
6
  IconKey,
7
7
  ListColumnSpec,
8
8
  PlatformComponent,
9
+ RowAction,
9
10
  ScreenSlots,
10
11
  } from "@cosmicdrift/kumiko-framework/ui-types";
11
12
 
@@ -247,7 +248,8 @@ export type EditFieldViewModel = {
247
248
  export type EditSectionViewModel =
248
249
  | EditFieldsSectionViewModel
249
250
  | EditExtensionSectionViewModel
250
- | EditRelatedListSectionViewModel;
251
+ | EditRelatedListSectionViewModel
252
+ | EditWriteFormSectionViewModel;
251
253
 
252
254
  export type EditFieldsSectionViewModel = {
253
255
  readonly kind: "fields";
@@ -287,6 +289,22 @@ export type EditRelatedListSectionViewModel = {
287
289
  readonly columns: readonly ListColumnSpec[];
288
290
  readonly pageSize?: number;
289
291
  readonly rowClick?: { readonly entity: string; readonly idColumn?: string };
292
+ readonly rowActions?: readonly RowAction[];
293
+ };
294
+
295
+ // Mirrors EditWriteFormSection, except `fields` is already resolved through
296
+ // the same per-field pipeline as EditFieldsSectionViewModel (computed via a
297
+ // recursive computeEditViewModel call against the section's own fieldDefs —
298
+ // see edit.ts) — the renderer needs no second field-resolution path.
299
+ export type EditWriteFormSectionViewModel = {
300
+ readonly kind: "writeForm";
301
+ readonly title?: string;
302
+ readonly description?: string;
303
+ readonly columns: number;
304
+ readonly fields: readonly EditFieldViewModel[];
305
+ readonly icon?: IconKey;
306
+ readonly handler: string;
307
+ readonly submitLabel?: string;
290
308
  };
291
309
 
292
310
  export type EditViewModel = {