@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
@@ -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
  }
@@ -21,6 +21,7 @@ import { useQuery } from "../hooks/use-query.js";
21
21
  import { referenceLookupSource } from "../hooks/use-reference-lookup.js";
22
22
  import { useLocale, useOptionalTimeZone, useTranslation } from "../i18n.js";
23
23
  import { InsideDrawerProvider, useInsideDrawer } from "../inside-drawer.js";
24
+ import { requestLeave } from "../leave-guard.js";
24
25
  import { PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "../page-header-slot.js";
25
26
  import { statusToneForOptionTone, statusToneForValue, usePrimitives, } from "../primitives.js";
26
27
  import { screenFillsHeight } from "../screen-fills-height.js";
@@ -37,7 +38,7 @@ import { FacetCountBridges } from "./facet-count-bridge.js";
37
38
  import { buildFormSchema } from "./form-schema.js";
38
39
  import { HeaderActionsBar } from "./header-actions-bar.js";
39
40
  import { hiddenRequiredFieldsFilled, layoutEditFields, layoutFieldNames } from "./layout-fields.js";
40
- import { isListNarrowed, useListEmptyContent } from "./list-empty-content.js";
41
+ import { isDefaultFilterActive, isListNarrowed, useListEmptyContent, } from "./list-empty-content.js";
41
42
  import { buildDateRangePayload, buildEntityFilterFacets, buildFacetCountRequests, buildFilterFacets, buildFilterPayload, clampDateRange, mergeReferenceFacetOptions, normalizeDefaultFilters, readDateRange, resolveDateRangeFacets, resolveProjectionFacetSpecs, } from "./list-facets.js";
42
43
  import { useInitialValuesHandoff, useNav } from "./nav.js";
43
44
  import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } from "./projection-detail-shim.js";
@@ -94,7 +95,6 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
94
95
  return _jsx(ReturnHostProvider, { value: ownHost, children: body });
95
96
  }
96
97
  function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, children, }) {
97
- const { Banner } = usePrimitives();
98
98
  const result = useQuery(visibleWhen.query, {}, { live: true });
99
99
  const wasShown = useRef(false);
100
100
  const verdict = evalVisibleWhenAfterShown(visibleWhen, result, wasShown.current);
@@ -105,7 +105,7 @@ function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, c
105
105
  if (verdict === "visible")
106
106
  return children;
107
107
  if (verdict === "loading") {
108
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
108
+ return _jsx(QueryLoadingBanner, {});
109
109
  }
110
110
  if (fallback !== undefined) {
111
111
  return _jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate });
@@ -724,7 +724,6 @@ function EntityEditScreen({ schema, screen, translate, entityId, onCopyLink, })
724
724
  return (_jsx(EntityEditCreateOrDisabled, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }) }));
725
725
  }
726
726
  function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
727
- const { Banner } = usePrimitives();
728
727
  const nav = useNav();
729
728
  const handoffValues = useInitialValuesHandoff(screen.id);
730
729
  const appFeatures = useAppFeatures();
@@ -800,7 +799,7 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
800
799
  // Never seed a tenant-declared money field with entityDefaultCurrency while
801
800
  // its real tenant currency is still in flight (fw#2933) — wait instead.
802
801
  if (currencyLoading) {
803
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
802
+ return _jsx(QueryLoadingBanner, {});
804
803
  }
805
804
  // Deliberately no `actions` prop here: `screen.actions` targets an
806
805
  // EXISTING record (publish/archive/duplicate and friends), which the
@@ -813,7 +812,6 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
813
812
  // `<feature>:write:<entity>:update` on submit. `buildPayload` shapes the
814
813
  // snapshot into Kumiko's update-event envelope `{ id, version, changes }`.
815
814
  function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onCopyLink, autoReloadOnSave, onDeleted, }) {
816
- const { Banner } = usePrimitives();
817
815
  const t = useTranslation();
818
816
  const effectiveTranslate = translate ?? t;
819
817
  const detailQn = `${toKebab(schema.featureName)}:query:${toKebab(screen.entity)}:detail`;
@@ -823,11 +821,13 @@ function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onC
823
821
  const returnTargetParam = useReturnTarget(screen.id);
824
822
  const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
825
823
  if (detailQuery.loading && detailQuery.data === null) {
826
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
824
+ return _jsx(QueryLoadingBanner, {});
827
825
  }
828
- if (detailQuery.error) {
829
- 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 }));
830
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;
831
831
  const record = detailQuery.data;
