@cosmicdrift/kumiko-headless 0.237.1 → 0.238.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.237.1",
3
+ "version": "0.238.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.237.1",
39
+ "@cosmicdrift/kumiko-framework": "0.238.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -676,3 +676,57 @@ describe("computeEditViewModel — multiSelect display/columns/maxRows passthrou
676
676
  expect(field?.maxRows).toBeUndefined();
677
677
  });
678
678
  });
679
+
680
+ describe("computeEditViewModel — declared reference metadata (fw#2662)", () => {
681
+ // projectionDetail fields have no real EntityDefinition — the shim that
682
+ // adapts them synthesizes a pseudo-entity where every field is hardcoded
683
+ // to "text" (see projection-detail-shim.ts). EditFieldSpec.refEntity must
684
+ // be evaluated BEFORE the entity.fields lookup, or it never fires against
685
+ // that shape.
686
+ const projectionPseudoEntity = {
687
+ fields: {
688
+ userId: { type: "text" },
689
+ ip: { type: "text" },
690
+ },
691
+ } as unknown as EntityDefinition;
692
+
693
+ test("EditFieldSpec.refEntity marks the field as a reference, even against a pseudo-entity whose fields are all 'text' (fw#2662)", () => {
694
+ const vm = computeEditViewModel({
695
+ screen: editScreen({
696
+ sections: [
697
+ { fields: [{ field: "userId", refEntity: "user:user", refLabelField: "displayName" }] },
698
+ ],
699
+ }),
700
+ entity: projectionPseudoEntity,
701
+ values: { userId: "u-1" },
702
+ translate,
703
+ featureName: "sessions",
704
+ });
705
+
706
+ const field = asFields(vm.sections[0]).fields[0];
707
+ expect(field).toMatchObject({
708
+ field: "userId",
709
+ type: "reference",
710
+ refEntity: "user",
711
+ refFeature: "user",
712
+ refLabelField: "displayName",
713
+ });
714
+ expect(field?.refMultiple).toBeUndefined();
715
+ });
716
+
717
+ test("non-regression: a projectionDetail field without refEntity metadata stays 'text', unchanged (fw#2662)", () => {
718
+ const vm = computeEditViewModel({
719
+ screen: editScreen({ sections: [{ fields: ["ip"] }] }),
720
+ entity: projectionPseudoEntity,
721
+ values: { ip: "10.0.0.1" },
722
+ translate,
723
+ featureName: "sessions",
724
+ });
725
+
726
+ const field = asFields(vm.sections[0]).fields[0];
727
+ expect(field?.type).toBe("text");
728
+ expect(field?.refEntity).toBeUndefined();
729
+ expect(field?.refFeature).toBeUndefined();
730
+ expect(field?.refLabelField).toBeUndefined();
731
+ });
732
+ });
@@ -339,6 +339,62 @@ describe("computeListViewModel", () => {
339
339
  });
340
340
  });
341
341
 
