@cosmicdrift/kumiko-renderer 0.294.1 → 0.296.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-renderer",
3
- "version": "0.294.1",
3
+ "version": "0.296.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.294.1",
19
- "@cosmicdrift/kumiko-headless": "0.294.1",
20
- "@cosmicdrift/kumiko-types": "0.294.1",
18
+ "@cosmicdrift/kumiko-framework": "0.296.0",
19
+ "@cosmicdrift/kumiko-headless": "0.296.0",
20
+ "@cosmicdrift/kumiko-types": "0.296.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.294.1"
31
+ "@cosmicdrift/kumiko-locale-de": "0.296.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -60,10 +60,12 @@ function stubDispatcher(): Dispatcher {
60
60
  data: { id: "sess-1", userId: "u-1", actedBy: SYSTEM_USER_ID, ip: "10.0.0.1" },
61
61
  };
62
62
  }
63
- if (type === "user:query:user:list") {
63
+ // user:user resolves through the tenant-scoped member directory, not the
64
+ // SystemAdmin-only user:query:user:list a TenantAdmin can't call (fw#3107).
65
+ if (type === "tenant:query:member-directory") {
64
66
  return {
65
67
  isSuccess: true,
66
- data: { rows: [{ id: "u-1", displayName: "Jane Doe" }], nextCursor: null },
68
+ data: { rows: [{ id: "u-1", label: "Jane Doe" }], nextCursor: null },
67
69
  };
68
70
  }
69
71
  return { isSuccess: true, data: { rows: [], nextCursor: null } };
@@ -75,16 +75,21 @@ function stubDispatcher(): Dispatcher {
75
75
  },
76
76
  };
77
77
  }
