@cosmicdrift/kumiko-renderer 0.377.0 → 0.379.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.
Files changed (107) hide show
  1. package/dist/app/action-form-shim.js +1 -2
  2. package/dist/app/action-form-support.js +16 -4
  3. package/dist/app/kumiko-screen.js +51 -38
  4. package/dist/app/list-empty-content.d.ts +1 -0
  5. package/dist/app/list-empty-content.js +5 -0
  6. package/dist/app/row-actions.d.ts +7 -1
  7. package/dist/app/row-actions.js +6 -1
  8. package/dist/app/secret-mint-body.js +3 -2
  9. package/dist/components/embedded-list-field.js +16 -7
  10. package/dist/components/file-list-field.js +4 -0
  11. package/dist/components/form-derivation-patch.d.ts +8 -0
  12. package/dist/components/form-derivation-patch.js +24 -0
  13. package/dist/components/form-screen-layout.d.ts +13 -0
  14. package/dist/components/form-screen-layout.js +13 -0
  15. package/dist/components/grid-cell-for-field.d.ts +4 -1
  16. package/dist/components/grid-cell-for-field.js +6 -2
  17. package/dist/components/list-empty-state.d.ts +4 -1
  18. package/dist/components/list-empty-state.js +4 -1
  19. package/dist/components/reference-create-dialog.js +1 -1
  20. package/dist/components/related-list-section.js +4 -1
  21. package/dist/components/render-edit-logic.d.ts +1 -1
  22. package/dist/components/render-edit-logic.js +8 -2
  23. package/dist/components/render-edit-types.d.ts +7 -1
  24. package/dist/components/render-edit.js +38 -6
  25. package/dist/components/render-field.d.ts +3 -1
  26. package/dist/components/render-field.js +27 -8
  27. package/dist/components/render-list.d.ts +3 -1
  28. package/dist/components/render-list.js +29 -8
  29. package/dist/components/write-form-section.js +4 -1
  30. package/dist/field-issue-text.d.ts +4 -0
  31. package/dist/field-issue-text.js +14 -0
  32. package/dist/file-ids-of.d.ts +1 -0
  33. package/dist/file-ids-of.js +7 -0
  34. package/dist/flow-diagram/icons.d.ts +3 -0
  35. package/dist/flow-diagram/icons.js +14 -0
  36. package/dist/flow-diagram/index.d.ts +2 -0
  37. package/dist/flow-diagram/index.js +1 -0
  38. package/dist/flow-diagram/render.d.ts +3 -0
  39. package/dist/flow-diagram/render.js +234 -0
  40. package/dist/flow-diagram/types.d.ts +18 -0
  41. package/dist/flow-diagram/types.js +1 -0
  42. package/dist/headless/dispatcher/index.d.ts +1 -0
  43. package/dist/headless/dispatcher/index.js +1 -0
  44. package/dist/headless/dispatcher/request-id.d.ts +1 -0
  45. package/dist/headless/dispatcher/request-id.js +15 -0
  46. package/dist/headless/form/form-controller.js +16 -1
  47. package/dist/headless/form/zod-bridge.js +7 -0
  48. package/dist/headless/index.d.ts +2 -1
  49. package/dist/headless/index.js +2 -1
  50. package/dist/headless/plural-key.d.ts +1 -0
  51. package/dist/headless/plural-key.js +5 -0
  52. package/dist/headless/view-model/edit.d.ts +8 -1
  53. package/dist/headless/view-model/edit.js +23 -7
  54. package/dist/headless/view-model/types.d.ts +11 -3
  55. package/dist/hooks/live-refetch-scheduler.d.ts +6 -0
  56. package/dist/hooks/live-refetch-scheduler.js +45 -0
  57. package/dist/hooks/use-query.js +8 -4
  58. package/dist/hooks/use-reference-lookup.d.ts +1 -0
  59. package/dist/hooks/use-reference-lookup.js +6 -3
  60. package/dist/i18n-defaults.js +28 -3
  61. package/dist/index.d.ts +6 -2
  62. package/dist/index.js +4 -1
  63. package/dist/leave-guard-dialog.d.ts +2 -0
  64. package/dist/leave-guard-dialog.js +28 -0
  65. package/dist/leave-guard.d.ts +15 -0
  66. package/dist/leave-guard.js +39 -0
  67. package/dist/live/dispatcher-live.js +1 -16
  68. package/dist/primitives.d.ts +39 -0
  69. package/dist/sse/live-events.d.ts +3 -0
  70. package/dist/sse/live-events.js +3 -0
  71. package/dist/web/app/create-app.js +2 -2
  72. package/dist/web/app/dashboard-body.js +13 -1
  73. package/dist/web/app/nav.js +51 -9
  74. package/dist/web/index.d.ts +2 -2
  75. package/dist/web/index.js +1 -1
  76. package/dist/web/layout/nav-tree.js +13 -3
  77. package/dist/web/layout/shell-header.js +3 -3
  78. package/dist/web/lib/file-upload-form-data.d.ts +2 -0
  79. package/dist/web/lib/file-upload-form-data.js +2 -0
  80. package/dist/web/lib/upload-error-key.d.ts +7 -0
  81. package/dist/web/lib/upload-error-key.js +23 -0
  82. package/dist/web/lib/upload-file-ref.d.ts +1 -1
  83. package/dist/web/primitives/embedded-list-input.js +5 -4
  84. package/dist/web/primitives/file-thumbnail.d.ts +6 -0
  85. package/dist/web/primitives/file-thumbnail.js +19 -19
  86. package/dist/web/primitives/file-upload.d.ts +2 -1
  87. package/dist/web/primitives/file-upload.js +11 -7
  88. package/dist/web/primitives/file-value-list.d.ts +3 -0
  89. package/dist/web/primitives/file-value-list.js +22 -0
  90. package/dist/web/primitives/files-dropzone.d.ts +16 -0
  91. package/dist/web/primitives/files-dropzone.js +52 -0
  92. package/dist/web/primitives/index.js +65 -30
  93. package/dist/web/primitives/segmented-select.js +8 -2
  94. package/dist/web/sse/live-events.d.ts +13 -0
  95. package/dist/web/sse/live-events.js +88 -23
  96. package/dist/web/widgets/bounded-load-cache.d.ts +1 -0
  97. package/dist/web/widgets/bounded-load-cache.js +25 -0
  98. package/dist/web/widgets/drawer.js +37 -2
  99. package/dist/web/widgets/index.d.ts +1 -0
  100. package/dist/web/widgets/index.js +1 -0
  101. package/dist/web/widgets/pdf-preview.d.ts +10 -0
  102. package/dist/web/widgets/pdf-preview.js +140 -0
  103. package/dist/web/widgets/upload-zone.d.ts +11 -2
  104. package/dist/web/widgets/upload-zone.js +30 -7
  105. package/package.json +14 -5
  106. package/src/changes.json +143 -0
  107. package/src/web/styles.css +13 -9
