@cosmicdrift/kumiko-renderer 0.387.0 → 0.389.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 (210) hide show
  1. package/dist/app/action-form-body.js +10 -2
  2. package/dist/app/action-form-support.d.ts +1 -0
  3. package/dist/app/action-form-support.js +1 -1
  4. package/dist/app/base-path-context.d.ts +9 -0
  5. package/dist/app/base-path-context.js +13 -0
  6. package/dist/app/base-path.d.ts +3 -0
  7. package/dist/app/base-path.js +27 -0
  8. package/dist/app/config-edit-body.d.ts +1 -1
  9. package/dist/app/config-edit-body.js +19 -6
  10. package/dist/app/config-edit-without-secret-fields.d.ts +2 -0
  11. package/dist/app/config-edit-without-secret-fields.js +28 -0
  12. package/dist/app/entity-edit-prefill.d.ts +11 -0
  13. package/dist/app/entity-edit-prefill.js +9 -4
  14. package/dist/app/entity-list-facet-specs.d.ts +3 -0
  15. package/dist/app/entity-list-facet-specs.js +65 -0
  16. package/dist/app/entity-list-screen.js +74 -206
  17. package/dist/app/extension-sections.d.ts +5 -3
  18. package/dist/app/form-schema.js +1 -1
  19. package/dist/app/header-actions-bar.js +1 -1
  20. package/dist/app/kumiko-screen.js +6 -14
  21. package/dist/app/list-bulk-actions.d.ts +18 -0
  22. package/dist/app/list-bulk-actions.js +54 -0
  23. package/dist/app/locked-screen-header.d.ts +9 -0
  24. package/dist/app/locked-screen-header.js +33 -0
  25. package/dist/app/projection-list-body.js +51 -46
  26. package/dist/app/row-actions.d.ts +3 -0
  27. package/dist/app/row-actions.js +10 -5
  28. package/dist/app/screen-shared.js +9 -2
  29. package/dist/app/use-entity-range-facets.d.ts +24 -0
  30. package/dist/app/use-entity-range-facets.js +64 -0
  31. package/dist/app/use-projection-date-range.d.ts +12 -0
  32. package/dist/app/use-projection-date-range.js +21 -0
  33. package/dist/app/use-screen-list-rows.d.ts +37 -0
  34. package/dist/app/use-screen-list-rows.js +104 -0
  35. package/dist/app/use-screen-list-table.d.ts +24 -0
  36. package/dist/app/use-screen-list-table.js +46 -0
  37. package/dist/components/edit-mounts.d.ts +2 -2
  38. package/dist/components/embedded-list-field.js +1 -1
  39. package/dist/components/list-group-page-bridges.d.ts +3 -0
  40. package/dist/components/list-group-page-bridges.js +31 -0
  41. package/dist/components/list-state-store.d.ts +16 -0
  42. package/dist/components/list-state-store.js +31 -0
  43. package/dist/components/related-list-section.js +51 -90
  44. package/dist/components/render-edit-action-button.d.ts +1 -3
  45. package/dist/components/render-edit-action-button.js +2 -2
  46. package/dist/components/render-edit.js +11 -8
  47. package/dist/components/render-field.js +11 -7
  48. package/dist/components/render-list-toolbar-action.d.ts +27 -0
  49. package/dist/components/render-list-toolbar-action.js +40 -0
  50. package/dist/components/render-list.d.ts +13 -29
  51. package/dist/components/render-list.js +72 -67
  52. package/dist/current-user.d.ts +2 -0
  53. package/dist/data-table-aggregate-types.d.ts +47 -0
  54. package/dist/data-table-aggregate-types.js +1 -0
  55. package/dist/data-table-bulk-types.d.ts +27 -0
  56. package/dist/data-table-bulk-types.js +1 -0
  57. package/dist/data-table-facet-types.d.ts +36 -0
  58. package/dist/data-table-facet-types.js +1 -0
  59. package/dist/data-table-view-types.d.ts +73 -0
  60. package/dist/data-table-view-types.js +1 -0
  61. package/dist/flow-diagram/render.js +9 -1
  62. package/dist/headless/form/form-controller.js +8 -0
  63. package/dist/headless/form/types.d.ts +1 -0
  64. package/dist/headless/format/index.js +89 -64
  65. package/dist/headless/view-model/edit.js +5 -1
  66. package/dist/headless/view-model/list.js +184 -149
  67. package/dist/headless/view-model/types.d.ts +7 -2
  68. package/dist/hooks/list-column-layout.d.ts +19 -0
  69. package/dist/hooks/list-column-layout.js +36 -0
  70. package/dist/hooks/list-group-pages.d.ts +26 -0
  71. package/dist/hooks/list-group-pages.js +37 -0
  72. package/dist/hooks/list-reset.d.ts +8 -0
  73. package/dist/hooks/list-reset.js +15 -0
  74. package/dist/hooks/list-server-groups.d.ts +39 -0
  75. package/dist/hooks/list-server-groups.js +84 -0
  76. package/dist/hooks/list-state-schema.d.ts +28 -0
  77. package/dist/hooks/list-state-schema.js +76 -0
  78. package/dist/hooks/live-refetch-scheduler.js +4 -0
  79. package/dist/hooks/use-bulk-run.d.ts +41 -0
  80. package/dist/hooks/use-bulk-run.js +65 -0
  81. package/dist/hooks/use-group-load-state.d.ts +11 -0
  82. package/dist/hooks/use-group-load-state.js +31 -0
  83. package/dist/hooks/use-list-aggregates.d.ts +38 -0
  84. package/dist/hooks/use-list-aggregates.js +107 -0
  85. package/dist/hooks/use-list-table.d.ts +38 -0
  86. package/dist/hooks/use-list-table.js +91 -0
  87. package/dist/hooks/use-list-view-state.d.ts +43 -0
  88. package/dist/hooks/use-list-view-state.js +161 -0
  89. package/dist/hooks/use-reference-lookup.d.ts +9 -1
  90. package/dist/hooks/use-reference-lookup.js +17 -3
  91. package/dist/hooks/use-related-list-rows.d.ts +27 -0
  92. package/dist/hooks/use-related-list-rows.js +81 -0
  93. package/dist/hooks/use-related-list-state.d.ts +15 -0
  94. package/dist/hooks/use-related-list-state.js +31 -0
  95. package/dist/hooks/use-remembered-list-params.d.ts +13 -0
  96. package/dist/hooks/use-remembered-list-params.js +53 -0
  97. package/dist/hooks/use-remembered-local-list-state.d.ts +26 -0
  98. package/dist/hooks/use-remembered-local-list-state.js +63 -0
  99. package/dist/i18n-defaults.js +59 -2
  100. package/dist/index.d.ts +4 -1
  101. package/dist/index.js +3 -0
  102. package/dist/primitives.d.ts +30 -47
  103. package/dist/web/app/create-app.d.ts +5 -0
  104. package/dist/web/app/create-app.js +15 -7
  105. package/dist/web/app/dashboard-body.js +17 -1
  106. package/dist/web/app/dashboard-panels.js +12 -2
  107. package/dist/web/app/history-leave-guard.d.ts +6 -0
  108. package/dist/web/app/history-leave-guard.js +115 -0
  109. package/dist/web/app/list-state-store-boot.d.ts +6 -0
  110. package/dist/web/app/list-state-store-boot.js +15 -0
  111. package/dist/web/app/nav.js +7 -54
  112. package/dist/web/app/session-list-state-store.d.ts +15 -0
  113. package/dist/web/app/session-list-state-store.js +117 -0
  114. package/dist/web/index.d.ts +2 -1
  115. package/dist/web/index.js +2 -1
  116. package/dist/web/layout/shell-header.js +2 -1
  117. package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
  118. package/dist/web/layout/uniform-bar-controls.js +4 -0
  119. package/dist/web/lib/export-xlsx-request.d.ts +11 -0
  120. package/dist/web/lib/export-xlsx-request.js +46 -0
  121. package/dist/web/lib/file-ref-download.js +4 -1
  122. package/dist/web/primitives/action-menu.js +1 -1
  123. package/dist/web/primitives/code-input.d.ts +6 -0
  124. package/dist/web/primitives/code-input.js +31 -0
  125. package/dist/web/primitives/data-table/actions-column.d.ts +16 -0
  126. package/dist/web/primitives/data-table/actions-column.js +34 -0
  127. package/dist/web/primitives/data-table/bulk-selection-bar.d.ts +20 -0
  128. package/dist/web/primitives/data-table/bulk-selection-bar.js +37 -0
  129. package/dist/web/primitives/data-table/card-sort-select.d.ts +14 -0
  130. package/dist/web/primitives/data-table/card-sort-select.js +40 -0
  131. package/dist/web/primitives/data-table/column-layout-mode.d.ts +5 -0
  132. package/dist/web/primitives/data-table/column-layout-mode.js +10 -0
  133. package/dist/web/primitives/data-table/column-picker-section.d.ts +7 -0
  134. package/dist/web/primitives/data-table/column-picker-section.js +35 -0
  135. package/dist/web/primitives/data-table/column-resize-handle.d.ts +14 -0
  136. package/dist/web/primitives/data-table/column-resize-handle.js +46 -0
  137. package/dist/web/primitives/data-table/density.d.ts +14 -0
  138. package/dist/web/primitives/data-table/density.js +20 -0
  139. package/dist/web/primitives/data-table/export-button.d.ts +5 -0
  140. package/dist/web/primitives/data-table/export-button.js +37 -0
  141. package/dist/web/primitives/data-table/facet-cluster.d.ts +8 -0
  142. package/dist/web/primitives/data-table/facet-cluster.js +37 -0
  143. package/dist/web/primitives/data-table/group-band.d.ts +28 -0
  144. package/dist/web/primitives/data-table/group-band.js +53 -0
  145. package/dist/web/primitives/data-table/measure-value.d.ts +10 -0
  146. package/dist/web/primitives/data-table/measure-value.js +28 -0
  147. package/dist/web/primitives/data-table/pin-section.d.ts +8 -0
  148. package/dist/web/primitives/data-table/pin-section.js +20 -0
  149. package/dist/web/primitives/data-table/reset-all-button.d.ts +4 -0
  150. package/dist/web/primitives/data-table/reset-all-button.js +8 -0
  151. package/dist/web/primitives/data-table/server-group-header.d.ts +11 -0
  152. package/dist/web/primitives/data-table/server-group-header.js +51 -0
  153. package/dist/web/primitives/data-table/sortable-header.d.ts +17 -0
  154. package/dist/web/primitives/data-table/sortable-header.js +41 -0
  155. package/dist/web/primitives/data-table/table-chrome.d.ts +13 -0
  156. package/dist/web/primitives/data-table/table-chrome.js +30 -0
  157. package/dist/web/primitives/data-table/table-footer.d.ts +7 -0
  158. package/dist/web/primitives/data-table/table-footer.js +20 -0
  159. package/dist/web/primitives/data-table/toolbar-end-cluster.d.ts +10 -0
  160. package/dist/web/primitives/data-table/toolbar-end-cluster.js +13 -0
  161. package/dist/web/primitives/data-table/totals-footer.d.ts +24 -0
  162. package/dist/web/primitives/data-table/totals-footer.js +48 -0
  163. package/dist/web/primitives/data-table/use-bulk-selection.d.ts +21 -0
  164. package/dist/web/primitives/data-table/use-bulk-selection.js +66 -0
  165. package/dist/web/primitives/data-table/use-column-pins.d.ts +32 -0
  166. package/dist/web/primitives/data-table/use-column-pins.js +117 -0
  167. package/dist/web/primitives/data-table/use-column-sizing.d.ts +22 -0
  168. package/dist/web/primitives/data-table/use-column-sizing.js +44 -0
  169. package/dist/web/primitives/data-table/use-measured-column-widths.d.ts +15 -0
  170. package/dist/web/primitives/data-table/use-measured-column-widths.js +83 -0
  171. package/dist/web/primitives/data-table/use-row-keyboard-navigation.d.ts +22 -0
  172. package/dist/web/primitives/data-table/use-row-keyboard-navigation.js +65 -0
  173. package/dist/web/primitives/data-table/use-row-selection.d.ts +14 -0
  174. package/dist/web/primitives/data-table/use-row-selection.js +48 -0
  175. package/dist/web/primitives/data-table/use-view-columns.d.ts +6 -0
  176. package/dist/web/primitives/data-table/use-view-columns.js +17 -0
  177. package/dist/web/primitives/data-table/view-menu.d.ts +8 -0
  178. package/dist/web/primitives/data-table/view-menu.js +45 -0
  179. package/dist/web/primitives/data-table-cell.d.ts +0 -22
  180. package/dist/web/primitives/data-table-cell.js +1 -58
  181. package/dist/web/primitives/data-table.d.ts +1 -1
  182. package/dist/web/primitives/data-table.js +125 -146
  183. package/dist/web/primitives/facet-filter.js +1 -1
  184. package/dist/web/primitives/file-thumbnail.d.ts +3 -0
  185. package/dist/web/primitives/file-thumbnail.js +5 -0
  186. package/dist/web/primitives/file-value-list.d.ts +1 -1
  187. package/dist/web/primitives/file-value-list.js +5 -5
  188. package/dist/web/primitives/input.js +6 -1
  189. package/dist/web/primitives/layout-elements.js +6 -4
  190. package/dist/web/primitives/number-range-facet.d.ts +7 -0
  191. package/dist/web/primitives/number-range-facet.js +33 -0
  192. package/dist/web/primitives/page-header.js +3 -1
  193. package/dist/web/primitives/row-actions.js +3 -3
  194. package/dist/web/primitives/use-focus-on-mount.d.ts +2 -0
  195. package/dist/web/primitives/use-focus-on-mount.js +9 -0
  196. package/dist/web/widgets/bounded-load-cache.d.ts +5 -1
  197. package/dist/web/widgets/bounded-load-cache.js +49 -21
  198. package/dist/web/widgets/choice-cards.d.ts +17 -0
  199. package/dist/web/widgets/choice-cards.js +12 -0
  200. package/dist/web/widgets/collapsible-section.js +1 -1
  201. package/dist/web/widgets/detail-list.d.ts +5 -1
  202. package/dist/web/widgets/detail-list.js +5 -1
  203. package/dist/web/widgets/index.d.ts +1 -0
  204. package/dist/web/widgets/index.js +1 -0
  205. package/dist/web/widgets/pdf-preview.js +10 -5
  206. package/dist/web/widgets/plan-card.d.ts +2 -0
  207. package/dist/web/widgets/plan-card.js +1 -1
  208. package/dist/web/widgets/upload-zone.js +13 -5
  209. package/package.json +5 -5
  210. package/src/changes.json +158 -0
