@cosmicdrift/kumiko-headless 0.64.0 → 0.66.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.64.0",
3
+ "version": "0.66.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>",
@@ -32,7 +32,7 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "@cosmicdrift/kumiko-framework": "0.57.2",
35
+ "@cosmicdrift/kumiko-framework": "0.66.0",
36
36
  "zod": "^4.4.3"
37
37
  },
38
38
  "publishConfig": {
@@ -199,4 +199,47 @@ describe("resolveNavigation", () => {
199
199
  expect(tree[0]?.children[0]?.qualifiedName).toBe("a:nav:mid");
200
200
  expect(tree[0]?.children[0]?.children[0]?.qualifiedName).toBe("a:nav:leaf");
201
201
  });
202
+
203
+ // Visual-Tree-Merge: die polymorphen Felder (target/actions/createAction/
204
+ // provider) müssen durch resolveNavigation durchgereicht werden, sonst
205
+ // kann der eine Renderer dynamische/dispatch-Knoten nicht bauen.
206
+ test("polymorphic fields pass through: target, actions, createAction, provider", () => {
207
+ const editTarget = { featureId: "text-content", action: "edit", args: { slug: "hero" } };
208
+ const createTarget = { featureId: "text-content", action: "create", args: { folder: "page" } };
209
+ const source = buildSource([
210
+ {
211
+ id: "tc:nav:content",
212
+ label: "Content",
213
+ provider: true,
214
+ createAction: { icon: "plus", label: "New page", target: createTarget },
215
+ },
216
+ {
217
+ id: "tc:nav:hero",
218
+ label: "Hero",
219
+ parent: "tc:nav:content",
220
+ target: editTarget,
221
+ actions: [{ icon: "trash", label: "Delete", target: editTarget }],
222
+ },
223
+ ]);
224
+
225
+ const tree = resolveNavigation({ source });
226
+ const content = tree[0];
227
+ expect(content?.provider).toBe(true);
228
+ expect(content?.createAction?.target).toEqual(createTarget);
229
+ const hero = content?.children[0];
230
+ expect(hero?.target).toEqual(editTarget);
231
+ expect(hero?.screen).toBeUndefined();
232
+ expect(hero?.actions).toHaveLength(1);
233
+ });
234
+
235
+ // Knoten ohne die neuen Felder dürfen sie NICHT als undefined-Keys tragen
236
+ // (conditional-spread): hält die resolved Node sauber + Snapshot-stabil.
237
+ test("absent polymorphic fields are omitted, not set to undefined", () => {
238
+ const source = buildSource([{ id: "a:nav:plain", label: "Plain", screen: "a:screen:x" }]);
239
+ const node = resolveNavigation({ source })[0];
240
+ expect(node).not.toBeUndefined();
241
+ expect("target" in (node ?? {})).toBe(false);
242
+ expect("provider" in (node ?? {})).toBe(false);
243
+ expect("actions" in (node ?? {})).toBe(false);
244
+ });
202
245
  });
@@ -31,6 +31,10 @@ export function resolveNavigation(options: ResolveNavigationOptions): NavTree {
31
31
  children,
32
32
  ...(entry.icon !== undefined && { icon: entry.icon }),
33
33
  ...(entry.screen !== undefined && { screen: entry.screen }),
34
+ ...(entry.target !== undefined && { target: entry.target }),
35
+ ...(entry.actions !== undefined && { actions: entry.actions }),
36
+ ...(entry.createAction !== undefined && { createAction: entry.createAction }),
37
+ ...(entry.provider !== undefined && { provider: entry.provider }),
34
38
  };
35
39
  }
36
40
 
package/src/nav/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { NavDefinition, TargetRef, TreeAction } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
 
3
3
  // A single resolved nav entry as the renderer consumes it. Labels are NOT
4
4
  // translated yet — nav can be used in SSR contexts where the locale isn't