342
+ // projectionList/relatedList columns have no real EntityDefinition — the
343
+ // shim that adapts them synthesizes a pseudo-entity where every field is
344
+ // hardcoded to "text" (see projection-list-shim.ts /
345
+ // related-list-section.tsx). ListColumnSpec.refEntity must be evaluated
346
+ // BEFORE the entity.fields lookup, or it never fires against that shape.
347
+ const projectionPseudoEntity = {
348
+ fields: {
349
+ tenantId: { type: "text" },
350
+ type: { type: "text" },
351
+ },
352
+ } as unknown as EntityDefinition;
353
+
354
+ test("ListColumnSpec.refEntity marks the column as a reference, even against a pseudo-entity whose fields are all 'text' (fw#2662)", () => {
355
+ const vm = computeListViewModel({
356
+ screen: listScreen([
357
+ {
358
+ field: "tenantId",
359
+ label: "delivery.log.col.tenantId",
360
+ refEntity: "tenant:tenant",
361
+ refLabelField: "name",
362
+ },
363
+ ]),
364
+ entity: projectionPseudoEntity,
365
+ rows: [],
366
+ translate,
367
+ featureName: "delivery",
368
+ });
369
+
370
+ expect(vm.columns[0]).toEqual({
371
+ field: "tenantId",
372
+ label: "delivery.log.col.tenantId",
373
+ type: "reference",
374
+ sortable: false,
375
+ refFeature: "tenant",
376
+ refEntity: "tenant",
377
+ refLabelField: "name",
378
+ });
379
+ });
380
+
381
+ test("non-regression: a projectionList column without refEntity metadata stays 'text', unchanged (fw#2662)", () => {
382
+ const vm = computeListViewModel({
383
+ screen: listScreen([{ field: "type", label: "delivery.log.col.type" }]),
384
+ entity: projectionPseudoEntity,
385
+ rows: [],
386
+ translate,
387
+ featureName: "delivery",
388
+ });
389
+
390
+ expect(vm.columns[0]).toEqual({
391
+ field: "type",
392
+ label: "delivery.log.col.type",
393
+ type: "text",
394
+ sortable: false,
395
+ });
396
+ });
397
+
342
398
  test("row-meta column: explicit label + renderer are passed through unchanged", () => {
343
399
  const fmt = { format: "currency" as const, symbol: "€" };
344
400
  const vm = computeListViewModel({
@@ -97,6 +97,18 @@ export function computeEditViewModel<
97
97
  `computeEditViewModel: screen "${screen.id}" references unknown field "${normalized.field}" on entity "${screen.entity}"`,
98
98
  );
99
99
  }
100
+ // Declared reference metadata (EditFieldSpec.refEntity) — for
101
+ // projectionDetail fields, which have no EntityDefinition field to
102
+ // carry a real "reference" type. Takes priority over fieldDef.type
103
+ // below so it also fires against the pseudo-entity that hardcodes
104
+ // every field as "text" (projection-detail-shim) — real entityEdit
105
+ // screens never set this, so their fieldDef.type === "reference"
106
+ // branch is unaffected.
107
+ const declaredRefTarget =
108
+ normalized.refEntity !== undefined
109
+ ? parseRefTarget(normalized.refEntity, featureName)
110
+ : undefined;
111
+ const effectiveType = declaredRefTarget !== undefined ? "reference" : fieldDef.type;
100
112
  const label = translate(
101
113
  screen.fieldLabels?.[normalized.field] ??
102
114
  fieldLabelKey(featureName, screen.entity, normalized.field),
@@ -160,26 +172,33 @@ export function computeEditViewModel<
160
172
  const min = dateBounds?.min;
161
173
  const max = dateBounds?.max;
162
174
  const dateLocale = dateBounds?.locale;
163
- // Tier 2.7e-3: Reference-Field — refEntity + refLabelField in
164
- // das ViewModel reichen damit der Renderer die Lookup-Query
165
- // bauen kann ohne noch an EntityDefinition zu greifen.
166
- // Tier 2.7e-3: Reference-Field — entity-String kann same-feature
167
- // ("user") oder cross-feature ("users:user") sein. parseRefTarget
168
- // splittet das, der Renderer baut die Lookup-QN aus
169
- // (refFeature, refEntity).
175
+ // Tier 2.7e-3: Reference-Field — refEntity + refLabelField travel into
176
+ // the view model so the renderer can build the lookup query without
177
+ // touching the EntityDefinition again. The entity-string can be
178
+ // same-feature ("user") or cross-feature ("users:user"); parseRefTarget
179
+ // splits that, the renderer builds the lookup QN from
180
+ // (refFeature, refEntity). Declared metadata (declaredRefTarget) takes
181
+ // priority, same as effectiveType above.
170
182
  const refRaw =
171
- fieldDef.type === "reference"
183
+ declaredRefTarget === undefined && fieldDef.type === "reference"
172
184
  ? (fieldDef as unknown as { entity?: string }).entity
173
185
  : undefined;
174
- const refTarget = refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined;
186
+ const refTarget =
187
+ declaredRefTarget ??
188
+ (refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined);
175
189
  const refEntity = refTarget?.entityName;
176
190
  const refFeature = refTarget?.featureName;
177
191
  const refLabelField =
178
- fieldDef.type === "reference"
179
- ? ((fieldDef as unknown as { labelField?: string }).labelField ?? "id")
180
- : undefined;
192
+ declaredRefTarget !== undefined
193
+ ? (normalized.refLabelField ?? "id")
194
+ : fieldDef.type === "reference"
195
+ ? ((fieldDef as unknown as { labelField?: string }).labelField ?? "id")
196
+ : undefined;
197
+ // Declared reference metadata has no `multiple` concept (it targets
198
+ // row-meta/derived fields, always single-valued) — only a real
199
+ // ReferenceFieldDef carries it.
181
200
  const refMultiple =
182
- fieldDef.type === "reference"
201
+ declaredRefTarget === undefined && fieldDef.type === "reference"
183
202
  ? ((fieldDef as unknown as { multiple?: boolean }).multiple ?? false)
184
203
  : undefined;
185
204
  // file/image: accept/maxSize ins ViewModel + entityType/fieldName für
@@ -272,7 +291,7 @@ export function computeEditViewModel<
272
291
  const view: EditFieldViewModel = {
273
292
  field: normalized.field,
274
293
  label,
275
- type: fieldDef.type,
294
+ type: effectiveType,
276
295
  value: values[normalized.field],
277
296
  visible,
278
297
  readOnly,
@@ -40,6 +40,31 @@ export function computeListViewModel(input: ComputeListViewModelInput): ListView
40
40
  const columns: ListColumnViewModel[] = [];
41
41
  for (const spec of screen.columns) {
42
42
  const normalized = normalizeListColumn(spec);
43
+ if (normalized.refEntity !== undefined) {
44
+ // Declared reference metadata (ListColumnSpec.refEntity) — for
45
+ // projectionList/relatedList columns, which have no EntityDefinition
46
+ // field to carry a real "reference" type. Checked before the
47
+ // entity.fields lookup below so it also fires against the pseudo-
48
+ // entities that hardcode every field as "text" (projection-list-shim,
49
+ // related-list-section) — those never reach the reference branch
50
+ // further down since fieldDef.type is always "text" there.
51
+ const refTarget = parseRefTarget(normalized.refEntity, featureName);
52
+ columns.push({
53
+ field: normalized.field,
54
+ label: translate(
55
+ normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field),
56
+ ),
57
+ type: "reference",
58
+ // The declared value is the referenced id, not the resolved label —
59
+ // there is no column to server-sort by, same as row-meta references.
60
+ sortable: false,
61
+ refEntity: refTarget.entityName,
62
+ refFeature: refTarget.featureName,
63
+ refLabelField: normalized.refLabelField ?? "id",
64
+ ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
65
+ });
66
+ continue;
67
+ }
43
68
  const fieldDef = entity.fields[normalized.field];
44
69
  if (!fieldDef) {
45
70
  // Not a stored field — may be a read-time derived field (value appended