@@ -1,16 +1,16 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { evalFieldCondition, parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
4
4
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
5
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import { useCallback, useEffect, useMemo, useState } from "react";
6
+ import { ListGroupPageBridges } from "../components/list-group-page-bridges.js";
6
7
  import { RenderList } from "../components/render-list.js";
7
8
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
8
9
  import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
9
10
  import { useUserRoles } from "../context/user-roles-context.js";
10
11
  import { rendererWarn } from "../dev-console.js";
11
- import { computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, } from "../headless/index.js";
12
+ import { computeRelatedListSectionViewModel } from "../headless/index.js";
12
13
  import { useListUrlState } from "../hooks/use-list-url-state.js";
13
- import { useQuery } from "../hooks/use-query.js";
14
14
  import { useTranslation } from "../i18n.js";
15
15
  import { usePrimitives } from "../primitives.js";
16
16
  import { screenFillsHeight } from "../screen-fills-height.js";
@@ -18,73 +18,21 @@ import { DrawerHost, useDrawerAction } from "./action-form-body.js";
18
18
  import { useAppFeatures } from "./app-features-context.js";
19
19
  import { useClientActionRuntime } from "./client-actions.js";
20
20
  import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
21
+ import { resolveEntityFacetSpecs } from "./entity-list-facet-specs.js";
21
22
  import { FacetCountBridges } from "./facet-count-bridge.js";
23
+ import { useListBulk } from "./list-bulk-actions.js";
22
24
  import { isDefaultFilterActive, isListNarrowed, useListEmptyContent, } from "./list-empty-content.js";
23
25
  import { buildEntityFilterFacets, buildFacetCountRequests, buildFilterPayload, mergeReferenceFacetOptions, normalizeDefaultFilters, } from "./list-facets.js";
24
26
  import { useNav } from "./nav.js";
25
27
  import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
26
28
  import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
27
29
  import { navigateWithReturnTo, useReturnHost } from "./return-to.js";
28
- import { buildClientActionRowAction, buildDefaultEditRowAction, buildDrawerRowAction, buildProjectionToolbarActions, evalRowExtractor, findRowClickAction, navigateActionSearchParams, refetchAfterWrite, runDrawerRowAction, } from "./row-actions.js";
30
+ import { buildClientActionRowAction, buildDefaultEditRowAction, buildDrawerRowAction, buildProjectionToolbarActions, buildWriteHandlerRowAction, findRowClickAction, navigateActionSearchParams, runDrawerRowAction, } from "./row-actions.js";
29
31
  import { findEditScreenFor } from "./screen-access.js";
30
- import { buildListQueryPayload, entityQueryCommand, isEntityEditScreenOfEntity, pageSizeOptionsFor, useNavigateToCreateFor, } from "./screen-shared.js";
31
- import { dispatcherErrorText, WriteFailedError } from "./write-failed-error.js";
32
- // entityList adapter — one DataTableFacet per filterable select/boolean
33
- // entity field, labels via the standard field/option i18n convention.
34
- function resolveEntityFacetSpecs(fields, featureName, entityName, translate) {
35
- const out = [];
36
- for (const [field, rawDef] of Object.entries(fields)) {
37
- // entity.fields is weakly typed at the renderer layer (Record<string,
38
- // unknown>, deserialized from the schema); boundary cast like buildInitialValues.
39
- const def = rawDef;
40
- if (def.filterable !== true)
41
- continue;
42
- const label = translate(fieldLabelKey(featureName, entityName, field));
43
- if (def.type === "select" && Array.isArray(def.options)) {
44
- out.push({
45
- field,
46
- type: "select",
47
- label,
48
- options: def.options.map((value) => ({
49
- value,
50
- label: translate(fieldOptionLabelKey(featureName, entityName, field, value)),
51
- })),
52
- });
53
- }
54
- else if (def.type === "boolean") {
55
- out.push({
56
- field,
57
- type: "boolean",
58
- label,
59
- options: [
60
- {
61
- value: "true",
62
- label: translate(fieldOptionLabelKey(featureName, entityName, field, "true")),
63
- },
64
- {
65
- value: "false",
66
- label: translate(fieldOptionLabelKey(featureName, entityName, field, "false")),
67
- },
68
- ],
69
- });
70
- }
71
- else if (def.type === "reference" && def.entity !== undefined) {
72
- const target = parseRefTarget(def.entity, featureName);
73
- out.push({
74
- field,
75
- type: "reference",
76
- label,
77
- options: [],
78
- reference: {
79
- refEntity: target.entityName,
80
- refFeature: target.featureName,
81
- labelField: def.labelField ?? "id",
82
- },
83
- });
84
- }
85
- }
86
- return out;
87
- }
32
+ import { buildListQueryPayload, entityQueryCommand, isEntityEditScreenOfEntity, useNavigateToCreateFor, } from "./screen-shared.js";
33
+ import { useEntityRangeFacets } from "./use-entity-range-facets.js";
34
+ import { listPager, useScreenListRows } from "./use-screen-list-rows.js";
35
+ import { useScreenListTable } from "./use-screen-list-table.js";
88
36
  export function EntityListScreen({ schema, screen, translate, onRowClick, }) {
89
37
  const { Banner, Text } = usePrimitives();
90
38
  const entity = schema.entities[screen.entity];
@@ -120,29 +68,17 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
120
68
  const paginationMode = screen.pagination ?? "pages";
121
69
  const usePager = paginationMode === "pages";
122
70
  const useInfinite = paginationMode === "infinite";
123
- // Infinite scroll: local state accumulates rows across several useQuery
124
- // calls (instead of replacing them like the pager). The cursor changes when
125
- // the user reaches the bottom sentinel; on a sort/q change everything is
126
- // reset, since the insert order of the accumulated rows would otherwise be
127
- // inconsistent with the new sorting.
128
- const [accumulated, setAccumulated] = useState([]);
129
- const [cursor, setCursor] = useState(undefined);
130
- const [hasMore, setHasMore] = useState(true);
131
- // Ref so the useEffect cleanup does not see stale state.
132
- const sortQRef = useRef("");
133
- const sortQKey = `${urlState.q}|${effectiveSort?.field ?? ""}|${effectiveSort?.dir ?? ""}`;
134
- // Reset rows + cursor on a sort/q change, before the next useQuery so the
135
- // reload starts with cursor=undefined.
136
- useEffect(() => {
137
- if (!useInfinite)
138
- return;
139
- if (sortQRef.current === sortQKey)
140
- return;
141
- sortQRef.current = sortQKey;
142
- setAccumulated([]);
143
- setCursor(undefined);
144
- setHasMore(true);
145
- }, [useInfinite, sortQKey]);
71
+ // Hook order: ALL hooks before the early return for loading/error,
72
+ // otherwise the hook slots collide between renders.
73
+ const t = useTranslation();
74
+ const effectiveTranslate = translate ?? t;
75
+ const range = useEntityRangeFacets({
76
+ fields: entity.fields,
77
+ screen,
78
+ featureName,
79
+ translate: effectiveTranslate,
80
+ urlState,
81
+ });
146
82
  // User-selected faceted filters from the URL state -> payload.filters.
147
83
  // typeOf reads the field type straight from entity.fields (not gated on
148
84
  // filterable, deliberately permissive; see the buildFilterPayload doc).
@@ -162,7 +98,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
162
98
  usePager,
163
99
  page: urlState.page,
164
100
  useInfinite,
165
- cursor,
101
+ cursor: undefined,
166
102
  });