@@ -13,6 +13,15 @@ export type NavNode = {
13
13
  readonly label: string;
14
14
  readonly icon?: string;
15
15
  readonly screen?: string;
16
+ // Polymorphes Klick-Ziel (EditorPanel via dispatch) — Alternative zu
17
+ // `screen`. Knoten trägt screen XOR target.
18
+ readonly target?: TargetRef;
19
+ // Hover-Actions + „+"-Affordance, durchgereicht aus der NavDefinition.
20
+ readonly actions?: readonly TreeAction[];
21
+ readonly createAction?: TreeAction;
22
+ // true → Children kommen zur Laufzeit aus einem nav-provider (lazy +
23
+ // SSE-live), nicht aus `children`. Renderer macht den Knoten expandable.
24
+ readonly provider?: boolean;
16
25
  readonly order: number;
17
26
  readonly children: readonly NavNode[];
18
27
  };
@@ -119,6 +119,12 @@ export function computeEditViewModel<
119
119
  fieldDef.type === "reference"
120
120
  ? ((fieldDef as unknown as { multiple?: boolean }).multiple ?? false)
121
121
  : undefined;
122
+ // file/image: accept/maxSize ins ViewModel + entityType/fieldName für
123
+ // den Upload-POST (Endpoint validiert gegen die richtige Field-Def).
124
+ const isFileType = fieldDef.type === "file" || fieldDef.type === "image";
125
+ const fileDef = isFileType
126
+ ? (fieldDef as unknown as { accept?: readonly string[]; maxSize?: string })
127
+ : undefined;
122
128
  const view: EditFieldViewModel = {
123
129
  field: normalized.field,
124
130
  label,
@@ -140,12 +146,16 @@ export function computeEditViewModel<
140
146
  ...(refFeature !== undefined && { refFeature }),
141
147
  ...(refLabelField !== undefined && { refLabelField }),
142
148
  ...(refMultiple !== undefined && { refMultiple }),
149
+ ...(fileDef?.accept !== undefined && { accept: fileDef.accept }),
150
+ ...(fileDef?.maxSize !== undefined && { maxSize: fileDef.maxSize }),
151
+ ...(isFileType && { entityType: screen.entity, fieldName: normalized.field }),
143
152
  };
144
153
  return view;
145
154
  });
146
155
  return {
147
156
  kind: "fields" as const,
148
- title: translate(sectionSpec.title),
157
+ // Titellose Section (flache Form) → kein h3; nur übersetzen wenn gesetzt.
158
+ ...(sectionSpec.title !== undefined && { title: translate(sectionSpec.title) }),
149
159
  columns: sectionSpec.columns ?? 1,
150
160
  fields,
151
161
  };
@@ -132,6 +132,17 @@ export type EditFieldViewModel = {
132
132
  /** Nur bei `type: "reference"` — Multi-Mode (Tier 2.7e-Multi):
133
133
  * Wert ist UUID-Array, Renderer mountet Multi-Combobox mit Tags. */
134
134
  readonly refMultiple?: boolean;
135
+ /** Nur bei `type: "file" | "image"` — erlaubte Extensions/MIME (z.B.
136
+ * ["jpg","png"]) + Max-Größe ("5mb"). Der Renderer setzt das `accept`-
137
+ * Attribut + zeigt die Grenze; die Validierung läuft serverseitig im
138
+ * Upload-Endpoint gegen die Field-Def. */
139
+ readonly accept?: readonly string[];
140
+ readonly maxSize?: string;
141
+ /** Nur bei `type: "file" | "image"` — Entity-Name + Field-Name für den
142
+ * Upload-POST (`/api/files`), damit der Endpoint maxSize/accept gegen
143
+ * die richtige Field-Def prüfen kann. */
144
+ readonly entityType?: string;
145
+ readonly fieldName?: string;
135
146
  };
136
147
 
137
148
  // Discriminated by `kind` — mirrors EditSectionSpec on the engine side.
@@ -141,7 +152,8 @@ export type EditSectionViewModel = EditFieldsSectionViewModel | EditExtensionSec
141
152
 
142
153
  export type EditFieldsSectionViewModel = {
143
154
  readonly kind: "fields";
144
- readonly title: string;
155
+ /** Optional — eine titellose Section rendert nur ihre Felder (flache Form). */
156
+ readonly title?: string;
145
157
  readonly columns: number;
146
158
  readonly fields: readonly EditFieldViewModel[];
147
159
  };