@cosmicdrift/kumiko-renderer 0.336.0 → 0.337.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.
@@ -1,9 +1,10 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { evalFieldCondition, isWriteFormEditSection, metricField, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { buildOptionLabels, computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, isSafeHref, } from "@cosmicdrift/kumiko-headless";
3
+ import { applyFormatSpec, buildOptionLabels, computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, isSafeHref, } from "@cosmicdrift/kumiko-headless";
4
4
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
5
5
  import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
6
6
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
7
+ import { Temporal } from "temporal-polyfill";
7
8
  import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
8
9
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
9
10
  import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
@@ -316,14 +317,9 @@ function formatSummaryValue(value, locale) {
316
317
  return new Intl.NumberFormat(locale, { style: "currency", currency: value.currency }).format(value.amount);
317
318
  }
318
319
  if (typeof value === "number")
319
- return value.toLocaleString(locale);
320
+ return applyFormatSpec({ format: "number", locale }, value);
320
321
  if (typeof value === "string" && ISO_DATE_PATTERN.test(value)) {
321
- return new Date(`${value}T00:00:00Z`).toLocaleDateString(locale, {
322
- year: "numeric",
323
- month: "2-digit",
324
- day: "2-digit",
325
- timeZone: "UTC",
326
- });
322
+ return applyFormatSpec({ format: "date", locale }, value);
327
323
  }
328
324
  return value === null || value === undefined ? "" : String(value);
329
325
  }
@@ -365,6 +361,8 @@ function coerceMoneyValue(raw, fieldName, defaultCurrency) {
365
361
  warnPrefillParam(`money field "${fieldName}" got an object without a usable {amount, currency} shape — the prefill is ignored.`);
366
362
  return undefined;
367
363
  }
364
+ if (typeof value === "string" && value.trim() === "")
365
+ return undefined;
368
366
  const amount = Number(typeof value === "number" ? value : raw);
369
367
  if (!Number.isFinite(amount))
370
368
  return undefined;
@@ -373,12 +371,38 @@ function coerceMoneyValue(raw, fieldName, defaultCurrency) {
373
371
  warnPrefillParam(`money field "${fieldName}" was prefilled with a bare number and no currency is available — pass {"amount":<number>,"currency":"<ISO code>"} as the param value, otherwise the handler's schema rejects the submit.`);
374
372
  return amount;
375
373
  }
374
+ // Mirrors isRepresentableAtScale in the framework's schema-builder, which the
375
+ // renderer cannot import (server engine). Keep the tolerance identical so a
376
+ // prefilled cell never passes here and fails the handler's schema.
377
+ function isRepresentableAtScale(value, scale) {
378
+ const scaled = value * 10 ** scale;
379
+ const tolerance = Math.abs(scaled) * 8 * Number.EPSILON + Number.EPSILON;
380
+ return Math.abs(scaled - Math.round(scaled)) <= tolerance;
381
+ }
382
+ function isIsoInstant(value) {
383
+ try {
384
+ Temporal.Instant.from(value);
385
+ return true;
386
+ }
387
+ catch {
388
+ return false;
389
+ }
390
+ }
376
391
  const EMBEDDED_LIST_PREFILL_ROW_LIMIT = 500;