167
103
  // Screen filter (tier 2.7c), declared by the author on the schema and
168
104
  // independent of the user's q search. Several buckets of the same entity
@@ -171,9 +107,9 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
171
107
  if (screen.filter !== undefined) {
172
108
  payload["filter"] = screen.filter;
173
109
  }
174
- if (filterPayload.length > 0) {
175
- payload["filters"] = filterPayload;
176
- }
110
+ const filters = [...filterPayload, ...range.rangeFilters];
111
+ if (filters.length > 0)
112
+ payload["filters"] = filters;
177
113
  return payload;
178
114
  }, [
179
115
  limit,
@@ -181,82 +117,51 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
181
117
  effectiveSort,
182
118
  screen.filter,
183
119
  filterPayload,
120
+ range.rangeFilters,
184
121
  usePager,
185
122
  urlState.page,
186
123
  useInfinite,
187
- cursor,
188
124
  ]);
189
- const rowsQuery = useQuery(queryType, queryPayload, { live: true });
190
125
  // Facet counts come from their own queries, which a write leaves untouched.
191
126
  const [facetCountRefreshNonce, setFacetCountRefreshNonce] = useState(0);
192
127
  const refreshFacetCounts = useCallback(() => setFacetCountRefreshNonce((n) => n + 1), []);
