@cosmicdrift/kumiko-renderer 0.353.0 → 0.354.1

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.
@@ -2,4 +2,5 @@ import type { EntityDefinition, EntityEditScreenDefinition } from "@cosmicdrift/
2
2
  import * as z from "zod";
3
3
  export declare function isPresent(value: unknown): boolean;
4
4
  export declare const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
5
+ export declare const OPTION_NOT_AVAILABLE_I18N_KEY = "kumiko.validation.optionNotAvailable";
5
6
  export declare function buildFormSchema(entity: EntityDefinition, screen: EntityEditScreenDefinition, unchangedFrom?: Readonly<Record<string, unknown>>): z.ZodType;
@@ -1,4 +1,4 @@
1
- import { evalFieldCondition, NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import { evalFieldCondition, findUnavailableSelectOptions, NO_WIDGET_FIELD_TYPES, } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
3
3
  import * as z from "zod";
4
4
  import { layoutEditFields } from "./layout-fields.js";
@@ -16,6 +16,7 @@ function isEmbeddedListField(field) {
16
16
  }
17
17
  // Renders raw to the user without a de+en default in i18n-defaults.ts.
18
18
  export const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
19
+ export const OPTION_NOT_AVAILABLE_I18N_KEY = "kumiko.validation.optionNotAvailable";
19
20
  // Client-side presence validation for the auto-wired entityEdit path —
20
21
  // checks that every rendered required field HAS a value, not that the
21
22
  // value has the right shape. Format/range/type validation stays server-
@@ -54,6 +55,7 @@ export function buildFormSchema(entity, screen, unchangedFrom) {
54
55
  // its keys — the runtime object always carries every form field.
55
56
  // @cast-boundary form-values
56
57
  const record = values;
58
+ const selectFieldsToCheck = {};
57
59
  for (const spec of fields) {
58
60
  const field = entity.fields[spec.field];
59
61
  if (!field)
@@ -62,6 +64,7 @@ export function buildFormSchema(entity, screen, unchangedFrom) {
62
64
  // same reason as the NO_WIDGET_FIELD_TYPES check below.
63
65
  if (spec.readOnly !== undefined && evalFieldCondition(spec.readOnly, record))
64
66
  continue;
67
+ selectFieldsToCheck[spec.field] = field;
65
68
  // Screen-spec `required` overrides the entity default, mirroring
66
69
  // `view-model/edit.ts` — the rendered form is the reference, and a
67
70
  // presence check stricter than the form blocks the user for nothing.
@@ -90,5 +93,15 @@ export function buildFormSchema(entity, screen, unchangedFrom) {
90
93
  params: { [I18N_KEY_PARAM]: REQUIRED_FIELD_I18N_KEY },
91
94
  });
92
95
  }
96
+ // Against `unchangedFrom` the helper still checks a stored value whose
97
+ // discriminator changed — the server validates the merged row.
98
+ for (const issue of findUnavailableSelectOptions(selectFieldsToCheck, record, unchangedFrom)) {
99
+ ctx.addIssue({
100
+ code: "custom",
101
+ path: [issue.field],
102
+ message: `"${issue.field}" is not available for the current selection.`,
103
+ params: { [I18N_KEY_PARAM]: OPTION_NOT_AVAILABLE_I18N_KEY, allowed: issue.allowed },
104
+ });
105
+ }
93
106
  });
94
107
  }