@@ -29,10 +29,14 @@ export function fieldCellWidth(field) {
29
29
  return "full";
30
30
  return FIELD_CELL_WIDTH_BY_TYPE[field.type] ?? "text";
31
31
  }
32
- export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, }) {
32
+ export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, sectionTitle, }) {
33
33
  // RenderField renders nothing for a hidden field, but the GridCell around it still claims the row.
34
34
  if (!field.visible)
35
35
  return null;
36
+ const hideLabel = sectionTitle !== undefined &&
37
+ field.type === "embedded" &&
38
+ field.embeddedListCells !== undefined &&
39
+ field.label === sectionTitle;
36
40
  const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
37
- return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), ...(fillDisabled === true && { fillDisabled }) }) }));
41
+ return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), ...(fillDisabled === true && { fillDisabled }), ...(hideLabel && { hideLabel }) }) }));
38
42
  }
@@ -5,10 +5,13 @@ export type ListEmptyStateContent = {
5
5
  readonly description?: string;
6
6
  readonly action?: RenderEditAction;
7
7
  };
8
- export declare function ListEmptyStateBody({ content, narrowed, createButton, translate, }: {
8
+ export declare function ListEmptyStateBody({ content, narrowed, defaultFilterActive, onShowAll, createButton, translate, }: {
9
9
  readonly content: ListEmptyStateContent | undefined;
10
10
  /** A search term or changed filter produced the empty result, so "create the first one" would mislead. */
11
11
  readonly narrowed: boolean;
12
+ /** A default filter is on and nothing else narrows: rows may exist outside it. */
13
+ readonly defaultFilterActive?: boolean;
14
+ readonly onShowAll?: () => void;
12
15
  readonly createButton: ReactNode;
13
16
  readonly translate: (key: string) => string;
14
17
  }): ReactNode;
@@ -2,11 +2,14 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useState } from "react";
3
3
  import { usePrimitives } from "../primitives.js";
4
4
  import { RenderEditActionButton } from "./render-edit-action-button.js";
5
- export function ListEmptyStateBody({ content, narrowed, createButton, translate, }) {
5
+ export function ListEmptyStateBody({ content, narrowed, defaultFilterActive = false, onShowAll, createButton, translate, }) {
6
6
  const { Text, Button, Dialog, Banner } = usePrimitives();
7
7
  const [actionError, setActionError] = useState(null);
8
8
  if (narrowed)
9
9
  return _jsx(Text, { children: translate("kumiko.list.empty.filtered") });
10
+ if (defaultFilterActive) {
11
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { children: translate("kumiko.list.empty.defaultFiltered") }), onShowAll !== undefined && (_jsx(Button, { variant: "secondary", onClick: onShowAll, testId: "render-list-empty-show-all", children: translate("kumiko.list.empty.showAll") }))] }));
12
+ }
10
13
  const description = content === undefined ? translate("kumiko.list.empty.hint") : content.description;
11
14
  return (_jsxs(_Fragment, { children: [_jsx(Text, { children: content?.title ?? translate("kumiko.list.empty.title") }), description !== undefined && _jsx(Text, { variant: "small", children: description }), content?.action !== undefined && (_jsx(RenderEditActionButton, { action: content.action, Button: Button, Dialog: Dialog, onError: setActionError })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-list-empty-state-action-error", children: actionError })), createButton] }));
12
15
  }
@@ -48,5 +48,5 @@ export function ReferenceCreateDialog({ open, onClose, onCreated, featureName, s
48
48
  };
49
49
  if (!open)
50
50
  return null;
51
- return (_jsx(Modal, { open: open, onOpenChange: (next) => !next && onClose(), title: t("kumiko.actions.create"), children: currencyLoading ? (_jsx(Banner, { padded: true, variant: "loading", testId: "reference-create-dialog-loading", children: "Loading\u2026" })) : (_jsx(RenderEdit, { screen: screen, entity: entity, featureName: featureName, initial: initial, writeCommand: writeCommand, onSubmit: handleSubmitted, onCancel: onClose, ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) })) }));
51
+ return (_jsx(Modal, { open: open, onOpenChange: (next) => !next && onClose(), title: t("kumiko.actions.create"), children: currencyLoading ? (_jsx(Banner, { padded: true, variant: "loading", testId: "reference-create-dialog-loading", children: t("kumiko.widget.loading") })) : (_jsx(RenderEdit, { screen: screen, entity: entity, featureName: featureName, initial: initial, writeCommand: writeCommand, onSubmit: handleSubmitted, onCancel: onClose, ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) })) }));
52
52
  }
@@ -254,10 +254,13 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
254
254
  : { [section.parentParam ?? "id"]: parentId },
255
255
  record,
256
256
  host,
257
+ access: { appFeatures, userRoles },
257
258
  ...(onOpenDrawer !== undefined && {
258
259
  openDrawer: (action) => onOpenDrawer(action, undefined),
259
260
  }),
260
261
  }), [
262
+ appFeatures,
263
+ userRoles,
261
264
  section.toolbarActions,
262
265
  section.parentParam,
263
266
  section.parentFilter,
@@ -319,7 +322,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
319
322
  const truncated = rowsQuery.data !== null &&
320
323
  typeof rowsQuery.data.nextCursor === "string" &&
321
324
  (rowsQuery.data.total === undefined || rowsQuery.data.total > rowsQuery.data.rows.length);
322
- const content = rowsQuery.loading && rowsQuery.data === null ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: "Loading\u2026" })) : rowsQuery.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
325
+ const content = rowsQuery.loading && rowsQuery.data === null ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: t("kumiko.widget.loading") })) : rowsQuery.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
323
326
  count: sortedRows.length,