193
- const refreshRowsAfterWrite = useCallback(async () => {
194
- refreshFacetCounts();
195
- if (useInfinite) {
196
- setAccumulated([]);
197
- setCursor(undefined);
198
- setHasMore(true);
199
- return;
200
- }
201
- await refetchAfterWrite(rowsQuery.refetch);
202
- }, [useInfinite, rowsQuery.refetch, refreshFacetCounts]);
203
- // Infinite scroll: on every successful result append the rows and derive
204
- // hasMore from nextCursor. Live updates (postgres NOTIFY) and initial loads
205
- // both pass through here; the useEffect dependency array pins the dedup to
206
- // the last processed data pointer.
207
- const lastDataRef = useRef(null);
208
- useEffect(() => {
209
- if (!useInfinite)
210
- return;
211
- const data = rowsQuery.data;
212
- if (data === null)
213
- return;
214
- if (data === lastDataRef.current)
215
- return;
216
- lastDataRef.current = data;
217
- setAccumulated((prev) => {
218
- // When cursor is undefined this is the first page after a sort/q change:
219
- // replace everything instead of appending. Otherwise dedupe on id in case
220
- // live updates bring in an entry on the next page that was already appended.
221
- if (cursor === undefined)
222
- return data.rows;
223
- const seen = new Set(prev.map((r) => r["id"]));
224
- const fresh = data.rows.filter((r) => !seen.has(r["id"]));
225
- return [...prev, ...fresh];
226
- });
227
- setHasMore(data.nextCursor !== null);
228
- }, [rowsQuery.data, useInfinite, cursor]);
229
- const loadMore = useCallback(() => {
230
- if (!useInfinite)
231
- return;
232
- if (rowsQuery.loading)
233
- return;
234
- const data = rowsQuery.data;
235
- if (data?.nextCursor === undefined || data.nextCursor === null)
236
- return;
237
- setCursor(data.nextCursor);
238
- }, [useInfinite, rowsQuery.loading, rowsQuery.data]);
239
- // RowActions: schema form (handler QN + i18n key) -> resolved form
240
- // (dispatcher call + translated strings). dispatcher.write knows the user
241
- // internally (JWT cookie). The schema may contain raw strings as well as
242
- // i18n keys; translate() returns the key when the bundle does not know it
243
- // (convention everywhere in the renderer).
244
- // Hook order: ALL hooks before the early return for loading/error,
245
- // otherwise the hook slots collide between renders.
246
- const t = useTranslation();
247
- const effectiveTranslate = translate ?? t;
248
128
  // Faceted filter: one dropdown per filterable select/boolean/reference field.