@@ -22,9 +22,10 @@ export declare function KumikoScreen({ schema, qn, translate, entityId, onRowCli
22
22
  export declare function buildInitialValues(fields: Readonly<Record<string, unknown>>, defaultCurrency?: string, moneyCurrencyOverrides?: Readonly<Record<string, string>>): Readonly<Record<string, unknown>>;
23
23
  export declare function tenantCurrencyMoneyFieldNames(fields: Readonly<Record<string, unknown>>): readonly string[];
24
24
  export declare function literalCurrencyOverrides(fields: Readonly<Record<string, unknown>>): Readonly<Record<string, string>>;
25
- export declare function useMoneyCurrencyOverrides(fields: Readonly<Record<string, unknown>>, tenantFieldNames: readonly string[], fallback: string): {
25
+ export declare function useMoneyCurrencyOverrides(fields: Readonly<Record<string, unknown>>, tenantFieldNames: readonly string[], fallback: string | undefined): {
26
26
  readonly overrides: Readonly<Record<string, string>> | undefined;
27
27
  readonly loading: boolean;
28
+ readonly unresolved: boolean;
28
29
  };
29
30
  export type InitialValueSources = {
30
31
  readonly searchParams: Readonly<Record<string, string>>;
@@ -30,7 +30,7 @@ import { useDashboardBody } from "./dashboard-body.js";
30
30
  import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
31
31
  import { FacetCountBridges } from "./facet-count-bridge.js";
32
32
  import { buildFormSchema } from "./form-schema.js";
33
- import { layoutFieldNames } from "./layout-fields.js";
33
+ import { hiddenRequiredFieldsFilled, layoutFieldNames } from "./layout-fields.js";
34
34
  import { buildDateRangePayload, buildEntityFilterFacets, buildFacetCountRequests, buildFilterFacets, buildFilterPayload, clampDateRange, mergeReferenceFacetOptions, normalizeDefaultFilters, readDateRange, resolveDateRangeFacets, resolveProjectionFacetSpecs, } from "./list-facets.js";
35
35
  import { useInitialValuesHandoff, useNav } from "./nav.js";
36
36
  import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } from "./projection-detail-shim.js";
@@ -202,12 +202,16 @@ function pageSizeOptionsFor(currentLimit) {
202
202
  ? PAGE_SIZE_OPTIONS
203
203
  : [...PAGE_SIZE_OPTIONS, currentLimit].sort((a, b) => a - b);
204
204
  }
205
- function useNavigateToCreateFor(schema, entityName, createScreen) {
205
+ function useNavigateToCreateFor(schema, entityName, createScreen, createUnavailable) {
206
206
  const nav = useNav();
207
207
  const host = useReturnHost();
208
208
  const editScreenId = useMemo(() => {
209
209
  if (createScreen !== undefined)
210
210
  return createScreen;
211
+ // The role may not open the declared create target; falling back to the
212
+ // entity's edit form would bypass the flow the app prescribes (e.g. a wizard).
213
+ if (createUnavailable === true)
214
+ return undefined;
211
215
  // allowCreate:false = update-only Edit-Screen (Create läuft über einen
212
216
  // Lifecycle-Write) — der zählt nicht als „+ Neu"-Ziel. singleton:true
213
217
  // öffnet ohne entityId den vorhandenen Record (EntityEditSingletonBody)
@@ -217,7 +221,7 @@ function useNavigateToCreateFor(schema, entityName, createScreen) {
217
221
  s.allowCreate !== false &&
218
222
  s.singleton !== true);
219
223
  return edit !== undefined ? lastSegment(edit.id) : undefined;
220
- }, [schema.screens, entityName, createScreen]);
224
+ }, [schema.screens, entityName, createScreen, createUnavailable]);
221
225
  const navigate = useCallback(() => {
222
226
  if (editScreenId !== undefined)
223
227
  navigateWithReturnTo(nav, { screenId: editScreenId }, host);
@@ -283,17 +287,20 @@ export function tenantCurrencyMoneyFieldNames(fields) {
283
287
  function resolveTenantCurrency(fieldNames, query, fallback) {
284
288
  if (fieldNames.length === 0)
285
289
  return { status: "not-needed" };
286
- if (query.error)
287
- return { status: "ready", currency: fallback };
290
+ if (query.error) {
291
+ return fallback === undefined
292
+ ? { status: "unresolved" }
293
+ : { status: "ready", currency: fallback };
294
+ }
288
295
  if (query.data === null)
289
296
  return { status: "loading" };
290
297
  const raw = query.data[TENANT_CURRENCY_CONFIG_KEY]?.value;
291
- return { status: "ready", currency: typeof raw === "string" && raw !== "" ? raw : fallback };
298
+ if (typeof raw === "string" && raw !== "")
299
+ return { status: "ready", currency: raw };
300
+ return fallback === undefined
301
+ ? { status: "unresolved" }
302
+ : { status: "ready", currency: fallback };
292
303
  }
293
- // An entity-less form screen has no `entity.defaultCurrency` to fall back on,
294
- // so a tenant-declared field whose config query errors or returns no value
295
- // lands here — same last resort as the entityEdit path's `?? "EUR"`.
296
- const ACTION_FORM_CURRENCY_FALLBACK = "EUR";
297
304
  const HEADER_CARD_DEFAULT_WIDTH = "4xl";
298
305
  // Shared by the update payload shape and the server-error path prefix so both can't drift.
299
306
  const UPDATE_CHANGES_KEY = "changes";
@@ -318,7 +325,10 @@ export function literalCurrencyOverrides(fields) {
318
325
  // on a fast click submit, the wrong currency. Callers pass the tenant field
319
326
  // names themselves because an edit form only needs the fetch for fields the
320
327
  // record has no stored value for.
321
- export function useMoneyCurrencyOverrides(fields, tenantFieldNames, fallback) {
328
+ export function useMoneyCurrencyOverrides(fields, tenantFieldNames,
329
+ // `undefined` = no safe last resort (entity-less forms): an unresolvable
330
+ // tenant currency is reported via `unresolved` instead of guessing a code.
331
+ fallback) {
322
332
  const query = useQuery("config:query:values", { keys: [TENANT_CURRENCY_CONFIG_KEY] }, { enabled: tenantFieldNames.length > 0 });
323
333
  const resolution = resolveTenantCurrency(tenantFieldNames, query, fallback);
324
334
  const resolvedTenantCurrency = resolution.status === "ready" ? resolution.currency : undefined;
@@ -330,7 +340,11 @@ export function useMoneyCurrencyOverrides(fields, tenantFieldNames, fallback) {
330
340
  }
331
341
  return Object.keys(out).length > 0 ? out : undefined;
332
342
  }, [fields, tenantFieldNames, resolvedTenantCurrency]);
333
- return { overrides, loading: resolution.status === "loading" };
343
+ return {
344
+ overrides,
345
+ loading: resolution.status === "loading",
346
+ unresolved: resolution.status === "unresolved",
347
+ };
334
348
  }
335
349
  function multiSelectOptionValues(shape) {
336
350
  if (shape.options === undefined || shape.options.length === 0)
@@ -822,6 +836,9 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
822
836
  onReload,
823
837
  screen.entity,
824
838
  ]);
839
+ const sameEntityScreenId = useCallback((targetScreen) => isEntityEditScreenOfOtherEntity(screen.entity, targetScreen, schema, appFeatures)
840
+ ? undefined
841
+ : entityId, [screen.entity, schema, appFeatures, entityId]);
825
842
  const buildSectionActions = useCallback((actions) => buildRecordActions({
826
843
  actions,
827
844
  record,
@@ -832,7 +849,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
832
849
  openDrawer,
833
850
  onWriteSuccess: onReload,
834
851
  onRecordLeft: handleRecordLeft,
835
- defaultScreenTargetEntityId: entityId,
852
+ sameEntityScreenId,
836
853
  defaultWritePayloadId: entityId,
837
854
  }), [
838
855
  record,
@@ -843,6 +860,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
843
860
  openDrawer,
844
861
  onReload,
845
862
  handleRecordLeft,
863
+ sameEntityScreenId,
846
864
  entityId,
847
865
  ]);
848
866
  const headerActions = useMemo(() => buildSectionActions(screen.actions ?? []), [buildSectionActions, screen.actions]);
@@ -1105,7 +1123,7 @@ function EntityListScreen({ schema, screen, translate, onRowClick, }) {
1105
1123
  function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1106
1124
  const isEmbeddedScreen = useIsEmbeddedScreen();
1107
1125
  const featureName = schema.featureName;
1108
- const onCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen);
1126
+ const onCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen, screen.createUnavailable);
1109
1127
  const { Banner } = usePrimitives();
1110
1128
  const queryType = entityQueryCommand(featureName, screen.entity, "list");
1111
1129
  const nav = useNav();
@@ -1322,9 +1340,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1322
1340
  // Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
1323
1341
  // entityId-Feldname gesetzt ist. Nur für Targets DERSELBEN Entity — sonst
1324
1342
  // bekäme ein Cross-Entity-Edit-Screen die falsche row.id injiziert.
1325
- const targetIsEntityEdit = schema.screens.some((s) => s.type === "entityEdit" &&
1326
- s.entity === screen.entity &&
1327
- lastSegment(s.id) === action.screen);
1343
+ const targetIsEntityEdit = isEntityEditScreenOfEntity(screen.entity, action.screen, schema, appFeatures);
1328
1344
  const explicit = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1329
1345
  const fallback = targetIsEntityEdit ? String(row.values["id"] ?? "") : undefined;
1330
1346
  const entityId = explicit ?? fallback;
@@ -1339,7 +1355,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1339
1355
  const params = navigateActionSearchParams(action, row.values);
1340
1356
  navigateWithReturnTo(nav, target, host, params);
1341
1357
  }
1342
- }, [nav, schema.screens, screen.entity, host]);
1358
+ }, [nav, schema, appFeatures, screen.entity, host]);
1343
1359
  const rowActions = useMemo(() => {
1344
1360
  const declared = screen.rowActions ?? [];
1345
1361
  // Prepended unless a declared rowAction already has id "edit" — declared wins.
@@ -2230,6 +2246,20 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2230
2246
  function redirectScreenTarget(redirect) {
2231
2247
  return typeof redirect === "string" ? redirect : redirect.screen;
2232
2248
  }
2249
+ // Same-entity detection for navigate defaults: only an entityEdit screen of the
2250
+ // SAME entity may receive the current record's id (it opens in UPDATE mode, which
2251
+ // is what the boot validator assumes). Searched across all features.
2252
+ function isEntityEditScreenOfEntity(entity, targetScreen, schema, appFeatures) {
2253
+ if (entity === undefined)
2254
+ return false;
2255
+ return [schema, ...appFeatures].some((feature) => feature.screens.some((s) => s.type === "entityEdit" && s.entity === entity && lastSegment(s.id) === targetScreen));
2256
+ }
2257
+ // Record-scoped targets (actionForm, secretMint) take the shown record's id; an
2258
+ // entityEdit of ANOTHER entity must not, or it would open that entity's UPDATE
2259
+ // mode on the wrong id instead of create.
2260
+ function isEntityEditScreenOfOtherEntity(entity, targetScreen, schema, appFeatures) {
2261
+ return [schema, ...appFeatures].some((feature) => feature.screens.some((s) => s.type === "entityEdit" && s.entity !== entity && lastSegment(s.id) === targetScreen));
2262
+ }
2233
2263
  // A qualified redirect names its feature, so the search space is THAT feature
2234
2264
  // only — the current schema's own screens must not shadow it with a same
2235
2265
  // short-id screen of another type. An unqualified redirect searches this
@@ -2331,11 +2361,11 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2331
2361
  const handoffValues = onSuccess === undefined ? pendingHandoff : undefined;
2332
2362
  // This screen has no entity, so a money field here declares its own currency
2333
2363
  // source (fw#2839, boot-enforced) — without one the form would seed a bare
2334
- // `0` the handler's schema rejects. ACTION_FORM_CURRENCY_FALLBACK only
2335
- // applies when a tenant-declared field's config query errors or holds no
2336
- // value, matching the entityEdit path's behaviour (fw#2937).
2364
+ // `0` the handler's schema rejects. When a tenant-declared field's config
2365
+ // query errors or holds no value there is no entity default to use, and
2366
+ // submitting a guessed currency would mis-book the amount — block instead.
2337
2367
  const tenantCurrencyFieldNames = useMemo(() => tenantCurrencyMoneyFieldNames(screen.fields), [screen.fields]);
2338
- const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(screen.fields, tenantCurrencyFieldNames, ACTION_FORM_CURRENCY_FALLBACK);
2368
+ const { overrides: moneyCurrencyOverrides, loading: currencyLoading, unresolved: currencyUnresolved, } = useMoneyCurrencyOverrides(screen.fields, tenantCurrencyFieldNames, undefined);
2339
2369
  const initial = useMemo(() => mergeSearchParamsIntoInitial(screen.fields, {
2340
2370
  searchParams: nav.searchParams,
2341
2371
  urlPrefillFields: screen.urlPrefillFields,
@@ -2424,12 +2454,17 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2424
2454
  if (currencyLoading) {
2425
2455
  return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2426
2456
  }
2457
+ if (currencyUnresolved) {
2458
+ return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-currency-unresolved", children: "The tenant currency could not be loaded, so this form cannot be submitted." }));
2459
+ }
2427
2460
  return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial,
2428
2461
  // Extension-Sections im actionForm sehen die initialen Form-Values
2429
2462
  // (inkl. searchParams-Prefill, z.B. ?incidentId=…) als initialValues
2430
2463
  // — anders als im entityEdit gibt es hier keinen record, aus dem
2431
2464
  // sie Kontext ziehen könnten.
2432
- extensionInitialValues: initial, ...(successText !== undefined && {
2465
+ extensionInitialValues: initial, ...(screen.submitPrefilled === true && {
2466
+ submitWithoutChanges: hiddenRequiredFieldsFilled(synthScreen, screen.fields, initial),
2467
+ }), ...(successText !== undefined && {
2433
2468
  leadContent: (_jsx(Banner, { variant: "info", testId: "action-form-success", children: successText })),
2434
2469
  }), writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(onDirtyChange !== undefined && { onDirtyChange }), ...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true }), ...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
2435
2470
  submitLabel: screen.submitLabel ?? submitLabelFallback,
@@ -1,3 +1,4 @@
1
- import type { EditFieldSpec, EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { EditFieldSpec, EntityEditScreenDefinition, SecretMintScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  export declare function layoutEditFields(screen: EntityEditScreenDefinition): readonly Exclude<EditFieldSpec, string>[];
3
3
  export declare function layoutFieldNames(screen: EntityEditScreenDefinition): ReadonlySet<string>;
4
+ export declare function hiddenRequiredFieldsFilled(screen: EntityEditScreenDefinition, fields: SecretMintScreenDefinition["fields"], initial: Readonly<Record<string, unknown>>): boolean;
@@ -18,3 +18,18 @@ export function layoutEditFields(screen) {
18
18
  export function layoutFieldNames(screen) {
19
19
  return new Set(layoutEditFields(screen).map((spec) => spec.field));
20
20
  }
21
+ function isRequiredField(field) {
22
+ return field !== undefined && "required" in field && field.required === true;
23
+ }
24
+ function hasValue(value) {
25
+ if (value === undefined || value === null || value === "")
26
+ return false;
27
+ return !(Array.isArray(value) && value.length === 0);
28
+ }
29
+ // A layout-hidden field can never be edited, so a required one without a seeded
30
+ // value would leave the form permanently unsubmittable.
31
+ export function hiddenRequiredFieldsFilled(screen, fields, initial) {
32
+ return layoutEditFields(screen).every((spec) => spec.visible !== false ||
33
+ !isRequiredField(fields[spec.field]) ||
34
+ hasValue(initial[spec.field]));
35
+ }
@@ -5,7 +5,7 @@ import { useTranslation } from "../i18n.js";
5
5
  import { usePrimitives } from "../primitives.js";
6
6
  import { synthesizeActionFormEntity, synthesizeActionFormScreen, synthesizeSecretMintConfirmScreen, } from "./action-form-shim.js";
7
7
  import { buildInitialValues, mergeSearchParamsIntoInitial, tenantCurrencyMoneyFieldNames, useMoneyCurrencyOverrides, } from "./kumiko-screen.js";
8
- import { layoutFieldNames } from "./layout-fields.js";
8
+ import { hiddenRequiredFieldsFilled, layoutFieldNames } from "./layout-fields.js";
9
9
  import { useInitialValuesHandoff, useNav } from "./nav.js";
10
10
  import { lastSegment } from "./qn.js";
11
11
  import { navigateToReturnOr, useReturnTarget } from "./return-to.js";
@@ -193,5 +193,7 @@ export function SecretMintBody({ schema, screen, translate }) {
193
193
  if (mintCurrencyLoading) {
194
194
  return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
195
195
  }
196
- return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, extensionInitialValues: initial, writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, fillScreenHeight: true, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) }, "mint"));
196
+ return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, extensionInitialValues: initial, ...(screen.submitPrefilled === true && {
197
+ submitWithoutChanges: hiddenRequiredFieldsFilled(synthScreen, screen.fields, initial),
198
+ }), writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, fillScreenHeight: true, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) }, "mint"));
197
199
  }
