@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
@@ -0,0 +1,107 @@
1
+ import { isListAggregateResult, } from "@cosmicdrift/kumiko-types/list-aggregate-types";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { withoutListPaging, } from "./list-group-pages.js";
4
+ import { buildServerGroups, measuresOf } from "./list-server-groups.js";
5
+ import { useGroupLoadState } from "./use-group-load-state.js";
6
+ import { useQuery } from "./use-query.js";
7
+ const EMPTY_BUILD = { groups: [], rows: [], requests: [] };
8
+ function aggregateOf(data) {
9
+ return isListAggregateResult(data) ? data : undefined;
10
+ }
11
+ function noticeOf(groups, totals) {
12
+ if (groups?.truncated === true)
13
+ return "truncated";
14
+ if (groups?.searchTruncated === true || totals?.searchTruncated === true) {
15
+ return "searchTruncated";
16
+ }
17
+ return undefined;
18
+ }
19
+ /** The groups and totals aggregate queries plus a refresh that reloads both. */
20
+ function useAggregateQueries(args) {
21
+ const { queryType, base, groupField, measures } = args;
22
+ const grouped = groupField !== undefined;
23
+ const withTotals = measures.length > 0;
24
+ const groupsPayload = useMemo(() => ({ ...base, aggregate: { groupBy: groupField, measures } }), [base, groupField, measures]);
25
+ const totalsPayload = useMemo(() => ({ ...base, aggregate: { measures } }), [base, measures]);
26
+ const groupsQuery = useQuery(queryType, groupsPayload, { live: true, enabled: grouped });
27
+ const totalsQuery = useQuery(queryType, totalsPayload, {
28
+ live: true,
29
+ enabled: withTotals,
30
+ });
31
+ const [refreshNonce, setRefreshNonce] = useState(0);
32
+ const refetchGroups = groupsQuery.refetch;
33
+ const refetchTotals = totalsQuery.refetch;
34
+ const refresh = useCallback(() => setRefreshNonce((n) => n + 1), []);
35
+ useEffect(() => {
36
+ // skip: the mount fetch already covers the first nonce
37
+ if (refreshNonce === 0)
38
+ return;
39
+ if (grouped)
40
+ void refetchGroups();
41
+ if (withTotals)
42
+ void refetchTotals();
43
+ }, [refreshNonce, grouped, withTotals, refetchGroups, refetchTotals]);
44
+ return {
45
+ groupsAnswer: grouped ? aggregateOf(groupsQuery.data) : undefined,
46
+ totalsAnswer: withTotals ? aggregateOf(totalsQuery.data) : undefined,
47
+ groupsError: grouped ? groupsQuery.error : null,
48
+ refresh,
49
+ refreshNonce,
50
+ };
51
+ }
52
+ /** Server-side grouping and totals of a list: groups are the load unit, each open
53
+ * and visible group pages through its own rows; nothing is summed on the client. */
54
+ export function useListAggregates({ queryType, payload, table, groupField, pageSize, paging, translate, }) {
55
+ const grouping = table?.grouping;
56
+ const totalSpecs = useMemo(() => table?.totals ?? [], [table?.totals]);
57
+ const base = useMemo(() => withoutListPaging(payload), [payload]);
58
+ const baseKey = JSON.stringify(base);
59
+ const measures = useMemo(() => totalSpecs.map((total) => ({ field: total.field, kind: total.kind })), [totalSpecs]);
60
+ const grouped = groupField !== undefined;
61
+ const { groupsAnswer, totalsAnswer, groupsError, refresh, refreshNonce } = useAggregateQueries({
62
+ queryType,
63
+ base,
64
+ groupField,
65
+ measures,
66
+ });
67
+ const { state, onToggle, onHeaderVisible, onLoadMore, onResult } = useGroupLoadState(`${groupField ?? ""}|${baseKey}`);
68
+ const active = groupField !== undefined && groupsAnswer !== undefined;
69
+ const { groups, rows, requests } = active
70
+ ? buildServerGroups({
71
+ answer: groupsAnswer.groups,
72
+ groupField,
73
+ declared: grouping?.field === groupField ? grouping : undefined,
74
+ state,
75
+ base,
76
+ pageSize,
77
+ paging,
78
+ measures: totalSpecs,
79
+ translate,
80
+ })
81
+ : EMPTY_BUILD;
82
+ const serverGroups = active
83
+ ? {
84
+ field: groupField,
85
+ groups,
86
+ totalCount: groups.reduce((sum, group) => sum + group.count, 0),
87
+ onToggle,
88
+ onLoadMore,
89
+ onHeaderVisible,
90
+ }
91
+ : undefined;
92
+ const totals = totalsAnswer !== undefined
93
+ ? { measures: measuresOf(totalSpecs, totalsAnswer.groups[0]) }
94
+ : undefined;
95
+ const notice = noticeOf(groupsAnswer, totalsAnswer);
96
+ return {
97
+ grouped,
98
+ rows,
99
+ ...(serverGroups !== undefined && { serverGroups }),
100
+ ...(totals !== undefined && { totals }),
101
+ ...(notice !== undefined && { notice }),
102
+ groupPages: { queryType, requests, onResult, refreshNonce },
103
+ refresh,
104
+ loading: grouped && groupsAnswer === undefined && groupsError === null,
105
+ error: groupsError,
106
+ };
107
+ }
@@ -0,0 +1,38 @@
1
+ import type { ListTableOptions } from "@cosmicdrift/kumiko-types/screen";
2
+ import type { DataTableExport, DataTableResetAll, DataTableViewOptions } from "../data-table-view-types.ts";
3
+ import { type ListViewSchemaColumn } from "./use-list-view-state.ts";
4
+ type ListTableArgs = {
5
+ readonly storeKey: string;
6
+ /** Undefined: the list has no `table` block and nothing below is active. */
7
+ readonly table: ListTableOptions | undefined;
8
+ /** Must be referentially stable across renders. */
9
+ readonly schemaColumns: readonly ListViewSchemaColumn[];
10
+ /** URL-backed list (entityList, projectionList): reset clears the `<screenId>.*` keys. */
11
+ readonly url?: {
12
+ readonly screenId: string;
13
+ };
14
+ /** List with component-local state (relatedList): the owner resets it. */
15
+ readonly local?: {
16
+ readonly atDefault: boolean;
17
+ readonly reset: () => void;
18
+ };
19
+ /** Screen identity and current list payload; used only when `table.export.xlsx` is on. */
20
+ readonly exportSource?: {
21
+ readonly screen: string;
22
+ readonly screenShortId: string;
23
+ readonly payload: Readonly<Record<string, unknown>>;
24
+ };
25
+ };
26
+ export type ListTableProps = {
27
+ readonly view?: DataTableViewOptions;
28
+ readonly resetAll?: DataTableResetAll;
29
+ readonly exportList?: DataTableExport;
30
+ };
31
+ export type ListTableState = {
32
+ readonly props: ListTableProps;
33
+ /** Field the list is grouped by right now; undefined = ungrouped. */
34
+ readonly groupField: string | undefined;
35
+ };
36
+ /** Props for the DataTable's `view` / `resetAll` from a screen's `table` block. */
37
+ export declare function useListTable({ storeKey, table, schemaColumns, url, local, exportSource, }: ListTableArgs): ListTableState;
38
+ export {};
@@ -0,0 +1,91 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { useNav } from "../app/nav.js";
3
+ import { useListStateStore } from "../components/list-state-store.js";
4
+ import { isListStateAtDefault, listResetUpdates } from "./list-reset.js";
5
+ import { useListViewState } from "./use-list-view-state.js";
6
+ const NO_TABLE_OPTIONS = {};
7
+ const PAGING_PAYLOAD_KEYS = ["limit", "offset", "cursor", "totalCount", "filter"];
8
+ function withoutPaging(payload) {
9
+ const kept = { ...payload };
10
+ for (const key of PAGING_PAYLOAD_KEYS)
11
+ delete kept[key];
12
+ return kept;
13
+ }
14
+ /** Props for the DataTable's `view` / `resetAll` from a screen's `table` block. */
15
+ export function useListTable({ storeKey, table, schemaColumns, url, local, exportSource, }) {
16
+ const nav = useNav();
17
+ const store = useListStateStore();
18
+ const options = table ?? NO_TABLE_OPTIONS;
19
+ const viewState = useListViewState({ storeKey, store, table: options, schemaColumns });
20
+ const urlParams = nav.searchParams;
21
+ const urlScreenId = url?.screenId;
22
+ const localAtDefault = local?.atDefault;
23
+ const localReset = local?.reset;
24
+ const atDefault = urlScreenId !== undefined
25
+ ? isListStateAtDefault(urlParams, urlScreenId)
26
+ : (localAtDefault ?? true);
27
+ // The remembered view (columns, density) survives: "reset view" is its own action.
28
+ const onReset = useCallback(() => {
29
+ if (urlScreenId !== undefined)
30
+ nav.setSearchParams(listResetUpdates(urlParams, urlScreenId));
31
+ localReset?.();
32
+ const { params: _forgotten, ...kept } = store.read(storeKey) ?? {};
33
+ if (kept.view === undefined)
34
+ store.remove(storeKey);
35
+ else
36
+ store.write(storeKey, kept);
37
+ }, [nav, urlParams, urlScreenId, localReset, store, storeKey]);
38
+ const resetAll = useMemo(() => (options.resetAll === true ? { active: !atDefault, onReset } : undefined), [options.resetAll, atDefault, onReset]);
39
+ const hasView = options.columnPicker === true ||
40
+ options.density !== undefined ||
41
+ options.pinning === true ||
42
+ options.resizing === true ||
43
+ options.keyboardNavigation === true ||
44
+ viewState.groupByFields !== undefined;
45
+ const view = useMemo(() => hasView
46
+ ? {
47
+ ...(viewState.columns !== undefined && {
48
+ columns: viewState.columns,
49
+ onColumnsChange: viewState.setColumns,
50
+ }),
51
+ density: viewState.density,
52
+ ...(viewState.userChoosesDensity && { onDensityChange: viewState.setDensity }),
53
+ ...(viewState.pins !== undefined && {
54
+ pins: viewState.pins,
55
+ onPinsChange: viewState.setPins,
56
+ }),
57
+ ...(viewState.widths !== undefined &&
58
+ viewState.minWidths !== undefined && {
59
+ widths: viewState.widths,
60
+ minWidths: viewState.minWidths,
61
+ onWidthChange: viewState.setWidth,
62
+ }),
63
+ ...(options.keyboardNavigation === true && { keyboardNavigation: true }),
64
+ ...(viewState.groupByFields !== undefined && {
65
+ groupBy: {
66
+ value: viewState.groupBy,
67
+ fields: viewState.groupByFields,
68
+ onChange: viewState.setGroupBy,
69
+ },
70
+ }),
71
+ viewIsDefault: viewState.viewIsDefault,
72
+ onViewReset: viewState.resetView,
73
+ }
74
+ : undefined, [hasView, viewState, options.keyboardNavigation]);
75
+ const exportEnabled = options.export?.xlsx === true;
76
+ const viewColumns = viewState.columns;
77
+ const exportList = useMemo(() => {
78
+ if (!exportEnabled || exportSource === undefined)
79
+ return undefined;
80
+ const ordered = viewColumns?.filter((column) => column.visible).map((column) => column.field) ??
81
+ schemaColumns.map((column) => column.field);
82
+ return { ...exportSource, payload: withoutPaging(exportSource.payload), columns: ordered };
83
+ }, [exportEnabled, exportSource, viewColumns, schemaColumns]);
84
+ const props = useMemo(() => ({
85
+ ...(view !== undefined && { view }),
86
+ ...(resetAll !== undefined && { resetAll }),
87
+ ...(exportList !== undefined && { exportList }),
88
+ }), [view, resetAll, exportList]);
89
+ const groupField = options.grouping !== undefined && viewState.groupBy !== "" ? viewState.groupBy : undefined;
90
+ return { props, groupField };
91
+ }
@@ -0,0 +1,43 @@
1
+ import type { ListColumnPin, ListColumnSpec, ListTableOptions } from "@cosmicdrift/kumiko-types/screen";
2
+ import type { ListStateStore } from "../components/list-state-store.tsx";
3
+ import type { DataTableColumnPins, DataTableColumnWidths, DataTableDensity, DataTableViewColumn } from "../data-table-view-types.ts";
4
+ export type ListViewSchemaColumn = {
5
+ readonly field: string;
6
+ readonly hidden?: boolean;
7
+ readonly pinned?: ListColumnPin;
8
+ readonly width?: number;
9
+ readonly minWidth?: number;
10
+ };
11
+ export type ListViewState = {
12
+ /** Undefined unless `table.columnPicker` is on. */
13
+ readonly columns: readonly DataTableViewColumn[] | undefined;
14
+ readonly density: DataTableDensity;
15
+ readonly userChoosesDensity: boolean;
16
+ readonly setColumns: (next: readonly DataTableViewColumn[]) => void;
17
+ readonly setDensity: (next: DataTableDensity) => void;
18
+ /** Undefined unless `table.pinning` is on. */
19
+ readonly pins: DataTableColumnPins | undefined;
20
+ readonly setPins: (next: DataTableColumnPins) => void;
21
+ /** Undefined unless `table.resizing` is on. */
22
+ readonly widths: DataTableColumnWidths | undefined;
23
+ readonly minWidths: DataTableColumnWidths | undefined;
24
+ readonly setWidth: (field: string, width: number) => void;
25
+ /** Active group field, "" = not grouped. */
26
+ readonly groupBy: string;
27
+ /** Undefined unless `table.grouping.userChoice` is on. */
28
+ readonly groupByFields: readonly string[] | undefined;
29
+ readonly setGroupBy: (next: string) => void;
30
+ readonly resetView: () => void;
31
+ readonly viewIsDefault: boolean;
32
+ };
33
+ export declare function listViewSchemaColumns(columns: readonly ListColumnSpec[]): ListViewSchemaColumn[];
34
+ type ViewStateArgs = {
35
+ readonly storeKey: string;
36
+ readonly store: ListStateStore;
37
+ readonly table: ListTableOptions;
38
+ readonly schemaColumns: readonly ListViewSchemaColumn[];
39
+ };
40
+ /** Column choice, order and density of one list. Lives in the list-state store
41
+ * (never in the URL) and is independent of `rememberState`. */
42
+ export declare function useListViewState(args: ViewStateArgs): ListViewState;
43
+ export {};
@@ -0,0 +1,161 @@
1
+ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import { clampWidth, DEFAULT_MIN_COLUMN_WIDTH_PX, defaultPins, defaultWidths, mergeSavedWidths, minWidthsOf, sameRecord, } from "./list-column-layout.js";
4
+ import { sanitizeStoredListState } from "./list-state-schema.js";
5
+ export function listViewSchemaColumns(columns) {
6
+ return columns.map((column) => {
7
+ const { field, hidden, pinned, width, minWidth } = normalizeListColumn(column);
8
+ return {
9
+ field,
10
+ ...(hidden === true && { hidden }),
11
+ ...(pinned !== undefined && { pinned }),
12
+ ...(width !== undefined && { width }),
13
+ ...(minWidth !== undefined && { minWidth }),
14
+ };
15
+ });
16
+ }
17
+ function schemaDefaultColumns(schema) {
18
+ return schema.map((column) => ({ field: column.field, visible: column.hidden !== true }));
19
+ }
20
+ /** Saved order wins; a column the saved view has never seen takes its schema
21
+ * position (after its schema predecessor) with its schema default. */
22
+ function mergeSavedColumns(schema, saved) {
23
+ const merged = [...saved];
24
+ const present = new Set(saved.map((column) => column.field));
25
+ schema.forEach((column, index) => {
26
+ // skip: column already saved
27
+ if (present.has(column.field))
28
+ return;
29
+ const predecessor = schema[index - 1];
30
+ const predecessorAt = predecessor === undefined ? -1 : merged.findIndex((m) => m.field === predecessor.field);
31
+ merged.splice(predecessorAt + 1, 0, {
32
+ field: column.field,
33
+ visible: column.hidden !== true,
34
+ });
35
+ present.add(column.field);
36
+ });
37
+ return merged;
38
+ }
39
+ function sameColumns(a, b) {
40
+ return (a.length === b.length &&
41
+ a.every((c, i) => c.field === b[i]?.field && c.visible === b[i]?.visible));
42
+ }
43
+ function hasVisibleColumn(columns) {
44
+ return columns.some((column) => column.visible);
45
+ }
46
+ function groupByChoices(grouping) {
47
+ if (grouping?.userChoice !== true)
48
+ return undefined;
49
+ const offered = [
50
+ ...(grouping.field !== undefined ? [grouping.field] : []),
51
+ ...(grouping.fields ?? []),
52
+ ];
53
+ return [...new Set(offered)];
54
+ }
55
+ function isOfferedGroupBy(grouping, saved) {
56
+ if (saved === undefined)
57
+ return false;
58
+ const choices = groupByChoices(grouping);
59
+ return choices !== undefined && (saved === "" || choices.includes(saved));
60
+ }
61
+ function readInitialView(args, defaults) {
62
+ const saved = sanitizeStoredListState(args.store.read(args.storeKey), {
63
+ columnFields: args.schemaColumns.map((column) => column.field),
64
+ filterFields: [],
65
+ })?.view;
66
+ const savedGroupBy = saved?.groupBy;
67
+ const merged = args.table.columnPicker === true && saved?.columns !== undefined
68
+ ? mergeSavedColumns(args.schemaColumns, saved.columns)
69
+ : defaults.columns;
70
+ return {
71
+ columns: hasVisibleColumn(merged) ? merged : defaults.columns,
72
+ density: args.table.density?.userChoice === true && saved?.density ? saved.density : defaults.density,
73
+ // Saved pins replace the schema pins entirely, so the user can release a schema pin.
74
+ pins: args.table.pinning === true && saved?.pins !== undefined ? saved.pins : defaults.pins,
75
+ widths: args.table.resizing === true && saved?.widths !== undefined
76
+ ? mergeSavedWidths(defaults.widths, saved.widths, minWidthsOf(args.schemaColumns))
77
+ : defaults.widths,
78
+ groupBy: isOfferedGroupBy(args.table.grouping, savedGroupBy) ? savedGroupBy : defaults.groupBy,
79
+ };
80
+ }
81
+ /** Column choice, order and density of one list. Lives in the list-state store
82
+ * (never in the URL) and is independent of `rememberState`. */
83
+ export function useListViewState(args) {
84
+ const { storeKey, store, table, schemaColumns } = args;
85
+ const columnPicker = table.columnPicker === true;
86
+ const userChoosesDensity = table.density?.userChoice === true;
87
+ const pinning = table.pinning === true;
88
+ const resizing = table.resizing === true;
89
+ const groupByFields = useMemo(() => groupByChoices(table.grouping), [table.grouping]);
90
+ const userChoosesGroupBy = groupByFields !== undefined;
91
+ const minWidths = useMemo(() => minWidthsOf(schemaColumns), [schemaColumns]);
92
+ const defaults = useMemo(() => ({
93
+ columns: schemaDefaultColumns(schemaColumns),
94
+ density: table.density?.default ?? "comfortable",
95
+ pins: pinning ? defaultPins(schemaColumns) : {},
96
+ widths: resizing ? defaultWidths(schemaColumns, minWidths) : {},
97
+ groupBy: table.grouping?.field ?? "",
98
+ }), [schemaColumns, table.density?.default, pinning, resizing, minWidths, table.grouping?.field]);
99
+ const [seeded, setSeeded] = useState(() => ({
100
+ storeKey,
101
+ view: readInitialView(args, defaults),
102
+ }));
103
+ const view = seeded.storeKey === storeKey ? seeded.view : readInitialView(args, defaults);
104
+ if (seeded.storeKey !== storeKey)
105
+ setSeeded({ storeKey, view });
106
+ const commit = useCallback((next) => {
107
+ setSeeded({ storeKey, view: next });
108
+ store.write(storeKey, {
109
+ ...store.read(storeKey),
110
+ view: {
111
+ ...(columnPicker && { columns: next.columns }),
112
+ ...(userChoosesDensity && { density: next.density }),
113
+ ...(pinning && { pins: next.pins }),
114
+ ...(resizing && { widths: next.widths }),
115
+ ...(userChoosesGroupBy && { groupBy: next.groupBy }),
116
+ },
117
+ });
118
+ }, [store, storeKey, columnPicker, userChoosesDensity, pinning, resizing, userChoosesGroupBy]);
119
+ const setColumns = useCallback((next) => {
120
+ // skip: a view must keep at least one visible column
121
+ if (!hasVisibleColumn(next))
122
+ return;
123
+ commit({ ...view, columns: next });
124
+ }, [commit, view]);
125
+ const setDensity = useCallback((next) => commit({ ...view, density: next }), [commit, view]);
126
+ const setPins = useCallback((next) => commit({ ...view, pins: next }), [commit, view]);
127
+ const setWidth = useCallback((field, width) => commit({
128
+ ...view,
129
+ widths: {
130
+ ...view.widths,
131
+ [field]: clampWidth(width, minWidths[field] ?? DEFAULT_MIN_COLUMN_WIDTH_PX),
132
+ },
133
+ }), [commit, view, minWidths]);
134
+ const setGroupBy = useCallback((next) => commit({ ...view, groupBy: next }), [commit, view]);
135
+ const resetView = useCallback(() => {
136
+ setSeeded({ storeKey, view: defaults });
137
+ const { view: _dropped, ...rest } = store.read(storeKey) ?? {};
138
+ store.write(storeKey, rest);
139
+ }, [store, storeKey, defaults]);
140
+ return {
141
+ columns: columnPicker ? view.columns : undefined,
142
+ density: view.density,
143
+ userChoosesDensity,
144
+ setColumns,
145
+ setDensity,
146
+ pins: pinning ? view.pins : undefined,
147
+ setPins,
148
+ widths: resizing ? view.widths : undefined,
149
+ minWidths: resizing ? minWidths : undefined,
150
+ setWidth,
151
+ groupBy: view.groupBy,
152
+ groupByFields,
153
+ setGroupBy,
154
+ resetView,
155
+ viewIsDefault: view.density === defaults.density &&
156
+ (!columnPicker || sameColumns(view.columns, defaults.columns)) &&
157
+ (!pinning || sameRecord(view.pins, defaults.pins)) &&
158
+ (!resizing || sameRecord(view.widths, defaults.widths)) &&
159
+ view.groupBy === defaults.groupBy,
160
+ };
161
+ }
@@ -13,10 +13,18 @@ export declare function referenceOptionSource(field: {
13
13
  readonly queryQn: string;
14
14
  readonly labelKey: string;
15
15
  };
16
- /** Empty while the query loads (or fails), so callers fall back to the raw id.
16
+ export declare function isCompleteRowPage(data: {
17
+ readonly nextCursor?: string | null;
18
+ } | null): boolean;
19
+ /** `map` holds the resolved labels; while the query loads or has failed an id is
20
+ * simply not in it. Callers decide the fallback: the raw id, or the "Unknown"
21
+ * label for entities listed in `UNRESOLVED_REFERENCE_LABEL_KEYS` once the
22
+ * lookup is complete.
17
23
  * `featureName` is the reference's target feature, not the screen's. */
18
24
  export declare function useReferenceLookup(featureName: string, refEntity: string, labelField: string): {
19
25
  readonly map: ReferenceLookupMap;
20
26
  readonly loading: boolean;
21
27
  readonly failed: boolean;
28
+ /** False when the lookup page was capped: absent ids may still exist. */
29
+ readonly complete: boolean;
22
30
  };
@@ -9,7 +9,8 @@
9
9
  // of N+1) but would have to extend the executor API with lookupTables.
10
10
  // The renderer-side path wins for the MVP.
11
11
  // - limit:200 is a hard UX limit: with more entries in the referenced
12
- // entity, the last rows show only UUIDs. A searchable combobox plus a
12
+ // entity, the last rows fall back to the raw id (or "Unknown" for
13
+ // opt-in entities once the lookup is complete). A searchable combobox plus a
13
14
  // server-side eager load with an ID whitelist will lift that later.
14
15
  //
15
16
  // The hook is call-stable: useQuery's internal cache shares the lookup map
@@ -44,7 +45,15 @@ export function referenceOptionSource(field, refFeature, refEntity) {
44
45
  }
45
46
  return referenceLookupSource(refFeature, refEntity, field.refLabelField ?? "id");
46
47
  }