249
129
  // Labels + select option labels use the same i18n convention as the column
250
130
  // headers (fieldLabelKey / :option:<value>); reference fields load their
251
131
  // options asynchronously through ReferenceFacetBridges.
252
132
  const entityFacetSpecs = useMemo(() => resolveEntityFacetSpecs(entity.fields, featureName, screen.entity, effectiveTranslate), [entity.fields, featureName, screen.entity, effectiveTranslate]);
133
+ const facetFilterFields = useMemo(() => [...entityFacetSpecs.map((spec) => spec.field), ...range.rangeFields], [entityFacetSpecs, range.rangeFields]);
134
+ const listTable = useScreenListTable({
135
+ screen,
136
+ featureName,
137
+ payload: queryPayload,
138
+ filterFields: facetFilterFields,
139
+ queryType,
140
+ pageSize: limit,
141
+ paging: useInfinite ? "cursor" : "offset",
142
+ translate: effectiveTranslate,
143
+ });
144
+ const listRows = useScreenListRows({
145
+ queryType,
146
+ payload: queryPayload,
147
+ infinite: useInfinite,
148
+ aggregates: listTable.aggregates,
149
+ });
150
+ const { refreshAfterWrite: refreshListAfterWrite } = listRows;
151
+ const refreshRowsAfterWrite = useCallback(async () => {
152
+ refreshFacetCounts();
153
+ await refreshListAfterWrite();
154
+ }, [refreshFacetCounts, refreshListAfterWrite]);
253
155
  const [entityReferenceFacetOptions, setEntityReferenceFacetOptions] = useState({});
254
156
  const handleEntityFacetOptions = useCallback((field, options) => setEntityReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
255
157
  const [facetCounts, setFacetCounts] = useState({});
256
158
  const handleFacetCount = useCallback((key, count) => setFacetCounts((prev) => (prev[key] === count ? prev : { ...prev, [key]: count })), []);
257
159
  const filterFacets = useMemo(() => buildEntityFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions), screen.facets, effectiveTranslate, facetCounts), [entityFacetSpecs, entityReferenceFacetOptions, screen.facets, effectiveTranslate, facetCounts]);
258
160
  const facetCountQueries = useMemo(() => buildFacetCountRequests(entityFacetSpecs, screen.facets, effectiveTranslate).map((request) => {
259
- const filters = buildFilterPayload({ ...urlState.filters, [request.field]: request.values }, (field) => entity.fields[field]?.type);
161
+ const filters = [
162
+ ...buildFilterPayload({ ...urlState.filters, [request.field]: request.values }, (field) => entity.fields[field]?.type),
163
+ ...range.rangeFilters,
164
+ ];
260
165
  return {
261
166
  key: request.key,
262
167
  payload: {
@@ -275,6 +180,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
275
180
  urlState.filters,
276
181
  urlState.q,
277
182
  entity.fields,
183
+ range.rangeFilters,
278
184
  ]);
279
185
  // Soft dispatcher: in tests that mount without a DispatcherProvider,
280
186
  // rowActions stays undefined instead of crashing. Real apps have the
@@ -371,35 +277,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
371
277
  }
372
278
  if (dispatcher === undefined)
373
279
  return null;
374
- const writeAction = action;
375
- const writeActionVisible = writeAction.visible;
376
- return {
377
- id: writeAction.id,
378
- label: effectiveTranslate(writeAction.label),
379
- style: writeAction.style,
380
- icon: resolveActionIcon(writeAction.id, writeAction.icon),
381
- confirm: writeAction.confirm !== undefined ? effectiveTranslate(writeAction.confirm) : undefined,
382
- confirmLabel: writeAction.confirmLabel !== undefined
383
- ? effectiveTranslate(writeAction.confirmLabel)
384
- : undefined,
385
- onTrigger: async (row) => {
386
- const buildPayload = writeAction.payload;
387
- const payload = buildPayload !== undefined
388
- ? evalRowExtractor(buildPayload, row.values)
389
- : { id: row.values["id"] };
390
- const result = await dispatcher.write(writeAction.handler, payload);
391
- // write() does not throw: a failure result MUST become an error here,
392
- // otherwise the confirm dialog closes silently and the user sees "nothing
393
- // happened" (prod bug 2026-06-07).
394
- if (!result.isSuccess) {
395
- throw new WriteFailedError(result.error, dispatcherErrorText(result.error, effectiveTranslate));
396
- }
397
- await refreshRowsAfterWrite();
398
- },
399
- isVisible: writeActionVisible !== undefined
400
- ? (row) => evalFieldCondition(writeActionVisible, row.values)
401
- : undefined,
402
- };
280
+ return buildWriteHandlerRowAction(action, effectiveTranslate, refreshRowsAfterWrite, dispatcher);
403
281
  })
404
282
  .filter((a) => a !== null);
405
283
  }, [
@@ -412,6 +290,13 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
412
290
  refreshRowsAfterWrite,
413
291
  openDrawer,
414
292
  ]);