324
327
  }) })), emptyStateActionError !== null && (_jsx(Banner, { variant: "error", testId: "related-list-empty-state-action-error", children: emptyStateActionError })), _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(RenderList, { screen: listScreen, entity: entity, rows: sortedRows, featureName: listFeatureName ?? featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
325
328
  searchable: true,
@@ -3,7 +3,7 @@ export declare function hasEditableSection(sections: readonly EditSectionViewMod
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
5
  export declare function issueRootField(path: string): string;
6
- export declare function hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[]): boolean;
6
+ export declare function hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[], derivedTargets?: ReadonlySet<string>): boolean;
7
7
  export declare function hasIssueWithoutVisibleField(issuePaths: readonly string[], fields: readonly EditFieldViewModel[]): boolean;
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[];
@@ -32,9 +32,15 @@ export function issueRootField(path) {
32
32
  return path.split(".")[0] ?? path;
33
33
  }
34
34
  // No issues at all counts as unmatched, so the caller keeps the banner.
35
- export function hasIssueWithoutRenderedField(issuePaths, sections) {
35
+ // `derivedTargets` are head fields the form computes (actionForm `derive`): an
36
+ // issue on one is a knock-on of another issue (an empty list leaves the total
37
+ // without a value), nobody can fix it there. It is ignored while another issue
38
+ // exists, so the banner does not bury the real one; alone it keeps the banner.
39
+ export function hasIssueWithoutRenderedField(issuePaths, sections, derivedTargets = new Set()) {
40
+ const fixablePaths = issuePaths.filter((path) => !derivedTargets.has(issueRootField(path)));
41
+ const paths = fixablePaths.length > 0 ? fixablePaths : issuePaths;
36
42
  const renderedFields = sections.flatMap((s) => s.kind === "fields" && s.visible ? s.fields : []);
37
- return hasIssueWithoutVisibleField(issuePaths, renderedFields);
43
+ return hasIssueWithoutVisibleField(paths, renderedFields);
38
44
  }
39
45
  export function hasIssueWithoutVisibleField(issuePaths, fields) {
40
46
  if (issuePaths.length === 0)
@@ -1,4 +1,4 @@
1
- import type { ActionFormFooterAction, EntityDefinition, EntityEditScreenDefinition, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { ActionFormFooterAction, EntityDefinition, EntityEditScreenDefinition, FormDerivationScreen, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import type { ReactNode } from "react";
3
3
  import type * as z from "zod";
4
4
  import type { DispatcherError, FormSnapshot, FormValues, SubmitResult, Translate } from "../headless/index.ts";
@@ -204,6 +204,12 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
204
204
  * `disabled`, which locks the whole form. For a gate the host owns, e.g.
205
205
  * an acknowledgement checkbox in `leadContent`. */
206
206
  readonly submitBlocked?: boolean;
207
+ /** actionForm `derive`: derived fields are recomputed into the form state on every
208
+ * change and render read-only. */
209
+ readonly formDerivation?: FormDerivationScreen;
210
+ /** QN of the actionForm screen this form renders; its file fields upload as pending for that
211
+ * screen. Unset for entityEdit and for secretMint/confirm forms, which take no uploads. */
212
+ readonly actionFormScreenQn?: string;
207
213
  /** Show the unsaved-changes footer (count, Discard, "Save changes") even
208
214
  * without an entity id or a screen-height form — for hosts that edit an
209
215
  * existing server-side record through `customSubmit` (configEdit, also when
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
4
4
  import { computeEditViewModel, groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues, } from "../headless/index.js";
5
+ import { changedDerivedValues } from "./form-derivation-patch.js";
5
6
  import { RenderEditActionButton } from "./render-edit-action-button.js";
6
7
  import { AllFieldsRequiredProvider } from "./render-field.js";
7
8
  import { useActionMenuItems } from "./use-action-menu-items.js";
@@ -15,6 +16,7 @@ import { formatWhen } from "../format-when.js";
15
16
  import { useForm } from "../hooks/use-form.js";
16
17
  import { useTranslation } from "../i18n.js";
17
18
  import { useInsideDrawer } from "../inside-drawer.js";
19
+ import { useUnsavedInputSource } from "../leave-guard.js";
18
20
  import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
19
21
  import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
20
22
  import { GridCellForField } from "./grid-cell-for-field.js";
@@ -29,7 +31,7 @@ const FORM_DRAFT_GET = "form-draft:query:get";
29
31
  const FORM_DRAFT_SAVE = "form-draft:write:save";
30
32
  const FORM_DRAFT_DISCARD = "form-draft:write:discard";
31
33
  const FORM_DRAFT_LIST = "form-draft:query:list";
32
- // Mirrors bundled-features/src/form-draft/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
34
+ // Mirrors bundled-features/src/form-draft/web/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
33
35
  // — same hardcoding rationale as the QNs above (no dependency on that package).
34
36
  const FORM_DRAFT_KEY_MAX_LENGTH = 256;
35
37
  // Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
@@ -48,7 +50,7 @@ function newDraftPrefix(screenId) {
48
50
  return `${screenId}:new:`;
49
51
  }
50
52
  // `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
51
- // without a polyfill — guard like dispatcher-live.ts's generateRequestId.
53
+ // without a polyfill — guard like generateRequestId in headless/dispatcher/request-id.ts.
52
54
  function mintDraftId() {
53
55
  const webCrypto = propertyOf(globalThis, "crypto");
54
56
  const randomUUID = propertyOf(webCrypto, "randomUUID");
@@ -229,7 +231,7 @@ function resolveTitleTemplate(template, values, translate, fallback) {
229
231
  return translate(template, values);
230
232
  }
231
233
  export function RenderEdit(props) {
232
- const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled: disabledProp = false, viewMode = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, } = props;
234
+ const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled: disabledProp = false, viewMode = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, formDerivation, actionFormScreenQn, } = props;
233
235
  // View mode is a locked form, so everything keyed on `disabled` follows.
234
236
  const disabled = disabledProp || viewMode;
235
237
  const i18nScreenId = props.i18nScreenId ?? screen.id;
@@ -254,6 +256,7 @@ export function RenderEdit(props) {
254
256
  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
255
257
  const [linkCopied, setLinkCopied] = useState(false);
256
258
  const [isSubmitting, setIsSubmitting] = useState(false);
259
+ const [focusFirstErrorKey, setFocusFirstErrorKey] = useState(0);
257
260
  const [formError, setFormError] = useState(null);
258
261
  // Only a submit's validation/server error is cleared by editing; a delete
259
262
  // rejection and a version conflict (reload is the way out) stay put.
@@ -304,9 +307,12 @@ export function RenderEdit(props) {
304
307
  // never enters `fields`/`controller.getSnapshot().values` — there is no
305
308
  // draft-blob-covered state left for persistExtensions() to compete over.
306
309
  const [extensionDirty, setExtensionDirty] = useState(false);
310
+ // Mirror for the leave guard: it must see "saved" before React re-renders.
311
+ const extensionDirtyRef = useRef(false);
307
312
  const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
308
313
  const [extensionErrorKey, setExtensionErrorKey] = useState(null);
309
314
  const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(({ anyDirty, hasRegistrations }) => {
315
+ extensionDirtyRef.current = anyDirty;
310
316
  setExtensionDirty(anyDirty);
311
317
  setHasExtensionRegistrations(hasRegistrations);
312
318
  });
@@ -367,6 +373,16 @@ export function RenderEdit(props) {
367
373
  ...(ctx !== undefined && { ctx }),
368
374
  ...(submitConfig !== undefined && { submit: submitConfig }),
369
375
  });
376
+ // Derived fields are written into form state (not only displayed) so required
377
+ // validation and the submit payload carry them; only a real change is set, which
378
+ // keeps this from looping.
379
+ useEffect(() => {
380
+ if (formDerivation === undefined)
381
+ return;
382
+ const patch = changedDerivedValues(snapshot.values, formDerivation);
383
+ if (patch !== undefined)
384
+ controller.setValues(patch); // @cast-boundary form-values
385
+ }, [snapshot.values, formDerivation, controller]);
370
386
  // Wired at the user-input boundary, not in controller.setField: computed
371
387
  // fields, prefill, draft restore and action patches also call setValues
372
388
  // after a failed submit and must not wipe the errors.
@@ -541,6 +557,9 @@ export function RenderEdit(props) {
541
557
  useEffect(() => {
542
558
  onDirtyChangeRef.current?.(hasUnsavedInput);
543
559
  }, [hasUnsavedInput]);
560
+ // Draft wizards keep their input server-side, so leaving loses nothing.
561
+ const persistsDraft = isWizard && screen.layout.draft === true;
562
+ useUnsavedInputSource(() => !persistsDraft && (controller.getSnapshot().isDirty || extensionDirtyRef.current));
544
563
  useEffect(() => {
545
564
  // skip: this screen does not persist a draft.
546
565
  if (!draftEnabled || dispatcher === undefined)
@@ -654,7 +673,11 @@ export function RenderEdit(props) {
654
673
  featureName,
655
674
  mode: writeVerb,
656
675
  ...(!isCreateMode && entityIdProp != null && { entityId: entityIdProp }),
676
+ ...(formDerivation !== undefined && { derivation: formDerivation }),
677
+ ...(actionFormScreenQn !== undefined && { actionFormScreenQn }),
657
678
  }), [
679
+ formDerivation,
680
+ actionFormScreenQn,
658
681
  screen,
659
682
  entity,
660
683
  snapshot.values,
@@ -962,6 +985,7 @@ export function RenderEdit(props) {
962
985
  // Same discard as the main path: an extension-only save is still a
963
986
  // successful submit, so the draft must not survive it.
964
987
  if (await persistExtensions()) {
988
+ extensionDirtyRef.current = false;
965
989
  submittedRef.current = true;
966
990
  await discardDraft();
967
991
  }
@@ -1023,8 +1047,11 @@ export function RenderEdit(props) {
1023
1047
  await discardDraft();
1024
1048
  }
1025
1049
  extensionsPersisted = await persistExtensions();
1050
+ if (extensionsPersisted)
1051
+ extensionDirtyRef.current = false;
1026
1052
  }
1027
1053
  else if (result.validationBlocked) {
1054
+ setFocusFirstErrorKey((key) => key + 1);
1028
1055
  // Root-level `.refine()`/cross-field issues from controller.validate()
1029
1056
  // — same "jump to the erroring step" treatment as a server field
1030
1057
  // error below, sourced from the freshly-validated snapshot.
@@ -1037,6 +1064,8 @@ export function RenderEdit(props) {
1037
1064
  else {
1038
1065
  const fieldIssues = result.error.details?.fields ?? [];
1039
1066
  const issuePaths = fieldIssues.map((i) => i.path);
1067
+ if (fieldIssues.length > 0)
1068
+ setFocusFirstErrorKey((key) => key + 1);
1040
1069
  // controller.submit() writes server issues into the controller itself;
1041
1070
  // customSubmit bypasses it, so push them here to reach the fields.
1042
1071
  if (customSubmit !== undefined && fieldIssues.length > 0) {
@@ -1056,7 +1085,9 @@ export function RenderEdit(props) {
1056
1085
  }
1057
1086
  setExtraDeleteIssues([]);
1058
1087
  setFormErrorSource("submit");
1059
- setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections) ? result.error : null);
1088
+ setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections, new Set(Object.keys(formDerivation?.derive ?? {})))
1089
+ ? result.error
1090
+ : null);
1060
1091
  }
1061
1092
  // skip: on entity-success-but-extension-failure the extension-error
1062
1093
  // banner is showing — don't notify (the caller navigates away on success
@@ -1291,7 +1322,7 @@ export function RenderEdit(props) {
1291
1322
  !pageHeaderCompact &&
1292
1323
  ActionOverflowMenu !== undefined && {
1293
1324
  actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
1294
- }) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(hideSectionTitles !== true &&
1325
+ }) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(focusFirstErrorKey > 0 && { focusFirstErrorKey }), ...(hideSectionTitles !== true &&
1295
1326
  !titleInShell &&
1296
1327
  !titleInSettingsHeader &&
1297
1328
  !recordTitleInHeaderRegion && { title: formTitle }), ...(formSubtitle !== undefined &&
@@ -1431,7 +1462,8 @@ export function RenderEdit(props) {
1431
1462
  appendixPlacement: fieldAppendixPlacement,
1432
1463
  }), ...(fieldDescription !== undefined && {
1433
1464
  description: fieldDescription(field.field),
1434
- }), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(disabled && { fillDisabled: true }), ...((!isCreate || markChangedFields) &&
1465
+ }), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(disabled && { fillDisabled: true }), ...(hideSectionTitles !== true &&
1466
+ section.title !== undefined && { sectionTitle: section.title }), ...((!isCreate || markChangedFields) &&
1435
1467
  field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
1436
1468
  // Tabs mode: the tab panel is the surface, so field sections render
1437
1469
  // as unframed padded panels; group cards inside stay framed.
@@ -42,6 +42,8 @@ export type RenderFieldProps = {
42
42
  /** The surrounding form is locked as a whole: the fill button stays visible
43
43
  * but inert, unlike a single readOnly field whose fill button keeps working. */
44
44
  readonly fillDisabled?: boolean;
45
+ /** Label stays for screen readers only (sr-only). */
46
+ readonly hideLabel?: boolean;
45
47
  };
46
48
  export declare const AllFieldsRequiredProvider: import("react").Provider<boolean>;
47
- export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, fillDisabled, }: RenderFieldProps): ReactNode;
49
+ export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, fillDisabled, hideLabel, }: RenderFieldProps): ReactNode;
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { isFormatSpec, SYSTEM_REFERENCE_LABELS, } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { isFormatSpec, SYSTEM_REFERENCE_LABELS, UNRESOLVED_REFERENCE_LABEL_KEYS, } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
4
4
  import { createContext, Fragment, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
5
5
  import { useAppFeatures } from "../app/app-features-context.js";
@@ -11,6 +11,7 @@ import { evalRowExtractor } from "../app/row-actions.js";
11
11
  import { screenAccessAllows } from "../app/screen-access.js";
12
12
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
13
13
  import { useUserRoles } from "../context/user-roles-context.js";
14
+ import { fileIdsOf } from "../file-ids-of.js";
14
15
  import { applyFormatSpec, currencyDecimals, EMPTY_VALUE_PLACEHOLDER, } from "../headless/index.js";
15
16
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
16
17
  import { useQuery } from "../hooks/use-query.js";
@@ -25,7 +26,7 @@ import { QueryOptionsSelect } from "./query-options-select.js";
25
26
  import { ReferenceCreateDialog } from "./reference-create-dialog.js";
26
27
  const AllFieldsRequiredContext = createContext(false);
27
28
  export const AllFieldsRequiredProvider = AllFieldsRequiredContext.Provider;
28
- export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, fillDisabled, }) {
29
+ export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, fillDisabled, hideLabel, }) {
29
30
  const { Field, Input, Banner, Text, JsonView } = usePrimitives();
30
31
  // App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
31
32
  // navigator.language (Browser-Sprache) zurück statt der gewählten
@@ -48,6 +49,11 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
48
49
  // Reference-Field rendert eine eigene Component — sie nutzt
49
50
  // useQuery() für den Live-Lookup, also muss sie als React-
50
51
  // Komponente gemountet werden (nicht als pure render-Call).
52
+ // The list input prints its own list-level issues (empty state included), so
53
+ // the Field wrapper must not print them a second time.
54
+ const listPrintsOwnIssues = field.type === "embedded" &&
55
+ field.embeddedListCells !== undefined &&
56
+ !(field.readOnly && field.renderer !== undefined);
51
57
  const control = field.readOnly && field.renderer !== undefined ? (_jsx(FieldRendererOutput, { field: field, renderer: field.renderer, appLocale: appLocale, ...(row !== undefined && { row }) })) : field.type === "embedded" && field.embeddedListCells !== undefined ? (_jsx(EmbeddedListField, { field: field, id: id, onChange: onChange, allIssues: allIssues ?? {}, featureName: featureName ?? "" })) : field.type === "reference" ? (readOnlyText ? (_jsx(ReadOnlyReferenceValue, { field: field, featureName: featureName ?? "" })) : (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }))) : field.type === "select" && field.selectOptionsQuery !== undefined && !readOnlyText ? (_jsx(QueryOptionsSelect, { field: field, query: field.selectOptionsQuery.query, payload: field.selectOptionsQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : field.type === "select" &&
52
58
  field.selectOptionsAvailabilityQuery !== undefined &&
53
59
  !readOnlyText ? (_jsx(AvailabilityOptionsSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, payload: field.selectOptionsAvailabilityQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) && !isFileFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale, t) })) : field.type === "text" && field.writeOnly === true && !readOnlyText ? (_jsx(WriteOnlyTextInput, { field: field, id: id, hasError: hasError, onChange: onChange, changed: changed === true })) : field.fillAction !== undefined ? (_jsx(FillActionControl, { field: field, fillAction: field.fillAction, row: row ?? { [field.field]: field.value }, onChange: onChange, disabled: fillDisabled === true, Banner: Banner, renderControl: (fill) => renderInput({
@@ -76,7 +82,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
76
82
  t,
77
83
  row,
78
84
  }));
79
- return (_jsx(Field, { id: id, label: field.label, required: field.required && field.type !== "boolean" && !allFieldsRequired, ...(issues !== undefined && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), testId: `field-${field.field}`, children: control }));
85
+ return (_jsx(Field, { id: id, label: field.label, required: field.required && field.type !== "boolean" && !allFieldsRequired, ...(issues !== undefined && !listPrintsOwnIssues && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), ...(hideLabel === true && { hideLabel }), testId: `field-${field.field}`, children: control }));
80
86
  }
81
87
  const NUMERIC_FIELD_TYPES = new Set(["number", "bigInt", "decimal"]);
82
88
  // A text field takes a number as its string form; a numeric field takes only a
@@ -280,7 +286,7 @@ function inputId(field) {
280
286
  // Doesn't resolve the `string` (cross-feature QN) FieldRenderer variant — the
281
287
  // list-column path (DataTableCell) doesn't either, so this stays in parity.
282
288
  function FieldRendererOutput({ field, renderer, row, appLocale, }) {
283
- const { Text, JsonView } = usePrimitives();
289
+ const { Text, JsonView, FileValueList } = usePrimitives();
284
290
  const t = useTranslation();
285
291
  const componentName = !isFormatSpec(renderer) && typeof renderer === "object" && renderer !== null
286
292
  ? extensionSectionName(renderer)
@@ -293,6 +299,12 @@ function FieldRendererOutput({ field, renderer, row, appLocale, }) {
293
299
  if (renderer.format === "json" && JsonView !== undefined) {
294
300
  return (_jsx(JsonView, { value: field.value, indent: renderer.indent, testId: `field-value-${field.field}` }));
295
301
  }
302
+ if (renderer.format === "file" && FileValueList !== undefined) {
303
+ const fileIds = fileIdsOf(field.value);
304
+ if (fileIds.length === 0)
305
+ return _jsx(Text, { testId: `field-value-${field.field}`, children: EMPTY_VALUE_PLACEHOLDER });
306
+ return _jsx(FileValueList, { fileIds: fileIds, testId: `field-value-${field.field}` });
307
+ }
296
308
  // App locale as default when the FormatSpec declares none of its own —
297
309
  // otherwise locale-sensitive formats (timestamp/date/number/decimal/
298
310
  // bigInt/unit) fell back to Intl's runtime default instead of the app
@@ -340,12 +352,17 @@ function ReadOnlyReferenceValue({ field, featureName, }) {
340
352
  // instead of the raw id.
341
353
  const systemLabel = SYSTEM_REFERENCE_LABELS[`${refFeature}:${refEntity}`];
342
354
  const parts = ids.map((id) => {
343
- if (systemLabel !== undefined && id === systemLabel.id) {
355
+ if (systemLabel !== undefined && (id === systemLabel.id || systemLabel.aliases?.includes(id))) {
344
356
  return { id, label: t(systemLabel.labelKey), link: undefined };
345
357
  }
346
358
  const row = rows.find((r) => String(r["id"] ?? "") === id);
347
- if (row === undefined)
348
- return { id, label: id, link: undefined };
359
+ if (row === undefined) {
360
+ const unknownKey = UNRESOLVED_REFERENCE_LABEL_KEYS[`${refFeature}:${refEntity}`];
361
+ const label = unknownKey !== undefined && !queryResult.loading && queryResult.error === null
362
+ ? t(unknownKey)
363
+ : id;
364
+ return { id, label, link: undefined };
365
+ }
349
366
  return { id, label: String(row[labelKey] ?? id), link: resolveReferenceLink(refEntity, id) };
350
367
  });
351
368
  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))) }));