832
832
  if (!record) {
833
833
  const onBack = returnTarget !== undefined || findEntityListScreen(schema, screen.entity) !== undefined
@@ -842,10 +842,9 @@ function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onC
842
842
  // weiter die stale version stampen und der Konflikt-Recovery wäre
843
843
  // kaputt.
844
844
  const recordKey = versionOf(record);
845
- 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}`)] }));
846
846
  }
847
847
  function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onReload, translate, onCopyLink, onSaved, onDeleted, }) {
848
- const { Banner } = usePrimitives();
849
848
  // Seed the form with the server values for the entity's declared
850
849
  // fields; anything else (id, tenant_id, created_at…) stays out of
851
850
  // the form and lives in the closure. The record's `version` is
@@ -1019,7 +1018,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
1019
1018
  // stored (non-empty) value is unaffected — it never reaches this branch's
1020
1019
  // fallback because `initial` above already prefers `record[name]`.
1021
1020
  if (currencyLoading) {
1022
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1021
+ return _jsx(QueryLoadingBanner, {});
1023
1022
  }
1024
1023
  // Evaluated against the stored record only, so typing in a form can never
1025
1024
  // flip the screen between editable and view.
@@ -1045,23 +1044,24 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
1045
1044
  // existing record via list(limit:1) before deciding create vs update —
1046
1045
  // otherwise every visit without an id would create another record.
1047
1046
  function EntityEditSingletonBody({ schema, screen, entity, translate, onCopyLink, }) {
1048
- const { Banner } = usePrimitives();
1049
1047
  const t = useTranslation();
1050
1048
  const effectiveTranslate = translate ?? t;
1051
1049
  const listQn = entityQueryCommand(schema.featureName, screen.entity, "list");
1052
1050
  const listQuery = useQuery(listQn, { limit: 1 });
1053
1051
  if (listQuery.loading && listQuery.data === null) {
1054
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1052
+ return _jsx(QueryLoadingBanner, {});
1055
1053
  }
1056
- if (listQuery.error) {
1057
- 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 }));
1058
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;
1059
1059
  const rawExistingId = listQuery.data?.rows[0]?.["id"];
1060
1060
  const existingId = typeof rawExistingId === "string" ? rawExistingId : undefined;
1061
1061
  if (existingId !== undefined) {
1062
- 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() })] }));
1063
1063
  }
1064
- 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() })] }));
1065
1065
  }
1066
1066
  // Shared no-entityId tail for both the plain create path and the
1067
1067
  // singleton path's empty-table fallback: `allowCreate: false` blocks
@@ -1248,7 +1248,6 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1248
1248
  const featureName = schema.featureName;
1249
1249
  const resolvedCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen, screen.createUnavailable);
1250
1250
  const onCreate = screen.allowCreate === false ? undefined : resolvedCreate;
1251
- const { Banner } = usePrimitives();
1252
1251
  const queryType = entityQueryCommand(featureName, screen.entity, "list");
1253
1252
  const nav = useNav();
1254
1253
  const host = useReturnHost();
@@ -1572,6 +1571,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1572
1571
  refetch: refreshRowsAfterWrite,
1573
1572
  openDrawer,
1574
1573
  host,
1574
+ access: { appFeatures, userRoles },
1575
1575
  }), [
1576
1576
  screen.toolbarActions,
1577
1577
  effectiveTranslate,
@@ -1580,6 +1580,8 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1580
1580
  dispatcher,
1581
1581
  refreshRowsAfterWrite,
1582
1582
  openDrawer,
1583
+ appFeatures,
1584
+ userRoles,
1583
1585
  ]);
1584
1586
  const emptyContent = useListEmptyContent(screen.emptyState, effectiveTranslate, {
1585
1587
  nav,
@@ -1619,11 +1621,13 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1619
1621
  invalidateExpansions,
1620
1622
  ]);
1621
1623
  if (rowsQuery.loading && rowsQuery.data === null) {
1622
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1624
+ return _jsx(QueryLoadingBanner, {});
1623
1625
  }
1624
- if (rowsQuery.error) {
1625
- 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 }));
1626
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;
1627
1631
  // RenderList's onRowClick is a 1-arg callback; KumikoScreen's
1628
1632
  // 2-arg shape (row, entityName) is the public surface — thread the
1629
1633
  // screen's entity through here so callers don't have to re-derive
@@ -1666,7 +1670,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1666
1670
  // Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
1667
1671
  // useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
1668
1672
  const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
1669
- 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 && {
1670
1674
  description: effectiveTranslate(screen.description),
1671
1675
  }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1672
1676
  ? { screenPadding: false, chromeless: true }
@@ -1838,6 +1842,7 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1838
1842
  refetch: rowsQuery.refetch,
1839
1843
  openDrawer,
1840
1844
  host,
1845
+ access: { appFeatures, userRoles },
1841
1846
  }), [
1842
1847
  screen.toolbarActions,
1843
1848
  effectiveTranslate,
@@ -1846,6 +1851,8 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1846
1851
  dispatcher,
1847
1852
  rowsQuery.refetch,
1848
1853
  openDrawer,
1854
+ appFeatures,
1855
+ userRoles,
1849
1856
  ]);
1850
1857
  const emptyContent = useListEmptyContent(screen.emptyState, effectiveTranslate, {
1851
1858
  nav,
@@ -1855,11 +1862,13 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1855
1862
  onWriteSuccess: rowsQuery.refetch,
1856
1863
  });
1857
1864
  if (rowsQuery.loading && rowsQuery.data === null) {
1858
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1865
+ return _jsx(QueryLoadingBanner, {});
1859
1866
  }
1860
- if (rowsQuery.error) {
1861
- 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 }));
1862
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;
1863
1872
  // rowsQuery.data is typed as PagedRows but the query handler is free to
1864
1873
  // return anything at runtime (fw#2216: a bare array silently rendered an
1865
1874
  // empty table instead of surfacing the mismatch); a system-boundary cast
@@ -1890,7 +1899,7 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1890
1899
  onPageSizeChange: urlState.setPageSize,
1891
1900
  }
1892
1901
  : undefined;
1893
- 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 && {
1894
1903
  description: effectiveTranslate(screen.description),
1895
1904
  }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: activeSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1896
1905
  ? { screenPadding: false, chromeless: true }
@@ -2181,13 +2190,13 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2181
2190
  host,
2182
2191
  dispatcher,
2183
2192
  openDrawer,
2184
- onWriteSuccess: detailQuery.refetch,
2193
+ onWriteSuccess: reloadDetail,
2185
2194
  onRecordLeft: handleRecordLeft,
2186
2195
  sameEntityScreenId,
2187
2196
  clientActions,
2188
2197
  }), [
2189
2198
  detailQuery.data,
2190
- detailQuery.refetch,
2199
+ reloadDetail,
2191
2200
  effectiveTranslate,
2192
2201
  nav,
2193
2202
  host,
@@ -2207,7 +2216,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2207
2216
  host,
2208
2217
  dispatcher,
2209
2218
  openDrawer,
2210
- onWriteSuccess: detailQuery.refetch,
2219
+ onWriteSuccess: reloadDetail,
2211
2220
  onRecordLeft: handleRecordLeft,
2212
2221
  sameEntityScreenId,
2213
2222
  clientActions,
@@ -2224,7 +2233,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2224
2233
  dispatcher,
2225
2234
  clientActions,
2226
2235
  detailQuery.data,
2227
- detailQuery.refetch,
2236
+ reloadDetail,
2228
2237
  openDrawer,
2229
2238
  handleRecordLeft,
2230
2239
  sameEntityScreenId,
@@ -2249,11 +2258,13 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2249
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."] }));
2250
2259
  }
2251
2260
  if (detailQuery.loading && detailQuery.data === null) {
2252
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2261
+ return _jsx(QueryLoadingBanner, {});
2253
2262
  }
2254
- if (detailQuery.error) {
2255
- 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 }));
2256
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;
2257
2268
  const record = detailQuery.data;
2258
2269
  if (!record) {
2259
2270
  const backListScreen = screen.listScreenId ??
@@ -2337,8 +2348,8 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2337
2348
  label: effectiveTranslate(section.title ?? section.id ?? ""),
2338
2349
  ...(count !== undefined && { count }),
2339
2350
  };
2340
- }), activeId: activeSection.id ?? "", onSelect: (id) => nav.setSearchParams({ tab: id }) }))] }));
2341
- 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 ||
2342
2353
  hasTabs ||
2343
2354
  screen.slots?.header !== undefined ||
2344
2355
  screen.recordTitleField !== undefined) && {
@@ -2644,7 +2655,7 @@ function ActionFormBody({ schema, screen, translate, entityId, initialOverrides,
2644
2655
  // Rendering the form before a tenant-declared currency lands would show the
2645
2656
  // fallback and, on a fast click, submit it (fw#2933) — hold instead.
2646
2657
  if (currencyLoading) {
2647
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2658
+ return _jsx(QueryLoadingBanner, {});
2648
2659
  }
2649
2660
  if (currencyUnresolved) {
2650
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." }));
@@ -2652,7 +2663,7 @@ function ActionFormBody({ schema, screen, translate, entityId, initialOverrides,
2652
2663
  if (loadState !== undefined && loadState.status !== "ready") {
2653
2664
  return (_jsx(ActionFormLoadStatus, { state: loadState, screenId: screen.id, translate: effectiveTranslate }));
2654
2665
  }
2655
- return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: derivedInitial, ...(screen.derive !== undefined && { formDerivation: screen }),
2666
+ return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: derivedInitial, ...(screen.derive !== undefined && { formDerivation: screen }), actionFormScreenQn: qualifyScreenId(schema.featureName, screen.id),
2656
2667
  // Extension-Sections im actionForm sehen die initialen Form-Values
2657
2668
  // (inkl. searchParams-Prefill, z.B. ?incidentId=…) als initialValues
2658
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);
@@ -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;
@@ -29,10 +29,14 @@ export function fieldCellWidth(field) {
29
29
  return "full";
30
30
  return FIELD_CELL_WIDTH_BY_TYPE[field.type] ?? "text";
31
31
  }
32
- export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, }) {
32
+ export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, sectionTitle, }) {
33
33
  // RenderField renders nothing for a hidden field, but the GridCell around it still claims the row.
34
34
  if (!field.visible)
35
35
  return null;
36
+ const hideLabel = sectionTitle !== undefined &&
37
+ field.type === "embedded" &&
38
+ field.embeddedListCells !== undefined &&
39
+ field.label === sectionTitle;
36
40
  const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
37
- return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), ...(fillDisabled === true && { fillDisabled }) }) }));
41
+ return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), ...(fillDisabled === true && { fillDisabled }), ...(hideLabel && { hideLabel }) }) }));
38
42
  }
@@ -5,10 +5,13 @@ export type ListEmptyStateContent = {
5
5
  readonly description?: string;
6
6
  readonly action?: RenderEditAction;
7
7
  };
8
- export declare function ListEmptyStateBody({ content, narrowed, createButton, translate, }: {
8
+ export declare function ListEmptyStateBody({ content, narrowed, defaultFilterActive, onShowAll, createButton, translate, }: {
9
9
  readonly content: ListEmptyStateContent | undefined;
10
10
  /** A search term or changed filter produced the empty result, so "create the first one" would mislead. */
11
11
  readonly narrowed: boolean;
12
+ /** A default filter is on and nothing else narrows: rows may exist outside it. */
13
+ readonly defaultFilterActive?: boolean;
14
+ readonly onShowAll?: () => void;
12
15
  readonly createButton: ReactNode;
13
16
  readonly translate: (key: string) => string;
14
17
  }): ReactNode;
@@ -2,11 +2,14 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useState } from "react";
3
3
  import { usePrimitives } from "../primitives.js";
4
4
  import { RenderEditActionButton } from "./render-edit-action-button.js";
5
- export function ListEmptyStateBody({ content, narrowed, createButton, translate, }) {
5
+ export function ListEmptyStateBody({ content, narrowed, defaultFilterActive = false, onShowAll, createButton, translate, }) {
6
6
  const { Text, Button, Dialog, Banner } = usePrimitives();
7
7
  const [actionError, setActionError] = useState(null);
8
8
  if (narrowed)
9
9
  return _jsx(Text, { children: translate("kumiko.list.empty.filtered") });
10
+ if (defaultFilterActive) {
11
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { children: translate("kumiko.list.empty.defaultFiltered") }), onShowAll !== undefined && (_jsx(Button, { variant: "secondary", onClick: onShowAll, testId: "render-list-empty-show-all", children: translate("kumiko.list.empty.showAll") }))] }));
12
+ }
10
13
  const description = content === undefined ? translate("kumiko.list.empty.hint") : content.description;
11
14
  return (_jsxs(_Fragment, { children: [_jsx(Text, { children: content?.title ?? translate("kumiko.list.empty.title") }), description !== undefined && _jsx(Text, { variant: "small", children: description }), content?.action !== undefined && (_jsx(RenderEditActionButton, { action: content.action, Button: Button, Dialog: Dialog, onError: setActionError })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-list-empty-state-action-error", children: actionError })), createButton] }));
12
15
  }
@@ -48,5 +48,5 @@ export function ReferenceCreateDialog({ open, onClose, onCreated, featureName, s
48
48
  };
49
49
  if (!open)
50
50
  return null;
51
- return (_jsx(Modal, { open: open, onOpenChange: (next) => !next && onClose(), title: t("kumiko.actions.create"), children: currencyLoading ? (_jsx(Banner, { padded: true, variant: "loading", testId: "reference-create-dialog-loading", children: "Loading\u2026" })) : (_jsx(RenderEdit, { screen: screen, entity: entity, featureName: featureName, initial: initial, writeCommand: writeCommand, onSubmit: handleSubmitted, onCancel: onClose, ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) })) }));
51
+ return (_jsx(Modal, { open: open, onOpenChange: (next) => !next && onClose(), title: t("kumiko.actions.create"), children: currencyLoading ? (_jsx(Banner, { padded: true, variant: "loading", testId: "reference-create-dialog-loading", children: t("kumiko.widget.loading") })) : (_jsx(RenderEdit, { screen: screen, entity: entity, featureName: featureName, initial: initial, writeCommand: writeCommand, onSubmit: handleSubmitted, onCancel: onClose, ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) })) }));
52
52
  }