293
+ const bulk = useListBulk({
294
+ bulkActions: screen.bulkActions,
295
+ rowActions: screen.rowActions,
296
+ translate: effectiveTranslate,
297
+ dispatcher,
298
+ reload: refreshRowsAfterWrite,
299
+ });
415
300
  // ToolbarActions: Schema → Resolved-Form (analog rowActions), shared with
416
301
  // projectionList and relatedList sections via buildProjectionToolbarActions.
417
302
  const toolbarActions = useMemo(() => buildProjectionToolbarActions({
@@ -471,14 +356,14 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
471
356
  refreshRowsAfterWrite,
472
357
  invalidateExpansions,
473
358
  ]);
474
- if (rowsQuery.loading && rowsQuery.data === null) {
359
+ if (listRows.firstLoad) {
475
360
  return _jsx(QueryLoadingBanner, {});
476
361
  }
477
- if (rowsQuery.error && rowsQuery.data === null) {
478
- return (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch }));
362
+ if (listRows.error && !listRows.hasData) {
363
+ return (_jsx(QueryErrorBanner, { error: listRows.error, translate: effectiveTranslate, onRetry: listRows.retry }));
479
364
  }
480
365
  // A failed refetch keeps the last good data on screen.
481
- const refetchErrorBanner = rowsQuery.error ? (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch })) : null;
366
+ const refetchErrorBanner = listRows.error ? (_jsx(QueryErrorBanner, { error: listRows.error, translate: effectiveTranslate, onRetry: listRows.retry })) : null;
482
367
  // RenderList's onRowClick is a 1-arg callback; KumikoScreen's
483
368
  // 2-arg shape (row, entityName) is the public surface — thread the
484
369
  // screen's entity through here so callers don't have to re-derive
@@ -507,41 +392,24 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
507
392
  // Assemble pager props only for pagination="pages". The server total
508
393
  // arrives async; until it is there RenderList renders the table without a
509
394
  // pager (the pager has its own total>0 guard).
510
- const total = rowsQuery.data?.total;
511
- const pager = usePager && total !== undefined
512
- ? {
513
- page: urlState.page,
514
- limit,
515
- total,
516
- onPageChange: urlState.setPage,
517
- pageSizeOptions: pageSizeOptionsFor(limit),
518
- onPageSizeChange: urlState.setPageSize,
519
- }
520
- : undefined;
521
- // In infinite mode rows come from accumulated (collected across several
522
- // useQuery calls), with pages/false from the current result.
523
- const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
524
- 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 && {
395
+ const pager = listPager({ usePager, page: listRows.page, limit, urlState });
396
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ListGroupPageBridges, { ...listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: listRows.rows, featureName: featureName, searchable: searchable, narrowed: isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
525
397
  description: effectiveTranslate(screen.description),
526
398
  }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
527
399
  ? { screenPadding: false, chromeless: true }
528
- : { screenPadding: true }), ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...(useInfinite && {
529
- onReachEnd: loadMore,
530
- loadingMore: rowsQuery.loading,
531
- hasMore,
532
- }), ...(onCreate !== undefined && { onCreate }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
533
- filterFacets,
400
+ : { screenPadding: true }), ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(bulk !== undefined && { bulk }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...listRows.infinite, ...(onCreate !== undefined && { onCreate }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && { filterFacets, onFilterChange: urlState.setFilter }), ...(range.rangeFacets.length > 0 && {
401
+ rangeFacets: range.rangeFacets,
402
+ onRangeChange: range.onRangeChange,
403
+ }), ...((filterFacets.length > 0 || range.rangeFacets.length > 0) && {
534
404
  filterValues: urlState.filters,
535
- onFilterChange: urlState.setFilter,
536
405
  onFilterReset: urlState.clearFilters,
537
- }), ...(expandableSection !== undefined && {
406
+ }), ...listTable.props, ...(expandableSection !== undefined && {
538
407
  expandedRowIds,
539
408
  onToggleRowExpanded: toggleRowExpanded,
540
409
  renderExpandedRow,
541
410
  }) }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
542
411
  closeDrawer();
543
412
  invalidateExpansions();
544
- refreshFacetCounts();
545
- void rowsQuery.refetch();
413
+ void refreshRowsAfterWrite();
546
414
  } })] }));
547
415
  }