@@ -76,6 +76,9 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
76
76
  * "Save" can be replaced by domain-specific strings ("Approve" /
77
77
  * "Dispatch" / etc.). */
78
78
  readonly submitLabel?: string;
79
+ /** Set by submitPrefilled screens: defined shows the submit button even when no field is
80
+ * editable (all hidden or read-only); `true` also enables it while the form is unchanged. */
81
+ readonly submitWithoutChanges?: boolean;
79
82
  /** Visual style of the submit button (actionForm `submitStyle`). Default "primary". */
80
83
  readonly submitVariant?: "primary" | "danger";
81
84
  /** Extra footer buttons (actionForm `footerActions`): each sets its `patch`
@@ -241,4 +244,9 @@ export type RenderEditControls<TValues extends FormValues> = {
241
244
  * guard — a host showing a pre-filled proposal must be able to accept it
242
245
  * untouched. */
243
246
  readonly submit: () => Promise<void>;
247
+ /** Wizard only (no-op elsewhere): validates the current step and advances,
248
+ * like the built-in Next button. A `hideActions` host steps with this. */
249
+ readonly next: () => void;
250
+ /** Wizard only (no-op elsewhere): goes back one step without validating. */
251
+ readonly back: () => void;
244
252
  };
@@ -223,7 +223,7 @@ function resolveTitleTemplate(template, values, translate, fallback) {
223
223
  return translate(template, values);
224
224
  }
