@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
@@ -14,8 +14,7 @@
14
14
  // RenderActionForm-Komponente, die `fields` direkt als Input nimmt.
15
15
  // Der Boot-Validator kennt actionForm bereits eigenständig (kein
16
16
  // entity-Lookup), also ist der Server-Pfad davon nicht betroffen.
17
- import { isFieldsEditSection, normalizeEditField, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
18
- const ACTION_FORM_PSEUDO_ENTITY = "__action-form__";
17
+ import { ACTION_FORM_PSEUDO_ENTITY, isFieldsEditSection, normalizeEditField, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
19
18
  /** Baut eine minimale EntityDefinition aus den Inline-Fields eines
20
19
  * ActionForm- oder SecretMint-Screens. RenderEdit + computeEditViewModel
21
20
  * iterieren über entity.fields zur Render-Zeit; alle weiteren
@@ -40,11 +40,12 @@ function pickDeclaredFields(record, fieldNames) {
40
40
  /** Loads the initial values of an actionForm declaring `load`; undefined for a
41
41
  * form without `load`. */
42
42
  export function useActionFormLoad(load, recordId, fieldNames) {
43
- const query = useQuery(load?.query ?? "", { [load?.idParam ?? DEFAULT_LOAD_ID_PARAM]: recordId }, { enabled: load !== undefined && recordId !== undefined });
43
+ const isSingleton = load?.singleton === true;
44
+ const query = useQuery(load?.query ?? "", isSingleton ? {} : { [load?.idParam ?? DEFAULT_LOAD_ID_PARAM]: recordId }, { enabled: load !== undefined && (isSingleton || recordId !== undefined) });
44
45
  const values = useMemo(() => pickDeclaredFields(query.data, fieldNames), [query.data, fieldNames]);
45
46
  if (load === undefined)
46
47
  return undefined;
47
- if (recordId === undefined)
48
+ if (!isSingleton && recordId === undefined)
48
49
  return { status: "missing-id" };
49
50
  if (query.error !== null)
50
51
  return { status: "error", error: query.error };
@@ -90,7 +91,18 @@ export function resolveSuccessFacts(facts, data, translate, locale) {
90
91
  });
91
92
  return resolved;
92
93
  }
94
+ // Lines can repeat (two files with one name), so the position is the only stable key.
95
+ function positionedLines(lines) {
96
+ return lines.map((line, position) => ({ position: String(position), line }));
97
+ }
93
98
  export function ActionFormSuccessBanner({ success, }) {
94
- const { Banner, Link, Text } = usePrimitives();
95
- return (_jsxs(Banner, { variant: "info", testId: "action-form-success", children: [success.text, success.facts.map((fact) => (_jsxs(Text, { testId: `action-form-success-fact-${fact.key}`, children: [fact.label, ": ", fact.lines.join(", ")] }, fact.key))), success.links.map((link) => (_jsx(Link, { href: link.href, onPress: link.onPress, className: "ml-2 underline", testId: `action-form-success-link-${link.key}`, children: link.label }, link.key)))] }));
99
+ const { Banner, Grid, Link, Text } = usePrimitives();
100
+ // Text renders inline, so the message and each fact sit in a one-column grid to get their own lines.
101
+ return (_jsxs(Banner, { variant: "info", testId: "action-form-success", children: [_jsxs(Grid, { columns: 1, children: [_jsx(Text, { testId: "action-form-success-text", children: success.text }), success.facts.map((fact) => {
102
+ const factTestId = `action-form-success-fact-${fact.key}`;
103
+ if (fact.lines.length === 1) {
104
+ return (_jsxs(Text, { testId: factTestId, children: [fact.label, ": ", fact.lines[0]] }, fact.key));
105
+ }
106
+ return (_jsxs(Grid, { columns: 1, testId: factTestId, children: [_jsxs(Text, { children: [fact.label, ":"] }), positionedLines(fact.lines).map(({ position, line }) => (_jsx(Text, { testId: `${factTestId}-line-${position}`, children: line }, position)))] }, fact.key));
107
+ })] }), success.links.map((link) => (_jsx(Link, { href: link.href, onPress: link.onPress, className: "ml-2 underline", testId: `action-form-success-link-${link.key}`, children: link.label }, link.key)))] }));
96
108
  }
@@ -5,6 +5,7 @@ import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
5
5
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
6
  import { isPlainObject, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
7
7
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
8
+ import { withDerivedValues } from "../components/form-derivation-patch.js";
8
9
  import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
9
10
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
10
11
  import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
@@ -20,6 +21,7 @@ import { useQuery } from "../hooks/use-query.js";
20
21
  import { referenceLookupSource } from "../hooks/use-reference-lookup.js";
21
22
  import { useLocale, useOptionalTimeZone, useTranslation } from "../i18n.js";
22
23
  import { InsideDrawerProvider, useInsideDrawer } from "../inside-drawer.js";
24
+ import { requestLeave } from "../leave-guard.js";
23
25
  import { PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "../page-header-slot.js";
24
26
  import { statusToneForOptionTone, statusToneForValue, usePrimitives, } from "../primitives.js";
25
27
  import { screenFillsHeight } from "../screen-fills-height.js";
@@ -36,7 +38,7 @@ import { FacetCountBridges } from "./facet-count-bridge.js";
36
38
  import { buildFormSchema } from "./form-schema.js";
37
39
  import { HeaderActionsBar } from "./header-actions-bar.js";
38
40
  import { hiddenRequiredFieldsFilled, layoutEditFields, layoutFieldNames } from "./layout-fields.js";
39
- import { isListNarrowed, useListEmptyContent } from "./list-empty-content.js";
41
+ import { isDefaultFilterActive, isListNarrowed, useListEmptyContent, } from "./list-empty-content.js";
40
42
  import { buildDateRangePayload, buildEntityFilterFacets, buildFacetCountRequests, buildFilterFacets, buildFilterPayload, clampDateRange, mergeReferenceFacetOptions, normalizeDefaultFilters, readDateRange, resolveDateRangeFacets, resolveProjectionFacetSpecs, } from "./list-facets.js";
41
43
  import { useInitialValuesHandoff, useNav } from "./nav.js";
42
44
  import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } from "./projection-detail-shim.js";
@@ -93,7 +95,6 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
93
95
  return _jsx(ReturnHostProvider, { value: ownHost, children: body });
94
96
  }
95
97
  function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, children, }) {
96
- const { Banner } = usePrimitives();
97
98
  const result = useQuery(visibleWhen.query, {}, { live: true });
98
99
  const wasShown = useRef(false);
99
100
  const verdict = evalVisibleWhenAfterShown(visibleWhen, result, wasShown.current);
@@ -104,7 +105,7 @@ function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, c
104
105
  if (verdict === "visible")
105
106
  return children;
106
107
  if (verdict === "loading") {
107
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
108
+ return _jsx(QueryLoadingBanner, {});
108
109
  }
109
110
  if (fallback !== undefined) {
110
111
  return _jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate });
@@ -723,7 +724,6 @@ function EntityEditScreen({ schema, screen, translate, entityId, onCopyLink, })
723
724
  return (_jsx(EntityEditCreateOrDisabled, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }) }));
724
725
  }
725
726
  function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
726
- const { Banner } = usePrimitives();
727
727
  const nav = useNav();
728
728
  const handoffValues = useInitialValuesHandoff(screen.id);
729
729
  const appFeatures = useAppFeatures();
@@ -799,7 +799,7 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
799
799
  // Never seed a tenant-declared money field with entityDefaultCurrency while
800
800
  // its real tenant currency is still in flight (fw#2933) — wait instead.
801
801
  if (currencyLoading) {
802
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
802
+ return _jsx(QueryLoadingBanner, {});
803
803
  }
804
804
  // Deliberately no `actions` prop here: `screen.actions` targets an
805
805
  // EXISTING record (publish/archive/duplicate and friends), which the
@@ -812,7 +812,6 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
812
812
  // `<feature>:write:<entity>:update` on submit. `buildPayload` shapes the
813
813
  // snapshot into Kumiko's update-event envelope `{ id, version, changes }`.
814
814
  function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onCopyLink, autoReloadOnSave, onDeleted, }) {
815
- const { Banner } = usePrimitives();
816
815
  const t = useTranslation();
817
816
  const effectiveTranslate = translate ?? t;
818
817
  const detailQn = `${toKebab(schema.featureName)}:query:${toKebab(screen.entity)}:detail`;
@@ -822,11 +821,13 @@ function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onC
822
821
  const returnTargetParam = useReturnTarget(screen.id);
823
822
  const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
824
823
  if (detailQuery.loading && detailQuery.data === null) {
825
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
824
+ return _jsx(QueryLoadingBanner, {});
826
825
  }
827
- if (detailQuery.error) {
828
- return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(detailQuery.error, effectiveTranslate) }));
826
+ if (detailQuery.error && detailQuery.data === null) {
827
+ return (_jsx(QueryErrorBanner, { error: detailQuery.error, translate: effectiveTranslate, onRetry: detailQuery.refetch }));
829
828
  }
829
+ // A failed refetch keeps the last good data on screen.
830
+ const refetchErrorBanner = detailQuery.error ? (_jsx(QueryErrorBanner, { error: detailQuery.error, translate: effectiveTranslate, onRetry: detailQuery.refetch })) : null;
830
831
  const record = detailQuery.data;
831
832
  if (!record) {
832
833
  const onBack = returnTarget !== undefined || findEntityListScreen(schema, screen.entity) !== undefined
@@ -841,10 +842,9 @@ function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onC
841
842
  // weiter die stale version stampen und der Konflikt-Recovery wäre
842
843
  // kaputt.
843
844
  const recordKey = versionOf(record);
844
- return (_jsx(EntityEditUpdateForm, { schema: schema, screen: screen, entity: entity, entityId: entityId, record: record, onReload: detailQuery.refetch, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(autoReloadOnSave === true && { onSaved: () => void detailQuery.refetch() }), ...(onDeleted !== undefined && { onDeleted }) }, `${entityId}:${recordKey}`));
845
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(EntityEditUpdateForm, { schema: schema, screen: screen, entity: entity, entityId: entityId, record: record, onReload: detailQuery.refetch, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(autoReloadOnSave === true && { onSaved: () => void detailQuery.refetch() }), ...(onDeleted !== undefined && { onDeleted }) }, `${entityId}:${recordKey}`)] }));
845
846
  }