@@ -43,10 +43,12 @@ export type ExtensionSectionProps = {
43
43
  * `snapshot.errors` on the field instead of a collective message.
44
44
  * Undefined outside entityEdit sections. */
45
45
  readonly validate?: () => boolean;
46
- /** Marks the current host form values as saved after the section wrote
47
- * them itself, so a following navigation does not ask to discard.
46
+ /** Marks host form values as saved after the section wrote them itself, so
47
+ * a following navigation does not ask to discard. Pass the field names the
48
+ * section persisted: other host fields keep their unsaved state. Without
49
+ * `fields`, every current host value counts as saved.
48
50
  * Undefined outside entityEdit sections and the entityEdit footer slot. */
49
- readonly markSaved?: () => void;
51
+ readonly markSaved?: (fields?: readonly string[]) => void;
50
52
  /** Whether the host form has unsaved changes. Only set in the entityEdit
51
53
  * footer slot (`screen.slots.footer`); undefined in all other mounts. */
52
54
  readonly hasUnsavedChanges?: boolean;
@@ -29,7 +29,7 @@ function isRequiredValueMissing(spec, field, record, unchangedFrom) {
29
29
  // — they're fillable, so NO_WIDGET_FIELD_TYPES's "embedded"
30
30
  // entry must not exempt them. A statically-`required: true` field
31
31
  // with no bound widget is also caught at boot
32
- // (validateNoWidgetRequiredField in boot-validator/screens.ts).
32
+ // (validateNoWidgetRequiredField in boot-validator/screens-forms.ts).
33
33
  if (NO_WIDGET_FIELD_TYPES.includes(field.type) && !isEmbeddedListField(field))
34
34
  return false;
35
35
  if (isPresent(record[spec.field]))
@@ -18,7 +18,7 @@ export function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu,
18
18
  if (compact && PageHeader !== undefined) {
19
19
  const compactPrimary = compactHeaderPrimary(actions, preferredPrimaryId);
20
20
  const overflowActions = actions.filter((a) => a !== compactPrimary?.action);
21
- return (_jsxs(_Fragment, { children: [compactPrimary !== undefined && (_jsx(RenderEditActionButton, { action: compactPrimary.action, Button: Button, Dialog: Dialog, iconOnly: compactPrimary.iconOnly, compactLabel: true, onError: onError }, compactPrimary.action.id)), overflowActions.length > 0 && (_jsx(PageHeader, { overflowItems: overflowActions.map(toMenuItem) })), pendingActionDialog] }));
21
+ return (_jsxs(_Fragment, { children: [compactPrimary !== undefined && (_jsx(RenderEditActionButton, { action: compactPrimary.action, Button: Button, Dialog: Dialog, iconOnly: compactPrimary.iconOnly, onError: onError }, compactPrimary.action.id)), overflowActions.length > 0 && (_jsx(PageHeader, { overflowItems: overflowActions.map(toMenuItem) })), pendingActionDialog] }));
22
22
  }
23
23
  const maxPlainButtons = collapseAfterPrimary ? 1 : 2;
24
24
  if (actions.length <= maxPlainButtons || ActionOverflowMenu === undefined) {
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef } from "react";
3
3
  import { useUserRoles } from "../context/user-roles-context.js";
4
4
  import { useQuery } from "../hooks/use-query.js";
5
5
  import { useTranslation } from "../i18n.js";
6
- import { PageHeaderSlotAvailableProvider, usePageHeaderSlotAvailable, } from "../page-header-slot.js";
6
+ import { PageHeaderSlotAvailableProvider } from "../page-header-slot.js";
7
7
  import { usePrimitives } from "../primitives.js";
8
8
  import { ActionFormBody } from "./action-form-body.js";
9
9
  import { ConfigEditBody } from "./config-edit-body.js";
@@ -11,6 +11,7 @@ import { useCustomScreenComponent } from "./custom-screens.js";
11
11
  import { useDashboardBody } from "./dashboard-body.js";
12
12
  import { EntityEditScreen } from "./entity-edit-screen.js";
13
13
  import { EntityListScreen } from "./entity-list-screen.js";
14
+ import { LockedHeaderScope, StandInChainBoundary } from "./locked-screen-header.js";
14
15
  import { ProjectionDetailBody } from "./projection-detail-body.js";
15
16
  import { ProjectionListBody } from "./projection-list-body.js";
16
17
  import { lastSegment } from "./qn.js";
@@ -54,7 +55,7 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
54
55
  onCopyLink,
55
56
  });
56
57
  // Gate every entry (incl. direct URL): the body element is only mounted once the condition holds.
57
- const body = screen.visibleWhen !== undefined ? (_jsx(ScreenVisibilityGate, { featureName: schema.featureName, visibleWhen: screen.visibleWhen, fallback: screen.fallback, translate: translate, children: screenBody })) : (screenBody);
58
+ const body = screen.visibleWhen !== undefined ? (_jsx(ScreenVisibilityGate, { featureName: schema.featureName, visibleWhen: screen.visibleWhen, fallback: screen.fallback, translate: translate, children: _jsx(StandInChainBoundary, { children: screenBody }) })) : (_jsx(StandInChainBoundary, { children: screenBody }));
58
59
  // An embedded screen (e.g. a dashboard panel) keeps its parent's host and
59
60
  // must not portal its page header into the shell that hosts the outer screen.
60
61
  if (outerHost !== undefined) {
@@ -78,16 +79,7 @@ function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, c
78
79
  if (fallback !== undefined) {
79
80
  return _jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate });
80
81
  }
81
- return (_jsxs(_Fragment, { children: [_jsx(LockedScreenHeader, {}), _jsx(ScreenUnavailableBanner, {})] }));
82
- }
83
- // The route still names the locked screen; its breadcrumb must not label what stands in for it.
84
- // With a stand-in the header is labelled with that screen instead, none if it has no title.
85
- function LockedScreenHeader({ standInScreenId }) {
86
- const { PageHeader } = usePrimitives();
87
- const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
88
- if (PageHeader === undefined || !pageHeaderSlotAvailable)
89
- return null;
90
- return standInScreenId !== undefined ? (_jsx(PageHeader, { hideBreadcrumb: true, breadcrumbScreenId: standInScreenId })) : (_jsx(PageHeader, { hideBreadcrumb: true }));
82
+ return (_jsxs(_Fragment, { children: [_jsx(LockedHeaderScope, {}), _jsx(ScreenUnavailableBanner, {})] }));
91
83
  }
92
84
  function ScreenUnavailableBanner() {
93
85
  const { Banner } = usePrimitives();
@@ -99,9 +91,9 @@ function FallbackScreen({ featureName, fallback, translate, }) {
99
91
  // Also undefined when the user lacks access to the fallback screen, which is
100
92
  // not a framework error.
101
93
  if (target === undefined) {
102
- return (_jsxs(_Fragment, { children: [_jsx(LockedScreenHeader, {}), _jsx(ScreenUnavailableBanner, {})] }));
94
+ return (_jsxs(_Fragment, { children: [_jsx(LockedHeaderScope, {}), _jsx(ScreenUnavailableBanner, {})] }));
103
95
  }
104
- return (_jsxs(_Fragment, { children: [_jsx(LockedScreenHeader, { standInScreenId: lastSegment(target.qn) }), _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate })] }));
96
+ return (_jsx(LockedHeaderScope, { standInScreenId: lastSegment(target.qn), children: _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate }) }));
105
97
  }
