@cosmicdrift/kumiko-headless 0.238.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 +2 -2
- package/src/index.ts +1 -0
- package/src/view-model/__tests__/edit.test.ts +154 -1
- package/src/view-model/edit.ts +62 -0
- package/src/view-model/index.ts +1 -0
- package/src/view-model/types.ts +19 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "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.
|
|
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
|
@@ -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 {
|
|
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
|
+
});
|
package/src/view-model/edit.ts
CHANGED
|
@@ -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) => {
|
package/src/view-model/index.ts
CHANGED
package/src/view-model/types.ts
CHANGED
|
@@ -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 = {
|