@cosmicdrift/kumiko-renderer 0.371.1 → 0.373.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/dist/app/nav.d.ts CHANGED
@@ -70,4 +70,7 @@ export type NavProviderProps = {
70
70
  export declare function NavProvider({ children, value }: NavProviderProps): ReactNode;
71
71
  export declare function useNavigateWithInitialValues(): (target: ScreenTarget, initialValues: Readonly<Record<string, unknown>>) => void;
72
72
  export declare function useInitialValuesHandoff(screenId: string): Readonly<Record<string, unknown>> | undefined;
73
+ /** Like useNav, but undefined without a provider, for views that only offer
74
+ * links when navigation exists. */
75
+ export declare function useOptionalNav(): NavApi | undefined;
73
76
  export declare function useNav(): NavApi;
package/dist/app/nav.js CHANGED
@@ -143,6 +143,11 @@ export function useInitialValuesHandoff(screenId) {
143
143
  }, [handoffSlot, values]);
144
144
  return values;
145
145
  }
146
+ /** Like useNav, but undefined without a provider, for views that only offer
147
+ * links when navigation exists. */
148
+ export function useOptionalNav() {
149
+ return useContext(NavContext);
150
+ }
146
151
  export function useNav() {
147
152
  const api = useContext(NavContext);
148
153
  if (api === undefined) {
@@ -0,0 +1,5 @@
1
+ import type { CellLink } from "@cosmicdrift/kumiko-headless";
2
+ /** Link to a referenced record's detail screen, or undefined when there is no
3
+ * navigation, the user may not open that screen, or no detail screen exists. */
4
+ export type ReferenceLinkResolver = (refEntity: string, id: string) => CellLink | undefined;
5
+ export declare function useReferenceLinkResolver(): ReferenceLinkResolver;
@@ -0,0 +1,32 @@
1
+ import { useCallback } from "react";
2
+ import { useUserRoles } from "../context/user-roles-context.js";
3
+ import { useAppFeatures } from "./app-features-context.js";
4
+ import { useOptionalNav } from "./nav.js";
5
+ import { navigateWithReturnTo, useReturnHost } from "./return-to.js";
6
+ import { navigateTargetAllows } from "./screen-access.js";
7
+ export function useReferenceLinkResolver() {
8
+ const nav = useOptionalNav();
9
+ const appFeatures = useAppFeatures();
10
+ const userRoles = useUserRoles();
11
+ const host = useReturnHost();
12
+ return useCallback((refEntity, id) => {
13
+ if (nav === undefined || refEntity === "" || id === "")
14
+ return undefined;
15
+ if (!navigateTargetAllows({ entity: refEntity }, appFeatures, userRoles))
16
+ return undefined;
17
+ const target = { entity: refEntity, id };
18
+ // hrefFor throws for an entity without a detailFor screen; outside
19
+ // createKumikoApp the feature list is empty, so the gate above can't
20
+ // rule that out. The NavApi owns target resolution, so ask it.
21
+ let href;
22
+ try {
23
+ href = nav.hrefFor(target);
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ if (href === "")
29
+ return undefined;
30
+ return { href, onPress: () => navigateWithReturnTo(nav, target, host) };
31
+ }, [nav, appFeatures, userRoles, host]);
32
+ }
@@ -8,3 +8,5 @@ export declare function hasIssueWithoutVisibleField(issuePaths: readonly string[
8
8
  export declare function findFirstErroringSectionIndex(sections: readonly EditSectionViewModel[], issuePaths: readonly string[]): number | undefined;
9
9
  export declare function filterEditSections(sections: readonly EditSectionViewModel[], fieldsFilter: readonly string[] | undefined): readonly EditSectionViewModel[];
10
10
  export declare function isDispatcherRejection(value: unknown): value is DispatcherError;
11
+ export declare const ROOT_ISSUE_PATH = "(root)";
12
+ export declare function issuesAfterEdit<TIssue>(errors: Readonly<Record<string, TIssue>>, changedField: string): Readonly<Record<string, TIssue>>;
@@ -84,3 +84,14 @@ export function isDispatcherRejection(value) {
84
84
  "i18nKey" in value &&
85
85
  typeof value.i18nKey === "string");
86
86
  }
87
+ export const ROOT_ISSUE_PATH = "(root)";
88
+ // Issues that stop applying once the user edits `changedField`: its own paths
89
+ // (and nested ones) and the form-level (root) issues. Returns the same object
90
+ // when nothing was dropped, so callers can skip the state update.
91
+ export function issuesAfterEdit(errors, changedField) {
92
+ const remaining = Object.fromEntries(Object.entries(errors).filter(([path]) => path !== ROOT_ISSUE_PATH &&
93
+ path !== changedField &&
94
+ !path.startsWith(`${changedField}.`) &&
95
+ !path.startsWith(`${changedField}[`)));
96
+ return Object.keys(remaining).length === Object.keys(errors).length ? errors : remaining;
97
+ }
@@ -19,7 +19,7 @@ import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header
19
19
  import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
20
20
  import { GridCellForField } from "./grid-cell-for-field.js";
21
21
  import { RelatedListSection } from "./related-list-section.js";
22
- import { filterEditSections, findFirstErroringSectionIndex, hasEditableSection, hasIssueWithoutRenderedField, isDispatcherRejection, resolveExtensionEntityId, shouldNotifyCaller, } from "./render-edit-logic.js";
22
+ import { filterEditSections, findFirstErroringSectionIndex, hasEditableSection, hasIssueWithoutRenderedField, isDispatcherRejection, issuesAfterEdit, ROOT_ISSUE_PATH, resolveExtensionEntityId, shouldNotifyCaller, } from "./render-edit-logic.js";
23
23
  import { WriteFormSection } from "./write-form-section.js";
24
24
  // Qualified names of the bundled `form-draft` feature. Hardcoded because the
25
25
  // renderer must not depend on @cosmicdrift/kumiko-bundled-features; a screen
@@ -255,6 +255,9 @@ export function RenderEdit(props) {
255
255
  const [linkCopied, setLinkCopied] = useState(false);
256
256
  const [isSubmitting, setIsSubmitting] = useState(false);
257
257
  const [formError, setFormError] = useState(null);
258
+ // Only a submit's validation/server error is cleared by editing; a delete
259
+ // rejection and a version conflict (reload is the way out) stay put.
260
+ const [formErrorSource, setFormErrorSource] = useState("submit");
258
261
  // A rejected delete can carry several blocking reasons; the first one is the
259
262
  // banner's headline (formError), the rest are listed beneath it.
260
263
  const [extraDeleteIssues, setExtraDeleteIssues] = useState([]);
@@ -364,6 +367,18 @@ export function RenderEdit(props) {
364
367
  ...(ctx !== undefined && { ctx }),
365
368
  ...(submitConfig !== undefined && { submit: submitConfig }),
366
369
  });
370
+ // Wired at the user-input boundary, not in controller.setField: computed
371
+ // fields, prefill, draft restore and action patches also call setValues
372
+ // after a failed submit and must not wipe the errors.
373
+ const clearErrorsOnInput = (changedField) => {
374
+ const errors = controller.getSnapshot().errors;
375
+ const remaining = issuesAfterEdit(errors, changedField);
376
+ if (remaining !== errors)
377
+ controller.setErrors(remaining);
378
+ if (formErrorSource === "submit" && formError?.code !== "version_conflict") {
379
+ setFormError(null);
380
+ }
381
+ };
367
382
  const lastRefreshTokenRef = useRef(refreshToken);
368
383
  useEffect(() => {
369
384
  if (lastRefreshTokenRef.current === refreshToken)
@@ -1040,6 +1055,7 @@ export function RenderEdit(props) {
1040
1055
  jumpToStep(step);
1041
1056
  }
1042
1057
  setExtraDeleteIssues([]);
1058
+ setFormErrorSource("submit");
1043
1059
  setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections) ? result.error : null);
1044
1060
  }
1045
1061
  // skip: on entity-success-but-extension-failure the extension-error
@@ -1403,6 +1419,7 @@ export function RenderEdit(props) {
1403
1419
  accent: fieldAccent?.(field.field) === true,
1404
1420
  }), issues: snapshot.errors[field.field], onChange: (v) => {
1405
1421
  controller.setField(field.field, v);
1422
+ clearErrorsOnInput(field.field);
1406
1423
  if (validateOnChange === true)
1407
1424
  controller.validate([field.field]);
1408
1425
  }, GridCell: GridCell, featureName: featureName, ...(labelAppendix !== undefined && {
@@ -1463,13 +1480,14 @@ export function RenderEdit(props) {
1463
1480
  onReload();
1464
1481
  setFormError(null);
1465
1482
  setExtraDeleteIssues([]);
1466
- }, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: [_jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }), extraDeleteIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-extra", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`)))] })), (snapshot.errors["(root)"] ?? []).map((issue) => (_jsx(Banner, { variant: "error", testId: "render-edit-root-issue", children: _jsx(Text, { children: translate(issue.i18nKey, issue.params) }) }, issue.i18nKey))), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
1483
+ }, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: [_jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }), extraDeleteIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-extra", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`)))] })), (snapshot.errors[ROOT_ISSUE_PATH] ?? []).map((issue) => (_jsx(Banner, { variant: "error", testId: "render-edit-root-issue", children: _jsx(Text, { children: translate(issue.i18nKey, issue.params) }) }, issue.i18nKey))), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
1467
1484
  const rejection = await onDelete();
1468
1485
  // A rejected delete (e.g. a preDelete hook) carries its reason in
1469
1486
  // the field issues; the top-level key is only "validation failed".
1470
1487
  if (isDispatcherRejection(rejection)) {
1471
1488
  const [issue, ...otherIssues] = rejection.details?.fields ?? [];
1472
1489
  setExtraDeleteIssues(otherIssues);
1490
+ setFormErrorSource("delete");
1473
1491
  setFormError(issue === undefined
1474
1492
  ? rejection
1475
1493
  : { ...rejection, i18nKey: issue.i18nKey, i18nParams: issue.params });
@@ -1,12 +1,13 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { isFormatSpec, SYSTEM_REFERENCE_LABELS, } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { applyFormatSpec, currencyDecimals, } from "@cosmicdrift/kumiko-headless";
3
+ import { applyFormatSpec, currencyDecimals, EMPTY_VALUE_PLACEHOLDER, } from "@cosmicdrift/kumiko-headless";
4
4
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
5
- import { createContext, useCallback, useContext, useMemo, useState } from "react";
5
+ import { createContext, Fragment, useCallback, useContext, useMemo, useState, } from "react";
6
6
  import { useAppFeatures } from "../app/app-features-context.js";
7
7
  import { useColumnRenderer } from "../app/column-renderers.js";
8
8
  import { extensionSectionName } from "../app/extension-sections.js";
9
9
  import { toKebab } from "../app/qn.js";
10
+ import { useReferenceLinkResolver } from "../app/reference-link.js";
10
11
  import { screenAccessAllows } from "../app/screen-access.js";
11
12
  import { useUserRoles } from "../context/user-roles-context.js";
12
13
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
@@ -243,8 +244,9 @@ function FieldRendererOutput({ field, renderer, row, appLocale, }) {
243
244
  // label via the same lookup query as ReferenceInput's combobox, but shows
244
245
  // plain text instead of mounting an (unusable, disabled) combobox.
245
246
  function ReadOnlyReferenceValue({ field, featureName, }) {
246
- const { Text } = usePrimitives();
247
+ const { Text, Link } = usePrimitives();
247
248
  const t = useTranslation();
249
+ const resolveReferenceLink = useReferenceLinkResolver();
248
250
  const refEntity = field.refEntity ?? "";
249
251
  const refFeature = field.refFeature ?? featureName;
250
252
  const isMultiple = field.refMultiple === true;
@@ -258,20 +260,23 @@ function ReadOnlyReferenceValue({ field, featureName, }) {
258
260
  ? [field.value]
259
261
  : [];
260
262
  if (ids.length === 0)
261
- return _jsx(Text, { testId: `field-value-${field.field}`, children: "\u2014" });
263
+ return _jsx(Text, { testId: `field-value-${field.field}`, children: EMPTY_VALUE_PLACEHOLDER });
262
264
  const rows = queryResult.data?.rows ?? [];
263
265
  // System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row —
264
266
  // same central fallback as useReferenceLookup (render-list.tsx), so a
265
267
  // reference field showing one on the detail path also gets a label
266
268
  // instead of the raw id.
267
269
  const systemLabel = SYSTEM_REFERENCE_LABELS[`${refFeature}:${refEntity}`];
268
- const labels = ids.map((id) => {
269
- if (systemLabel !== undefined && id === systemLabel.id)
270
- return t(systemLabel.labelKey);
270
+ const parts = ids.map((id) => {
271
+ if (systemLabel !== undefined && id === systemLabel.id) {
272
+ return { id, label: t(systemLabel.labelKey), link: undefined };
273
+ }
271
274
  const row = rows.find((r) => String(r["id"] ?? "") === id);
272
- return row !== undefined ? String(row[labelKey] ?? id) : id;
275
+ if (row === undefined)
276
+ return { id, label: id, link: undefined };
277
+ return { id, label: String(row[labelKey] ?? id), link: resolveReferenceLink(refEntity, id) };
273
278
  });
274
- return _jsx(Text, { testId: `field-value-${field.field}`, children: labels.join(", ") });
279
+ return (_jsx(Text, { testId: `field-value-${field.field}`, children: parts.map(({ id, label, link }, index) => (_jsxs(Fragment, { children: [index > 0 && ", ", link === undefined ? (label) : (_jsx(Link, { href: link.href, onPress: link.onPress, testId: `field-link-${field.field}-${id}`, children: label }))] }, id))) }));
275
280
  }
276
281
  // Field types whose read display already isn't a boxed "disabled input" look
277
282
  // (embedded/jsonb render as an info Banner) — `readOnlyDisplayText` doesn't
@@ -534,7 +539,7 @@ function locatedValue(v) {
534
539
  function readOnlyDisplayText(field, appLocale, t) {
535
540
  const { type, value } = field;
536
541
  if (value === undefined || value === null || value === "")
537
- return "—";
542
+ return EMPTY_VALUE_PLACEHOLDER;
538
543
  if (type === "text" && field.writeOnly === true)
539
544
  return t("kumiko.field.writeOnly.set");
540
545
  if (type === "text" && field.format === "password")
@@ -549,7 +554,7 @@ function readOnlyDisplayText(field, appLocale, t) {
549
554
  case "locatedTimestamp": {
550
555
  const located = locatedValue(value);
551
556
  if (located === "" || located.at === "")
552
- return "—";
557
+ return EMPTY_VALUE_PLACEHOLDER;
553
558
  return applyFormatSpec({ format: "timestamp", locale: field.dateLocale ?? appLocale }, located.utc ?? located.at);
554
559
  }
555
560
  case "number":
@@ -557,14 +562,14 @@ function readOnlyDisplayText(field, appLocale, t) {
557
562
  case "decimal": {
558
563
  const n = numberValue(value);
559
564
  return n === ""
560
- ? "—"
565
+ ? EMPTY_VALUE_PLACEHOLDER
561
566
  : new Intl.NumberFormat(appLocale, { useGrouping: field.grouping ?? true }).format(n);
562
567
  }
563
568
  case "money": {
564
569
  const currency = resolveMoneyCurrency(value, field.currency);
565
570
  const minor = moneyMinorValue(value, currency);
566
571
  if (minor === "")
567
- return "—";
572
+ return EMPTY_VALUE_PLACEHOLDER;
568
573
  const major = minor / 10 ** currencyDecimals(currency);
569
574
  return new Intl.NumberFormat(appLocale, { style: "currency", currency }).format(major);
570
575
  }
@@ -573,7 +578,7 @@ function readOnlyDisplayText(field, appLocale, t) {
573
578
  const labels = field.optionLabels;
574
579
  const values = Array.isArray(value) ? value : [value];
575
580
  if (values.length === 0)
576
- return "—";
581
+ return EMPTY_VALUE_PLACEHOLDER;
577
582
  return values
578
583
  .map((v) => (typeof v === "string" ? (labels?.[v] ?? v) : stringValue(v)))
579
584
  .join(", ");
@@ -1,7 +1,8 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { computeListViewModel } from "@cosmicdrift/kumiko-headless";
2
+ import { computeListViewModel, EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-headless";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
5
+ import { useReferenceLinkResolver } from "../app/reference-link.js";
5
6
  import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
6
7
  import { useTranslation } from "../i18n.js";
7
8
  import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
@@ -88,6 +89,7 @@ export function RenderList(props) {
88
89
  return { ...prev, [field]: map };
89
90
  });
90
91
  }, []);
92
+ const resolveReferenceLink = useReferenceLinkResolver();
91
93
  const enrichedColumns = useMemo(() => {
92
94
  if (referenceColumns.length === 0)
93
95
  return vm.columns;
@@ -100,35 +102,49 @@ export function RenderList(props) {
100
102
  return col;
101
103
  const map = referenceLookups[col.field];
102
104
  const labelField = col.refLabelField ?? "id";
105
+ const refEntity = col.refEntity ?? "";
106
+ // Only a row the user can see is linkable: resolved by the server
107
+ // eagerload or by the lookup query. A multi-reference stays text.
108
+ const linkFor = (value, row) => {
109
+ if (value === null || value === undefined || value === "" || Array.isArray(value)) {
110
+ return undefined;
111
+ }
112
+ const id = String(value);
113
+ const eagerloaded = eagerloadedRef(row, col.field);
114
+ // The server leaves a missing or unreadable target out; null is treated the same.
115
+ const resolved = (typeof eagerloaded === "object" &&
116
+ eagerloaded !== null &&
117
+ !Array.isArray(eagerloaded)) ||
118
+ map?.has(id);
119
+ return resolved === true ? resolveReferenceLink(refEntity, id) : undefined;
120
+ };
103
121
  const renderer = (value, row) => {
104
122
  // Tier 2.7e Server-Eagerload: wenn der Server _refs mit-
105
123
  // schickt, lesen wir den Display-Wert direkt aus der
106
124
  // resolved Row — kein Roundtrip durch die Bridge-Map nötig
107
125
  // und keine limit:200-Constraint. EagerloadedRow-Type aus
108
126
  // @cosmicdrift/kumiko-framework/db pinnt die Form von _refs.
109
- const eagerloadedRow = row;
110
- const resolved = eagerloadedRow?._refs?.[col.field];
127
+ const resolved = eagerloadedRef(row, col.field);
111
128
  if (Array.isArray(resolved) && resolved.length > 0) {
112
129
  return resolved.map((r) => String(r[labelField] ?? r["id"] ?? "")).join(", ");
113
130
  }
114
- if (resolved !== undefined && !Array.isArray(resolved)) {
115
- const single = resolved; // @cast-boundary render-helper
116
- return String(single[labelField] ?? single["id"] ?? "");
131
+ if (isPlainRecord(resolved)) {
132
+ return String(resolved[labelField] ?? resolved["id"] ?? "");
117
133
  }
118
134
  // Renderer-Side-Fallback (kein Server-Eagerload aktiv).
119
135
  if (Array.isArray(value)) {
120
136
  if (value.length === 0)
121
- return "—";
137
+ return EMPTY_VALUE_PLACEHOLDER;
122
138
  return value.map((v) => map?.get(String(v)) ?? String(v)).join(", ");
123
139
  }
124
140
  if (value === null || value === undefined || value === "")
125
- return "—";
141
+ return EMPTY_VALUE_PLACEHOLDER;
126
142
  const idStr = String(value);
127
143
  return map?.get(idStr) ?? idStr;
128
144
  };
129
- return { ...col, renderer };
145
+ return { ...col, renderer, linkFor };
130
146
  });
131
- }, [vm.columns, referenceColumns, referenceLookups]);
147
+ }, [vm.columns, referenceColumns, referenceLookups, resolveReferenceLink]);
132
148
  const enrichedVm = useMemo(() => ({
133
149
  ...vm,
134
150
  columns: enrichedColumns,
@@ -155,15 +171,14 @@ export function RenderList(props) {
155
171
  const toolbarIconOnly = hasToolbarActions && shouldRenderActionsIconOnly(toolbarActions);
156
172
  const createButton = onCreate !== undefined && createInPageHeader && pageHeaderCompact ? (_jsx(Button, { variant: "primary", icon: "plus", size: "icon", ariaLabel: effectiveCreateLabel, title: effectiveCreateLabel, onClick: onCreate, testId: "render-list-create" })) : onCreate !== undefined ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-create", className: "px-3.5", children: effectiveCreateLabel })) : undefined;
157
173
  // Without onCreate (e.g. a navigate action as the list's only CTA) the primary
158
- // toolbar action takes the header slot on phones, so it is not a text button
159
- // squeezed next to the search field. Desktop keeps it in the toolbar.
160
- const promotedToolbarAction = onCreate === undefined &&
161
- PageHeader !== undefined &&
162
- pageHeaderSlotAvailable &&
163
- pageHeaderCompact
174
+ // toolbar action takes the page-header slot like a "+ Neu" button would: an icon
175
+ // on phones, labelled on desktop, so projection lists look like entity lists.
176
+ const promotedToolbarAction = onCreate === undefined && PageHeader !== undefined && pageHeaderSlotAvailable
164
177
  ? toolbarActions?.find(isHeaderPromotableToolbarAction)
165
178
  : undefined;
166
- const promotedActionButton = promotedToolbarAction !== undefined ? (_jsx(ToolbarActionView, { action: { ...promotedToolbarAction, icon: promotedToolbarAction.icon ?? "plus" }, iconOnly: true, Button: Button, Dialog: Dialog, Banner: Banner })) : undefined;
179
+ const promotedActionButton = promotedToolbarAction !== undefined ? (_jsx(ToolbarActionView, { action: pageHeaderCompact
180
+ ? { ...promotedToolbarAction, icon: promotedToolbarAction.icon ?? "plus" }
181
+ : promotedToolbarAction, iconOnly: pageHeaderCompact, Button: Button, Dialog: Dialog, Banner: Banner })) : undefined;
167
182
  const toolbarActionsInToolbar = toolbarActions?.filter((a) => a !== promotedToolbarAction) ?? [];
168
183
  const hasToolbarActionsInToolbar = toolbarActionsInToolbar.length > 0;
169
184
  const toolbarEnd = hasHeaderSlot ||
@@ -266,3 +281,16 @@ function ToolbarActionView({ action, iconOnly = false, Button, Dialog, Banner, }
266
281
  }
267
282
  }, testId: `render-list-toolbar-action-${action.id}`, children: showIconOnly ? null : action.label }), _jsx(Dialog, { open: confirmOpen, onOpenChange: setConfirmOpen, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: trigger, testId: `render-list-toolbar-action-${action.id}-dialog` }), errorText !== null && (_jsx(Banner, { variant: "error", testId: `render-list-toolbar-action-${action.id}-error`, children: errorText }))] }));
268
283
  }
284
+ function isPlainRecord(value) {
285
+ return typeof value === "object" && value !== null && !Array.isArray(value);
286
+ }
287
+ // The server leaves a missing or unreadable target out of `_refs`, and a null
288
+ // entry means the same: both read as unresolved.
289
+ function eagerloadedRef(row, field) {
290
+ if (!isPlainRecord(row) || !isPlainRecord(row["_refs"]))
291
+ return undefined;
292
+ const ref = row["_refs"][field];
293
+ if (Array.isArray(ref))
294
+ return ref.filter(isPlainRecord);
295
+ return isPlainRecord(ref) ? ref : undefined;
296
+ }
@@ -8,7 +8,7 @@ import { useForm } from "../hooks/use-form.js";
8
8
  import { useTranslation } from "../i18n.js";
9
9
  import { STICKY_PRIMARY_ACTION_PROP, usePrimitives } from "../primitives.js";
10
10
  import { GridCellForField } from "./grid-cell-for-field.js";
11
- import { hasIssueWithoutVisibleField } from "./render-edit-logic.js";
11
+ import { hasIssueWithoutVisibleField, issuesAfterEdit } from "./render-edit-logic.js";
12
12
  function buildWriteFormSchema(fields) {
13
13
  return z
14
14
  .object({})
@@ -93,7 +93,14 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
93
93
  onFooterAction(submitButton);
94
94
  return () => onFooterAction(undefined);
95
95
  }, [onFooterAction, isSubmitting, submitLabel]);
96
- const content = (_jsxs(_Fragment, { children: [_jsx(Grid, { columns: section.columns, children: section.fields.map((field) => (_jsx(GridCellForField, { field: { ...field, value: snapshot.values[field.field] }, columns: section.columns, issues: snapshot.errors[field.field], onChange: (v) => controller.setField(field.field, v), GridCell: GridCell, featureName: featureName, allIssues: snapshot.errors, valueDisplay: "form", row: snapshot.values }, field.field))) }), error !== null && (_jsx(Banner, { variant: "error", testId: "write-form-section-error", children: error })), onFooterAction === undefined && (_jsx(Card, { options: { framed: false, footerBordered: false, footerInset: false }, slots: { footer: submitButton }, testId: "write-form-section-submit-row" }))] }));
96
+ const content = (_jsxs(_Fragment, { children: [_jsx(Grid, { columns: section.columns, children: section.fields.map((field) => (_jsx(GridCellForField, { field: { ...field, value: snapshot.values[field.field] }, columns: section.columns, issues: snapshot.errors[field.field], onChange: (v) => {
97
+ controller.setField(field.field, v);
98
+ const errors = controller.getSnapshot().errors;
99
+ const remaining = issuesAfterEdit(errors, field.field);
100
+ if (remaining !== errors)
101
+ controller.setErrors(remaining);
102
+ setError(null);
103
+ }, GridCell: GridCell, featureName: featureName, allIssues: snapshot.errors, valueDisplay: "form", row: snapshot.values }, field.field))) }), error !== null && (_jsx(Banner, { variant: "error", testId: "write-form-section-error", children: error })), onFooterAction === undefined && (_jsx(Card, { options: { framed: false, footerBordered: false, footerInset: false }, slots: { footer: submitButton }, testId: "write-form-section-submit-row" }))] }));
97
104
  // Section always flattens to a borderless divider when rendered inside
98
105
  // RenderEdit's own <Form> in tabs mode (hideTitle) — an unframed Card panel
99
106
  // stands in, like render-edit.tsx's tabs-mode fields/extension branches.
@@ -94,6 +94,11 @@ export const kumikoDefaultTranslations = {
94
94
  "kumiko.list.count.other": "{count} entries",
95
95
  "kumiko.list.row-actions.more": "More actions",
96
96
  "kumiko.page-header.actions": "Page actions",
97
+ "kumiko.list.actions": "Actions",
98
+ "kumiko.sidebar.toggle": "Toggle sidebar",
99
+ "kumiko.sidebar.title": "Navigation",
100
+ "kumiko.sidebar.description": "The app's main navigation.",
101
+ "kumiko.breadcrumb.label": "Breadcrumb",
97
102
  "kumiko.theme.auto": "Automatic theme",
98
103
  "kumiko.theme.dark": "Dark theme",
99
104
  "kumiko.theme.light": "Light theme",
package/dist/index.d.ts CHANGED
@@ -23,7 +23,7 @@ export { KumikoScreen, qualifyNavId, qualifyScreenId, ScreenMissingState, } from
23
23
  export type { DateRangeBound, DateRangeValue, ResolvedDateRangeFacet } from "./app/list-facets.ts";
24
24
  export { buildDateRangePayload, clampDateRange, readDateRange, resolveDateRangeFacets, } from "./app/list-facets.ts";
25
25
  export type { NavApi, NavigateOptions, NavProviderProps, NavRoute, NavTarget, ObjectTarget, ScreenTarget, } from "./app/nav.tsx";
26
- export { formatPath, hasDetailScreen, NavProvider, parsePath, resolveTarget, useNav, useNavigateWithInitialValues, } from "./app/nav.tsx";
26
+ export { formatPath, hasDetailScreen, NavProvider, parsePath, resolveTarget, useNav, useNavigateWithInitialValues, useOptionalNav, } from "./app/nav.tsx";
27
27
  export { lastSegment } from "./app/qn.ts";
28
28
  export type { ReturnHost, ReturnTo } from "./app/return-to.tsx";
29
29
  export { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, RETURN_TO_PARAM, ReturnHostProvider, resolveReturnTarget, splitReturnTo, useReturnHost, useReturnTarget, } from "./app/return-to.tsx";
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ export { ExtensionSectionsProvider, extensionSectionName, useExtensionSectionCom
21
21
  export { isAppSchema, toAppSchema } from "./app/feature-schema.js";
22
22
  export { KumikoScreen, qualifyNavId, qualifyScreenId, ScreenMissingState, } from "./app/kumiko-screen.js";
23
23
  export { buildDateRangePayload, clampDateRange, readDateRange, resolveDateRangeFacets, } from "./app/list-facets.js";
24
- export { formatPath, hasDetailScreen, NavProvider, parsePath, resolveTarget, useNav, useNavigateWithInitialValues, } from "./app/nav.js";
24
+ export { formatPath, hasDetailScreen, NavProvider, parsePath, resolveTarget, useNav, useNavigateWithInitialValues, useOptionalNav, } from "./app/nav.js";
25
25
  export { lastSegment } from "./app/qn.js";
26
26
  export { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, RETURN_TO_PARAM, ReturnHostProvider, resolveReturnTarget, splitReturnTo, useReturnHost, useReturnTarget, } from "./app/return-to.js";
27
27
  export { useEmbeddedScreen } from "./app/use-embedded-screen.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.371.1",
3
+ "version": "0.373.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.371.1",
19
- "@cosmicdrift/kumiko-headless": "0.371.1",
20
- "@cosmicdrift/kumiko-types": "0.371.1",
18
+ "@cosmicdrift/kumiko-framework": "0.373.0",
19
+ "@cosmicdrift/kumiko-headless": "0.373.0",
20
+ "@cosmicdrift/kumiko-types": "0.373.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.371.1"
31
+ "@cosmicdrift/kumiko-locale-de": "0.373.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.372.0",
4
+ "type": "fix",
5
+ "title": "Empty values use one placeholder everywhere",
6
+ "detail": "Empty table cells, reference columns, read-only fields and dashboard lists now share one em dash placeholder (exported as EMPTY_VALUE_PLACEHOLDER from the headless package) instead of mixing en and em dashes."
7
+ },
8
+ {
9
+ "version": "0.372.0",
10
+ "type": "fix",
11
+ "title": "Form errors clear once the user edits the affected field",
12
+ "detail": "In RenderEdit, typing into a field removes that field's server issues and the form-level issues, and dismisses the submit error banner. A version_conflict banner and a delete rejection stay until reloaded or retried. Programmatic value changes (initial, draft restore, action patches) leave errors untouched."
13
+ },
14
+ {
15
+ "version": "0.372.0",
16
+ "type": "improvement",
17
+ "title": "Reference fields and columns link to the referenced record",
18
+ "detail": "A resolved reference in a list column or a read-only field links to the detail screen of the referenced entity when the user may open it, so the link also works in a new tab. Unresolved ids, system labels, multi-reference list cells and cells in a clickable first column or card stay plain text. New useOptionalNav hook returns undefined without a NavProvider."
19
+ },
2
20
  {
3
21
  "version": "0.371.0",
4
22
  "type": "improvement",