846
847
  function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onReload, translate, onCopyLink, onSaved, onDeleted, }) {
847
- const { Banner } = usePrimitives();
848
848
  // Seed the form with the server values for the entity's declared
849
849
  // fields; anything else (id, tenant_id, created_at…) stays out of
850
850
  // the form and lives in the closure. The record's `version` is
@@ -1018,7 +1018,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
1018
1018
  // stored (non-empty) value is unaffected — it never reaches this branch's
1019
1019
  // fallback because `initial` above already prefers `record[name]`.
1020
1020
  if (currencyLoading) {
1021
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1021
+ return _jsx(QueryLoadingBanner, {});
1022
1022
  }
1023
1023
  // Evaluated against the stored record only, so typing in a form can never
1024
1024
  // flip the screen between editable and view.
@@ -1044,23 +1044,24 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
1044
1044
  // existing record via list(limit:1) before deciding create vs update —
1045
1045
  // otherwise every visit without an id would create another record.
1046
1046
  function EntityEditSingletonBody({ schema, screen, entity, translate, onCopyLink, }) {
1047
- const { Banner } = usePrimitives();
1048
1047
  const t = useTranslation();
1049
1048
  const effectiveTranslate = translate ?? t;
1050
1049
  const listQn = entityQueryCommand(schema.featureName, screen.entity, "list");
1051
1050
  const listQuery = useQuery(listQn, { limit: 1 });
1052
1051
  if (listQuery.loading && listQuery.data === null) {
1053
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1052
+ return _jsx(QueryLoadingBanner, {});
1054
1053
  }
1055
- if (listQuery.error) {
1056
- return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(listQuery.error, effectiveTranslate) }));
1054
+ if (listQuery.error && listQuery.data === null) {
1055
+ return (_jsx(QueryErrorBanner, { error: listQuery.error, translate: effectiveTranslate, onRetry: listQuery.refetch }));
1057
1056
  }
1057
+ // A failed refetch keeps the last good data on screen.
1058
+ const refetchErrorBanner = listQuery.error ? (_jsx(QueryErrorBanner, { error: listQuery.error, translate: effectiveTranslate, onRetry: listQuery.refetch })) : null;
1058
1059
  const rawExistingId = listQuery.data?.rows[0]?.["id"];
1059
1060
  const existingId = typeof rawExistingId === "string" ? rawExistingId : undefined;
1060
1061
  if (existingId !== undefined) {
1061
- return (_jsx(EntityEditUpdateBody, { schema: schema, screen: screen, entity: entity, entityId: existingId, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), autoReloadOnSave: true, onDeleted: () => void listQuery.refetch() }));
1062
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(EntityEditUpdateBody, { schema: schema, screen: screen, entity: entity, entityId: existingId, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), autoReloadOnSave: true, onDeleted: () => void listQuery.refetch() })] }));
1062
1063
  }
1063
- return (_jsx(EntityEditCreateOrDisabled, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), onSaved: () => void listQuery.refetch() }));
1064
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(EntityEditCreateOrDisabled, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), onSaved: () => void listQuery.refetch() })] }));
1064
1065
  }
1065
1066
  // Shared no-entityId tail for both the plain create path and the
1066
1067
  // singleton path's empty-table fallback: `allowCreate: false` blocks
@@ -1247,7 +1248,6 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1247
1248
  const featureName = schema.featureName;
1248
1249
  const resolvedCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen, screen.createUnavailable);
1249
1250
  const onCreate = screen.allowCreate === false ? undefined : resolvedCreate;
1250
- const { Banner } = usePrimitives();
1251
1251
  const queryType = entityQueryCommand(featureName, screen.entity, "list");
1252
1252
  const nav = useNav();
1253
1253
  const host = useReturnHost();
@@ -1571,6 +1571,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1571
1571
  refetch: refreshRowsAfterWrite,
1572
1572
  openDrawer,
1573
1573
  host,
1574
+ access: { appFeatures, userRoles },
1574
1575
  }), [
1575
1576
  screen.toolbarActions,
1576
1577
  effectiveTranslate,
@@ -1579,6 +1580,8 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1579
1580
  dispatcher,
1580
1581
  refreshRowsAfterWrite,
1581
1582
  openDrawer,
1583
+ appFeatures,
1584
+ userRoles,
1582
1585
  ]);
1583
1586
  const emptyContent = useListEmptyContent(screen.emptyState, effectiveTranslate, {
1584
1587
  nav,
@@ -1618,11 +1621,13 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1618
1621
  invalidateExpansions,
1619
1622
  ]);
1620
1623
  if (rowsQuery.loading && rowsQuery.data === null) {
1621
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1624
+ return _jsx(QueryLoadingBanner, {});
1622
1625
  }
