@cosmicdrift/kumiko-headless 0.1.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 +37 -0
- package/src/contracts/__tests__/contracts.test.ts +123 -0
- package/src/contracts/asset.ts +48 -0
- package/src/contracts/index.ts +23 -0
- package/src/contracts/locale.ts +33 -0
- package/src/contracts/primitives.ts +158 -0
- package/src/dispatcher/__tests__/contract.test.ts +160 -0
- package/src/dispatcher/index.ts +14 -0
- package/src/dispatcher/types.ts +194 -0
- package/src/form/__tests__/conditional-fields.test.ts +177 -0
- package/src/form/__tests__/form-controller.test.ts +195 -0
- package/src/form/__tests__/submit.test.ts +315 -0
- package/src/form/__tests__/validation.test.ts +124 -0
- package/src/form/form-controller.ts +333 -0
- package/src/form/index.ts +15 -0
- package/src/form/types.ts +264 -0
- package/src/form/zod-bridge.ts +75 -0
- package/src/index.ts +81 -0
- package/src/nav/__tests__/resolve.test.ts +202 -0
- package/src/nav/index.ts +8 -0
- package/src/nav/resolve.ts +77 -0
- package/src/nav/types.ts +61 -0
- package/src/store/__tests__/create-store.test.ts +139 -0
- package/src/store/__tests__/equality.test.ts +66 -0
- package/src/store/create-store.ts +44 -0
- package/src/store/equality.ts +34 -0
- package/src/store/index.ts +3 -0
- package/src/store/types.ts +27 -0
- package/src/view-model/__tests__/edit.test.ts +242 -0
- package/src/view-model/__tests__/list.test.ts +139 -0
- package/src/view-model/edit.ts +164 -0
- package/src/view-model/index.ts +19 -0
- package/src/view-model/list.ts +158 -0
- package/src/view-model/types.ts +150 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntityDefinition,
|
|
3
|
+
EntityEditScreenDefinition,
|
|
4
|
+
FieldCondition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
+
import { normalizeEditField, parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
|
+
import { buildOptionLabels, fieldLabelKey } from "./list";
|
|
8
|
+
import type { EditFieldViewModel, EditSectionViewModel, EditViewModel, Translate } from "./types";
|
|
9
|
+
|
|
10
|
+
export type ComputeEditViewModelInput<
|
|
11
|
+
TValues extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>,
|
|
12
|
+
TCtx = unknown,
|
|
13
|
+
> = {
|
|
14
|
+
readonly screen: EntityEditScreenDefinition;
|
|
15
|
+
readonly entity: EntityDefinition;
|
|
16
|
+
readonly values: TValues;
|
|
17
|
+
readonly translate: Translate;
|
|
18
|
+
readonly featureName: string;
|
|
19
|
+
// Optional condition context — forwarded to field visible/readonly/required
|
|
20
|
+
// predicates. Normally the host app passes `{ user, config, ... }`.
|
|
21
|
+
readonly ctx?: TCtx;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Pure transform from screen-def + entity-def + row-values to the flat
|
|
25
|
+
// section/field tree the renderer draws. Conditional predicates are
|
|
26
|
+
// evaluated here so the renderer never re-runs them during React render.
|
|
27
|
+
//
|
|
28
|
+
// TValues / TCtx are propagated through evalCondition so call-sites can
|
|
29
|
+
// `computeEditViewModel<OrderRow, AdminCtx>(...)` and get typed data/ctx
|
|
30
|
+
// inside their predicates. Default `unknown` keeps unannotated call-sites
|
|
31
|
+
// working unchanged.
|
|
32
|
+
export function computeEditViewModel<
|
|
33
|
+
TValues extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>,
|
|
34
|
+
TCtx = unknown,
|
|
35
|
+
>(input: ComputeEditViewModelInput<TValues, TCtx>): EditViewModel {
|
|
36
|
+
const { screen, entity, values, translate, featureName, ctx } = input;
|
|
37
|
+
|
|
38
|
+
const sections: EditSectionViewModel[] = screen.layout.sections.map((sectionSpec) => {
|
|
39
|
+
const fields: EditFieldViewModel[] = sectionSpec.fields.map((fieldSpec) => {
|
|
40
|
+
const normalized = normalizeEditField(fieldSpec);
|
|
41
|
+
const fieldDef = entity.fields[normalized.field];
|
|
42
|
+
if (!fieldDef) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`computeEditViewModel: screen "${screen.id}" references unknown field "${normalized.field}" on entity "${screen.entity}"`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const label = translate(fieldLabelKey(featureName, screen.entity, normalized.field));
|
|
48
|
+
const visible = evalCondition<TValues, TCtx>(normalized.visible, true, values, ctx);
|
|
49
|
+
// `readOnly` (camelCase) is the name on both sides: EditFieldSpec
|
|
50
|
+
// in the engine, and the view-model emitted here. One convention
|
|
51
|
+
// through the stack beats translating at the boundary.
|
|
52
|
+
const readOnly = evalCondition<TValues, TCtx>(normalized.readOnly, false, values, ctx);
|
|
53
|
+
// `required` on the field-spec overrides the entity-default. A
|
|
54
|
+
// field that's required at the entity-level but marked required:
|
|
55
|
+
// false on the screen (e.g. a soft-onboarding wizard that
|
|
56
|
+
// collects less up-front) respects the screen override.
|
|
57
|
+
const entityRequired = (fieldDef as unknown as { required?: boolean }).required === true;
|
|
58
|
+
const required = evalCondition<TValues, TCtx>(
|
|
59
|
+
normalized.required,
|
|
60
|
+
entityRequired,
|
|
61
|
+
values,
|
|
62
|
+
ctx,
|
|
63
|
+
);
|
|
64
|
+
// Select-Optionen bei `type: "select"` mitnehmen — der Renderer
|
|
65
|
+
// braucht sie für das Dropdown ohne nochmal die EntityDefinition
|
|
66
|
+
// zu reichen. Plus translated Labels (gleiche Convention wie der
|
|
67
|
+
// List-Builder), damit Form-Selects und List-Cells dieselbe
|
|
68
|
+
// i18n-Quelle teilen.
|
|
69
|
+
const options =
|
|
70
|
+
fieldDef.type === "select"
|
|
71
|
+
? ((fieldDef as unknown as { options?: readonly string[] }).options ?? [])
|
|
72
|
+
: undefined;
|
|
73
|
+
const optionLabels =
|
|
74
|
+
options !== undefined
|
|
75
|
+
? buildOptionLabels(translate, featureName, screen.entity, normalized.field, options)
|
|
76
|
+
: undefined;
|
|
77
|
+
// Multiline-Hint bei `type: "text"` — der Renderer wechselt
|
|
78
|
+
// dann auf textarea. ViewModel hält die Form-Render-Decision
|
|
79
|
+
// damit der Renderer nicht selbst auf die FieldDefinition greift.
|
|
80
|
+
const multiline =
|
|
81
|
+
fieldDef.type === "text"
|
|
82
|
+
? (fieldDef as unknown as { multiline?: boolean | { rows?: number } }).multiline
|
|
83
|
+
: undefined;
|
|
84
|
+
// Tier 2.7e-3: Reference-Field — refEntity + refLabelField in
|
|
85
|
+
// das ViewModel reichen damit der Renderer die Lookup-Query
|
|
86
|
+
// bauen kann ohne noch an EntityDefinition zu greifen.
|
|
87
|
+
// Tier 2.7e-3: Reference-Field — entity-String kann same-feature
|
|
88
|
+
// ("user") oder cross-feature ("users:user") sein. parseRefTarget
|
|
89
|
+
// splittet das, der Renderer baut die Lookup-QN aus
|
|
90
|
+
// (refFeature, refEntity).
|
|
91
|
+
const refRaw =
|
|
92
|
+
fieldDef.type === "reference"
|
|
93
|
+
? (fieldDef as unknown as { entity?: string }).entity
|
|
94
|
+
: undefined;
|
|
95
|
+
const refTarget = refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined;
|
|
96
|
+
const refEntity = refTarget?.entityName;
|
|
97
|
+
const refFeature = refTarget?.featureName;
|
|
98
|
+
const refLabelField =
|
|
99
|
+
fieldDef.type === "reference"
|
|
100
|
+
? ((fieldDef as unknown as { labelField?: string }).labelField ?? "id")
|
|
101
|
+
: undefined;
|
|
102
|
+
const refMultiple =
|
|
103
|
+
fieldDef.type === "reference"
|
|
104
|
+
? ((fieldDef as unknown as { multiple?: boolean }).multiple ?? false)
|
|
105
|
+
: undefined;
|
|
106
|
+
const view: EditFieldViewModel = {
|
|
107
|
+
field: normalized.field,
|
|
108
|
+
label,
|
|
109
|
+
type: fieldDef.type,
|
|
110
|
+
value: values[normalized.field],
|
|
111
|
+
visible,
|
|
112
|
+
readOnly,
|
|
113
|
+
required,
|
|
114
|
+
...(normalized.span !== undefined && { span: normalized.span }),
|
|
115
|
+
...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
|
|
116
|
+
...(options !== undefined && { options }),
|
|
117
|
+
...(optionLabels !== undefined && { optionLabels }),
|
|
118
|
+
...(multiline !== undefined && { multiline }),
|
|
119
|
+
...(refEntity !== undefined && { refEntity }),
|
|
120
|
+
...(refFeature !== undefined && { refFeature }),
|
|
121
|
+
...(refLabelField !== undefined && { refLabelField }),
|
|
122
|
+
...(refMultiple !== undefined && { refMultiple }),
|
|
123
|
+
};
|
|
124
|
+
return view;
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
title: translate(sectionSpec.title),
|
|
128
|
+
columns: sectionSpec.columns ?? 1,
|
|
129
|
+
fields,
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const id = (values["id"] as string | undefined) ?? null;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
screenId: screen.id,
|
|
137
|
+
entityName: screen.entity,
|
|
138
|
+
id,
|
|
139
|
+
sections,
|
|
140
|
+
...(screen.slots && { slots: screen.slots }),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Resolves a FieldCondition (undefined | predicate) into its boolean
|
|
145
|
+
// value against the current row values + ctx. `undefined` means "not
|
|
146
|
+
// declared" — caller substitutes the entity-level default.
|
|
147
|
+
//
|
|
148
|
+
// TValues / TCtx mirror the generics on FieldCondition. EditFieldSpec
|
|
149
|
+
// stores predicates as FieldCondition<unknown, unknown>, which is
|
|
150
|
+
// assignable to FieldCondition<TValues, TCtx> for any TValues/TCtx by
|
|
151
|
+
// parameter contravariance (a predicate that accepts `unknown` accepts
|
|
152
|
+
// anything). The cast on ctx is the one rough edge: input.ctx is
|
|
153
|
+
// optional, so at runtime we may pass undefined to a predicate that
|
|
154
|
+
// declared a concrete TCtx — the caller is responsible for not doing
|
|
155
|
+
// that when they narrow TCtx away from `unknown`.
|
|
156
|
+
function evalCondition<TValues, TCtx>(
|
|
157
|
+
condition: FieldCondition<TValues, TCtx> | undefined,
|
|
158
|
+
fallback: boolean,
|
|
159
|
+
values: TValues,
|
|
160
|
+
ctx: TCtx | undefined,
|
|
161
|
+
): boolean {
|
|
162
|
+
if (condition === undefined) return fallback;
|
|
163
|
+
return condition(values, ctx as TCtx);
|
|
164
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type { ComputeEditViewModelInput } from "./edit";
|
|
2
|
+
export { computeEditViewModel } from "./edit";
|
|
3
|
+
export type { ComputeListViewModelInput } from "./list";
|
|
4
|
+
export { computeListViewModel, fieldLabelKey } from "./list";
|
|
5
|
+
export type {
|
|
6
|
+
EditFieldSpec,
|
|
7
|
+
EditFieldViewModel,
|
|
8
|
+
EditSectionSpec,
|
|
9
|
+
EditSectionViewModel,
|
|
10
|
+
EditViewModel,
|
|
11
|
+
FieldConditionCtx,
|
|
12
|
+
FieldRenderer,
|
|
13
|
+
ListColumnSpec,
|
|
14
|
+
ListColumnViewModel,
|
|
15
|
+
ListRowViewModel,
|
|
16
|
+
ListViewModel,
|
|
17
|
+
ScreenSlots,
|
|
18
|
+
Translate,
|
|
19
|
+
} from "./types";
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntityDefinition,
|
|
3
|
+
EntityListScreenDefinition,
|
|
4
|
+
FieldDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
+
import { normalizeListColumn, parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
|
+
import type { ListColumnViewModel, ListRowViewModel, ListViewModel, Translate } from "./types";
|
|
8
|
+
|
|
9
|
+
export type ComputeListViewModelInput = {
|
|
10
|
+
readonly screen: EntityListScreenDefinition;
|
|
11
|
+
readonly entity: EntityDefinition;
|
|
12
|
+
readonly rows: readonly Readonly<Record<string, unknown>>[];
|
|
13
|
+
// Translate callback — normally LocaleResolver.translate. Labels use the
|
|
14
|
+
// i18n convention "{feature}:entity:{entityName}:field:{fieldName}"; if
|
|
15
|
+
// the key is absent from the active bundle, i18next falls back to the
|
|
16
|
+
// key itself, which is fine for dev.
|
|
17
|
+
readonly translate: Translate;
|
|
18
|
+
// Feature + entity name are required for i18n key composition —
|
|
19
|
+
// ScreenDefinition carries `entity: string` but not the feature scope,
|
|
20
|
+
// and i18n keys are prefixed by feature. Passed in by the caller (the
|
|
21
|
+
// renderer knows its host-feature context).
|
|
22
|
+
readonly featureName: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Pure transform: takes the declared screen + entity + incoming rows, spits
|
|
26
|
+
// out the flat shape the renderer draws. No conditions, no access-checks —
|
|
27
|
+
// those are list-level decisions that the caller makes BEFORE calling
|
|
28
|
+
// here (e.g. filtering rows by ownership on the server / before query).
|
|
29
|
+
// Field-level read-access (Level 3 of UI-architecture.md §Permission) is a
|
|
30
|
+
// follow-up; this v1 assumes the caller passed a row-filter that drops any
|
|
31
|
+
// field the user may not see.
|
|
32
|
+
export function computeListViewModel(input: ComputeListViewModelInput): ListViewModel {
|
|
33
|
+
const { screen, entity, rows, translate, featureName } = input;
|
|
34
|
+
|
|
35
|
+
const columns: ListColumnViewModel[] = [];
|
|
36
|
+
for (const spec of screen.columns) {
|
|
37
|
+
const normalized = normalizeListColumn(spec);
|
|
38
|
+
const fieldDef = entity.fields[normalized.field];
|
|
39
|
+
if (!fieldDef) {
|
|
40
|
+
// Screen references a field that's not on the entity. Fail loud —
|
|
41
|
+
// the boot-validator (r.screen) should catch this, but a stale
|
|
42
|
+
// field-rename would leave the screen referring to a ghost column
|
|
43
|
+
// until ops re-runs boot. We throw so the renderer sees the error
|
|
44
|
+
// instead of drawing an empty column.
|
|
45
|
+
throw new Error(
|
|
46
|
+
`computeListViewModel: screen "${screen.id}" references unknown field "${normalized.field}" on entity "${screen.entity}"`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const label = translate(fieldLabelKey(featureName, screen.entity, normalized.field));
|
|
50
|
+
// Tier 2.7e-3 + Cross-Feature: Reference-Field — entity-String
|
|
51
|
+
// kann same-feature ("user") oder cross-feature ("users:user")
|
|
52
|
+
// sein. parseRefTarget gibt (featureName, entityName), der
|
|
53
|
+
// Renderer baut die Lookup-QN als
|
|
54
|
+
// `<refFeature>:query:<refEntity>:list`.
|
|
55
|
+
const refRaw =
|
|
56
|
+
fieldDef.type === "reference"
|
|
57
|
+
? (fieldDef as unknown as { entity?: string }).entity
|
|
58
|
+
: undefined;
|
|
59
|
+
const refTarget = refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined;
|
|
60
|
+
const refEntity = refTarget?.entityName;
|
|
61
|
+
const refFeature = refTarget?.featureName;
|
|
62
|
+
const refLabelField =
|
|
63
|
+
fieldDef.type === "reference"
|
|
64
|
+
? ((fieldDef as unknown as { labelField?: string }).labelField ?? "id")
|
|
65
|
+
: undefined;
|
|
66
|
+
// Bei select-Feldern: translated Option-Labels einbacken. Convention
|
|
67
|
+
// matcht den Form-Path → eine Translation-Quelle für List + Form.
|
|
68
|
+
// Missing-Key returnt convention-gemäß den Key zurück; Renderer hat
|
|
69
|
+
// dann humanizeSlug-Fallback.
|
|
70
|
+
const optionLabels =
|
|
71
|
+
fieldDef.type === "select"
|
|
72
|
+
? buildOptionLabels(
|
|
73
|
+
translate,
|
|
74
|
+
featureName,
|
|
75
|
+
screen.entity,
|
|
76
|
+
normalized.field,
|
|
77
|
+
(fieldDef as unknown as { options?: readonly string[] }).options ?? [],
|
|
78
|
+
)
|
|
79
|
+
: undefined;
|
|
80
|
+
const column: ListColumnViewModel = {
|
|
81
|
+
field: normalized.field,
|
|
82
|
+
label,
|
|
83
|
+
type: fieldDef.type,
|
|
84
|
+
sortable: fieldIsSortable(fieldDef),
|
|
85
|
+
...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
|
|
86
|
+
...(optionLabels !== undefined && { optionLabels }),
|
|
87
|
+
...(refEntity !== undefined && { refEntity }),
|
|
88
|
+
...(refFeature !== undefined && { refFeature }),
|
|
89
|
+
...(refLabelField !== undefined && { refLabelField }),
|
|
90
|
+
};
|
|
91
|
+
columns.push(column);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const listRows: ListRowViewModel[] = rows.map((row) => ({
|
|
95
|
+
id: String(row["id"] ?? ""),
|
|
96
|
+
values: row,
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
screenId: screen.id,
|
|
101
|
+
entityName: screen.entity,
|
|
102
|
+
columns,
|
|
103
|
+
rows: listRows,
|
|
104
|
+
...(screen.slots && { slots: screen.slots }),
|
|
105
|
+
isEmpty: listRows.length === 0,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Field-i18n-key convention matches what features register translations
|
|
110
|
+
// under (see packages/framework/src/i18n/ for the pattern). Duplicated
|
|
111
|
+
// here as a plain function — the ui-core boundary forbids depending on
|
|
112
|
+
// the i18n module directly.
|
|
113
|
+
export function fieldLabelKey(featureName: string, entityName: string, fieldName: string): string {
|
|
114
|
+
return `${featureName}:entity:${entityName}:field:${fieldName}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function fieldOptionLabelKey(
|
|
118
|
+
featureName: string,
|
|
119
|
+
entityName: string,
|
|
120
|
+
fieldName: string,
|
|
121
|
+
value: string,
|
|
122
|
+
): string {
|
|
123
|
+
return `${featureName}:entity:${entityName}:field:${fieldName}:option:${value}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Build a value→label map for a select-field's options. Convention:
|
|
127
|
+
// translate() returns the input key when the lookup misses (i18next
|
|
128
|
+
// default + LocaleResolver convention) — we surface the *raw value* in
|
|
129
|
+
// that case so the renderer's humanizeSlug fallback can take over.
|
|
130
|
+
// Without that fallback, an unlabeled option would render as the full
|
|
131
|
+
// `feature:entity:field:option:value`-key.
|
|
132
|
+
//
|
|
133
|
+
// Shared between list-VM and edit-VM so both builders produce
|
|
134
|
+
// identical option-translations.
|
|
135
|
+
export function buildOptionLabels(
|
|
136
|
+
translate: (key: string, params?: Readonly<Record<string, unknown>>) => string,
|
|
137
|
+
featureName: string,
|
|
138
|
+
entityName: string,
|
|
139
|
+
fieldName: string,
|
|
140
|
+
options: readonly string[],
|
|
141
|
+
): Readonly<Record<string, string>> {
|
|
142
|
+
const out: Record<string, string> = {};
|
|
143
|
+
for (const value of options) {
|
|
144
|
+
const key = fieldOptionLabelKey(featureName, entityName, fieldName, value);
|
|
145
|
+
const translated = translate(key);
|
|
146
|
+
out[value] = translated === key ? value : translated;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A field can declare `sortable: true` on the FieldDefinition. This is
|
|
152
|
+
// framework-level metadata used both by the server's list-query builder
|
|
153
|
+
// (ORDER BY safety) and by the UI (column header click indicator). All
|
|
154
|
+
// field types can bear the flag; it's off by default.
|
|
155
|
+
function fieldIsSortable(field: FieldDefinition): boolean {
|
|
156
|
+
const flag = (field as unknown as { sortable?: boolean }).sortable;
|
|
157
|
+
return flag === true;
|
|
158
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EditFieldSpec,
|
|
3
|
+
EditSectionSpec,
|
|
4
|
+
FieldRenderer,
|
|
5
|
+
ListColumnSpec,
|
|
6
|
+
ScreenSlots,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
8
|
+
|
|
9
|
+
// View-Models — plain data structures produced by computeListViewModel and
|
|
10
|
+
// computeEditViewModel. They flatten the combined [screen-def + entity-def
|
|
11
|
+
// + row-data + user] inputs into a shape the renderer draws directly,
|
|
12
|
+
// without re-resolving conditions or re-reading the entity field map.
|
|
13
|
+
//
|
|
14
|
+
// The renderer's render-list.tsx / render-edit.tsx are essentially
|
|
15
|
+
// (viewModel) => JSX // on web
|
|
16
|
+
// (viewModel) => <View/Text> // on native
|
|
17
|
+
// so everything the renderer needs to render one frame must be in here.
|
|
18
|
+
//
|
|
19
|
+
// Why pre-compute instead of resolving at render-time: predicates
|
|
20
|
+
// (visible/readonly/required) are arbitrary JS closures — running them
|
|
21
|
+
// inside React's render is wasteful (happens on every re-render, even
|
|
22
|
+
// when values haven't changed) and mixes side-effect-free view code
|
|
23
|
+
// with business logic. Computing once on form-state-change gives the
|
|
24
|
+
// renderer pure data and keeps render paths trivial.
|
|
25
|
+
|
|
26
|
+
// --- list view model ---
|
|
27
|
+
|
|
28
|
+
// One column, fully resolved. `label` is the localized string the
|
|
29
|
+
// renderer puts in the column header; view-model builder runs it through
|
|
30
|
+
// LocaleResolver.translate() from the i18nKey wired onto the field.
|
|
31
|
+
// `renderer` passes through ScreenDefinition's FieldRenderer verbatim.
|
|
32
|
+
export type ListColumnViewModel = {
|
|
33
|
+
readonly field: string;
|
|
34
|
+
readonly label: string;
|
|
35
|
+
readonly type: string; // field-type ("text", "number", "money", ...)
|
|
36
|
+
readonly renderer?: FieldRenderer;
|
|
37
|
+
readonly sortable: boolean;
|
|
38
|
+
/** Nur bei `type: "select"` — translated Option-Labels keyed nach raw
|
|
39
|
+
* value. Renderer rendert `optionLabels[value]` statt humanizeSlug
|
|
40
|
+
* wenn vorhanden. Convention-Key: `<feature>:entity:<entity>:field:<field>:option:<value>`. */
|
|
41
|
+
readonly optionLabels?: Readonly<Record<string, string>>;
|
|
42
|
+
/** Nur bei `type: "reference"` — referenced Entity-Name für Bulk-
|
|
43
|
+
* Lookup im Renderer (`<refFeature>:query:<refEntity>:list`). */
|
|
44
|
+
readonly refEntity?: string;
|
|
45
|
+
/** Nur bei `type: "reference"` — Feature-Name in dem die referenced
|
|
46
|
+
* Entity wohnt. Default = current feature. Cross-Feature über
|
|
47
|
+
* qualifizierte Form ("feature:entity") am ReferenceFieldDef. */
|
|
48
|
+
readonly refFeature?: string;
|
|
49
|
+
/** Nur bei `type: "reference"` — Welches Feld der referenced Entity
|
|
50
|
+
* als Display-Wert in der Cell erscheint (Default "id"). */
|
|
51
|
+
readonly refLabelField?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ListRowViewModel = {
|
|
55
|
+
readonly id: string;
|
|
56
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type ListViewModel = {
|
|
60
|
+
readonly screenId: string;
|
|
61
|
+
readonly entityName: string;
|
|
62
|
+
readonly columns: readonly ListColumnViewModel[];
|
|
63
|
+
readonly rows: readonly ListRowViewModel[];
|
|
64
|
+
readonly slots?: ScreenSlots;
|
|
65
|
+
// Flags for the renderer to decide what kind of container to draw.
|
|
66
|
+
readonly isEmpty: boolean;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// --- edit view model ---
|
|
70
|
+
|
|
71
|
+
// Resolved field — all predicates evaluated, labels translated. The
|
|
72
|
+
// renderer reads `{ visible, readonly, required }` directly without
|
|
73
|
+
// re-running any predicate.
|
|
74
|
+
export type EditFieldViewModel = {
|
|
75
|
+
readonly field: string;
|
|
76
|
+
readonly label: string;
|
|
77
|
+
readonly type: string;
|
|
78
|
+
readonly value: unknown;
|
|
79
|
+
readonly visible: boolean;
|
|
80
|
+
// `readOnly` (camelCase) matches the spec-side name on EditFieldSpec —
|
|
81
|
+
// one convention through the stack. The TS property modifier `readonly`
|
|
82
|
+
// (lowercase) only collides as a key name in type declarations of the
|
|
83
|
+
// spec; here in the view-model we could have used either, but symmetric
|
|
84
|
+
// naming beats clever ergonomics. Parallel-agent chose `readOnly` on
|
|
85
|
+
// the input; we honour it on the output.
|
|
86
|
+
readonly readOnly: boolean;
|
|
87
|
+
readonly required: boolean;
|
|
88
|
+
readonly span?: number;
|
|
89
|
+
readonly renderer?: FieldRenderer;
|
|
90
|
+
/** Nur bei `type: "select"` gesetzt — die zugelassenen Werte. Wird
|
|
91
|
+
* vom Renderer als Dropdown-Optionen genutzt. Quelle ist
|
|
92
|
+
* SelectFieldDef.options aus der EntityDefinition. */
|
|
93
|
+
readonly options?: readonly string[];
|
|
94
|
+
/** Nur bei `type: "select"` gesetzt — translated Labels pro Option,
|
|
95
|
+
* keyed nach raw value. Renderer zeigt `optionLabels[value]` als
|
|
96
|
+
* Dropdown-Label statt raw value. Convention-Key:
|
|
97
|
+
* `<feature>:entity:<entity>:field:<field>:option:<value>`. */
|
|
98
|
+
readonly optionLabels?: Readonly<Record<string, string>>;
|
|
99
|
+
/** Nur bei `type: "text"` gesetzt wenn TextFieldDef.multiline true
|
|
100
|
+
* ist — dann rendert der Renderer textarea statt single-line input.
|
|
101
|
+
* `true` = Default-Zeilen, `{ rows }` = explizite Höhe. */
|
|
102
|
+
readonly multiline?: boolean | { readonly rows?: number };
|
|
103
|
+
/** Nur bei `type: "reference"` gesetzt — Tier 2.7e-3.
|
|
104
|
+
* Die referenced Entity (kurz, ohne feature-prefix). Der Renderer
|
|
105
|
+
* baut die Query-QN als `<refFeature>:query:<refEntity>:list`. */
|
|
106
|
+
readonly refEntity?: string;
|
|
107
|
+
/** Nur bei `type: "reference"` gesetzt — Feature-Name in dem die
|
|
108
|
+
* referenced Entity wohnt. Same-feature default = aktuelles
|
|
109
|
+
* Feature. Cross-Feature wird über "feature:entity" am
|
|
110
|
+
* ReferenceFieldDef.entity erkannt. */
|
|
111
|
+
readonly refFeature?: string;
|
|
112
|
+
/** Nur bei `type: "reference"` gesetzt — Welches Feld der referenced
|
|
113
|
+
* Entity als Display-Label im Dropdown erscheint. Default: "id". */
|
|
114
|
+
readonly refLabelField?: string;
|
|
115
|
+
/** Nur bei `type: "reference"` — Multi-Mode (Tier 2.7e-Multi):
|
|
116
|
+
* Wert ist UUID-Array, Renderer mountet Multi-Combobox mit Tags. */
|
|
117
|
+
readonly refMultiple?: boolean;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export type EditSectionViewModel = {
|
|
121
|
+
readonly title: string;
|
|
122
|
+
readonly columns: number;
|
|
123
|
+
readonly fields: readonly EditFieldViewModel[];
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export type EditViewModel = {
|
|
127
|
+
readonly screenId: string;
|
|
128
|
+
readonly entityName: string;
|
|
129
|
+
readonly id: string | null; // null on create (no existing row)
|
|
130
|
+
readonly sections: readonly EditSectionViewModel[];
|
|
131
|
+
readonly slots?: ScreenSlots;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// --- resolver interfaces (host-injected) ---
|
|
135
|
+
|
|
136
|
+
// The view-model builder calls this to translate i18n keys into strings.
|
|
137
|
+
// Normally the host passes the renderer's LocaleResolver.translate
|
|
138
|
+
// directly — keeping the interface narrow here avoids dragging the full
|
|
139
|
+
// LocaleResolver (with subscribe) into a pure compute path.
|
|
140
|
+
export type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
|
|
141
|
+
|
|
142
|
+
// Optional condition context forwarded to field visibility/readonly/required
|
|
143
|
+
// predicates. Same ctx the form-controller uses in conditional-fields — so
|
|
144
|
+
// a predicate that gated "admin-only" there keeps working here.
|
|
145
|
+
export type FieldConditionCtx = unknown;
|
|
146
|
+
|
|
147
|
+
// Re-export the ScreenDef spec halves we don't want ui-core callers to
|
|
148
|
+
// import from @cosmicdrift/kumiko-framework separately. Renderer packages expect a
|
|
149
|
+
// single import surface.
|
|
150
|
+
export type { EditFieldSpec, EditSectionSpec, FieldRenderer, ListColumnSpec, ScreenSlots };
|