@cosmicdrift/kumiko-renderer 0.371.1 → 0.372.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 +3 -0
- package/dist/app/nav.js +5 -0
- package/dist/app/reference-link.d.ts +5 -0
- package/dist/app/reference-link.js +32 -0
- package/dist/components/render-edit-logic.d.ts +2 -0
- package/dist/components/render-edit-logic.js +11 -0
- package/dist/components/render-edit.js +20 -2
- package/dist/components/render-field.js +19 -14
- package/dist/components/render-list.js +45 -17
- package/dist/components/write-form-section.js +9 -2
- package/dist/i18n-defaults.js +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +5 -5
- package/src/changes.json +18 -0
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[
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 (
|
|
115
|
-
|
|
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
|
|
159
|
-
//
|
|
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:
|
|
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) =>
|
|
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.
|
package/dist/i18n-defaults.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.372.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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.372.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.372.0",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.372.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.
|
|
31
|
+
"@cosmicdrift/kumiko-locale-de": "0.372.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",
|