1623
- if (rowsQuery.error) {
1624
- return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) }));
1626
+ if (rowsQuery.error && rowsQuery.data === null) {
1627
+ return (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch }));
1625
1628
  }
1629
+ // A failed refetch keeps the last good data on screen.
1630
+ const refetchErrorBanner = rowsQuery.error ? (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch })) : null;
1626
1631
  // RenderList's onRowClick is a 1-arg callback; KumikoScreen's
1627
1632
  // 2-arg shape (row, entityName) is the public surface — thread the
1628
1633
  // screen's entity through here so callers don't have to re-derive
@@ -1665,7 +1670,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1665
1670
  // Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
1666
1671
  // useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
1667
1672
  const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
1668
- return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, narrowed: isListNarrowed(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
1673
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, narrowed: isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
1669
1674
  description: effectiveTranslate(screen.description),
1670
1675
  }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1671
1676
  ? { screenPadding: false, chromeless: true }
@@ -1837,6 +1842,7 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1837
1842
  refetch: rowsQuery.refetch,
1838
1843
  openDrawer,
1839
1844
  host,
1845
+ access: { appFeatures, userRoles },
1840
1846
  }), [
1841
1847
  screen.toolbarActions,
1842
1848
  effectiveTranslate,
@@ -1845,6 +1851,8 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1845
1851
  dispatcher,
1846
1852
  rowsQuery.refetch,
1847
1853
  openDrawer,
1854
+ appFeatures,
1855
+ userRoles,
1848
1856
  ]);
1849
1857
  const emptyContent = useListEmptyContent(screen.emptyState, effectiveTranslate, {
1850
1858
  nav,
@@ -1854,11 +1862,13 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1854
1862
  onWriteSuccess: rowsQuery.refetch,
1855
1863
  });
1856
1864
  if (rowsQuery.loading && rowsQuery.data === null) {
1857
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1865
+ return _jsx(QueryLoadingBanner, {});
1858
1866
  }
1859
- if (rowsQuery.error) {
1860
- return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) }));
1867
+ if (rowsQuery.error && rowsQuery.data === null) {
1868
+ return (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch }));
1861
1869
  }
1870
+ // A failed refetch keeps the last good data on screen.
1871
+ const refetchErrorBanner = rowsQuery.error ? (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch })) : null;
1862
1872
  // rowsQuery.data is typed as PagedRows but the query handler is free to
1863
1873
  // return anything at runtime (fw#2216: a bare array silently rendered an
1864
1874
  // empty table instead of surfacing the mismatch); a system-boundary cast
@@ -1889,7 +1899,7 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1889
1899
  onPageSizeChange: urlState.setPageSize,
1890
1900
  }
1891
1901
  : undefined;