225
225
  export function RenderEdit(props) {
226
- const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, } = props;
226
+ const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, } = props;
227
227
  const i18nScreenId = props.i18nScreenId ?? screen.id;
228
228
  const { customSubmit } = props;
229
229
  // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
@@ -398,6 +398,8 @@ export function RenderEdit(props) {
398
398
  // snapshot/extensionDirty, while the onControlsReady effect fires once per
399
399
  // mount — the ref lets controls.submit() always reach the current one.
400
400
  const handleSubmitRef = useRef(async () => { });
401
+ const wizardNextRef = useRef(() => { });
402
+ const wizardBackRef = useRef(() => { });
401
403
  const currentStepRef = useRef(0);
402
404
  const draftSaveTimerRef = useRef(null);
403
405
  useEffect(() => {
@@ -452,7 +454,11 @@ export function RenderEdit(props) {
452
454
  patch: patchAndScheduleDraftSave,
453
455
  validate: scopedValidate,
454
456
  getValues: () => controller.getSnapshot().values,
455
- submit: () => handleSubmitRef.current(),
457
+ // saveNow: a host-driven submit must save on a wizard's intermediate
458
+ // steps too instead of silently advancing; stepping is next()/back().
459
+ submit: () => handleSubmitRef.current({ saveNow: true }),
460
+ next: () => wizardNextRef.current(),
461
+ back: () => wizardBackRef.current(),
456
462
  });
457
463
  // Dep on boolean presence only — inline-arrow identity must not redeliver.
458
464
  }, [hasControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
@@ -1011,6 +1017,8 @@ export function RenderEdit(props) {
1011
1017
  }
1012
1018
  }
1013
1019
  handleSubmitRef.current = handleSubmit;
1020
+ wizardNextRef.current = isWizard ? handleWizardNext : () => { };
1021
+ wizardBackRef.current = isWizard ? handleWizardBack : () => { };
1014
1022
  // Screen forms (entityEdit/actionForm filling the shell height) drop the card;
1015
1023
  // the title then lives in the shell header when the shell offers a slot.
1016
1024
  const isScreenForm = fillScreenHeight === true && hideSectionTitles !== true && !insideDrawer;
@@ -1089,7 +1097,10 @@ export function RenderEdit(props) {
1089
1097
  // draw a no-op Save. Extension registrations still get their Save, since
1090
1098
  // persistExtensions() runs on the customSubmit path too.
1091
1099
  const isFieldlessSubmitForm = isFieldless && screen.entity !== PROJECTION_DETAIL_ENTITY;
1092
- const showsSubmit = isFormEditable || hasExtensionRegistrations || isFieldlessSubmitForm;
1100
+ const showsSubmit = isFormEditable ||
1101
+ hasExtensionRegistrations ||
1102
+ isFieldlessSubmitForm ||
1103
+ submitWithoutChanges !== undefined;
1093
1104
  const footerSlot = screen.slots?.footer;
1094
1105
  // An unregistered footer component renders null, so only a resolvable one may
1095
1106
  // count as an action (else read-only screens get an empty footer strip).
@@ -1131,7 +1142,11 @@ export function RenderEdit(props) {
1131
1142
  footerActions?.map((action) => (_jsx(Button, { type: "button", variant: action.variant ?? "secondary", ...(action.icon !== undefined && { icon: action.icon }), disabled: isSubmitting || disabled, onClick: () => {
1132
1143
  controller.setValues(action.patch); // @cast-boundary form-values
1133
1144
  void handleSubmit();
1134
- }, testId: `render-edit-footer-action-${action.id}`, children: translate(action.label) }, action.id))), hasDirtyFooter && showsSubmit && unsavedCount !== undefined && unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => controller.reset(), testId: "render-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.form.discard") })), showSubmit && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged && !extensionDirty && !isFieldlessSubmitForm && !insideDrawer) ||
1145
+ }, testId: `render-edit-footer-action-${action.id}`, children: translate(action.label) }, action.id))), hasDirtyFooter && showsSubmit && unsavedCount !== undefined && unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => controller.reset(), testId: "render-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.form.discard") })), showSubmit && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged &&
1146
+ !extensionDirty &&
1147
+ !isFieldlessSubmitForm &&
1148
+ !insideDrawer &&
1149
+ submitWithoutChanges !== true) ||
1135
1150
  isSubmitting ||