47
- /** Empty while the query loads (or fails), so callers fall back to the raw id.
48
+ // A page with a continuation cursor is only a prefix of the referenced rows,
49
+ // so a missing id in it proves nothing about the row's existence.
50
+ export function isCompleteRowPage(data) {
51
+ return data !== null && (data.nextCursor ?? null) === null;
52
+ }
53
+ /** `map` holds the resolved labels; while the query loads or has failed an id is
54
+ * simply not in it. Callers decide the fallback: the raw id, or the "Unknown"
55
+ * label for entities listed in `UNRESOLVED_REFERENCE_LABEL_KEYS` once the
56
+ * lookup is complete.
48
57
  * `featureName` is the reference's target feature, not the screen's. */
49
58
  export function useReferenceLookup(featureName, refEntity, labelField) {
50
59
  const { queryQn, labelKey } = referenceLookupSource(featureName, refEntity, labelField);
@@ -72,5 +81,10 @@ export function useReferenceLookup(featureName, refEntity, labelField) {
72
81
  }
73
82
  return out;
74
83
  }, [result.data, labelKey, systemLabel, translate]);
75
- return { map, loading: result.loading, failed: result.error !== null };
84
+ return {
85
+ map,
86
+ loading: result.loading,
87
+ failed: result.error !== null,
88
+ complete: isCompleteRowPage(result.data),
89
+ };
76
90
  }