78
- if (type === "tenant:query:tenant:list") {
78
+ // tenant:tenant resolves through the tenant directory, not the
79
+ // SystemAdmin-only tenant:query:tenant:list a TenantAdmin can't call
80
+ // (fw#3142).
81
+ if (type === "tenant:query:tenant-directory") {
79
82
  return {
80
83
  isSuccess: true,
81
- data: { rows: [{ id: REAL_TENANT_ID, name: "Acme Inc" }], nextCursor: null },
84
+ data: { rows: [{ id: REAL_TENANT_ID, label: "Acme Inc" }], nextCursor: null },
82
85
  };
83
86
  }
84
- if (type === "user:query:user:list") {
87
+ // user:user resolves through the tenant-scoped member directory, not the
88
+ // SystemAdmin-only user:query:user:list a TenantAdmin can't call (fw#3107).
89
+ if (type === "tenant:query:member-directory") {
85
90
  return {
86
91
  isSuccess: true,
87
- data: { rows: [{ id: REAL_USER_ID, displayName: "Ada Lovelace" }], nextCursor: null },
92
+ data: { rows: [{ id: REAL_USER_ID, label: "Ada Lovelace" }], nextCursor: null },
88
93
  };
89
94
  }
90
95
  return { isSuccess: true, data: { rows: [], nextCursor: null } };
package/src/changes.json CHANGED
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.296.0",
4
+ "type": "improvement",
5
+ "title": "entityEdit accepts layout.mode \"tabs\" (fw#3134)",
6
+ "detail": "A long edit form no longer has to choose between one endless page\n(`mode: \"single\"`) and a forced walk through every step (`mode: \"wizard\"`).\nTabs were rejected at boot because a required field on a hidden tab would block\nsubmit with nothing on screen to explain it.\nWhat resolves that is not scoping validation to the active tab — that would\nskip the field instead of showing it — but the opposite: every tab stays\nmounted, one submit validates across all of them, and a field error activates\nthe tab holding it. Values from a tab the user never opened travel with that\nsubmit. The tab strip needs the same per-tab `id` and title projectionDetail\nalready requires, now checked by a shared validator for both.\n`actionForm`, `configEdit` and `secretMint` still reject tabs: none of them has\na jump-to-erroring-tab path, so the silent-block failure would remain.\nThe tab is activated, not focused — focus would need a platform-specific call\nthis layer does not have. The error renders inline on the now-visible field.\nTwo side effects worth knowing before declaring a tabs layout: a section that\nomits `columns` defaults to 2 under tabs (the existing projectionDetail rule now\nalso reaches entityEdit), and the section title is dropped from the panel\nbecause the tab label already carries it.",
7
+ "migration": "Nothing to change: `single` and `wizard` behave as before. A screen that\nswitches to `mode: \"tabs\"` needs at least two sections, each with a title and\na kebab-case `id` — the same shape projectionDetail already requires. Two\ndefaults differ from `single` inside a tabs layout: a section that omits\n`columns` renders in 2 columns, and its title is dropped from the panel\nbecause the tab label carries it."
8
+ },
2
9
  {
3
10
  "version": "0.292.0",
4
11
  "type": "fix",
@@ -11,9 +11,9 @@ import {
11
11
  sumEmbeddedListColumn,
12
12
  } from "@cosmicdrift/kumiko-headless";
13
13
  import { type ReactNode, useState } from "react";
14
- import { toKebab } from "../app/qn";
15
14
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
16
15
  import { useQuery } from "../hooks/use-query";
16
+ import { referenceLookupSource } from "../hooks/use-reference-lookup";
17
17
  import { useTranslation } from "../i18n";
18
18
  import type { EmbeddedListColumn, EmbeddedListTotal } from "../primitives";
19
19
  import { usePrimitives } from "../primitives";
@@ -148,15 +148,21 @@ export function EmbeddedListField({
148
148
  const derived = field.embeddedListDerived;
149
149
 
150
150
  const referenceCells = cells.filter((c) => c.type === "reference");
151
- const referenceQueries = referenceCells.map((cell) => {
152
- const refFeature = cell.refFeature ?? featureName;
153
- const refEntity = cell.refEntity ?? "";
154
- const qn = cell.refOptionsQuery ?? `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
151
+ const referenceSources = referenceCells.map((cell) =>
152
+ cell.refOptionsQuery !== undefined
153
+ ? { queryQn: cell.refOptionsQuery, labelKey: "label" }
154
+ : referenceLookupSource(
155
+ cell.refFeature ?? featureName,
156
+ cell.refEntity ?? "",
157
+ cell.refLabelField ?? "id",
158
+ ),
159
+ );
160
+ const referenceQueries = referenceSources.map((source) =>
155
161
  // biome-ignore lint/correctness/useHookAtTopLevel: referenceCells comes from the entity-schema definition — fixed for the screen's lifetime, not a real conditional-hook risk.
156
- return useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(qn, {
162
+ useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(source.queryQn, {
157
163
  limit: REFERENCE_COMBOBOX_LIMIT,
158
- });
159
- });
164
+ }),
165
+ );
160
166
 
161
167
  if (EmbeddedListInput === undefined) return null;
162
168
 
@@ -165,7 +171,7 @@ export function EmbeddedListField({
165
171
  if (cell.type === "reference") {
166
172
  const idx = referenceCells.indexOf(cell);
167
173
  const query = referenceQueries[idx];
168
- const labelKey = cell.refOptionsQuery !== undefined ? "label" : (cell.refLabelField ?? "id");
174
+ const labelKey = referenceSources[idx]?.labelKey ?? "id";
169
175
  const referenceOptions = (query?.data?.rows ?? []).map((row) => ({
170
176
  value: String(row["id"] ?? ""),
171
177
  label: String(row[labelKey] ?? row["id"] ?? ""),
@@ -109,6 +109,16 @@ function toConditionValue<TValues extends FormValues, TCtx>(
109
109
  return (values: TValues) => evalFieldCondition(cond, values as Record<string, unknown>);
110
110
  }
111
111
 
112
+ // Tab-strip key. The boot-validator makes `id` mandatory on a tabs layout and
113
+ // entityEdit only ever has "fields"/"extension" sections (relatedList and
114
+ // writeForm are rejected outright), so the positional fallback is unreachable
115
+ // for a tabs screen — it only keeps this total for other layouts.
116
+ function tabIdAt(section: EditSectionViewModel | undefined, index: number): string {
117
+ const declared =
118
+ section?.kind === "fields" || section?.kind === "extension" ? section.id : undefined;
119
+ return declared ?? `section-${index}`;
120
+ }
121
+
112
122
  function deriveFormFields<TValues extends FormValues, TCtx>(
113
123
  screen: EntityEditScreenDefinition,
114
124
  ): Record<string, FieldConditions<TValues, TCtx>> {
@@ -285,6 +295,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
285
295
  const dispatcher = useOptionalDispatcher();
286
296
 
287
297
  const isWizard = screen.layout.mode === "wizard";
298
+ // Tabs reuse the wizard's step machinery: one section visible at a time,
299
+ // all of them mounted, `currentStep` as the active index. What they do NOT
300
+ // share is navigation — a tab form submits from any tab, so every
301
+ // step-transition path below stays wizard-only (fw#3134).
302
+ const isTabs = screen.layout.mode === "tabs";
288
303
  const draftEnabled = isWizard && screen.layout.draft === true && dispatcher !== undefined;
289
304
  const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
290
305
  const draftStorage = useDraftStorage();
@@ -350,8 +365,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
350
365
  Progress,
351
366
  StepBar,
352
367
  WizardStepGroup,
368
+ Tabs,
353
369
  } = usePrimitives();
354
370
 
371
+ // Both stepped layouts show one section at a time and keep the rest mounted
372
+ // but inert. A tabs layout on a host without the Tabs primitive has no strip
373
+ // to switch with, so it falls back to rendering every section stacked rather
374
+ // than stranding the user on section 0 (fw#3134).
375
+ const isStepped = isWizard || (isTabs && Tabs !== undefined);
355
376
  const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
356
377
 
357
378
  // Must be computed before submitConfig/useForm bakes it in (the controller
@@ -683,8 +704,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
683
704
  // button, and it can never go dirty either.
684
705
  const isFieldless = Object.keys(entity.fields).length === 0;
685
706
 
686
- // A lone relatedList tab (hideSectionTitles is only ever set by the tabs
687
- // layout, which also narrows filteredSections to that one active section)
707
+ // A lone relatedList tab (`hideSectionTitles` is only ever set by
708
+ // ProjectionDetailBody, which hands this component ONE section — its
709
+ // synthesized screen drops layout.mode, so an entityEdit tabs layout never
710
+ // reaches this line and `filteredSections[0]` stays the active section)
688
711
  // needs its own tab panel capped at the available height so its table
689
712
  // scrolls inside the panel instead of the whole page stretching to the
690
713
  // row count (fw#2722) — a short table still sizes to its content instead
@@ -850,14 +873,16 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
850
873
  }
851
874
  }
852
875
 
853
- // Wizard mode: locates the first step whose "fields" section contains one
854
- // of the given field paths — matched on the path's first segment, since
855
- // `FieldIssue.path`/`snapshot.errors` keys can be dotted (embedded-list
856
- // rows, e.g. `tasks.2.title`) while a section's field name is always the
857
- // plain top-level name. Returns undefined for a root-level issue (path
858
- // `"(root)"`, see zod-bridge.ts) or one that matches no rendered field —
859
- // callers must NOT suppress the error banner in that case, there is
860
- // nowhere to jump the user to.
876
+ // Stepped modes (wizard, tabs): locates the first step whose "fields"
877
+ // section contains one of the given field paths — matched on the path's
878
+ // first segment, since `FieldIssue.path`/`snapshot.errors` keys can be
879
+ // dotted (embedded-list rows, e.g. `tasks.2.title`) while a section's field
880
+ // name is always the plain top-level name. Returns undefined for a
881
+ // root-level issue (path `"(root)"`, see zod-bridge.ts) or one that matches
882
+ // no rendered field — callers must NOT suppress the error banner in that
883
+ // case, there is nowhere to jump the user to. Extension sections are not
884
+ // jump targets by design: they validate through the controlled-mode
885
+ // controls.validate() API and own no field specs to match against.
861
886
  function findFirstErroringStep(fieldPaths: readonly string[]): number | undefined {
862
887
  const erroredFields = new Set(fieldPaths.map((p) => p.split(".")[0]));
863
888
  const idx = filteredSections.findIndex(
@@ -956,18 +981,21 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
956
981
  // Root-level `.refine()`/cross-field issues from controller.validate()
957
982
  // — same "jump to the erroring step" treatment as a server field
958
983
  // error below, sourced from the freshly-validated snapshot.
959
- if (isWizard) {
984
+ if (isStepped) {
960
985
  const step = findFirstErroringStep(Object.keys(controller.getSnapshot().errors));
961
986
  if (step !== undefined) setRawStep(step);
962
987
  }
963
988
  } else {
964
989
  const fieldIssues = result.error.details?.fields ?? [];
965
- // A server field error on a step the wizard isn't currently showing
966
- // is otherwise invisible — jump to the first step that contains one
967
- // of the errored fields. Non-wizard forms render every field at
968
- // once, so the original suppress-when-field-issues-exist rule still
969
- // applies there (the field itself already shows the error inline).
970
- if (isWizard) {
990
+ // A server field error on a step the form isn't currently showing is
991
+ // otherwise invisible — jump to the first step that contains one of
992
+ // the errored fields. For tabs this is what keeps the submit from
993
+ // blocking in silence, the failure mode the boot-validator used to
994
+ // prevent by rejecting the layout outright (fw#3134). Single-section
995
+ // forms render every field at once, so the original
996
+ // suppress-when-field-issues-exist rule still applies there (the
997
+ // field itself already shows the error inline).
998
+ if (isStepped) {
971
999
  const step = findFirstErroringStep(fieldIssues.map((i) => i.path));
972
1000
  if (step !== undefined) {
973
1001
  setRawStep(step);
@@ -1293,18 +1321,44 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1293
1321
  })()}
1294
1322
  </>
1295
1323
  )}
1324
+ {isTabs &&
1325
+ (() => {
1326
+ // No Tabs primitive registered → every section renders stacked
1327
+ // (stepHidden below is gated on the same condition), which is the
1328
+ // single-layout behaviour. Degrading to one long form beats
1329
+ // hiding sections behind a strip that never drew.
1330
+ if (Tabs === undefined) return null;
1331
+ return (
1332
+ <Tabs
1333
+ testId="render-edit-tabs"
1334
+ items={filteredSections.map((section, index) => ({
1335
+ id: tabIdAt(section, index),
1336
+ label: section.title ?? "",
1337
+ }))}
1338
+ activeId={tabIdAt(filteredSections[currentStep], currentStep)}
1339
+ onSelect={(id) => {
1340
+ const index = filteredSections.findIndex((s, i) => tabIdAt(s, i) === id);
1341
+ // skip: the strip reported an id no section owns.
1342
+ if (index === -1) return;
1343
+ setRawStep(index);
1344
+ }}
1345
+ />
1346
+ );
1347
+ })()}
1296
1348
  {filteredSections.map((section: EditSectionViewModel, sectionIndex: number) => {
1297
- // Wizard steps stay mounted while off-screen (native `hidden`, not
1298
- // unmounted) so an extension section's submit-registry entry
1299
- // (useExtensionFormSubmit → registry.remove on unmount) survives
1300
- // navigating past its step — otherwise Finish only ran the last
1301
- // mounted step's handler and silently dropped earlier steps' writes.
1302
- const stepHidden = isWizard && sectionIndex !== currentStep;
1349
+ // Wizard steps and tabs stay mounted while off-screen (native
1350
+ // `hidden`, not unmounted) so an extension section's submit-registry
1351
+ // entry (useExtensionFormSubmit → registry.remove on unmount)
1352
+ // survives navigating past its step — otherwise Finish only ran the
1353
+ // last mounted step's handler and silently dropped earlier steps'
1354
+ // writes. For tabs this is also what makes ONE submit cover every
1355
+ // tab, including tabs the user never opened (fw#3134).
1356
+ const stepHidden = isStepped && sectionIndex !== currentStep;
1303
1357
  const wrapWizardStep = (key: string, el: ReactNode): ReactNode => {
1304
- if (!isWizard) return el;
1358
+ if (!isStepped) return el;
1305
1359
  if (WizardStepGroup === undefined) {
1306
1360
  throw new Error(
1307
- "RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
1361
+ `RenderEdit: ${screen.layout.mode} layout requires primitives.WizardStepGroup, but none is registered.`,
1308
1362
  );
1309
1363
  }
1310
1364
  return (
@@ -1446,8 +1500,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1446
1500
  return wrapWizardStep(sectionKey, cardEl);
1447
1501
  }
1448
1502
  // Suppress the section header when it would just repeat the form
1449
- // title verbatim (typical for single-section actionForms).
1450
- const sectionTitle = section.title === formTitle ? undefined : section.title;
1503
+ // title verbatim (typical for single-section actionForms), or the
1504
+ // tab label right above it. Without a Tabs primitive the sections
1505
+ // render stacked, and then the titles are what tells them apart.
1506
+ const repeatsTabLabel = isTabs && isStepped;
1507
+ const sectionTitle =
1508
+ section.title === formTitle || repeatsTabLabel ? undefined : section.title;
1451
1509
  const sectionEl = (
1452
1510
  <Section
1453
1511
  key={sectionKey}
@@ -20,6 +20,7 @@ import { screenAccessAllows } from "../app/screen-access";
20
20
  import { useUserRoles } from "../context/user-roles-context";
21
21
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
22
22
  import { useQuery } from "../hooks/use-query";
23
+ import { referenceLookupSource } from "../hooks/use-reference-lookup";
23
24
  import { useLocale, useTranslation } from "../i18n";
24
25
  import { usePrimitives } from "../primitives";
25
26
  import { EmbeddedListField } from "./embedded-list-field";
@@ -179,10 +180,7 @@ function referenceOptionSource(
179
180
  if (field.refOptionsQuery !== undefined) {
180
181
  return { queryQn: field.refOptionsQuery, labelKey: "label" };
181
182
  }
182
- return {
183
- queryQn: `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`,
184
- labelKey: field.refLabelField ?? "id",
185
- };
183
+ return referenceLookupSource(refFeature, refEntity, field.refLabelField ?? "id");
186
184
  }
187
185
 
188
186
  // Tier 2.7e-3 + 2.1c: Reference-Input rendert eine Searchable Combobox
@@ -17,7 +17,10 @@
17
17
  // internen Cache shared zwischen List + Edit-Form für die gleiche
18
18
  // Entity, Live-Updates kommen via SSE (use-query-live).
19
19
 
20
- import { SYSTEM_REFERENCE_LABELS } from "@cosmicdrift/kumiko-framework/ui-types";
20
+ import {
21
+ REFERENCE_LOOKUP_SOURCES,
22
+ SYSTEM_REFERENCE_LABELS,
23
+ } from "@cosmicdrift/kumiko-framework/ui-types";
21
24
  import { useMemo } from "react";
22
25
  import { toKebab } from "../app/qn";
23
26
  import { useTranslation } from "../i18n";
@@ -26,19 +29,30 @@ import { useQuery } from "./use-query";
26
29
 
27
30
  export type ReferenceLookupMap = ReadonlyMap<string, string>;
28
31
 
29
- /** Bulk-Lookup für eine einzelne Reference-Spalte. Liefert eine Map
30
- * von UUID → Display-Value (aus labelField). Während die Query lädt,
31
- * ist die Map leer; der Caller fällt dann auf den UUID-Fallback.
32
- *
33
- * `featureName` ist hier das **target**-Feature (refFeature aus
34
- * ViewModel), nicht das current Feature — Cross-Feature-Refs lookup
35
- * laufen damit gegen `<refFeature>:query:<refEntity>:list`. */
32
+ /** Where a reference target's label rows come from: the central override for
33
+ * that `feature:entity` if one exists, else the entity-convention list
34
+ * handler plus the caller's own label field. */
35
+ export function referenceLookupSource(
36
+ refFeature: string,
37
+ refEntity: string,
38
+ labelField: string,
39
+ ): { readonly queryQn: string; readonly labelKey: string } {
40
+ const override = REFERENCE_LOOKUP_SOURCES[`${refFeature}:${refEntity}`];
41
+ if (override !== undefined) return override;
42
+ return {
43
+ queryQn: `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`,
44
+ labelKey: labelField,
45
+ };
46
+ }
47
+
48
+ /** Empty while the query loads (or fails), so callers fall back to the raw id.
49
+ * `featureName` is the reference's target feature, not the screen's. */
36
50
  export function useReferenceLookup(
37
51
  featureName: string,
38
52
  refEntity: string,
39
53
  labelField: string,
40
54
  ): { readonly map: ReferenceLookupMap; readonly loading: boolean } {
41
- const queryQn = `${toKebab(featureName)}:query:${toKebab(refEntity)}:list`;
55
+ const { queryQn, labelKey } = referenceLookupSource(featureName, refEntity, labelField);
42
56
  const result = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(queryQn, {
43
57
  limit: REFERENCE_LIST_LOOKUP_LIMIT,
44
58
  });
@@ -50,13 +64,13 @@ export function useReferenceLookup(
50
64
  const id = row["id"];
51
65
  if (id === undefined || id === null) continue;
52
66
  const idStr = String(id);
53
- const label = row[labelField] ?? id;
67
+ const label = row[labelKey] ?? id;
54
68
  out.set(idStr, String(label));
55
69
  }
56
70
  // System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row, so
57
71
  // the bulk lookup above never covers them — inject the label directly.
58
72
  if (systemLabel !== undefined) out.set(systemLabel.id, translate(systemLabel.labelKey));
59
73
  return out;
60
- }, [result.data, labelField, systemLabel, translate]);
74
+ }, [result.data, labelKey, systemLabel, translate]);
61
75
  return { map, loading: result.loading };
62
76
  }