@@ -471,7 +488,9 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
471
488
  case "image": {
472
489
  const kind = field.type === "image" ? "image" : "file";
473
490
  const fileId = typeof field.value === "string" && field.value !== "" ? field.value : null;
474
- return (_jsx(Input, { kind: kind, ...common, value: fileId, onChange: (v) => onChange(v), ...(field.accept !== undefined && { accept: field.accept }), ...(field.maxSize !== undefined && { maxSize: field.maxSize }), ...(field.entityType !== undefined && { entityType: field.entityType }), ...(field.entityId !== undefined && { entityId: field.entityId }), ...(field.fieldName !== undefined && { fieldName: field.fieldName }), ...(field.imageVariant !== undefined && { imageVariant: field.imageVariant }), ...(field.capture !== undefined && { capture: field.capture }) }));
491
+ return (_jsx(Input, { kind: kind, ...common, value: fileId, onChange: (v) => onChange(v), ...(field.accept !== undefined && { accept: field.accept }), ...(field.maxSize !== undefined && { maxSize: field.maxSize }), ...(field.entityType !== undefined && { entityType: field.entityType }), ...(field.uploadActionForm !== undefined && {
492
+ uploadActionForm: field.uploadActionForm,
493
+ }), ...(field.entityId !== undefined && { entityId: field.entityId }), ...(field.fieldName !== undefined && { fieldName: field.fieldName }), ...(field.imageVariant !== undefined && { imageVariant: field.imageVariant }), ...(field.capture !== undefined && { capture: field.capture }) }));
475
494
  }