@@ -0,0 +1,27 @@
1
+ import type { EditRelatedListSectionViewModel, Translate } from "../headless/index.ts";
2
+ import type { DataTableSort } from "../primitives.tsx";
3
+ import { type ListAggregates } from "./use-list-aggregates.ts";
4
+ type Row = Readonly<Record<string, unknown>>;
5
+ export type RelatedListRows = {
6
+ readonly rows: readonly Row[];
7
+ readonly firstLoad: boolean;
8
+ readonly error: ListAggregates["error"];
9
+ readonly hasData: boolean;
10
+ /** The one-shot fetch stopped early, so the client-side sort covers part of the set. */
11
+ readonly truncated: boolean;
12
+ readonly aggregates: ListAggregates;
13
+ readonly refetchSelfAndHost: () => Promise<void>;
14
+ };
15
+ /** Rows of a relatedList section: one fetch sorted on the client, or the loaded
16
+ * rows of its server groups in server order when `table.grouping` is active. */
17
+ export declare function useRelatedListRows(args: {
18
+ readonly section: EditRelatedListSectionViewModel;
19
+ readonly payload: Readonly<Record<string, unknown>>;
20
+ readonly groupField: string | undefined;
21
+ readonly sort: DataTableSort | null;
22
+ readonly locale: string | undefined;
23
+ readonly refreshToken: unknown;
24
+ readonly onAfterWrite: (() => Promise<void> | void) | undefined;
25
+ readonly translate: Translate;
26
+ }): RelatedListRows;
27
+ export {};
@@ -0,0 +1,81 @@
1
+ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { useCallback, useEffect, useMemo, useRef } from "react";
3
+ import { sortByAccessor } from "../sort-by-accessor.js";
4
+ import { useListAggregates } from "./use-list-aggregates.js";
5
+ import { useQuery } from "./use-query.js";
6
+ const DEFAULT_GROUP_PAGE_SIZE = 50;
7
+ // A handler that omits `nextCursor` (or reports a `total` the fetched rows
8
+ // already cover) has no further rows; only a real cursor marks truncation.
9
+ function isTruncatedFetch(data) {
10
+ return (data !== null &&
11
+ typeof data.nextCursor === "string" &&
12
+ (data.total === undefined || data.total > data.rows.length));
13
+ }
14
+ /** Rows of a relatedList section: one fetch sorted on the client, or the loaded
15
+ * rows of its server groups in server order when `table.grouping` is active. */
16
+ export function useRelatedListRows(args) {
17
+ const { section, payload, groupField, sort, locale, refreshToken, onAfterWrite } = args;
18
+ const aggregates = useListAggregates({
19
+ queryType: section.query,
20
+ payload,
21
+ table: section.table,
22
+ groupField,
23
+ pageSize: section.pageSize ?? DEFAULT_GROUP_PAGE_SIZE,
24
+ paging: "cursor",
25
+ translate: args.translate,
26
+ });
27
+ const { grouped, refresh: refreshAggregates } = aggregates;
28
+ const rowsQuery = useQuery(section.query, payload, { enabled: !grouped });
29
+ const ownRefetch = rowsQuery.refetch;
30
+ const refetchOwn = useCallback(async () => {
31
+ refreshAggregates();
32
+ if (!grouped)
33
+ await ownRefetch();
34
+ }, [refreshAggregates, grouped, ownRefetch]);
35
+ const lastRefreshTokenRef = useRef(refreshToken);
36
+ useEffect(() => {
37
+ // skip: the token has not changed since the last refetch
38
+ if (lastRefreshTokenRef.current === refreshToken)
39
+ return;
40
+ lastRefreshTokenRef.current = refreshToken;
41
+ void refetchOwn();
42
+ }, [refreshToken, refetchOwn]);
43
+ const refetchSelfAndHost = useCallback(async () => {
44
+ await refetchOwn();
45
+ await onAfterWrite?.();
46
+ }, [refetchOwn, onAfterWrite]);
47
+ const sortAccessors = useMemo(() => {
48
+ const accessors = {};
49
+ for (const col of section.columns) {
50
+ const field = normalizeListColumn(col).field;
51
+ accessors[field] = (row) => {
52
+ const value = row[field];
53
+ if (value === null || value === undefined)
54
+ return null;
55
+ return typeof value === "number" ? value : String(value);
56
+ };
57
+ }
58
+ return accessors;
59
+ }, [section.columns]);
60
+ const sortedRows = useMemo(() => sortByAccessor(rowsQuery.data?.rows ?? [], sort, sortAccessors, locale), [rowsQuery.data, sort, sortAccessors, locale]);
61
+ if (grouped) {
62
+ return {
63
+ rows: aggregates.rows,
64
+ firstLoad: aggregates.loading,
65
+ error: aggregates.error,
66
+ hasData: aggregates.serverGroups !== undefined,
67
+ truncated: false,
68
+ aggregates,
69
+ refetchSelfAndHost,
70
+ };
71
+ }
72
+ return {
73
+ rows: sortedRows,
74
+ firstLoad: rowsQuery.loading && rowsQuery.data === null,
75
+ error: rowsQuery.error,
76
+ hasData: rowsQuery.data !== null,
77
+ truncated: isTruncatedFetch(rowsQuery.data),
78
+ aggregates,
79
+ refetchSelfAndHost,
80
+ };
81
+ }
@@ -0,0 +1,15 @@
1
+ import type { EditRelatedListSectionViewModel } from "../headless/index.ts";
2
+ /** Search, filters and sort of a relatedList section (component-local: it has
3
+ * no URL namespace) plus the DataTable props of its `table` block. */
4
+ export declare function useRelatedListState(section: EditRelatedListSectionViewModel, parentScreenId: string | undefined): {
5
+ search: string;
6
+ filters: Readonly<Record<string, readonly string[]>>;
7
+ sort: import("./use-list-url-state.ts").ListSort | null;
8
+ setSearch: (search: string) => void;
9
+ setFilters: import("react").Dispatch<import("react").SetStateAction<Readonly<Record<string, readonly string[]>>>>;
10
+ setSort: (sort: import("./use-list-url-state.ts").ListSort | null) => void;
11
+ reset: () => void;
12
+ atDefault: boolean;
13
+ listTable: import("./use-list-table.ts").ListTableProps;
14
+ groupField: string | undefined;
15
+ };
@@ -0,0 +1,31 @@
1
+ import { useMemo } from "react";
2
+ import { useListStateStore } from "../components/list-state-store.js";
3
+ import { useListTable } from "./use-list-table.js";
4
+ import { listViewSchemaColumns } from "./use-list-view-state.js";
5
+ import { useRememberedLocalListState } from "./use-remembered-local-list-state.js";
6
+ /** Search, filters and sort of a relatedList section (component-local: it has
7
+ * no URL namespace) plus the DataTable props of its `table` block. */
8
+ export function useRelatedListState(section, parentScreenId) {
9
+ const store = useListStateStore();
10
+ const storeKey = `${parentScreenId ?? ""}#${section.id ?? section.query}`;
11
+ const defaultSort = useMemo(() => section.defaultSort ?? null, [section.defaultSort]);
12
+ const schemaColumns = useMemo(() => listViewSchemaColumns(section.columns), [section.columns]);
13
+ const known = useMemo(() => ({
14
+ columnFields: schemaColumns.map((column) => column.field),
15
+ filterFields: (section.facets ?? []).map((facet) => facet.field),
16
+ }), [schemaColumns, section.facets]);
17
+ const local = useRememberedLocalListState({
18
+ storeKey,
19
+ store,
20
+ remember: section.table?.rememberState === true,
21
+ defaultSort,
22
+ known,
23
+ });
24
+ const { props: listTable, groupField } = useListTable({
25
+ storeKey,
26
+ table: section.table,
27
+ schemaColumns,
28
+ local: { atDefault: local.atDefault, reset: local.reset },
29
+ });
30
+ return { ...local, listTable, groupField };
31
+ }