1136
1151
  disabled, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ??
1137
1152
  (isWizard
@@ -209,6 +209,7 @@ export const kumikoDefaultTranslations = {
209
209
  "kumiko.secretMint.copied": "Copied",
210
210
  "kumiko.secretMint.done": "Done.",
211
211
  "kumiko.validation.required": "Required.",
212
+ "kumiko.validation.optionNotAvailable": "Not available for the current selection.",
212
213
  "kumiko.validation.invalid": "Invalid value.",
213
214
  "kumiko.validation.too-short": "Too short (at least {min} characters).",
214
215
  "kumiko.validation.too-long": "Too long (at most {max} characters).",
package/dist/i18n.d.ts CHANGED
@@ -11,6 +11,9 @@ export type TranslationsByLocale = Readonly<Record<string, TranslationBundle>>;
11
11
  export type TranslationsByKey = Readonly<Record<string, Readonly<Record<string, TranslationValue>>>>;
12
12
  /** Pivot key-first server translations to locale-first client bundles. */
13
13
  export declare function translationsByLocaleFromKeys(source: TranslationsByKey): TranslationsByLocale;
14
+ /** Inverse of `translationsByLocaleFromKeys`: pivot locale-first bundles to the key-first
15
+ * shape `r.translations({ keys })` expects, so one source feeds server and client. */
16
+ export declare function translationsByKeyFromLocales(source: TranslationsByLocale): TranslationsByKey;
14
17
  /** Merged zwei TranslationsByLocale-Maps — der override gewinnt pro Key,
15
18
  * die Locales werden zusammengeführt. Standard-Baustein für Client-
16
19
  * Plugins, die App-Overrides über ihre Default-Bundles legen. */
package/dist/i18n.js CHANGED
@@ -27,6 +27,18 @@ export function translationsByLocaleFromKeys(source) {
27
27
  }
28
28
  return out;
29
29
  }
30
+ /** Inverse of `translationsByLocaleFromKeys`: pivot locale-first bundles to the key-first
31
+ * shape `r.translations({ keys })` expects, so one source feeds server and client. */
32
+ export function translationsByKeyFromLocales(source) {
33
+ const out = {};
34
+ for (const [locale, bundle] of Object.entries(source)) {
35
+ for (const [key, value] of Object.entries(bundle)) {
36
+ out[key] ??= {};
37
+ out[key][locale] = value;
38
+ }
39
+ }
40
+ return out;
41
+ }
30
42
  /** Merged zwei TranslationsByLocale-Maps — der override gewinnt pro Key,
31
43
  * die Locales werden zusammengeführt. Standard-Baustein für Client-
32
44
  * Plugins, die App-Overrides über ihre Default-Bundles legen. */
package/dist/index.d.ts CHANGED
@@ -67,7 +67,7 @@ export { useStore, useStoreSelector } from "./hooks/use-store.js";
67
67
  export type { StreamStatus, UseStreamHandlerOptions, UseStreamHandlerResult, } from "./hooks/use-stream-handler.js";
68
68
  export { useStreamHandler } from "./hooks/use-stream-handler.js";
69
69
  export type { LocaleProviderProps, TranslationBundle, TranslationsByKey, TranslationsByLocale, } from "./i18n.js";
70
- export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.js";
70
+ export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByKeyFromLocales, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.js";
71
71
  export { kumikoDefaultTranslations } from "./i18n-defaults.js";
72
72
  export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer.js";
73
73
  export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.js";
package/dist/index.js CHANGED
@@ -48,7 +48,7 @@ export { entityFromQueryType, useQuery } from "./hooks/use-query.js";
48
48
  export { useReportStepComplete } from "./hooks/use-report-step-complete.js";
49
49
  export { useStore, useStoreSelector } from "./hooks/use-store.js";
50
50
  export { useStreamHandler } from "./hooks/use-stream-handler.js";
51
- export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.js";
51
+ export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByKeyFromLocales, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.js";
52
52
  export { kumikoDefaultTranslations } from "./i18n-defaults.js";
53
53
  export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer.js";
54
54
  export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.js";
@@ -28,7 +28,14 @@ export type FooterActionMarker = {
28
28
  * und sollte mit `disabled` kombiniert werden, wenn die Action wirklich
29
29
  * blockiert bis das Loading durch ist (z.B. async submit). Native-
30
30
  * Impls können den Spinner als Activity-Indicator rendern. */
31
- export type ButtonProps = {
31
+ export type ButtonProps = ButtonBaseProps & ({
32
+ readonly children: ReactNode;
33
+ } | {
34
+ readonly children?: undefined;
35
+ readonly icon: NavIconKey;
36
+ readonly ariaLabel: string;
37
+ });
38
+ type ButtonBaseProps = {
32
39
  readonly type?: "button" | "submit";
33
40
  readonly onClick?: () => void | Promise<void>;
34
41
  readonly disabled?: boolean;
@@ -59,11 +66,6 @@ export type ButtonProps = {
59
66
  * die Container-Breite (Karten/Panels). Andere Breiten sind Layout-Sache
60
67
  * des Containers, kein Button-Prop (Kit hält arbiträres Sizing draußen). */
61
68
  readonly width?: "full" | "auto";
62
- /** Optional for icon-only buttons (`size="icon"` with a resolved `icon`)
63
- * — the icon carries the content then, `ariaLabel` the accessible name.
64
- * Required in practice otherwise: without children and without `icon`
65
- * the button stays empty. */
66
- readonly children?: ReactNode;
67
69
  readonly testId?: string;
68
70
  /** Layout extras — Web merges via cn(), native impls ignore it
69
71
  * (precedent: LinkProps.className). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.353.0",
3
+ "version": "0.354.1",
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.353.0",
19
- "@cosmicdrift/kumiko-headless": "0.353.0",
20
- "@cosmicdrift/kumiko-types": "0.353.0",
18
+ "@cosmicdrift/kumiko-framework": "0.354.1",
19
+ "@cosmicdrift/kumiko-headless": "0.354.1",
20
+ "@cosmicdrift/kumiko-types": "0.354.1",
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.353.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.354.1"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,41 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "Navigate no longer injects the current id into entityEdit targets of another entity",
6
+ "detail": "Navigate actions no longer inject the current record's id into entityEdit targets of another entity; entityList rowActions now recognise same-entity entityEdit targets across features and open them in UPDATE mode"
7
+ },
8
+ {
9
+ "version": "0.354.0",
10
+ "type": "improvement",
11
+ "title": "translationsByKeyFromLocales export and email-template subpath exports",
12
+ "detail": "Renderer exports translationsByKeyFromLocales (inverse of translationsByLocaleFromKeys); bundled-features exposes the auth-email-password and user-data-rights email-templates as subpath exports"
13
+ },
14
+ {
15
+ "version": "0.354.0",
16
+ "type": "breaking",
17
+ "title": "Grid columns is number | \"auto\"",
18
+ "detail": "Final review batch F: renderer, renderer-web, types\nThe boot validator rejects a `listScreenId` that matches no mounted screen, a `sortable` column that the list cannot sort (entityList: any value; projectionList: `true`) and an authored `urlPrefillFields`. `TreeAction` is a union: exactly one of `screen` or `target`. `Button` without children needs `icon` and `ariaLabel`. `RenderEditControls` gains `next` and `back`, and `controls.submit()` saves on an intermediate wizard step. An actionForm money field with a tenant currency that cannot be loaded shows an error instead of submitting with a guessed EUR. The MFA setup and recovery-code buttons sit below the content. The boot context passes Redis to the delivery service, so `notify` with an `idempotencyKey` dedupes. `user:query:user:detail` skips the tenants label when the tenant feature is not mounted.",
19
+ "migration": "Pass a number or \"auto\" to Grid columns; other values no longer type-check."
20
+ },
21
+ {
22
+ "version": "0.354.0",
23
+ "type": "breaking",
24
+ "title": "Button without children requires icon and ariaLabel",
25
+ "migration": "A custom wrapper that forwards ButtonProps must pass either children or both icon and ariaLabel."
26
+ },
27
+ {
28
+ "version": "0.354.0",
29
+ "type": "fix",
30
+ "title": "RenderEditControls has next and back; submit saves on an intermediate wizard step",
31
+ "migration": "No action needed. A host that renders its own wizard buttons with hideActions can call controls.next(), controls.back() and controls.submit()."
32
+ },
33
+ {
34
+ "version": "0.354.0",
35
+ "type": "fix",
36
+ "title": "actionForm money fields with a tenant currency block the form when the currency cannot be loaded",
37
+ "migration": "No action needed. secretMint and the reference create dialog keep their fallback currency."
38
+ },
2
39
  {
3
40
  "version": "0.353.0",
4
41
  "type": "fix",