377
392
  function coerceEmbeddedCell(value, cell) {
378
393
  switch (cell.type) {
379
394
  case "number":
380
- case "decimal":
381
395
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
396
+ case "decimal":
397
+ return typeof value === "number" &&
398
+ Number.isFinite(value) &&
399
+ (cell.scale === undefined || isRepresentableAtScale(value, cell.scale))
400
+ ? value
401
+ : undefined;
402
+ case "date":
403
+ return typeof value === "string" && ISO_DATE_PATTERN.test(value) ? value : undefined;
404
+ case "timestamp":
405
+ return typeof value === "string" && isIsoInstant(value) ? value : undefined;
382
406
  case "money":
383
407
  // Row cells are signed minor units; the currency lives on the head, so a
384
408
  // top-level `{amount, currency}` in major units would land 100x off.
@@ -428,7 +452,7 @@ function coerceEmbeddedListRows(raw, fieldName, schema, maxItems) {
428
452
  function coercePrefillString(raw, name, shape, fallback, defaultCurrency, moneyCurrencyOverrides) {
429
453
  if (shape.type === "number") {
430
454
  const parsed = Number(raw);
431
- return Number.isNaN(parsed) ? fallback : parsed;
455
+ return Number.isFinite(parsed) ? parsed : fallback;
432
456
  }
433
457
  if (shape.type === "money") {
434
458
  const coerced = coerceMoneyValue(raw, name, moneyCurrencyOverrides?.[name] ?? defaultCurrency);
@@ -706,6 +730,8 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
706
730
  const handleRecordLeft = useCallback((action, resultData, deletedShownRecord) => {
707
731
  // Never return onto the just-deleted record.
708
732
  const target = deletedShownRecord && returnTarget?.entityId === entityId ? undefined : returnTarget;
733
+ if (deletedShownRecord)
734
+ onDeleted?.();
709
735
  if (action.redirect !== undefined) {
710
736
  navigateAfterRedirect({
711
737
  nav,
@@ -718,9 +744,24 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
718
744
  });
719
745
  return;
720
746
  }
721
- navigateToReturnOr(nav, target, navigateToList);
722
- onDeleted?.();
723
- }, [nav, schema, appFeatures, returnTarget, record, entityId, navigateToList, onDeleted]);
747
+ navigateToReturnOr(nav, target, () => {
748
+ if (findEntityListScreen(schema, screen.entity) !== undefined)
749
+ navigateToList();
750
+ else if (onDeleted === undefined)
751
+ void onReload();
752
+ });
753
+ }, [
754
+ nav,
755
+ schema,
756
+ appFeatures,
757
+ returnTarget,
758
+ record,
759
+ entityId,
760
+ navigateToList,
761
+ onDeleted,
762
+ onReload,
763
+ screen.entity,
764
+ ]);
724
765
  const buildSectionActions = useCallback((actions) => buildRecordActions({
725
766
  actions,
726
767
  record,
@@ -959,9 +1000,10 @@ function DrawerHost({ schema, drawerAction, drawerScreen, drawerInitialValues, u
959
1000
  closeAndReset();
960
1001
  }, [hasUnsavedInput, closeAndReset]);
961
1002
  // Drawer is an optional Core-Primitive (additive rollout) — same "skip +
962
- // warn once" precedent as rowActions without a mounted DispatcherProvider
963
- // above, instead of crashing when a web app hasn't upgraded its
964
- // createKumikoApp wiring yet.
1003
+ // warn" precedent as rowActions without a mounted DispatcherProvider above,
1004
+ // instead of crashing when a web app hasn't upgraded its createKumikoApp
1005
+ // wiring yet. The warning repeats per mount and whenever the action changes.
1006
+ // Dev-time setup error, so no dedupe.
965
1007
  useEffect(() => {
966
1008
  if (drawerAction !== null && Drawer === undefined) {
967
1009
  // biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
@@ -1757,20 +1799,29 @@ function primaryHeaderAction(actions) {
1757
1799
  function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, PageHeader, }) {
1758
1800
  const t = useTranslation();
1759
1801
  const [pendingAction, setPendingAction] = useState(null);
1802
+ // Menu items have no button of their own to carry RenderEditActionButton's
1803
+ // busy state; without it the menu can be reopened mid-write and fire the
1804
+ // same writeHandler twice.
1805
+ const [busyActionId, setBusyActionId] = useState(null);
1760
1806
  const trigger = async (action) => {
1761
1807
  onError(null);
1808
+ setBusyActionId(action.id);
1762
1809
  try {
1763
1810
  await action.onPress();
1764
1811
  }
1765
1812
  catch (e) {
1766
1813
  onError(e instanceof Error ? e.message : String(e));
1767
1814
  }
1815
+ finally {
1816
+ setBusyActionId(null);
1817
+ }
1768
1818
  };
1769
1819
  const toMenuItem = (action) => ({
1770
1820
  id: action.id,
1771
1821
  label: action.label,
1772
1822
  ...(action.icon !== undefined && { icon: action.icon }),
1773
1823
  variant: action.style === "danger" ? "danger" : "default",
1824
+ ...(busyActionId !== null && { disabled: true }),
1774
1825
  onSelect: () => {
1775
1826
  if (needsActionConfirm(action)) {
1776
1827
  setPendingAction(action);
@@ -2078,7 +2129,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2078
2129
  status: (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })),
2079
2130
  }), ...(hasHeaderActions && {
2080
2131
  actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true, compact: pageHeaderCompact, PageHeader: PageHeader }) })),
2081
- }) }), MetricBand === undefined && headerSlot, (hasMetrics || headerSubtitleSlot !== undefined || headerSlot !== undefined) &&
2132
+ }) }), MetricBand === undefined && headerSubtitleSlot, MetricBand === undefined && headerSlot, (hasMetrics || headerSubtitleSlot !== undefined || headerSlot !== undefined) &&
2082
2133
  (MetricBand !== undefined || hasMetrics) &&
