@cosmicdrift/kumiko-renderer 0.378.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 (79) 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 +49 -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/grid-cell-for-field.d.ts +4 -1
  12. package/dist/components/grid-cell-for-field.js +6 -2
  13. package/dist/components/list-empty-state.d.ts +4 -1
  14. package/dist/components/list-empty-state.js +4 -1
  15. package/dist/components/reference-create-dialog.js +1 -1
  16. package/dist/components/related-list-section.js +4 -1
  17. package/dist/components/render-edit-logic.d.ts +1 -1
  18. package/dist/components/render-edit-logic.js +8 -2
  19. package/dist/components/render-edit-types.d.ts +3 -0
  20. package/dist/components/render-edit.js +25 -6
  21. package/dist/components/render-field.d.ts +3 -1
  22. package/dist/components/render-field.js +19 -7
  23. package/dist/components/render-list.d.ts +3 -1
  24. package/dist/components/render-list.js +29 -8
  25. package/dist/components/write-form-section.js +4 -1
  26. package/dist/field-issue-text.d.ts +4 -0
  27. package/dist/field-issue-text.js +14 -0
  28. package/dist/headless/dispatcher/index.d.ts +1 -0
  29. package/dist/headless/dispatcher/index.js +1 -0
  30. package/dist/headless/dispatcher/request-id.d.ts +1 -0
  31. package/dist/headless/dispatcher/request-id.js +15 -0
  32. package/dist/headless/form/form-controller.js +16 -1
  33. package/dist/headless/form/zod-bridge.js +7 -0
  34. package/dist/headless/index.d.ts +2 -1
  35. package/dist/headless/index.js +2 -1
  36. package/dist/headless/plural-key.d.ts +1 -0
  37. package/dist/headless/plural-key.js +5 -0
  38. package/dist/headless/view-model/edit.d.ts +4 -0
  39. package/dist/headless/view-model/edit.js +10 -5
  40. package/dist/headless/view-model/types.d.ts +10 -2
  41. package/dist/hooks/live-refetch-scheduler.d.ts +6 -0
  42. package/dist/hooks/live-refetch-scheduler.js +45 -0
  43. package/dist/hooks/use-query.js +8 -4
  44. package/dist/hooks/use-reference-lookup.d.ts +1 -0
  45. package/dist/hooks/use-reference-lookup.js +6 -3
  46. package/dist/i18n-defaults.js +22 -3
  47. package/dist/index.d.ts +3 -1
  48. package/dist/index.js +3 -1
  49. package/dist/leave-guard-dialog.d.ts +2 -0
  50. package/dist/leave-guard-dialog.js +28 -0
  51. package/dist/leave-guard.d.ts +15 -0
  52. package/dist/leave-guard.js +39 -0
  53. package/dist/live/dispatcher-live.js +1 -16
  54. package/dist/primitives.d.ts +29 -0
  55. package/dist/sse/live-events.d.ts +3 -0
  56. package/dist/sse/live-events.js +3 -0
  57. package/dist/web/app/create-app.js +2 -2
  58. package/dist/web/app/dashboard-body.js +13 -1
  59. package/dist/web/app/nav.js +51 -9
  60. package/dist/web/layout/nav-tree.js +13 -3
  61. package/dist/web/layout/shell-header.js +3 -3
  62. package/dist/web/lib/file-upload-form-data.d.ts +2 -0
  63. package/dist/web/lib/file-upload-form-data.js +2 -0
  64. package/dist/web/lib/upload-error-key.d.ts +7 -0
  65. package/dist/web/lib/upload-error-key.js +23 -0
  66. package/dist/web/lib/upload-file-ref.d.ts +1 -1
  67. package/dist/web/primitives/embedded-list-input.js +5 -4
  68. package/dist/web/primitives/file-upload.d.ts +2 -1
  69. package/dist/web/primitives/file-upload.js +11 -7
  70. package/dist/web/primitives/files-dropzone.d.ts +16 -0
  71. package/dist/web/primitives/files-dropzone.js +52 -0
  72. package/dist/web/primitives/index.js +62 -29
  73. package/dist/web/primitives/segmented-select.js +8 -2
  74. package/dist/web/sse/live-events.d.ts +13 -0
  75. package/dist/web/sse/live-events.js +88 -23
  76. package/dist/web/widgets/upload-zone.d.ts +11 -2
  77. package/dist/web/widgets/upload-zone.js +30 -7
  78. package/package.json +5 -5
  79. package/src/changes.json +104 -0