476
495
  case "files":
477
496
  case "images":
@@ -1,4 +1,4 @@
1
- import type { EntityDefinition, EntityListScreenDefinition, IconKey } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import { type EntityDefinition, type EntityListScreenDefinition, type IconKey } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { type ReactNode } from "react";
3
3
  import type { ListRowViewModel, Translate } from "../headless/index.ts";
4
4
  import type { ListSort } from "../hooks/use-list-url-state.ts";
@@ -34,6 +34,8 @@ export type RenderListProps = {
34
34
  readonly emptyContent?: ListEmptyStateContent;
35
35
  /** Search or filters changed the result; the empty box then says "no matches". */
36
36
  readonly narrowed?: boolean;
37
+ /** Nothing was narrowed by the user, but a default filter is still on; the empty box then offers "show all" instead of "create the first one". */
38
+ readonly defaultFilterActive?: boolean;
37
39
  /** Plural-forms key for the footer count noun; default is the entity's `:noun` convention key. */
38
40
  readonly itemNounKey?: string;
39
41
  /** Aktueller Search-Term (vom URL-State / Parent). RenderList puffert
@@ -1,4 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { UNRESOLVED_REFERENCE_LABEL_KEYS, } from "@cosmicdrift/kumiko-framework/ui-types";
2
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
4
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
4
5
  import { useReferenceLinkResolver } from "../app/reference-link.js";
@@ -15,7 +16,7 @@ function isHeaderPromotableToolbarAction(action) {
15
16
  return action.style === "primary" && !needsActionConfirm(action);
16
17
  }
17
18
  export function RenderList(props) {
18
- const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, emptyContent, narrowed = false, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, rowGrouping, rowTone, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
19
+ const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, emptyContent, narrowed = false, defaultFilterActive: defaultFilterActiveProp = false, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, rowGrouping, rowTone, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
19
20
  // Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
20
21
  // wären Column-Header raw i18n-Keys.
21
22
  const t = useTranslation();
@@ -101,6 +102,14 @@ export function RenderList(props) {
101
102
  if (col.renderer !== undefined)
102
103
  return col;
103
104
  const map = referenceLookups[col.field];
105
+ const unknownKey = UNRESOLVED_REFERENCE_LABEL_KEYS[`${col.refFeature ?? featureName}:${col.refEntity ?? ""}`];
106
+ // No raw id for entities that opt in: a placeholder while the lookup
107
+ // loads, the "unknown" label once it found nothing.
108
+ const unresolvedLabel = (id) => unknownKey === undefined
109
+ ? id
110
+ : map === undefined
111
+ ? EMPTY_VALUE_PLACEHOLDER
112
+ : translate(unknownKey);
104
113
  const labelField = col.refLabelField ?? "id";
105
114
  const refEntity = col.refEntity ?? "";
106
115
  // Only a row the user can see is linkable: resolved by the server
@@ -126,7 +135,7 @@ export function RenderList(props) {
126
135
  return value.map((v) => {
127
136
  const id = String(v);
128
137
  const target = eagerById.get(id);
129
- const label = String(target?.[labelField] ?? map?.get(id) ?? id);
138
+ const label = String(target?.[labelField] ?? map?.get(id) ?? unresolvedLabel(id));
130
139
  // Only a row the user can see is linkable: server eagerload or lookup query.
131
140
  const link = target !== undefined || map?.has(id) === true
132
141
  ? resolveReferenceLink(refEntity, id)
@@ -151,16 +160,23 @@ export function RenderList(props) {
151
160
  if (Array.isArray(value)) {
152
161
  if (value.length === 0)
153
162
  return EMPTY_VALUE_PLACEHOLDER;
154
- return value.map((v) => map?.get(String(v)) ?? String(v)).join(", ");
163
+ return value.map((v) => map?.get(String(v)) ?? unresolvedLabel(String(v))).join(", ");
155
164
  }
156
165
  if (value === null || value === undefined || value === "")
157
166
  return EMPTY_VALUE_PLACEHOLDER;
158
167
  const idStr = String(value);
159
- return map?.get(idStr) ?? idStr;
168
+ return map?.get(idStr) ?? unresolvedLabel(idStr);
160
169
  };
161
170
  return { ...col, renderer, linkFor, linkItemsFor };
162
171
  });
163
- }, [vm.columns, referenceColumns, referenceLookups, resolveReferenceLink]);
172
+ }, [
173
+ vm.columns,
174
+ referenceColumns,
175
+ referenceLookups,
176
+ resolveReferenceLink,
177
+ featureName,
178
+ translate,
179
+ ]);
164
180
  const enrichedVm = useMemo(() => ({
165
181
  ...vm,
166
182
  columns: enrichedColumns,
@@ -205,8 +221,9 @@ export function RenderList(props) {
205
221
  // Button. Caller kann via emptyState-Prop komplett überschreiben.
206
222
  // Wenn weder onCreate noch ein Override gegeben ist, fällt die
207
223
  // DataTable auf den DataTable-Default zurück (`kumiko.list.no-entries`).
224
+ const defaultFilterActive = defaultFilterActiveProp && onFilterReset !== undefined;
208
225
  const composedEmptyState = emptyState ??
209
- (onCreate !== undefined || emptyContent !== undefined || narrowed ? (_jsx(ListEmptyStateBody, { content: emptyContent, narrowed: narrowed, translate: translate, createButton: onCreate !== undefined && !narrowed && !createInPageHeader ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-empty-create", children: effectiveCreateLabel })) : undefined })) : undefined);
226
+ (onCreate !== undefined || emptyContent !== undefined || narrowed || defaultFilterActive ? (_jsx(ListEmptyStateBody, { content: emptyContent, narrowed: narrowed, defaultFilterActive: defaultFilterActive, ...(onFilterReset !== undefined && { onShowAll: onFilterReset }), translate: translate, createButton: onCreate !== undefined && !narrowed && !defaultFilterActive && !createInPageHeader ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-empty-create", children: effectiveCreateLabel })) : undefined })) : undefined);
210
227
  // Title-Resolution: ein konventioneller i18n-Key `screen:<id>.title`.
211
228
  // Wenn das Bundle den Key kennt, schöner Titel; sonst kommt der
212
229
  // screenId selber raus (kein "Untitled"-Fallback — der App-Dev sieht
@@ -254,9 +271,13 @@ function ReferenceLookupBridge({ field, refEntity, labelField, featureName, onMa
254
271
  // useEffect statt direktem call damit setState außerhalb des Render-
255
272
  // Pfads passiert (sonst React-Warning "Cannot update a component
256
273
  // while rendering a different component").
274
+ // Unpublished while loading or after a failed lookup: the renderer treats a missing map as
275
+ // "not resolved", an empty published map as "nothing found". Only the latter may say "Unknown".
276
+ const resolved = !lookup.loading && !lookup.failed;
257
277
  useEffect(() => {
258
- onMap(field, lookup.map);
259
- }, [field, lookup.map, onMap]);
278
+ if (resolved)
279
+ onMap(field, lookup.map);
280
+ }, [field, lookup.map, resolved, onMap]);
260
281
  return null;
261
282
  }
262
283
  // ToolbarActionView — pro Toolbar-Action ein Button (+ Confirm-Dialog
@@ -6,6 +6,7 @@ import { dispatcherErrorText } from "../app/write-failed-error.js";
6
6
  import { I18N_KEY_PARAM } from "../headless/index.js";
7
7
  import { useForm } from "../hooks/use-form.js";
8
8
  import { useTranslation } from "../i18n.js";
9
+ import { useUnsavedInputSource } from "../leave-guard.js";
9
10
  import { STICKY_PRIMARY_ACTION_PROP, usePrimitives } from "../primitives.js";
10
11
  import { GridCellForField } from "./grid-cell-for-field.js";
11
12
  import { hasIssueWithoutVisibleField, issuesAfterEdit } from "./render-edit-logic.js";
@@ -44,6 +45,7 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
44
45
  submit: { type: section.handler, payloadMode: "values" },
45
46
  schema,
46
47
  });
48
+ useUnsavedInputSource(() => controller.getSnapshot().isDirty);
47
49
  const [isSubmitting, setIsSubmitting] = useState(false);
48
50
  const [error, setError] = useState(null);
49
51
  async function handleSubmit() {
@@ -100,7 +102,8 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
100
102
  if (remaining !== errors)
101
103
  controller.setErrors(remaining);
102
104
  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" }))] }));
105
+ }, GridCell: GridCell, featureName: featureName, allIssues: snapshot.errors, valueDisplay: "form", row: snapshot.values, ...(hideTitle !== true &&
106
+ section.title !== undefined && { sectionTitle: section.title }) }, 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" }))] }));
104
107
  // Section always flattens to a borderless divider when rendered inside
105
108
  // RenderEdit's own <Form> in tabs mode (hideTitle) — an unframed Card panel
106
109
  // stands in, like render-edit.tsx's tabs-mode fields/extension branches.
@@ -0,0 +1,4 @@
1
+ import type { FieldIssue } from "./headless/index.ts";
2
+ type TranslateFn = (key: string, params?: Readonly<Record<string, unknown>>) => string;
3
+ export declare function fieldIssueText(t: TranslateFn, issue: FieldIssue): string;
4
+ export {};
@@ -0,0 +1,14 @@
1
+ const INVALID_FORMAT_KEY = "errors.validation.invalid_format";
2
+ // Zod reports every string-format failure under one code; the per-format key
3
+ // (…invalid_format.email) carries a worked example, the generic key stays the
4
+ // fallback for formats without one.
5
+ export function fieldIssueText(t, issue) {
6
+ const format = issue.params?.["format"];
7
+ if (issue.i18nKey === INVALID_FORMAT_KEY && typeof format === "string") {
8
+ const formatKey = `${INVALID_FORMAT_KEY}.${format}`;
9
+ const text = t(formatKey, issue.params);
10
+ if (text !== formatKey)
11
+ return text;
12
+ }
13
+ return t(issue.i18nKey, issue.params);
14
+ }
@@ -0,0 +1 @@
1
+ export declare function fileIdsOf(value: unknown): readonly string[];
@@ -0,0 +1,7 @@
1
+ export function fileIdsOf(value) {
2
+ if (typeof value === "string")
3
+ return value === "" ? [] : [value];
4
+ if (!Array.isArray(value))
5
+ return [];
6
+ return value.filter((entry) => typeof entry === "string" && entry !== "");
7
+ }
@@ -0,0 +1,3 @@
1
+ import type { FlowIcon } from "./types.ts";
2
+ /** Inner markup for a 24x24 viewBox, stroked via the `.icon` CSS class. "ai" renders as a text badge instead — see render.ts. */
3
+ export declare const ICONS: Record<Exclude<FlowIcon, "ai">, string>;
@@ -0,0 +1,14 @@
1
+ /** Inner markup for a 24x24 viewBox, stroked via the `.icon` CSS class. "ai" renders as a text badge instead — see render.ts. */
2
+ export const ICONS = {
3
+ ui: '<rect class="icon" x="3" y="4" width="18" height="15" rx="1.5"/><path class="icon" d="M3 8h18"/>',
4
+ api: '<path class="icon" d="M8 6l-5 6 5 6"/><path class="icon" d="M16 6l5 6-5 6"/>',
5
+ handler: '<path class="icon" d="M8 4l-4 8 4 8"/><path class="icon" d="M16 4l4 8-4 8"/><path class="icon" d="M11 20l2-16"/>',
6
+ "event-store": '<path class="icon" d="M4 6c0-1.5 3.6-2.5 8-2.5S20 4.5 20 6s-3.6 2.5-8 2.5S4 7.5 4 6z"/><path class="icon" d="M4 6v12c0 1.5 3.6 2.5 8 2.5s8-1 8-2.5V6"/><path class="icon" d="M4 12c0 1.5 3.6 2.5 8 2.5s8-1 8-2.5"/>',
7
+ database: '<path class="icon" d="M4 6c0-1.5 3.6-2.5 8-2.5S20 4.5 20 6s-3.6 2.5-8 2.5S4 7.5 4 6z"/><path class="icon" d="M4 6v12c0 1.5 3.6 2.5 8 2.5s8-1 8-2.5V6"/>',
8
+ inbox: '<path class="icon" d="M3 6h18v13H3z"/><path class="icon" d="M3 6l9 7 9-7"/>',
9
+ broadcast: '<path class="icon" d="M12 20v-1"/><path class="icon" d="M8.5 15.5a5 5 0 017 0"/><path class="icon" d="M5 12a9 9 0 0114 0"/>',
10
+ person: '<circle class="icon" cx="12" cy="8" r="3.2"/><path class="icon" d="M5 20c0-4 3-6 7-6s7 2 7 6"/>',
11
+ check: '<path class="icon" d="M5 12l5 5L20 6"/>',
12
+ stop: '<rect class="icon" x="7" y="7" width="10" height="10" rx="1.5"/>',
13
+ box: '<rect class="icon" x="4" y="4" width="16" height="16" rx="2"/>',
14
+ };
@@ -0,0 +1,2 @@
1
+ export { renderFlow } from "./render.ts";
2
+ export type { FlowEdge, FlowIcon, FlowNode, FlowNodeKind, FlowSpec } from "./types.ts";