2083
2134
  metricsBlock(headerSubtitleSlot, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined || recordTitle !== undefined) && (_jsxs(Card, { slots: {
2084
2135
  ...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
@@ -2309,6 +2360,32 @@ function isWriteOnlyTextField(fieldDef) {
2309
2360
  function hasStoredValue(stored) {
2310
2361
  return stored !== undefined && stored !== null && stored !== "";
2311
2362
  }
2363
+ const CONFIG_WRITE_ISSUE_PATH = "value";
2364
+ // write-helpers report every issue at `value`; in a batch that path is
2365
+ // meaningless to the form, so point it at the field whose command failed.
2366
+ function mapFailedConfigCommandIssuesToField(error, failedIndex, commandFieldNames) {
2367
+ const fieldName = commandFieldNames[failedIndex];
2368
+ const fields = error.details?.fields;
2369
+ if (fieldName === undefined || fields === undefined)
2370
+ return error;
2371
+ return {
2372
+ ...error,
2373
+ details: {
2374
+ ...error.details,
2375
+ fields: fields.map((issue) => {
2376
+ if (issue.path === CONFIG_WRITE_ISSUE_PATH)
2377
+ return { ...issue, path: fieldName };
2378
+ if (issue.path.startsWith(`${CONFIG_WRITE_ISSUE_PATH}.`)) {
2379
+ return {
2380
+ ...issue,
2381
+ path: `${fieldName}${issue.path.slice(CONFIG_WRITE_ISSUE_PATH.length)}`,
2382
+ };
2383
+ }
2384
+ return issue;
2385
+ }),
2386
+ },
2387
+ };
2388
+ }
2312
2389
  function ConfigEditBody({ schema, screen, translate, }) {
2313
2390
  const { ConfigCascadeView } = usePrimitives();
2314
2391
  const t = useTranslation();
@@ -2397,6 +2474,7 @@ function ConfigEditBody({ schema, screen, translate, }) {
2397
2474
  // Write durch ist, das Form bleibt dirty.
2398
2475
  const customSubmit = useCallback(async (snapshot) => {
2399
2476
  const commands = [];
2477
+ const commandFieldNames = [];
2400
2478
  for (const [shortName, value] of Object.entries(snapshot.changes)) {
2401
2479
  const qualified = screen.configKeys[shortName];
2402
2480
  if (qualified === undefined)
@@ -2406,12 +2484,14 @@ function ConfigEditBody({ schema, screen, translate, }) {
2406
2484
  if (isWriteOnlyTextField(screen.fields[shortName])) {
2407
2485
  // true (untouched/undone) and "" mean keep the stored secret; null removes it.
2408
2486
  if (value === null) {
2487
+ commandFieldNames.push(shortName);
2409
2488
  commands.push({
2410
2489
  type: "config:write:reset",
2411
2490
  payload: { key: qualified, scope: screen.scope },
2412
2491
  });
2413
2492
  }
2414
2493
  else if (typeof value === "string" && value !== "") {
2494
+ commandFieldNames.push(shortName);
2415
2495
  commands.push({
2416
2496
  type: "config:write:set",
2417
2497
  payload: { key: qualified, value, scope: screen.scope },
@@ -2425,12 +2505,14 @@ function ConfigEditBody({ schema, screen, translate, }) {
2425
2505
  // value. set can't do it: deleting the row is irreversible, reset is
2426
2506
  // the high-risk handler.
2427
2507
  if (ftype === "select" && fieldDef?.optionsQuery !== undefined && value === "") {
2508
+ commandFieldNames.push(shortName);
2428
2509
  commands.push({
2429
2510
  type: "config:write:reset",
2430
2511
  payload: { key: qualified, scope: screen.scope },
2431
2512
  });
2432
2513
  continue;
2433
2514
  }
2515
+ commandFieldNames.push(shortName);
2434
2516
  commands.push({
2435
2517
  type: "config:write:set",
2436
2518
  payload: {
@@ -2445,7 +2527,11 @@ function ConfigEditBody({ schema, screen, translate, }) {
2445
2527
  }
2446
2528
  const result = await dispatcher.batch(commands);
2447
2529
  if (!result.isSuccess) {
2448
- return { validationBlocked: false, isSuccess: false, error: result.error };
2530
+ return {
2531
+ validationBlocked: false,
2532
+ isSuccess: false,
2533
+ error: mapFailedConfigCommandIssuesToField(result.error, result.failedIndex, commandFieldNames),
2534
+ };
2449
2535
  }
2450
2536
  // Cascade-Disclosure + Maskenwerte nach dem Write nachziehen, sonst
2451
2537
  // bleibt die Anzeige stale bis Reload — gleiche refetch-Calls wie onReset.
@@ -239,10 +239,16 @@ function dayBoundInstant(date, bound, timeZone) {
239
239
  })
240
240
  .toString();
241
241
  }
242
+ // A shared or hand-built URL can carry from > to; the handler's `from <= to`
243
+ // refine would answer 422, so the bounds are put in order before conversion.
244
+ function swapInvertedRange(range) {
245
+ const inverted = range.from !== "" && range.to !== "" && range.from > range.to;
246
+ return inverted ? { from: range.to, to: range.from } : range;
247
+ }
242
248
  export function buildDateRangePayload(specs, urlFilters, timeZone) {
243
249
  const out = {};
244
250
  for (const spec of specs) {
245
- const range = readDateRange(urlFilters, spec.field);
251
+ const range = swapInvertedRange(readDateRange(urlFilters, spec.field));
246
252
  for (const [bound, param] of [
247
253
  ["from", spec.fromParam],
248
254
  ["to", spec.toParam],
@@ -105,7 +105,10 @@ export function navigateWithReturnTo(nav, target, host, params) {
105
105
  * with them, so a chain unwinds one level per jump. */
106
106
  export function navigateToReturn(nav, target) {
107
107
  const raw = nav.searchParams[RETURN_TO_PARAM];
108
- const state = raw === undefined ? NO_RETURN_STATE : splitReturnTo(raw).state;
108
+ // The state belongs to the host the raw value names; a caller-supplied
109
+ // target elsewhere must not inherit it.
110
+ const returnTo = raw === undefined ? undefined : splitReturnTo(raw);
111
+ const state = returnTo?.path === formatReturnTo(target) ? returnTo.state : NO_RETURN_STATE;
109
112
  nav.navigate(target);
110
113
  // The explicit null drops this screen's own returnTo on nav impls whose
111
114
  // navigate keeps the query.
@@ -1,10 +1,12 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useMemo, useState } from "react";
3
3
  import { useDispatcher } from "../context/dispatcher-context.js";
4
+ import { useUserRoles } from "../context/user-roles-context.js";
4
5
  import { useQuery } from "../hooks/use-query.js";
5
6
  import { useTranslation } from "../i18n.js";
6
7
  import { STICKY_PRIMARY_ACTION_PROP, usePrimitives } from "../primitives.js";
7
8
  import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
9
+ import { screenAccessAllows } from "./screen-access.js";
8
10
  import { dispatcherErrorText } from "./write-failed-error.js";
9
11
  function groupSectionsByTitle(sections) {
10
12
  const byTitle = new Map();
@@ -24,6 +26,10 @@ export function SecretsEditBody({ screen, translate }) {
24
26
  const effectiveTranslate = translate ?? t;
25
27
  const dispatcher = useDispatcher();
26
28
  const listQuery = useQuery("secrets:query:list", {});
29
+ const userRoles = useUserRoles();
30
+ // Hidden fields are excluded from rendering and submit alike; the server
31
+ // write gate stays authoritative.
32
+ const visibleFieldIds = useMemo(() => new Set(Object.keys(screen.secretKeys).filter((fieldId) => screenAccessAllows(screen.fieldAccess?.[fieldId], userRoles))), [screen.secretKeys, screen.fieldAccess, userRoles]);
27
33
  const [drafts, setDrafts] = useState({});
28
34
  const [submitting, setSubmitting] = useState(false);
29
35
  const [submitError, setSubmitError] = useState(null);
@@ -40,6 +46,8 @@ export function SecretsEditBody({ screen, translate }) {
40
46
  }, []);
41
47
  const handleSubmit = useCallback(async () => {
42
48
  const commands = Object.entries(screen.secretKeys).flatMap(([fieldId, qualified]) => {
49
+ if (!visibleFieldIds.has(fieldId))
50
+ return [];
43
51
  const value = drafts[fieldId]?.trim();
44
52
  return value ? [{ type: "secrets:write:set", payload: { key: qualified, value } }] : [];
45
53
  });
@@ -64,7 +72,14 @@ export function SecretsEditBody({ screen, translate }) {
64
72
  finally {
65
73
  setSubmitting(false);
66
74
  }
67
- }, [dispatcher, drafts, screen.secretKeys, listQuery.refetch, effectiveTranslate]);
75
+ }, [
76
+ dispatcher,
77
+ drafts,
78
+ screen.secretKeys,
79
+ visibleFieldIds,
80
+ listQuery.refetch,
81
+ effectiveTranslate,
82
+ ]);
68
83
  const handleDelete = useCallback(async (qualified) => {
69
84
  setDeleting(true);
70
85
  try {
@@ -90,7 +105,12 @@ export function SecretsEditBody({ screen, translate }) {
90
105
  }
91
106
  // The generator emits one section per feature; features without a declared
92
107
  // title all fall under the generic "Secrets" heading and share one band.
93
- const sectionGroups = groupSectionsByTitle(screen.sections);
108
+ const sectionGroups = groupSectionsByTitle(screen.sections)
109
+ .map((group) => ({
110
+ ...group,
111
+ fields: group.fields.filter((fieldId) => visibleFieldIds.has(fieldId)),
112
+ }))
113
+ .filter((group) => group.fields.length > 0);
94
114
  const unsavedCount = Object.values(drafts).filter((value) => value.trim() !== "").length;
95
115
  return (_jsxs(_Fragment, { children: [_jsxs(Form, { onSubmit: () => {
96
116
  void handleSubmit();
@@ -7,4 +7,4 @@ export type EmbeddedListFieldProps = {
7
7
  readonly allIssues: Readonly<Record<string, readonly FieldIssue[]>>;
8
8
  readonly featureName: string;
9
9
  };
10
- export declare function EmbeddedListField({ field, id, onChange, allIssues, featureName, }: EmbeddedListFieldProps): ReactNode;
10
+ export declare function EmbeddedListField(props: EmbeddedListFieldProps): ReactNode;
@@ -99,7 +99,17 @@ function coerceCellValue(column, text, currency) {
99
99
  }
100
100
  }
101
101
  }
102
- export function EmbeddedListField({ field, id, onChange, allIssues, featureName, }) {
102
+ // The reference-column queries are hooks called in a loop, so their count must
103
+ // stay fixed per mount. Keying on the reference cells remounts when that set
104
+ // changes (field-level access, in-place entity swap) instead of breaking hook order.
105
+ export function EmbeddedListField(props) {
106
+ const referenceKey = (props.field.embeddedListCells ?? [])
107
+ .filter((c) => c.type === "reference")
108
+ .map((c) => `${c.field}:${c.refFeature ?? ""}:${c.refEntity ?? ""}`)
109
+ .join("|");
110
+ return _jsx(EmbeddedListFieldInner, { ...props }, referenceKey);
111
+ }
112
+ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }) {
103
113
  const { EmbeddedListInput } = usePrimitives();
104
114
  const t = useTranslation();
105
115
  const [pasteWarning, setPasteWarning] = useState(null);
@@ -109,7 +119,7 @@ export function EmbeddedListField({ field, id, onChange, allIssues, featureName,
109
119
  const referenceCells = cells.filter((c) => c.type === "reference");
110
120
  const referenceSources = referenceCells.map((cell) => referenceOptionSource(cell, cell.refFeature ?? featureName, cell.refEntity ?? ""));
111
121
  const referenceQueries = referenceSources.map((source) =>
112
- // biome-ignore lint/correctness/useHookAtTopLevel: referenceCells comes from the entity-schema definition — fixed for the screen's lifetime, not a real conditional-hook risk.
122
+ // biome-ignore lint/correctness/useHookAtTopLevel: the wrapper keys this component on the reference-cell set, so the hook count is fixed per mount.
113
123
  useQuery(source.queryQn, {
114
124
  limit: REFERENCE_COMBOBOX_LIMIT,
115
125
  }));
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { evalFieldCondition, normalizeListColumn, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { evalFieldCondition, normalizeListColumn, parseRefTarget, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { useCallback, useMemo, useState } from "react";
4
4
  import { useAppFeatures } from "../app/app-features-context.js";
5
5
  import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
@@ -49,9 +49,12 @@ function warnRelatedListEmptyStateDrawerDropped(actionId) {
49
49
  }
50
50
  function buildRowGrouping(groupBy, translate, locale) {
51
51
  return {
52
- keyOf: (row) => String(row.values[groupBy.field] ?? ""),
53
- startsCollapsed: (key) => groupBy.collapsedWhen !== undefined && key === String(groupBy.collapsedWhen),
52
+ keyOf: (row) => relatedListGroupKey(row.values[groupBy.field]),
53
+ startsCollapsed: (key) => groupBy.collapsedWhen !== undefined && key === relatedListGroupKey(groupBy.collapsedWhen),
54
54
  headerLabel: (key, rows) => {
55
+ const labelKey = relatedListGroupHeaderLabel(groupBy, key);
56
+ if (labelKey === undefined)
57
+ return undefined;
55
58
  const dates = groupBy.dateField === undefined
56
59
  ? []
57
60
  : rows
@@ -59,7 +62,7 @@ function buildRowGrouping(groupBy, translate, locale) {
59
62
  .filter((value) => value !== "")
60
63
  .sort();
61
64
  const latest = dates[dates.length - 1];
62
- return translate(groupBy.labels?.[key] ?? groupBy.label, {
65
+ return translate(labelKey, {
63
66
  count: rows.length,
64
67
  value: key,
65
68
  lastDate: latest === undefined
@@ -164,10 +167,9 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
164
167
  }, [ownRefetch, onAfterWrite]);
165
168
  const onFilterChange = useCallback((field, values) => setFilters((prev) => ({ ...prev, [field]: values })), []);
166
169
  const onFilterReset = useCallback(() => setFilters({}), []);
167
- // Sorted client-side over the already-loaded rows — this section has no
168
- // pager (see `payload` above: a one-shot fetch, no cursor/offset), so the
169
- // loaded set already IS the full display set and there is no "other page"
170
- // a client-side sort could misleadingly hide (fw#2722).
170
+ // Sorted client-side over the already-loaded rows only. The section has no
171
+ // pager (one-shot fetch); when that fetch is truncated (`nextCursor` set) the
172
+ // truncation banner below tells the user the sorted set is partial.
171
173
  const [sort, setSort] = useState(section.defaultSort ?? null);
172
174
  const sortAccessors = useMemo(() => {
173
175
  const accessors = {};
@@ -1,7 +1,10 @@
1
- import type { EditFieldViewModel, EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
1
+ import type { DispatcherError, EditFieldViewModel, EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
2
2
  export declare function hasEditableSection(sections: readonly EditSectionViewModel[]): boolean;
3
3
  export declare function resolveExtensionEntityId(entityIdProp: string | null | undefined, vmId: string | null): string | null;
4
4
  export declare function shouldNotifyCaller(result: SubmitResult<unknown>, extensionsPersisted: boolean): boolean;
5
+ export declare function issueRootField(path: string): string;
5
6
  export declare function hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[]): boolean;
6
7
  export declare function hasIssueWithoutVisibleField(issuePaths: readonly string[], fields: readonly EditFieldViewModel[]): boolean;
8
+ export declare function findFirstErroringSectionIndex(sections: readonly EditSectionViewModel[], issuePaths: readonly string[]): number | undefined;
7
9
  export declare function filterEditSections(sections: readonly EditSectionViewModel[], fieldsFilter: readonly string[] | undefined): readonly EditSectionViewModel[];
10
+ export declare function isDispatcherRejection(value: unknown): value is DispatcherError;
@@ -27,19 +27,29 @@ export function resolveExtensionEntityId(entityIdProp, vmId) {
27
27
  export function shouldNotifyCaller(result, extensionsPersisted) {
28
28
  return !(result.isSuccess && !extensionsPersisted);
29
29
  }
30
- // Matched on the first path segment (embedded-list rows are dotted, e.g. `tasks.2.title`).
30
+ // Embedded-list rows are dotted (e.g. `tasks.2.title`); the form field is the first segment.
31
+ export function issueRootField(path) {
32
+ return path.split(".")[0] ?? path;
33
+ }
31
34
  // No issues at all counts as unmatched, so the caller keeps the banner.
32
35
  export function hasIssueWithoutRenderedField(issuePaths, sections) {
33
- const renderedFields = sections
34
- .filter((s) => s.kind === "fields" && (s.visible || s.fields.length === 0))
35
- .flatMap((s) => (s.kind === "fields" ? s.fields : []));
36
+ const renderedFields = sections.flatMap((s) => s.kind === "fields" && s.visible ? s.fields : []);
36
37
  return hasIssueWithoutVisibleField(issuePaths, renderedFields);
37
38
  }
38
39
  export function hasIssueWithoutVisibleField(issuePaths, fields) {
39
40
  if (issuePaths.length === 0)
40
41
  return true;
41
42
  const visibleFields = new Set(fields.filter((f) => f.visible).map((f) => f.field));
42
- return issuePaths.some((path) => !visibleFields.has(path.split(".")[0] ?? path));
43
+ return issuePaths.some((path) => !visibleFields.has(issueRootField(path)));
44
+ }
45
+ // Same visibility rule as hasIssueWithoutRenderedField: a hidden field shows
46
+ // no error, so jumping to its step would strand the user on an error-less step.
47
+ export function findFirstErroringSectionIndex(sections, issuePaths) {
48
+ const erroredFields = new Set(issuePaths.map(issueRootField));
49
+ const index = sections.findIndex((s) => s.kind === "fields" &&
50
+ s.visible &&
51
+ s.fields.some((f) => f.visible && erroredFields.has(f.field)));
52
+ return index === -1 ? undefined : index;
43
53
  }
44
54
  // Extension, relatedList and writeForm sections skip the `fields` filter
45
55
  // (writeForm's fields belong to its own independent form, not the host's);
@@ -64,3 +74,13 @@ export function filterEditSections(sections, fieldsFilter) {
64
74
  }
65
75
  return result;
66
76
  }
77
+ // `onDelete` is typed `() => void`-compatible, so a caller can return any value
78
+ // at runtime (a WriteResult, `true`); only a real DispatcherError is a rejection.
79
+ export function isDispatcherRejection(value) {
80
+ return (typeof value === "object" &&
81
+ value !== null &&
82
+ "code" in value &&
83
+ typeof value.code === "string" &&
84
+ "i18nKey" in value &&
85
+ typeof value.i18nKey === "string");
86
+ }
@@ -1,6 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { computeEditViewModel, relevantFieldIssues, zodErrorToFieldIssues, } from "@cosmicdrift/kumiko-headless";
3
+ import { computeEditViewModel, groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues, } from "@cosmicdrift/kumiko-headless";
4
4
  import { RenderEditActionButton } from "./render-edit-action-button.js";
5
5
  import { AllFieldsRequiredProvider } from "./render-field.js";
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -17,7 +17,7 @@ import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header
17
17
  import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
18
18
  import { GridCellForField } from "./grid-cell-for-field.js";
19
19
  import { RelatedListSection } from "./related-list-section.js";
20
- import { filterEditSections, hasEditableSection, hasIssueWithoutRenderedField, resolveExtensionEntityId, shouldNotifyCaller, } from "./render-edit-logic.js";
20
+ import { filterEditSections, findFirstErroringSectionIndex, hasEditableSection, hasIssueWithoutRenderedField, isDispatcherRejection, resolveExtensionEntityId, shouldNotifyCaller, } from "./render-edit-logic.js";
21
21
  import { WriteFormSection } from "./write-form-section.js";
22
22
  // Qualified names of the bundled `form-draft` feature. Hardcoded because the
23
23
  // renderer must not depend on @cosmicdrift/kumiko-bundled-features; a screen
@@ -95,21 +95,32 @@ function deriveFormFields(screen) {
95
95
  return out;
96
96
  }
97
97
  // Dry-run parse against `schema` — NOT controller.validate(), which writes
98
- // field errors into the snapshot. Issues are filtered exactly like the
99
- // controller's validate(): hidden fields never count, `scope` narrows.
100
- function dryRunFieldIssues(schema, snapshot, scope, includeRoot) {
98
+ // field errors into the snapshot. Undefined when the values parse cleanly.
99
+ // Parse once, then narrow per scope with scopedDryRunIssues: the wizard step
100
+ // check would otherwise re-parse the whole form per section on every keystroke.
101
+ function dryRunParse(schema, snapshot) {
101
102
  const parsed = schema.safeParse(snapshot.values);
102
103
  if (parsed.success)
103
- return [];
104
+ return undefined;
104
105
  const hiddenFields = new Set(Object.entries(snapshot.fields)
105
106
  .filter(([, state]) => !state.visible)
106
107
  .map(([fieldKey]) => fieldKey));
107
- return relevantFieldIssues(zodErrorToFieldIssues(parsed.error), {
108
- hiddenFields,
108
+ return { issues: zodErrorToFieldIssues(parsed.error), hiddenFields };
109
+ }
110
+ // Issues are filtered exactly like the controller's validate(): hidden
111
+ // fields never count, `scope` narrows.
112
+ function scopedDryRunIssues(parse, scope, includeRoot) {
113
+ if (parse === undefined)
114
+ return [];
115
+ return relevantFieldIssues(parse.issues, {
116
+ hiddenFields: parse.hiddenFields,
109
117
  ...(scope !== undefined && { scope }),
110
118
  includeRoot,
111
119
  });
112
120
  }
121
+ function dryRunFieldIssues(schema, snapshot, scope, includeRoot) {
122
+ return scopedDryRunIssues(dryRunParse(schema, snapshot), scope, includeRoot);
123
+ }
113
124
  // Index-independent: indices shift when a fields section hides.
114
125
  function wizardStepKey(section, index) {
115
126
  const declaredId = section.kind === "fields" || section.kind === "extension" ? section.id : undefined;
@@ -130,11 +141,10 @@ function isEmptyFormValue(value) {
130
141
  // select/boolean are excluded from the "holds user data" check: they are
131
142
  // often pre-filled (defaults, server-set values), so they say nothing about
132
143
  // whether the step was filled in. They still count through the validation.
133
- function isFieldsStepFilled(section, schema, snapshot) {
144
+ function isFieldsStepFilled(section, parse) {
134
145
  const fieldNames = section.fields.map((f) => f.field);
135
- if (schema !== undefined && dryRunFieldIssues(schema, snapshot, fieldNames, false).length > 0) {
146
+ if (scopedDryRunIssues(parse, fieldNames, false).length > 0)
136
147
  return false;
137
- }
138
148
  return section.fields.some((f) => f.visible &&
139
149
  !f.readOnly &&
140
150
  f.type !== "boolean" &&
@@ -296,8 +306,9 @@ export function RenderEdit(props) {
296
306
  // Both stepped layouts show one section at a time and keep the rest mounted
297
307
  // but inert. A tabs layout on a host without the Tabs primitive has no strip
298
308
  // to switch with, so it falls back to rendering every section stacked rather
299
- // than stranding the user on section 0 (fw#3134).
300
- const isStepped = isWizard || (isTabs && Tabs !== undefined);
309
+ // than stranding the user on section 0 (fw#3134). Same fallback when the
310
+ // WizardStepGroup wrapper is missing.
311
+ const isStepped = isWizard || (isTabs && Tabs !== undefined && WizardStepGroup !== undefined);
301
312
  const fields = useMemo(() => deriveFormFields(screen), [screen]);
302
313
  // Must be computed before submitConfig/useForm bakes it in (the controller
303
314
  // freezes it on first render). This is the unfiltered field-name set from
@@ -671,12 +682,14 @@ export function RenderEdit(props) {
671
682
  const wizardDoneSteps = useMemo(() => {
672
683
  if (!isWizard || isCreateMode)
673
684
  return undefined;
674
- const snapshotForDryRun = { values: snapshot.values, fields: snapshot.fields };
685
+ const parse = schema === undefined
686
+ ? undefined
687
+ : dryRunParse(schema, { values: snapshot.values, fields: snapshot.fields });
675
688
  return filteredSections.map((section, index) => {
676
689
  if (passedStepKeys.has(wizardStepKey(section, index)))
677
690
  return true;
678
691
  if (section.kind === "fields")
679
- return isFieldsStepFilled(section, schema, snapshotForDryRun);
692
+ return isFieldsStepFilled(section, parse);
680
693
  return (section.kind === "extension" &&
681
694
  extensionStepComplete[wizardStepKey(section, index)] === true);
682
695
  });
@@ -856,9 +869,7 @@ export function RenderEdit(props) {
856
869
  // jump targets by design: they validate through the controlled-mode
857
870
  // controls.validate() API and own no field specs to match against.
858
871
  function findFirstErroringStep(fieldPaths) {
859
- const erroredFields = new Set(fieldPaths.map((p) => p.split(".")[0]));
860
- const idx = filteredSections.findIndex((s) => s.kind === "fields" && s.fields.some((f) => erroredFields.has(f.field)));
861
- return idx === -1 ? undefined : idx;
872
+ return findFirstErroringSectionIndex(filteredSections, fieldPaths);
862
873
  }
863
874
  async function handleSubmit(options) {
864
875
  // Enter in the active step triggers the native form submit (Next is
@@ -967,6 +978,11 @@ export function RenderEdit(props) {
967
978
  else {
968
979
  const fieldIssues = result.error.details?.fields ?? [];
969
980
  const issuePaths = fieldIssues.map((i) => i.path);
981
+ // controller.submit() writes server issues into the controller itself;
982
+ // customSubmit bypasses it, so push them here to reach the fields.
983
+ if (customSubmit !== undefined && fieldIssues.length > 0) {
984
+ controller.setErrors(groupIssuesByPath(fieldIssues));
985
+ }
970
986
  // A server field error on a step the form isn't currently showing is
971
987
  // otherwise invisible — jump to the first step that contains one of
972
988
  // the errored fields. For tabs this is what keeps the submit from
@@ -1216,6 +1232,7 @@ export function RenderEdit(props) {
1216
1232
  ], children: _jsx(Text, { children: translate(draftCandidates.length === 1
1217
1233
  ? "kumiko.form.draft.resume-single"
1218
1234
  : "kumiko.form.draft.resume-multiple") }) })), isWizard && !stepRail && (_jsxs(_Fragment, { children: [Progress !== undefined && (_jsx(Progress, { value: (currentStep + 1) / (lastStepIndex + 1), testId: "render-edit-wizard-progress" })), StepBar === undefined ? (_jsx(Text, { variant: "small", testId: "render-edit-wizard-step-label", children: compactStepLabel })) : (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardStepSelect, ...updateModeStepBarProps, testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" }))] })), isTabs &&
1235
+ isStepped &&
1219
1236
  (() => {
1220
1237
  // No Tabs primitive registered → every section renders stacked
1221
1238
  // (stepHidden below is gated on the same condition), which is the
@@ -1380,7 +1397,7 @@ export function RenderEdit(props) {
1380
1397
  const rejection = await onDelete();
1381
1398
  // A rejected delete (e.g. a preDelete hook) carries its reason in
1382
1399
  // the field issues; the top-level key is only "validation failed".
1383
- if (rejection !== undefined) {
1400
+ if (isDispatcherRejection(rejection)) {
1384
1401
  const [issue, ...otherIssues] = rejection.details?.fields ?? [];
1385
1402
  setExtraDeleteIssues(otherIssues);
1386
1403
  setFormError(issue === undefined
@@ -446,11 +446,14 @@ function numberValue(v) {
446
446
  // stored-config coercion) is MAJOR units too — every producer in this repo
447
447
  // hands rehydrateMoney's `{amount,…}` shape or a raw major-unit number, never
448
448
  // pre-scaled minor units.
449
+ // A stored row can carry a legacy/imported currency that is not an ISO-4217
450
+ // shape; Intl.NumberFormat throws a RangeError on it and would crash the screen.
451
+ const ISO_CURRENCY_CODE = /^[A-Z]{3}$/;
449
452
  function resolveMoneyCurrency(value, fieldCurrency) {
450
- if (typeof value === "object" &&
451
- value !== null &&
452
- typeof value.currency === "string") {
453
- return value.currency;
453
+ if (typeof value === "object" && value !== null && "currency" in value) {
454
+ const { currency } = value;
455
+ if (typeof currency === "string" && ISO_CURRENCY_CODE.test(currency))
456
+ return currency;
454
457
  }
455
458
  return fieldCurrency ?? "EUR";
456
459
  }
@@ -1,6 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { computeListViewModel } from "@cosmicdrift/kumiko-headless";
3
- import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
5
5
  import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
6
6
  import { useTranslation } from "../i18n.js";
@@ -30,16 +30,25 @@ export function RenderList(props) {
30
30
  // mount and can flush after a keystroke landing in the same commit,
31
31
  // resetting localQ and swallowing the input.
32
32
  const [syncedSearchValue, setSyncedSearchValue] = useState(searchValue);
33
+ // The parent may echo our own debounced emit back late (router/URL round
34
+ // trip); adopting that stale echo would erase keys typed in between.
35
+ const lastEmittedRef = useRef(undefined);
33
36
  if (searchValue !== syncedSearchValue) {
34
37
  setSyncedSearchValue(searchValue);
35
- setLocalQ(searchValue ?? "");
38
+ if (searchValue !== lastEmittedRef.current) {
39
+ setLocalQ(searchValue ?? "");
40
+ lastEmittedRef.current = undefined;
41
+ }
36
42
  }
37
43
  useEffect(() => {
38
44
  if (onSearchChange === undefined)
39
45
  return;
40
46
  if (localQ === (searchValue ?? ""))
41
47
  return;
42
- const timer = setTimeout(() => onSearchChange(localQ), SEARCH_DEBOUNCE_MS);
48
+ const timer = setTimeout(() => {
49
+ lastEmittedRef.current = localQ;
50
+ onSearchChange(localQ);
51
+ }, SEARCH_DEBOUNCE_MS);
43
52
  return () => clearTimeout(timer);
44
53
  }, [localQ, searchValue, onSearchChange]);
45
54
  const vm = useMemo(() => computeListViewModel({ screen, entity, rows, translate, featureName }), [screen, entity, rows, translate, featureName]);
@@ -100,8 +100,7 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
100
100
  headerActions: titleRowActions,
101
101
  }, testId: `write-form-${section.title ?? section.handler}`, children: content }));
102
102
  }
103
- // Routed through Section's `actions` slot (same mechanism render-edit.tsx
104
- // uses via Form's `actions`) so the button gets the established right-
105
- // aligned footer treatment instead of stretching full-width inline.
103
+ // Submit plus caller actions share the title row's actions slot (submit is
104
+ // lifted into the footer instead when onFooterAction is set).
106
105
  return (_jsx(Section, { ...(section.title !== undefined && { title: section.title }), ...(section.description !== undefined && { subtitle: section.description }), ...(section.icon !== undefined && { icon: section.icon }), actions: titleRowActions, testId: `write-form-${section.title ?? section.handler}`, children: content }));
107
106
  }
@@ -250,13 +250,14 @@ export type InputProps = {
250
250
  /** Closed FieldIconKey vocabulary (FIELD_ICONS registry, renderer-web). */
251
251
  readonly icon?: FieldIconKey;
252
252
  /** `<input step>`. "any" disables the native stepMismatch constraint
253
- * (needed for decimal fields — integer fields leave this unset). */
253
+ * (needed for decimal fields — integer fields leave this unset).
254
+ * Ignored by the web renderer, whose number input is a text field. */
254
255
  readonly step?: number | "any";
255
256
  /** BCP-47 tag for the grouped display and the decimal separator when typing. */
256
257
  readonly locale?: string;
257
258
  /** `false` shows the value without a thousands separator (e.g. a year). */
258
259
  readonly grouping?: boolean;
259
- /** Integer-only entry (bigInt): fractional input is rejected on blur. */
260
+ /** Integer-only entry (bigInt): fractional drafts are not emitted via `onChange`. */
260
261
  readonly integer?: boolean;
261
262
  /** Resolved display suffix (static or from a sibling field) — never
262
263
  * part of the numeric value. */
@@ -584,10 +585,12 @@ export type DataTableDateRangeFacet = {
584
585
  readonly to: string;
585
586
  };
586
587
  /** Splits rows into collapsible groups (order of first appearance). A
587
- * renderer without grouping support shows the flat list. */
588
+ * renderer without grouping support shows the flat list. A group whose
589
+ * `headerLabel` is undefined shows its rows without a header and never
590
+ * collapses. */
588
591
  export type DataTableRowGrouping = {
589
592
  readonly keyOf: (row: ListRowViewModel) => string;
590
- readonly headerLabel: (groupKey: string, rows: readonly ListRowViewModel[]) => string;
593
+ readonly headerLabel: (groupKey: string, rows: readonly ListRowViewModel[]) => string | undefined;
591
594
  readonly startsCollapsed: (groupKey: string) => boolean;
592
595
  };
593
596
  export type DataTableProps = {
@@ -713,8 +716,8 @@ export type DataTableProps = {
713
716
  /** Uses the shared screen padding (wider bottom inset) instead of the
714
717
  * table's symmetric embedded inset — for a table that IS the screen body
715
718
  * (entityList/projectionList), so a list screen ends at the same footer
716
- * distance as a form or custom screen (fw#2640). Hosts set this or
717
- * `scrollBody`, not both: the wider bottom inset competes with
719
+ * distance as a form or custom screen (fw#2640). Ignored when
720
+ * `scrollBody` is true: the wider bottom inset would compete with
718
721
  * `scrollBody`'s flex-fill height budget.
719
722
  * Default false: unchanged symmetric inset for an embedded table. */
720
723
  readonly screenPadding?: boolean;
@@ -2,10 +2,12 @@ import { type ReactNode } from "react";
2
2
  export type LiveEvent = {
3
3
  readonly type: string;
4
4
  readonly data: {
5
- readonly id: string;
5
+ /** Absent on anonymous connections: the signal would reveal hidden rows. */
6
+ readonly id?: string;
6
7
  readonly aggregateType: string;
7
8
  readonly eventType: string;
8
- readonly version: number;
9
+ /** Absent on anonymous connections, like `id`. */
10
+ readonly version?: number;
9
11
  readonly createdAt: string;
10
12
  };
11
13
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.336.0",
3
+ "version": "0.337.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.336.0",
19
- "@cosmicdrift/kumiko-headless": "0.336.0",
20
- "@cosmicdrift/kumiko-types": "0.336.0",
18
+ "@cosmicdrift/kumiko-framework": "0.337.1",
19
+ "@cosmicdrift/kumiko-headless": "0.337.1",
20
+ "@cosmicdrift/kumiko-types": "0.337.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.336.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.337.1"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,42 @@
1
1
  [
2
+ {
3
+ "version": "0.337.1",
4
+ "type": "improvement",
5
+ "title": "Secrets screen hides keys the user cannot write",
6
+ "detail": "The generated secrets screen now hides fixed secret keys the current user may not write according to the key's `writeRoles`, and drops sections left without visible keys. The generator emits a per-field `fieldAccess` map on the screen for this. It only affects what the UI shows; the server-side write check is unchanged and still decides."
7
+ },
8
+ {
9
+ "version": "0.337.0",
10
+ "type": "fix",
11
+ "title": "configEdit shows server validation errors at the affected field",
12
+ "detail": "`config:write:set` reports its issues at path `value`. The configEdit submit maps the issues of the failed batch command (`failedIndex`) to that command's field, and RenderEdit puts server field issues from a `customSubmit` into the form controller, the same way `controller.submit()` does. The banner stays for issues no rendered field can show and for network failures.",
13
+ "migration": "No code change needed."
14
+ },
15
+ {
16
+ "version": "0.337.0",
17
+ "type": "breaking",
18
+ "title": "DataTableRowGrouping.headerLabel may return undefined",
19
+ "detail": "`DataTableRowGrouping.headerLabel` returns `string | undefined`. `undefined` means the group has no header: the default web DataTable renders its rows without a header row and never collapses them.",
20
+ "migration": "Custom DataTable primitives that render `rowGrouping` handle `undefined` from `headerLabel` by rendering the group's rows without a header."
21
+ },
22
+ {
23
+ "version": "0.337.0",
24
+ "type": "fix",
25
+ "title": "Tabs fallback, error-tab jump, list search echo and money currency no longer misbehave",
26
+ "detail": "A tabs layout on a host with `Tabs` but no `WizardStepGroup` now falls back to a stacked form instead of throwing. After a server validation error the form jumps to the first tab that actually shows the errored field, skipping tabs where it is hidden. A delete handler that returns a non-error value no longer shows an empty error banner. The list search box keeps keystrokes typed while the parent echoes the previous search value late. A money value whose stored currency is not a three-letter code falls back to the field currency instead of crashing the screen. Embedded-list reference columns remount instead of breaking hook order when the column set changes. The wizard step check parses the form once per change instead of once per step."
27
+ },
28
+ {
29
+ "version": "0.337.0",
30
+ "type": "fix",
31
+ "title": "Header subtitle without MetricBand and navigateToReturn state no longer misbehave",
32
+ "detail": "A projectionDetail header subtitle stays visible on a host with `PageHeader` but no `MetricBand`. `navigateToReturn` applies the restored host state only when the return target is the host the `returnTo` value names, so a caller-supplied target no longer inherits a foreign host's tab, filters and nested `returnTo`. The `screenPadding` and `scrollBody` docs now state that `scrollBody` wins, and the number input docs match the web behavior."
33
+ },
34
+ {
35
+ "version": "0.337.0",
36
+ "type": "fix",
37
+ "title": "Screen actions and URL prefill no longer leave stale, doubled or invalid state",
38
+ "detail": "An entityEdit delete action with a `redirect` now still reports the deletion to its host, and a delete with no entity list screen reloads the record instead of leaving the form on the deleted record. Overflow-menu actions are disabled while one is running, so a second click can no longer fire the write twice. URL prefill ignores an empty money param, non-finite number params, embedded-list cells with too many decimals or invalid dates, and a dateRange filter with `from` after `to` is put in order before it reaches the handler."
39
+ },
2
40
  {
3
41
  "version": "0.336.0",
4
42
  "type": "breaking",