@@ -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)
@@ -207,6 +207,9 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
207
207
  /** actionForm `derive`: derived fields are recomputed into the form state on every
208
208
  * change and render read-only. */
209
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;
210
213
  /** Show the unsaved-changes footer (count, Discard, "Save changes") even
211
214
  * without an entity id or a screen-height form — for hosts that edit an
212
215
  * existing server-side record through `customSubmit` (configEdit, also when
@@ -16,6 +16,7 @@ import { formatWhen } from "../format-when.js";
16
16
  import { useForm } from "../hooks/use-form.js";
17
17
  import { useTranslation } from "../i18n.js";
18
18
  import { useInsideDrawer } from "../inside-drawer.js";
19
+ import { useUnsavedInputSource } from "../leave-guard.js";
19
20
  import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
20
21
  import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
21
22
  import { GridCellForField } from "./grid-cell-for-field.js";
@@ -30,7 +31,7 @@ const FORM_DRAFT_GET = "form-draft:query:get";
30
31
  const FORM_DRAFT_SAVE = "form-draft:write:save";
31
32
  const FORM_DRAFT_DISCARD = "form-draft:write:discard";
32
33
  const FORM_DRAFT_LIST = "form-draft:query:list";
33
- // 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
34
35
  // — same hardcoding rationale as the QNs above (no dependency on that package).
35
36
  const FORM_DRAFT_KEY_MAX_LENGTH = 256;
36
37
  // Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
@@ -49,7 +50,7 @@ function newDraftPrefix(screenId) {
49
50
  return `${screenId}:new:`;
50
51
  }
51
52
  // `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
52
- // without a polyfill — guard like dispatcher-live.ts's generateRequestId.
53
+ // without a polyfill — guard like generateRequestId in headless/dispatcher/request-id.ts.
53
54
  function mintDraftId() {
54
55
  const webCrypto = propertyOf(globalThis, "crypto");
55
56
  const randomUUID = propertyOf(webCrypto, "randomUUID");
@@ -230,7 +231,7 @@ function resolveTitleTemplate(template, values, translate, fallback) {
230
231
  return translate(template, values);
231
232
  }
232
233
  export function RenderEdit(props) {
233
- 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, } = 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;
234
235
  // View mode is a locked form, so everything keyed on `disabled` follows.
235
236
  const disabled = disabledProp || viewMode;
236
237
  const i18nScreenId = props.i18nScreenId ?? screen.id;
@@ -255,6 +256,7 @@ export function RenderEdit(props) {
255
256
  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
256
257
  const [linkCopied, setLinkCopied] = useState(false);
257
258
  const [isSubmitting, setIsSubmitting] = useState(false);
259
+ const [focusFirstErrorKey, setFocusFirstErrorKey] = useState(0);
258
260
  const [formError, setFormError] = useState(null);
259
261
  // Only a submit's validation/server error is cleared by editing; a delete
260
262
  // rejection and a version conflict (reload is the way out) stay put.
@@ -305,9 +307,12 @@ export function RenderEdit(props) {
305
307
  // never enters `fields`/`controller.getSnapshot().values` — there is no
306
308
  // draft-blob-covered state left for persistExtensions() to compete over.
307
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);
308
312
  const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
309
313
  const [extensionErrorKey, setExtensionErrorKey] = useState(null);
310
314
  const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(({ anyDirty, hasRegistrations }) => {
315
+ extensionDirtyRef.current = anyDirty;
311
316
  setExtensionDirty(anyDirty);
312
317
  setHasExtensionRegistrations(hasRegistrations);
313
318
  });
@@ -552,6 +557,9 @@ export function RenderEdit(props) {
552
557
  useEffect(() => {
553
558
  onDirtyChangeRef.current?.(hasUnsavedInput);
554
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));
555
563
  useEffect(() => {
556
564
  // skip: this screen does not persist a draft.
557
565
  if (!draftEnabled || dispatcher === undefined)
@@ -666,8 +674,10 @@ export function RenderEdit(props) {
666
674
  mode: writeVerb,
667
675
  ...(!isCreateMode && entityIdProp != null && { entityId: entityIdProp }),
668
676
  ...(formDerivation !== undefined && { derivation: formDerivation }),
677
+ ...(actionFormScreenQn !== undefined && { actionFormScreenQn }),
669
678
  }), [
670
679
  formDerivation,
680
+ actionFormScreenQn,
671
681
  screen,
672
682
  entity,
673
683
  snapshot.values,
@@ -975,6 +985,7 @@ export function RenderEdit(props) {
975
985
  // Same discard as the main path: an extension-only save is still a
976
986
  // successful submit, so the draft must not survive it.
977
987
  if (await persistExtensions()) {
988
+ extensionDirtyRef.current = false;
978
989
  submittedRef.current = true;
979
990
  await discardDraft();
980
991
  }
@@ -1036,8 +1047,11 @@ export function RenderEdit(props) {
1036
1047
  await discardDraft();
1037
1048
  }
1038
1049
  extensionsPersisted = await persistExtensions();
1050
+ if (extensionsPersisted)
1051
+ extensionDirtyRef.current = false;
1039
1052
  }
1040
1053
  else if (result.validationBlocked) {
1054
+ setFocusFirstErrorKey((key) => key + 1);
1041
1055
  // Root-level `.refine()`/cross-field issues from controller.validate()
1042
1056
  // — same "jump to the erroring step" treatment as a server field
1043
1057
  // error below, sourced from the freshly-validated snapshot.
@@ -1050,6 +1064,8 @@ export function RenderEdit(props) {
1050
1064
  else {
1051
1065
  const fieldIssues = result.error.details?.fields ?? [];
1052
1066
  const issuePaths = fieldIssues.map((i) => i.path);
1067
+ if (fieldIssues.length > 0)
1068
+ setFocusFirstErrorKey((key) => key + 1);
1053
1069
  // controller.submit() writes server issues into the controller itself;
1054
1070
  // customSubmit bypasses it, so push them here to reach the fields.
1055
1071
  if (customSubmit !== undefined && fieldIssues.length > 0) {
@@ -1069,7 +1085,9 @@ export function RenderEdit(props) {
1069
1085
  }
1070
1086
  setExtraDeleteIssues([]);
1071
1087
  setFormErrorSource("submit");
1072
- setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections) ? result.error : null);
1088
+ setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections, new Set(Object.keys(formDerivation?.derive ?? {})))
1089
+ ? result.error
1090
+ : null);
1073
1091
  }
1074
1092
  // skip: on entity-success-but-extension-failure the extension-error
1075
1093
  // banner is showing — don't notify (the caller navigates away on success
@@ -1304,7 +1322,7 @@ export function RenderEdit(props) {
1304
1322
  !pageHeaderCompact &&
1305
1323
  ActionOverflowMenu !== undefined && {
1306
1324
  actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
1307
- }) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(hideSectionTitles !== true &&
1325
+ }) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(focusFirstErrorKey > 0 && { focusFirstErrorKey }), ...(hideSectionTitles !== true &&
1308
1326
  !titleInShell &&
1309
1327
  !titleInSettingsHeader &&
1310
1328
  !recordTitleInHeaderRegion && { title: formTitle }), ...(formSubtitle !== undefined &&
@@ -1444,7 +1462,8 @@ export function RenderEdit(props) {
1444
1462
  appendixPlacement: fieldAppendixPlacement,
1445
1463
  }), ...(fieldDescription !== undefined && {
1446
1464
  description: fieldDescription(field.field),
1447
- }), 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) &&
1448
1467
  field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
1449
1468
  // Tabs mode: the tab panel is the surface, so field sections render
1450
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";
@@ -26,7 +26,7 @@ import { QueryOptionsSelect } from "./query-options-select.js";
26
26
  import { ReferenceCreateDialog } from "./reference-create-dialog.js";
27
27
  const AllFieldsRequiredContext = createContext(false);
28
28
  export const AllFieldsRequiredProvider = AllFieldsRequiredContext.Provider;
29
- 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, }) {
30
30
  const { Field, Input, Banner, Text, JsonView } = usePrimitives();
31
31
  // App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
32
32
  // navigator.language (Browser-Sprache) zurück statt der gewählten
@@ -49,6 +49,11 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
49
49
  // Reference-Field rendert eine eigene Component — sie nutzt
50
50
  // useQuery() für den Live-Lookup, also muss sie als React-
51
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);
52
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" &&
53
58
  field.selectOptionsAvailabilityQuery !== undefined &&
54
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({
@@ -77,7 +82,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
77
82
  t,
78
83
  row,
79
84
  }));
80
- 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 }));
81
86
  }
82
87
  const NUMERIC_FIELD_TYPES = new Set(["number", "bigInt", "decimal"]);
83
88
  // A text field takes a number as its string form; a numeric field takes only a
@@ -347,12 +352,17 @@ function ReadOnlyReferenceValue({ field, featureName, }) {
347
352
  // instead of the raw id.
348
353
  const systemLabel = SYSTEM_REFERENCE_LABELS[`${refFeature}:${refEntity}`];
349
354
  const parts = ids.map((id) => {
350
- if (systemLabel !== undefined && id === systemLabel.id) {
355
+ if (systemLabel !== undefined && (id === systemLabel.id || systemLabel.aliases?.includes(id))) {
351
356
  return { id, label: t(systemLabel.labelKey), link: undefined };
352
357
  }
353
358
  const row = rows.find((r) => String(r["id"] ?? "") === id);
354
- if (row === undefined)
355
- 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
+ }
356
366
  return { id, label: String(row[labelKey] ?? id), link: resolveReferenceLink(refEntity, id) };
357
367
  });
358
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))) }));
@@ -478,7 +488,9 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
478
488
  case "image": {
479
489
  const kind = field.type === "image" ? "image" : "file";
480
490
  const fileId = typeof field.value === "string" && field.value !== "" ? field.value : null;
481
- 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 }) }));
482
494
  }
483
495
  case "files":
484
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
+ }
@@ -1,2 +1,3 @@
1
+ export { generateRequestId } from "./request-id.ts";
1
2
  export type { BatchResult, Command, Dispatcher, DispatcherError, DispatcherStatus, FieldIssue, PendingFile, PendingWrite, QueryOpts, QueryResult, StreamOpts, WriteOpts, WriteResult, } from "./types.ts";
2
3
  export { StreamFrame, type StreamFrameEvent } from "./types.ts";
@@ -1 +1,2 @@
1
+ export { generateRequestId } from "./request-id.js";
1
2
  export { StreamFrame } from "./types.js";
@@ -0,0 +1 @@
1
+ export declare function generateRequestId(): string;
@@ -0,0 +1,15 @@
1
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
2
+ // crypto.randomUUID where available (browser, Bun, Node); Math.random
3
+ // fallback for React-Native runtimes without the WebCrypto polyfill.
4
+ // Uniqueness only needs to hold per user within the server's dedup window —
5
+ // this is an idempotency key, not a security token.
6
+ export function generateRequestId() {
7
+ const webCrypto = propertyOf(globalThis, "crypto");
8
+ const randomUUID = propertyOf(webCrypto, "randomUUID");
9
+ if (typeof randomUUID === "function") {
10
+ const id = Reflect.apply(randomUUID, webCrypto, []);
11
+ if (typeof id === "string")
12
+ return id;
13
+ }
14
+ return `req-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
15
+ }
@@ -1,3 +1,4 @@
1
+ import { generateRequestId } from "../dispatcher/index.js";
1
2
  import { createStore } from "../store/index.js";
2
3
  import { groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues } from "./zod-bridge.js";
3
4
  // Paths without the prefix (hook-level errors, "(root)") pass through unchanged.
@@ -91,6 +92,11 @@ function omitPayloadFields(payload, omitFields) {
91
92
  return payload;
92
93
  return Object.fromEntries(Object.entries(payload).filter(([key]) => !omitFields.includes(key)));
93
94
  }
95
+ function isTransportFailure(result) {
96
+ return (!result.isSuccess &&
97
+ result.error.httpStatus === 0 &&
98
+ (result.error.code === "network_error" || result.error.code === "aborted"));
99
+ }
94
100
  export function createFormController(options) {
95
101
  // Shallow copy so mutating the input after creation doesn't bleed into
96
102
  // the controller's internal state. `initial` stays fixed across the
@@ -115,6 +121,10 @@ export function createFormController(options) {
115
121
  // coherent (rebasing twice in quick succession would mis-align the
116
122
  // baseline with what the server actually saw).
117
123
  let submitInFlight = null;
124
+ // Idempotency key of the last attempt that got no server answer. The server
125
+ // caches every answer (errors too) under the requestId, so the key may only
126
+ // be reused for an identical retry after a transport failure.
127
+ let unansweredAttempt = null;
118
128
  function buildSnapshot() {
119
129
  const changes = valuesDiff(values, initial);
120
130
  const isDirty = Object.keys(changes).length > 0;
@@ -365,7 +375,12 @@ export function createFormController(options) {
365
375
  };
366
376
  }
367
377
  const runWrite = async () => {
368
- const result = await dispatcher.write(submitCfg.type, payload);
378
+ const fingerprint = JSON.stringify([submitCfg.type, payload]);
379
+ const requestId = unansweredAttempt?.fingerprint === fingerprint
380
+ ? unansweredAttempt.requestId
381
+ : generateRequestId();
382
+ const result = await dispatcher.write(submitCfg.type, payload, { requestId });
383
+ unansweredAttempt = isTransportFailure(result) ? { requestId, fingerprint } : null;
369
384
  if (result.isSuccess) {
370
385
  // Remember intentional empty clears so post-rebase submits keep them (#1858).
371
386
  if (payload && typeof payload === "object" && !Array.isArray(payload)) {
@@ -84,6 +84,13 @@ function extractIssueParams(issue) {
84
84
  if (bag[key] !== undefined)
85
85
  out[key] = bag[key];
86
86
  }
87
+ // A custom issue that overrides its i18n key also carries the values the text interpolates.
88
+ if (issue.code === "custom" && typeof issue.params?.[I18N_KEY_PARAM] === "string") {
89
+ for (const [key, value] of Object.entries(issue.params)) {
90
+ if (key !== I18N_KEY_PARAM && value !== undefined)
91
+ out[key] = value;
92
+ }
93
+ }
87
94
  return Object.keys(out).length > 0 ? out : undefined;
88
95
  }
89
96
  // Groups a flat FieldIssue list into the `errors` map carried by a
@@ -2,12 +2,13 @@ export type { ParsedRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  export { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  export type { AssetResolution, AssetResolveContext, AssetResolver, BadgeProps, ButtonProps, CardProps, DatePickerProps, IconProps, LocaleResolver, ModalProps, NumberInputProps, PrimitiveCommonProps, PrimitivesContract, SelectOption, SelectProps, TextInputProps, ToastIntent, ToastProps, ToggleProps, } from "./contracts/index.ts";
4
4
  export type { BatchResult, Command, Dispatcher, DispatcherError, DispatcherStatus, FieldIssue, PendingFile, PendingWrite, QueryOpts, QueryResult, StreamOpts, WriteOpts, WriteResult, } from "./dispatcher/index.ts";
5
- export { StreamFrame, type StreamFrameEvent } from "./dispatcher/index.ts";
5
+ export { generateRequestId, StreamFrame, type StreamFrameEvent } from "./dispatcher/index.ts";
6
6
  export type { FieldConditionPredicate, FieldConditions, FieldConditionValue, FieldState, FormController, FormControllerOptions, FormSnapshot, FormValues, SubmitConfig, SubmitPayloadMode, SubmitResult, } from "./form/index.ts";
7
7
  export { createFormController, groupIssuesByPath, I18N_KEY_PARAM, relevantFieldIssues, zodErrorToFieldIssues, } from "./form/index.ts";
8
8
  export { applyFormatSpec, currencyDecimals, DOCUMENT_FALLBACK_CURRENCY, EMPTY_VALUE_PLACEHOLDER, escapeHtml, escapeHtmlAttr, escapeXml, formatDocumentDate, formatDocumentMoney, formatDocumentPercent, formatMoney, type HtmlValue, html, isSafeHref, RawHtml, raw, resolveSafeMoneyLocale, stripControlChars, toInstant, } from "./format/index.ts";
9
9
  export type { NavDefinition, NavNode, NavRegistrySlice, NavTree, ResolveNavigationOptions, } from "./nav/index.ts";
10
10
  export { resolveNavigation } from "./nav/index.ts";
11
+ export { pluralKey } from "./plural-key.ts";
11
12
  export type { Store, WritableStore } from "./store/index.ts";
12
13
  export { createStore, shallowEqual } from "./store/index.ts";
13
14
  export type { CellLink, CellLinkItem, ComputeEditViewModelInput, ComputeListViewModelInput, DerivedCellRoundingTarget, EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditFormMode, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedDerivedOp, EmbeddedListCellViewModel, EmbeddedListIssueGroups, FieldConditionCtx, FieldRenderer, ListColumnFileDisplay, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeCellLink, RuntimeCellLinkItems, RuntimeRenderer, ScreenSlots, Translate, } from "./view-model/index.ts";
@@ -1,9 +1,10 @@
1
1
  // Re-Export aus framework/ui-types damit Renderer-Code denselben
2
2
  // Parser nutzt wie der Server-Boot-Validator (Cross-Feature-Refs).
3
3
  export { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
4
- export { StreamFrame } from "./dispatcher/index.js";
4
+ export { generateRequestId, StreamFrame } from "./dispatcher/index.js";
5
5
  export { createFormController, groupIssuesByPath, I18N_KEY_PARAM, relevantFieldIssues, zodErrorToFieldIssues, } from "./form/index.js";
6
6
  export { applyFormatSpec, currencyDecimals, DOCUMENT_FALLBACK_CURRENCY, EMPTY_VALUE_PLACEHOLDER, escapeHtml, escapeHtmlAttr, escapeXml, formatDocumentDate, formatDocumentMoney, formatDocumentPercent, formatMoney, html, isSafeHref, RawHtml, raw, resolveSafeMoneyLocale, stripControlChars, toInstant, } from "./format/index.js";
7
7
  export { resolveNavigation } from "./nav/index.js";
8
+ export { pluralKey } from "./plural-key.js";
8
9
  export { createStore, shallowEqual } from "./store/index.js";
9
10
  export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/index.js";
@@ -0,0 +1 @@
1
+ export declare function pluralKey(baseKey: string, count: number): string;
@@ -0,0 +1,5 @@
1
+ // Manual plural convention for locale bundles that hold plain strings: `<base>.one` for a
2
+ // count of exactly 1, `<base>.other` for everything else (0 included).
3
+ export function pluralKey(baseKey, count) {
4
+ return `${baseKey}.${count === 1 ? "one" : "other"}`;
5
+ }
@@ -15,6 +15,10 @@ export type ComputeEditViewModelInput<TValues extends Readonly<Record<string, un
15
15
  /** actionForm `derive`: computed fields render read-only (and never block submit
16
16
  * as required) unless their derivation currently leaves them to the user. */
17
17
  readonly derivation?: FormDerivationScreen;
18
+ /** Qualified name of the actionForm screen this form belongs to. File fields then upload
19
+ * as pending for that screen (`uploadActionForm`) instead of for an entity. Set explicitly:
20
+ * secretMint and confirm forms share the pseudo-entity but cannot take uploads. */
21
+ readonly actionFormScreenQn?: string;
18
22
  };
19
23
  export type EditFormMode = "create" | "update";
20
24
  export declare function computeRelatedListSectionViewModel(sectionSpec: EditRelatedListSection, translate: Translate): EditRelatedListSectionViewModel;