106
98
  function renderScreenBody({ schema, screen, translate, entityId, onRowClick, onCopyLink, }) {
107
99
  switch (screen.type) {
@@ -0,0 +1,18 @@
1
+ import type { ListBulkAction, RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { DataTableBulk, DataTableBulkAction } from "../data-table-bulk-types.ts";
3
+ import type { Dispatcher, Translate } from "../headless/index.ts";
4
+ /** Resolves each bulk action against its writeHandler row action; one that resolves to nothing is dropped. */
5
+ export declare function buildListBulkActions(options: {
6
+ readonly bulkActions: readonly ListBulkAction[];
7
+ readonly rowActions: readonly RowAction[] | undefined;
8
+ readonly translate: Translate;
9
+ readonly dispatcher: Dispatcher;
10
+ }): readonly DataTableBulkAction[];
11
+ /** DataTable `bulk` prop of a list screen; undefined without bulk actions or dispatcher. */
12
+ export declare function useListBulk(options: {
13
+ readonly bulkActions: readonly ListBulkAction[] | undefined;
14
+ readonly rowActions: readonly RowAction[] | undefined;
15
+ readonly translate: Translate;
16
+ readonly dispatcher: Dispatcher | undefined;
17
+ readonly reload: () => Promise<unknown>;
18
+ }): DataTableBulk | undefined;
@@ -0,0 +1,54 @@
1
+ import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
3
+ import { useMemo } from "react";
4
+ import { isWriteHandlerRowAction, refetchAfterWrite, writeRowAction } from "./row-actions.js";
5
+ import { dispatcherErrorText } from "./write-failed-error.js";
6
+ function asksForConfirm(bulk, rowAction) {
7
+ return (bulk.confirm !== undefined || rowAction.confirm !== undefined || rowAction.style === "danger");
8
+ }
9
+ function buildBulkAction(bulk, rowAction, translate, dispatcher) {
10
+ const label = translate(rowAction.label);
11
+ const visible = rowAction.visible;
12
+ const confirmKey = bulk.confirm ?? "kumiko.list.bulk.confirm";
13
+ return {
14
+ id: bulk.id,
15
+ label,
16
+ ...(rowAction.style !== undefined && { style: rowAction.style }),
17
+ icon: resolveActionIcon(rowAction.id, rowAction.icon),
18
+ ...(asksForConfirm(bulk, rowAction) && {
19
+ confirmText: (count) => translate(confirmKey, { count, action: label }),
20
+ }),
21
+ ...(rowAction.confirmLabel !== undefined && {
22
+ confirmLabel: translate(rowAction.confirmLabel),
23
+ }),
24
+ appliesTo: (row) => visible === undefined || evalFieldCondition(visible, row.values),
25
+ writeRow: async (row) => {
26
+ const result = await writeRowAction(rowAction, row, dispatcher);
27
+ return result.isSuccess
28
+ ? { kind: "succeeded" }
29
+ : { kind: "failed", message: dispatcherErrorText(result.error, translate) };
30
+ },
31
+ };
32
+ }
33
+ /** Resolves each bulk action against its writeHandler row action; one that resolves to nothing is dropped. */
34
+ export function buildListBulkActions(options) {
35
+ const { bulkActions, rowActions, translate, dispatcher } = options;
36
+ return bulkActions.flatMap((bulk) => {
37
+ const rowAction = rowActions?.find((action) => action.id === bulk.rowAction);
38
+ if (rowAction === undefined || !isWriteHandlerRowAction(rowAction))
39
+ return [];
40
+ return [buildBulkAction(bulk, rowAction, translate, dispatcher)];
41
+ });
42
+ }
43
+ /** DataTable `bulk` prop of a list screen; undefined without bulk actions or dispatcher. */
44
+ export function useListBulk(options) {
45
+ const { bulkActions, rowActions, translate, dispatcher, reload } = options;
46
+ return useMemo(() => {
47
+ if (bulkActions === undefined || dispatcher === undefined)
48
+ return undefined;
49
+ const actions = buildListBulkActions({ bulkActions, rowActions, translate, dispatcher });
50
+ if (actions.length === 0)
51
+ return undefined;
52
+ return { actions, reload: () => refetchAfterWrite(reload) };
53
+ }, [bulkActions, rowActions, translate, dispatcher, reload]);
54
+ }
@@ -0,0 +1,9 @@
1
+ import { type ReactNode } from "react";
2
+ /** Screen bodies (and anything embedded in them) are not part of a fallback chain. */
3
+ export declare function StandInChainBoundary({ children }: {
4
+ readonly children: ReactNode;
5
+ }): ReactNode;
6
+ export declare function LockedHeaderScope({ standInScreenId, children, }: {
7
+ readonly standInScreenId?: string;
8
+ readonly children?: ReactNode;
9
+ }): ReactNode;
@@ -0,0 +1,33 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useState } from "react";
3
+ import { usePageHeaderSlotAvailable } from "../page-header-slot.js";
4
+ import { usePrimitives } from "../primitives.js";
5
+ // The route still names the locked screen; its breadcrumb must not label what stands in for it.
6
+ // With a stand-in the header is labelled with that screen instead, none if it has no title.
7
+ function LockedScreenHeader({ standInScreenId }) {
8
+ const { PageHeader } = usePrimitives();
9
+ const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
10
+ if (PageHeader === undefined || !pageHeaderSlotAvailable)
11
+ return null;
12
+ return standInScreenId !== undefined ? (_jsx(PageHeader, { hideBreadcrumb: true, breadcrumbScreenId: standInScreenId })) : (_jsx(PageHeader, { hideBreadcrumb: true }));
13
+ }
14
+ // A stand-in can itself be locked and fall back again. The header slot is a single value that
15
+ // every mounted PageHeader overwrites and clears on unmount, so only the outermost scope mounts
16
+ // a header; nested scopes report the screen that finally renders up to it.
17
+ const ReportStandInContext = createContext(undefined);
18
+ /** Screen bodies (and anything embedded in them) are not part of a fallback chain. */
19
+ export function StandInChainBoundary({ children }) {
20
+ return (_jsx(ReportStandInContext.Provider, { value: undefined, children: children }));
21
+ }
22
+ export function LockedHeaderScope({ standInScreenId, children, }) {
23
+ const reportToOuter = useContext(ReportStandInContext);
24
+ const [inner, setInner] = useState(undefined);
25
+ const effectiveScreenId = inner !== undefined ? inner.screenId : standInScreenId;
26
+ useEffect(() => {
27
+ if (reportToOuter === undefined)
28
+ return;
29
+ reportToOuter({ screenId: effectiveScreenId });
30
+ return () => reportToOuter(undefined);
31
+ }, [reportToOuter, effectiveScreenId]);
32
+ return (_jsxs(_Fragment, { children: [reportToOuter === undefined && (_jsx(LockedScreenHeader, { ...(effectiveScreenId !== undefined && { standInScreenId: effectiveScreenId }) })), _jsx(ReportStandInContext.Provider, { value: setInner, children: children })] }));
33
+ }