1892
- return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleProjectionFacetOptions }), _jsx(RenderList, { screen: listScreen, entity: entity, rows: rowsQuery.data?.rows ?? [], featureName: schema.featureName, searchable: searchable, narrowed: isListNarrowed(activeSearch, urlState.filters), ...(screen.description !== undefined && {
1902
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleProjectionFacetOptions }), _jsx(RenderList, { screen: listScreen, entity: entity, rows: rowsQuery.data?.rows ?? [], featureName: schema.featureName, searchable: searchable, narrowed: isListNarrowed(activeSearch, urlState.filters), ...(screen.description !== undefined && {
1893
1903
  description: effectiveTranslate(screen.description),
1894
1904
  }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: activeSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1895
1905
  ? { screenPadding: false, chromeless: true }
@@ -2180,13 +2190,13 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2180
2190
  host,
2181
2191
  dispatcher,
2182
2192
  openDrawer,
2183
- onWriteSuccess: detailQuery.refetch,
2193
+ onWriteSuccess: reloadDetail,
2184
2194
  onRecordLeft: handleRecordLeft,
2185
2195
  sameEntityScreenId,
2186
2196
  clientActions,
2187
2197
  }), [
2188
2198
  detailQuery.data,
2189
- detailQuery.refetch,
2199
+ reloadDetail,
2190
2200
  effectiveTranslate,
2191
2201
  nav,
2192
2202
  host,
@@ -2206,7 +2216,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2206
2216
  host,
2207
2217
  dispatcher,
2208
2218
  openDrawer,
2209
- onWriteSuccess: detailQuery.refetch,
2219
+ onWriteSuccess: reloadDetail,
2210
2220
  onRecordLeft: handleRecordLeft,
2211
2221
  sameEntityScreenId,
2212
2222
  clientActions,
@@ -2223,7 +2233,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2223
2233
  dispatcher,
2224
2234
  clientActions,
2225
2235
  detailQuery.data,
2226
- detailQuery.refetch,
2236
+ reloadDetail,
2227
2237
  openDrawer,
2228
2238
  handleRecordLeft,
2229
2239
  sameEntityScreenId,
@@ -2248,11 +2258,13 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2248
2258
  return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-projection-detail-missing-id", children: ["Screen ", _jsx(Text, { variant: "code", children: screen.id }), " (projectionDetail) needs a row id in the path \u2014 open it from a row action."] }));
2249
2259
  }
2250
2260
  if (detailQuery.loading && detailQuery.data === null) {
2251
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2261
+ return _jsx(QueryLoadingBanner, {});
2252
2262
  }
2253
- if (detailQuery.error) {
2254
- return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(detailQuery.error, effectiveTranslate) }));
2263
+ if (detailQuery.error && detailQuery.data === null) {
2264
+ return (_jsx(QueryErrorBanner, { error: detailQuery.error, translate: effectiveTranslate, onRetry: detailQuery.refetch }));
2255
2265
  }
2266
+ // A failed refetch keeps the last good record on screen.
2267
+ const refetchErrorBanner = detailQuery.error ? (_jsx(QueryErrorBanner, { error: detailQuery.error, translate: effectiveTranslate, onRetry: detailQuery.refetch })) : null;
2256
2268
  const record = detailQuery.data;
2257
2269
  if (!record) {
2258
2270
  const backListScreen = screen.listScreenId ??
@@ -2336,8 +2348,8 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2336
2348
  label: effectiveTranslate(section.title ?? section.id ?? ""),
2337
2349
  ...(count !== undefined && { count }),
2338
2350
  };
2339
- }), activeId: activeSection.id ?? "", onSelect: (id) => nav.setSearchParams({ tab: id }) }))] }));
2340
- return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { refreshToken: reloadNonce, screen: renderedDetailScreen, entity: entity, featureName: schema.featureName, initial: record, entityId: effectiveEntityId, customSubmit: async () => ({ isSuccess: true, validationBlocked: false, data: undefined }), onReload: reloadDetail, onRelatedListDrawerAction: openDrawer, ...(translate !== undefined && { translate }), ...(hasTabs && { hideSectionTitles: true }), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...((hasHeaderCard ||
2351
+ }), activeId: activeSection.id ?? "", onSelect: (id) => requestLeave(() => nav.setSearchParams({ tab: id })) }))] }));
2352
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(RenderEdit, { refreshToken: reloadNonce, screen: renderedDetailScreen, entity: entity, featureName: schema.featureName, initial: record, entityId: effectiveEntityId, customSubmit: async () => ({ isSuccess: true, validationBlocked: false, data: undefined }), onReload: reloadDetail, onRelatedListDrawerAction: openDrawer, ...(translate !== undefined && { translate }), ...(hasTabs && { hideSectionTitles: true }), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...((hasHeaderCard ||
2341
2353
  hasTabs ||
2342
2354
  screen.slots?.header !== undefined ||
2343
2355
  screen.recordTitleField !== undefined) && {
@@ -2549,6 +2561,7 @@ function ActionFormBody({ schema, screen, translate, entityId, initialOverrides,
2549
2561
  handoffValues,
2550
2562
  loadedValues,
2551
2563
  ]);
2564
+ const derivedInitial = useMemo(() => withDerivedValues(initial, screen.derive !== undefined ? screen : undefined), [initial, screen]);
2552
2565
  const t = useTranslation();
2553
2566
  const effectiveTranslate = translate ?? t;
2554
2567
  const summary = useMemo(() => resolveActionFormSummary(screen.summary, initialOverrides, effectiveTranslate, locale), [screen.summary, initialOverrides, effectiveTranslate, locale]);
@@ -2642,7 +2655,7 @@ function ActionFormBody({ schema, screen, translate, entityId, initialOverrides,
2642
2655
  // Rendering the form before a tenant-declared currency lands would show the
2643
2656
  // fallback and, on a fast click, submit it (fw#2933) — hold instead.
2644
2657
  if (currencyLoading) {
2645
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2658
+ return _jsx(QueryLoadingBanner, {});
2646
2659
  }
2647
2660
  if (currencyUnresolved) {
2648
2661
  return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-currency-unresolved", children: "The tenant currency could not be loaded, so this form cannot be submitted." }));
@@ -2650,7 +2663,7 @@ function ActionFormBody({ schema, screen, translate, entityId, initialOverrides,
2650
2663
  if (loadState !== undefined && loadState.status !== "ready") {
2651
2664
  return (_jsx(ActionFormLoadStatus, { state: loadState, screenId: screen.id, translate: effectiveTranslate }));
2652
2665
  }
2653
- return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial,
2666
+ return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: derivedInitial, ...(screen.derive !== undefined && { formDerivation: screen }), actionFormScreenQn: qualifyScreenId(schema.featureName, screen.id),
2654
2667
  // Extension-Sections im actionForm sehen die initialen Form-Values
2655
2668
  // (inkl. searchParams-Prefill, z.B. ?incidentId=…) als initialValues
2656
2669
  // — anders als im entityEdit gibt es hier keinen record, aus dem
@@ -4,5 +4,6 @@ import type { Translate } from "../headless/index.ts";
4
4
  import { buildRecordActions } from "./row-actions.ts";
5
5
  type ActionBindings = Pick<Parameters<typeof buildRecordActions>[0], "nav" | "host" | "dispatcher" | "openDrawer" | "onWriteSuccess" | "clientActions">;
6
6
  export declare function useListEmptyContent(spec: ListEmptyStateSpec | undefined, translate: Translate, bindings: ActionBindings): ListEmptyStateContent | undefined;
7
+ export declare function isDefaultFilterActive(search: string, filters: Readonly<Record<string, readonly string[]>>, defaultFilters: Readonly<Record<string, readonly string[]>>): boolean;
7
8
  export declare function isListNarrowed(search: string, filters: Readonly<Record<string, readonly string[]>>, defaultFilters?: Readonly<Record<string, readonly string[]>>): boolean;
8
9
  export {};
@@ -27,6 +27,11 @@ export function useListEmptyContent(spec, translate, bindings) {
27
27
  };
28
28
  }, [spec, translate, nav, host, dispatcher, openDrawer, onWriteSuccess, clientActions]);
29
29
  }
30
+ // Empty although the user changed nothing: a default filter hides rows the list would otherwise show.
31
+ export function isDefaultFilterActive(search, filters, defaultFilters) {
32
+ return (!isListNarrowed(search, filters, defaultFilters) &&
33
+ Object.values(defaultFilters).some((values) => values.length > 0));
34
+ }
30
35
  // Facets with a default are only "narrowed" once the user moved off that default.
31
36
  export function isListNarrowed(search, filters, defaultFilters = {}) {
32
37
  if (search.trim() !== "")
@@ -1,4 +1,4 @@
1
- import type { EntityEditScreenDefinition, RelatedListToolbarAction, RowAction, RowActionClientAction, RowActionDrawer, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ToolbarAction } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { EntityEditScreenDefinition, FeatureSchema, RelatedListToolbarAction, RowAction, RowActionClientAction, RowActionDrawer, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ToolbarAction } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import type { RenderEditAction } from "../components/render-edit-types.ts";
3
3
  import type { ToolbarActionButton } from "../components/render-list.tsx";
4
4
  import type { Dispatcher, ListRowViewModel, Translate } from "../headless/index.ts";
@@ -59,6 +59,12 @@ export declare function buildProjectionToolbarActions(options: {
59
59
  * entityList/projectionList toolbars have no record and omit this. */
60
60
  readonly record?: Readonly<Record<string, unknown>>;
61
61
  readonly host: ReturnHost | undefined;
62
+ /** With the user's roles, a navigate action is dropped when the user may not open its target
63
+ * screen. Omitted (embedded or test hosts without a screen registry) keeps every action. */
64
+ readonly access?: {
65
+ readonly appFeatures: readonly FeatureSchema[];
66
+ readonly userRoles: readonly string[] | undefined;
67
+ };
62
68
  }): readonly ToolbarActionButton[] | undefined;
63
69
  export type DiscardChanges = {
64
70
  readonly confirm: string;
@@ -3,6 +3,7 @@ import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
3
3
  import { runClientAction } from "./client-actions.js";
4
4
  import { lastSegment } from "./qn.js";
5
5
  import { navigateWithReturnTo } from "./return-to.js";
6
+ import { navigateTargetAllows } from "./screen-access.js";
6
7
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error.js";
7
8
  // entityId is explicit: the edit screen may live in another feature than
8
9
  // the row source, where the same-feature fallback would miss it.
@@ -306,7 +307,7 @@ function buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher)
306
307
  // Shared by entityList, projectionList, and relatedList toolbars so the
307
308
  // three call sites can't drift apart.
308
309
  export function buildProjectionToolbarActions(options) {
309
- const { toolbarActions, translate, dispatcher, nav, refetch, openDrawer, navigatePrefill, record, host, } = options;
310
+ const { toolbarActions, translate, dispatcher, nav, refetch, openDrawer, navigatePrefill, record, host, access, } = options;
310
311
  if (toolbarActions === undefined)
311
312
  return undefined;
312
313
  const out = [];
@@ -317,6 +318,10 @@ export function buildProjectionToolbarActions(options) {
317
318
  continue;
318
319
  }
319
320
  if (action.kind === "navigate") {
321
+ if (access !== undefined &&
322
+ !navigateTargetAllows({ screen: action.screen }, access.appFeatures, access.userRoles)) {
323
+ continue;
324
+ }
320
325
  out.push(buildNavigateToolbarAction(action, translate, nav, host, navigatePrefill, record));
321
326
  continue;
322
327
  }
@@ -9,6 +9,7 @@ import { buildInitialValues, mergeSearchParamsIntoInitial, tenantCurrencyMoneyFi
9
9
  import { hiddenRequiredFieldsFilled, layoutFieldNames } from "./layout-fields.js";
10
10
  import { useInitialValuesHandoff, useNav } from "./nav.js";
11
11
  import { lastSegment } from "./qn.js";
12
+ import { QueryLoadingBanner } from "./query-state-banners.js";
12
13
  import { navigateToReturnOr, useReturnTarget } from "./return-to.js";
13
14
  function extractRevealValue(data, field) {
14
15
  if (typeof data !== "object" || data === null)
@@ -189,14 +190,14 @@ export function SecretMintBody({ schema, screen, translate }) {
189
190
  // Hold until a tenant-declared currency lands, else the form would seed (and
190
191
  // on a fast click submit) the fallback currency (fw#2933).
191
192
  if (confirmCurrencyLoading) {
192
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
193
+ return _jsx(QueryLoadingBanner, {});
193
194
  }
194
195
  return (_jsx(RenderEdit, { screen: confirmScreen, i18nScreenId: screen.id, entity: confirmEntity, featureName: schema.featureName, initial: confirmInitial ?? {}, writeCommand: confirm.handler, payloadMode: "values", buildPayload: (snapshot) => ({ ...snapshot.values, ...carriedRef.current }), onSubmit: handleConfirmSubmitted, onCancel: handleCancel ?? restartMint, fillScreenHeight: true, submitBlocked: acknowledgeBlocked, leadContent: _jsx(Section, { testId: "kumiko-screen-secret-mint-card", children: revealContent }), ...(translate !== undefined && { translate }), ...(confirm.submitLabel !== undefined && { submitLabel: confirm.submitLabel }) }, "confirm"));
195
196
  }
196
197
  return (_jsxs(Card, { options: { screenBody: true }, testId: "kumiko-screen-secret-mint-card", children: [revealContent, _jsx(Button, { type: "button", variant: "primary", onClick: finishMint, disabled: acknowledgeBlocked, testId: "kumiko-screen-secret-mint-confirm", children: effectiveTranslate(screen.reveal.confirmLabel ?? "kumiko.secretMint.confirm") })] }));
197
198
  }
198
199
  if (mintCurrencyLoading) {
199
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
200
+ return _jsx(QueryLoadingBanner, {});
200
201
  }
201
202
  return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, extensionInitialValues: initial, ...(screen.submitPrefilled === true && {
202
203
  submitWithoutChanges: hiddenRequiredFieldsFilled(synthScreen, screen.fields, initial),
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
3
  import { computeDerivedCellValue, currencyDecimals, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "../headless/index.js";
4
+ import { pluralKey } from "../headless/plural-key.js";
4
5
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
5
6
  import { useQuery } from "../hooks/use-query.js";
6
7
  import { referenceOptionSource } from "../hooks/use-reference-lookup.js";
@@ -158,12 +159,20 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
158
159
  ...(cell.display !== undefined && { display: cell.display }),
159
160
  };
160
161
  });
161
- const totals = (field.embeddedListTotals ?? []).map((subFieldName) => {
162
- const cell = cells.find((c) => c.field === subFieldName);
162
+ const totals = (field.embeddedListTotals ?? []).map((entry) => {
163
+ if (typeof entry === "string") {
164
+ const cell = cells.find((c) => c.field === entry);
165
+ return {
166
+ field: entry,
167
+ label: cell?.label ?? entry,
168
+ value: sumEmbeddedListColumn(rows, entry),
169
+ };
170
+ }
163
171
  return {
164
- field: subFieldName,
165
- label: cell?.label ?? subFieldName,
166
- value: sumEmbeddedListColumn(rows, subFieldName),
172
+ field: entry.from.join("+"),
173
+ label: t(entry.label),
174
+ value: entry.from.reduce((sum, name) => sum + sumEmbeddedListColumn(rows, name), 0),
175
+ money: entry.from.every((name) => cells.find((c) => c.field === name)?.type === "money"),
167
176
  };
168
177
  });
169
178
  const { listIssues, rowIssues, cellIssues } = groupEmbeddedListIssues(allIssues, field.field);
@@ -176,7 +185,7 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
176
185
  {
177
186
  path: field.field,
178
187
  code: "paste-rows-truncated",
179
- i18nKey: "kumiko.field.embedded-list.paste-rows-truncated",
188
+ i18nKey: pluralKey("kumiko.field.embedded-list.paste-rows-truncated", pasteWarning.droppedRows),
180
189
  params: { count: pasteWarning.droppedRows },
181
190
  },
182
191
  ]
@@ -186,7 +195,7 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
186
195
  {
187
196
  path: field.field,
188
197
  code: "paste-cells-unmatched",
189
- i18nKey: "kumiko.field.embedded-list.paste-cells-unmatched",
198
+ i18nKey: pluralKey("kumiko.field.embedded-list.paste-cells-unmatched", pasteWarning.unmatchedCells),
190
199
  params: { count: pasteWarning.unmatchedCells },
191
200
  },
192
201
  ]
@@ -25,12 +25,16 @@ export function FileListField({ field, id, kind, onChange, hasError, }) {
25
25
  ...(field.accept !== undefined && { accept: field.accept }),
26
26
  ...(field.maxSize !== undefined && { maxSize: field.maxSize }),
27
27
  ...(field.entityType !== undefined && { entityType: field.entityType }),
28
+ ...(field.uploadActionForm !== undefined && { uploadActionForm: field.uploadActionForm }),
28
29
  ...(field.entityId !== undefined && { entityId: field.entityId }),
29
30
  ...(field.fieldName !== undefined && { fieldName: field.fieldName }),
30
31
  ...(kind === "image" &&
31
32
  field.imageVariant !== undefined && { imageVariant: field.imageVariant }),
32
33
  ...(kind === "image" && field.capture !== undefined && { capture: field.capture }),
33
34
  };
35
+ if (field.fileDisplay === "dropzone") {
36
+ return (_jsx(Input, { kind: "files-dropzone", id: id, name: field.field, value: fileIds, onChange: onChange, disabled: disabled, hasError: hasError, testId: `${id}-file-list`, ...(field.accept !== undefined && { accept: field.accept }), ...(field.maxSize !== undefined && { maxSize: field.maxSize }), ...(field.maxCount !== undefined && { maxCount: field.maxCount }), ...(field.entityType !== undefined && { entityType: field.entityType }), ...(field.uploadActionForm !== undefined && { uploadActionForm: field.uploadActionForm }), ...(field.entityId !== undefined && { entityId: field.entityId }), ...(field.fieldName !== undefined && { fieldName: field.fieldName }) }));
37
+ }
34
38
  function move(fromIndex, toIndex) {
35
39
  const next = [...fileIds];
36
40
  const [moved] = next.splice(fromIndex, 1);
@@ -0,0 +1,8 @@
1
+ import type { FormDerivationScreen } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ /** The derived values that differ from the current form values, or undefined
3
+ * when everything is already up to date. A derived value that no longer
4
+ * resolves is reported as `undefined`. */
5
+ export declare function changedDerivedValues(values: unknown, screen: FormDerivationScreen): Record<string, unknown> | undefined;
6
+ /** `values` with every derived field already resolved, so the form's baseline
7
+ * matches what the live recomputation would produce and starts out clean. */
8
+ export declare function withDerivedValues<TValues extends Record<string, unknown>>(values: TValues, screen: FormDerivationScreen | undefined): TValues;
@@ -0,0 +1,24 @@
1
+ import { applyFormDerivations, isUnchangedValue } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
+ /** The derived values that differ from the current form values, or undefined
4
+ * when everything is already up to date. A derived value that no longer
5
+ * resolves is reported as `undefined`. */
6
+ export function changedDerivedValues(values, screen) {
7
+ const next = applyFormDerivations(values, screen);
8
+ if (!isPlainObject(values) || !isPlainObject(next))
9
+ return undefined;
10
+ const patch = {};
11
+ for (const target of Object.keys(screen.derive ?? {})) {
12
+ if (!isUnchangedValue(values[target], next[target]))
13
+ patch[target] = next[target];
14
+ }
15
+ return Object.keys(patch).length === 0 ? undefined : patch;
16
+ }
17
+ /** `values` with every derived field already resolved, so the form's baseline
18
+ * matches what the live recomputation would produce and starts out clean. */
19
+ export function withDerivedValues(values, screen) {
20
+ if (screen === undefined)
21
+ return values;
22
+ const patch = changedDerivedValues(values, screen);
23
+ return patch === undefined ? values : { ...values, ...patch };
24
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from "react";
2
+ import { type FormWidth } from "../primitives.tsx";
3
+ export type FormScreenLayoutProps = {
4
+ readonly children: ReactNode;
5
+ readonly description?: ReactNode;
6
+ readonly actions?: ReactNode;
7
+ readonly secondaryActions?: ReactNode;
8
+ readonly onSubmit?: () => void;
9
+ readonly width?: FormWidth;
10
+ readonly testId?: string;
11
+ };
12
+ /** For custom screens: the standard form screen layout; fill it with `Section` (title, icon, subtitle). */
13
+ export declare function FormScreenLayout({ children, description, actions, secondaryActions, onSubmit, width, testId, }: FormScreenLayoutProps): ReactNode;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { usePrimitives } from "../primitives.js";
3
+ const FORM_SCREEN_LAYOUT_PROPS = {
4
+ screenForm: true,
5
+ fillHeight: true,
6
+ stickyActions: true,
7
+ };
8
+ const NOOP = () => { };
9
+ /** For custom screens: the standard form screen layout; fill it with `Section` (title, icon, subtitle). */
10
+ export function FormScreenLayout({ children, description, actions, secondaryActions, onSubmit = NOOP, width, testId, }) {
11
+ const { Form } = usePrimitives();
12
+ return (_jsx(Form, { onSubmit: onSubmit, ...FORM_SCREEN_LAYOUT_PROPS, ...(description !== undefined && { subtitle: description }), ...(actions !== undefined && { actions }), ...(secondaryActions !== undefined && { secondaryActions }), ...(width !== undefined && { width }), ...(testId !== undefined && { testId }), children: children }));
13
+ }
@@ -28,6 +28,9 @@ export type GridCellForFieldProps = {
28
28
  readonly accent?: boolean;
29
29
  /** Passed through to RenderField — see RenderFieldProps.fillDisabled. */
30
30
  readonly fillDisabled?: boolean;
31
+ /** Title the surrounding section shows. An embedded list whose label equals it
32
+ * keeps its label for screen readers only, so it is not printed twice. */
33
+ readonly sectionTitle?: string;
31
34
  };
32
35
  export declare function fieldCellWidth(field: EditFieldViewModel): FieldCellWidth;
33
- export declare function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, }: GridCellForFieldProps): ReactNode;
36
+ export declare function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, sectionTitle, }: GridCellForFieldProps): ReactNode;