@cosmicdrift/kumiko-renderer 0.388.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 (161) hide show
  1. package/dist/app/base-path-context.d.ts +9 -0
  2. package/dist/app/base-path-context.js +13 -0
  3. package/dist/app/base-path.d.ts +3 -0
  4. package/dist/app/base-path.js +27 -0
  5. package/dist/app/config-edit-body.d.ts +1 -1
  6. package/dist/app/config-edit-body.js +15 -6
  7. package/dist/app/config-edit-without-secret-fields.d.ts +2 -0
  8. package/dist/app/config-edit-without-secret-fields.js +28 -0
  9. package/dist/app/entity-list-facet-specs.d.ts +3 -0
  10. package/dist/app/entity-list-facet-specs.js +65 -0
  11. package/dist/app/entity-list-screen.js +74 -206
  12. package/dist/app/kumiko-screen.js +6 -14
  13. package/dist/app/list-bulk-actions.d.ts +18 -0
  14. package/dist/app/list-bulk-actions.js +54 -0
  15. package/dist/app/locked-screen-header.d.ts +9 -0
  16. package/dist/app/locked-screen-header.js +33 -0
  17. package/dist/app/projection-list-body.js +51 -46
  18. package/dist/app/row-actions.d.ts +3 -0
  19. package/dist/app/row-actions.js +10 -5
  20. package/dist/app/screen-shared.js +9 -2
  21. package/dist/app/use-entity-range-facets.d.ts +24 -0
  22. package/dist/app/use-entity-range-facets.js +64 -0
  23. package/dist/app/use-projection-date-range.d.ts +12 -0
  24. package/dist/app/use-projection-date-range.js +21 -0
  25. package/dist/app/use-screen-list-rows.d.ts +37 -0
  26. package/dist/app/use-screen-list-rows.js +104 -0
  27. package/dist/app/use-screen-list-table.d.ts +24 -0
  28. package/dist/app/use-screen-list-table.js +46 -0
  29. package/dist/components/list-group-page-bridges.d.ts +3 -0
  30. package/dist/components/list-group-page-bridges.js +31 -0
  31. package/dist/components/list-state-store.d.ts +16 -0
  32. package/dist/components/list-state-store.js +31 -0
  33. package/dist/components/related-list-section.js +37 -89
  34. package/dist/components/render-field.js +6 -5
  35. package/dist/components/render-list-toolbar-action.d.ts +27 -0
  36. package/dist/components/render-list-toolbar-action.js +40 -0
  37. package/dist/components/render-list.d.ts +13 -29
  38. package/dist/components/render-list.js +42 -50
  39. package/dist/current-user.d.ts +2 -0
  40. package/dist/data-table-aggregate-types.d.ts +47 -0
  41. package/dist/data-table-aggregate-types.js +1 -0
  42. package/dist/data-table-bulk-types.d.ts +27 -0
  43. package/dist/data-table-bulk-types.js +1 -0
  44. package/dist/data-table-facet-types.d.ts +36 -0
  45. package/dist/data-table-facet-types.js +1 -0
  46. package/dist/data-table-view-types.d.ts +73 -0
  47. package/dist/data-table-view-types.js +1 -0
  48. package/dist/headless/view-model/edit.js +3 -1
  49. package/dist/headless/view-model/types.d.ts +4 -2
  50. package/dist/hooks/list-column-layout.d.ts +19 -0
  51. package/dist/hooks/list-column-layout.js +36 -0
  52. package/dist/hooks/list-group-pages.d.ts +26 -0
  53. package/dist/hooks/list-group-pages.js +37 -0
  54. package/dist/hooks/list-reset.d.ts +8 -0
  55. package/dist/hooks/list-reset.js +15 -0
  56. package/dist/hooks/list-server-groups.d.ts +39 -0
  57. package/dist/hooks/list-server-groups.js +84 -0
  58. package/dist/hooks/list-state-schema.d.ts +28 -0
  59. package/dist/hooks/list-state-schema.js +76 -0
  60. package/dist/hooks/use-bulk-run.d.ts +41 -0
  61. package/dist/hooks/use-bulk-run.js +65 -0
  62. package/dist/hooks/use-group-load-state.d.ts +11 -0
  63. package/dist/hooks/use-group-load-state.js +31 -0
  64. package/dist/hooks/use-list-aggregates.d.ts +38 -0
  65. package/dist/hooks/use-list-aggregates.js +107 -0
  66. package/dist/hooks/use-list-table.d.ts +38 -0
  67. package/dist/hooks/use-list-table.js +91 -0
  68. package/dist/hooks/use-list-view-state.d.ts +43 -0
  69. package/dist/hooks/use-list-view-state.js +161 -0
  70. package/dist/hooks/use-related-list-rows.d.ts +27 -0
  71. package/dist/hooks/use-related-list-rows.js +81 -0
  72. package/dist/hooks/use-related-list-state.d.ts +15 -0
  73. package/dist/hooks/use-related-list-state.js +31 -0
  74. package/dist/hooks/use-remembered-list-params.d.ts +13 -0
  75. package/dist/hooks/use-remembered-list-params.js +53 -0
  76. package/dist/hooks/use-remembered-local-list-state.d.ts +26 -0
  77. package/dist/hooks/use-remembered-local-list-state.js +63 -0
  78. package/dist/i18n-defaults.js +53 -0
  79. package/dist/index.d.ts +3 -1
  80. package/dist/index.js +2 -0
  81. package/dist/primitives.d.ts +11 -47
  82. package/dist/web/app/create-app.d.ts +5 -0
  83. package/dist/web/app/create-app.js +7 -4
  84. package/dist/web/app/history-leave-guard.d.ts +6 -0
  85. package/dist/web/app/history-leave-guard.js +115 -0
  86. package/dist/web/app/list-state-store-boot.d.ts +6 -0
  87. package/dist/web/app/list-state-store-boot.js +15 -0
  88. package/dist/web/app/nav.js +7 -54
  89. package/dist/web/app/session-list-state-store.d.ts +15 -0
  90. package/dist/web/app/session-list-state-store.js +117 -0
  91. package/dist/web/index.d.ts +1 -0
  92. package/dist/web/index.js +1 -0
  93. package/dist/web/lib/export-xlsx-request.d.ts +11 -0
  94. package/dist/web/lib/export-xlsx-request.js +46 -0
  95. package/dist/web/primitives/data-table/actions-column.d.ts +16 -0
  96. package/dist/web/primitives/data-table/actions-column.js +34 -0
  97. package/dist/web/primitives/data-table/bulk-selection-bar.d.ts +20 -0
  98. package/dist/web/primitives/data-table/bulk-selection-bar.js +37 -0
  99. package/dist/web/primitives/data-table/card-sort-select.d.ts +14 -0
  100. package/dist/web/primitives/data-table/card-sort-select.js +40 -0
  101. package/dist/web/primitives/data-table/column-layout-mode.d.ts +5 -0
  102. package/dist/web/primitives/data-table/column-layout-mode.js +10 -0
  103. package/dist/web/primitives/data-table/column-picker-section.d.ts +7 -0
  104. package/dist/web/primitives/data-table/column-picker-section.js +35 -0
  105. package/dist/web/primitives/data-table/column-resize-handle.d.ts +14 -0
  106. package/dist/web/primitives/data-table/column-resize-handle.js +46 -0
  107. package/dist/web/primitives/data-table/density.d.ts +14 -0
  108. package/dist/web/primitives/data-table/density.js +20 -0
  109. package/dist/web/primitives/data-table/export-button.d.ts +5 -0
  110. package/dist/web/primitives/data-table/export-button.js +37 -0
  111. package/dist/web/primitives/data-table/facet-cluster.d.ts +8 -0
  112. package/dist/web/primitives/data-table/facet-cluster.js +37 -0
  113. package/dist/web/primitives/data-table/group-band.d.ts +28 -0
  114. package/dist/web/primitives/data-table/group-band.js +53 -0
  115. package/dist/web/primitives/data-table/measure-value.d.ts +10 -0
  116. package/dist/web/primitives/data-table/measure-value.js +28 -0
  117. package/dist/web/primitives/data-table/pin-section.d.ts +8 -0
  118. package/dist/web/primitives/data-table/pin-section.js +20 -0
  119. package/dist/web/primitives/data-table/reset-all-button.d.ts +4 -0
  120. package/dist/web/primitives/data-table/reset-all-button.js +8 -0
  121. package/dist/web/primitives/data-table/server-group-header.d.ts +11 -0
  122. package/dist/web/primitives/data-table/server-group-header.js +51 -0
  123. package/dist/web/primitives/data-table/sortable-header.d.ts +17 -0
  124. package/dist/web/primitives/data-table/sortable-header.js +41 -0
  125. package/dist/web/primitives/data-table/table-chrome.d.ts +13 -0
  126. package/dist/web/primitives/data-table/table-chrome.js +30 -0
  127. package/dist/web/primitives/data-table/table-footer.d.ts +7 -0
  128. package/dist/web/primitives/data-table/table-footer.js +20 -0
  129. package/dist/web/primitives/data-table/toolbar-end-cluster.d.ts +10 -0
  130. package/dist/web/primitives/data-table/toolbar-end-cluster.js +13 -0
  131. package/dist/web/primitives/data-table/totals-footer.d.ts +24 -0
  132. package/dist/web/primitives/data-table/totals-footer.js +48 -0
  133. package/dist/web/primitives/data-table/use-bulk-selection.d.ts +21 -0
  134. package/dist/web/primitives/data-table/use-bulk-selection.js +66 -0
  135. package/dist/web/primitives/data-table/use-column-pins.d.ts +32 -0
  136. package/dist/web/primitives/data-table/use-column-pins.js +117 -0
  137. package/dist/web/primitives/data-table/use-column-sizing.d.ts +22 -0
  138. package/dist/web/primitives/data-table/use-column-sizing.js +44 -0
  139. package/dist/web/primitives/data-table/use-measured-column-widths.d.ts +15 -0
  140. package/dist/web/primitives/data-table/use-measured-column-widths.js +83 -0
  141. package/dist/web/primitives/data-table/use-row-keyboard-navigation.d.ts +22 -0
  142. package/dist/web/primitives/data-table/use-row-keyboard-navigation.js +65 -0
  143. package/dist/web/primitives/data-table/use-row-selection.d.ts +14 -0
  144. package/dist/web/primitives/data-table/use-row-selection.js +48 -0
  145. package/dist/web/primitives/data-table/use-view-columns.d.ts +6 -0
  146. package/dist/web/primitives/data-table/use-view-columns.js +17 -0
  147. package/dist/web/primitives/data-table/view-menu.d.ts +8 -0
  148. package/dist/web/primitives/data-table/view-menu.js +45 -0
  149. package/dist/web/primitives/data-table-cell.d.ts +0 -22
  150. package/dist/web/primitives/data-table-cell.js +1 -58
  151. package/dist/web/primitives/data-table.d.ts +1 -1
  152. package/dist/web/primitives/data-table.js +122 -144
  153. package/dist/web/primitives/file-value-list.d.ts +1 -1
  154. package/dist/web/primitives/file-value-list.js +5 -5
  155. package/dist/web/primitives/number-range-facet.d.ts +7 -0
  156. package/dist/web/primitives/number-range-facet.js +33 -0
  157. package/dist/web/widgets/bounded-load-cache.d.ts +5 -1
  158. package/dist/web/widgets/bounded-load-cache.js +48 -26
  159. package/dist/web/widgets/pdf-preview.js +9 -4
  160. package/package.json +5 -5
  161. package/src/changes.json +109 -0
@@ -0,0 +1,104 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useQuery } from "../hooks/use-query.js";
3
+ import { refetchAfterWrite } from "./row-actions.js";
4
+ import { pageSizeOptionsFor } from "./screen-shared.js";
5
+ function freshInfiniteState(resetKey) {
6
+ return { resetKey, rows: [], cursor: undefined, hasMore: true };
7
+ }
8
+ // Live updates can bring an entry onto the next page that was already appended.
9
+ function appendUnseen(previous, next) {
10
+ const seen = new Set(previous.map((row) => row["id"]));
11
+ return [...previous, ...next.filter((row) => !seen.has(row["id"]))];
12
+ }
13
+ /** Rows of a URL-backed list screen: one page, accumulated infinite pages, or the
14
+ * loaded rows of the open server groups. `payload` carries no cursor. */
15
+ export function useScreenListRows({ queryType, payload, infinite, aggregates, }) {
16
+ const { grouped, refresh: refreshAggregates } = aggregates;
17
+ const resetKey = JSON.stringify(payload);
18
+ const [infiniteState, setInfiniteState] = useState(() => freshInfiniteState(resetKey));
19
+ const state = infiniteState.resetKey === resetKey ? infiniteState : freshInfiniteState(resetKey);
20
+ if (infiniteState.resetKey !== resetKey)
21
+ setInfiniteState(state);
22
+ const cursor = infinite ? state.cursor : undefined;
23
+ const rowsPayload = useMemo(() => (cursor === undefined ? payload : { ...payload, cursor }), [payload, cursor]);
24
+ const rowsQuery = useQuery(queryType, rowsPayload, { live: true, enabled: !grouped });
25
+ const { data, loading, refetch } = rowsQuery;
26
+ const lastDataRef = useRef(null);
27
+ useEffect(() => {
28
+ // skip: only a new answer of an infinite list is appended
29
+ if (!infinite || data === null || data === lastDataRef.current)
30
+ return;
31
+ lastDataRef.current = data;
32
+ setInfiniteState((current) => current.resetKey !== resetKey
33
+ ? current
34
+ : {
35
+ ...current,
36
+ rows: current.cursor === undefined ? data.rows : appendUnseen(current.rows, data.rows),
37
+ hasMore: data.nextCursor !== null,
38
+ });
39
+ }, [data, infinite, resetKey]);
40
+ const onReachEnd = useCallback(() => {
41
+ // skip: the next page waits for the current answer
42
+ if (loading)
43
+ return;
44
+ const nextCursor = data?.nextCursor;
45
+ // skip: no cursor means the last page is loaded
46
+ if (nextCursor === undefined || nextCursor === null)
47
+ return;
48
+ setInfiniteState((current) => current.resetKey === resetKey ? { ...current, cursor: nextCursor } : current);
49
+ }, [loading, data, resetKey]);
50
+ const refreshAfterWrite = useCallback(async () => {
51
+ refreshAggregates();
52
+ // skip: grouped rows reload through the aggregates refresh
53
+ if (grouped)
54
+ return;
55
+ if (infinite && cursor !== undefined) {
56
+ setInfiniteState(freshInfiniteState(resetKey));
57
+ // skip: starting over refetches the first page
58
+ return;
59
+ }
60
+ await refetchAfterWrite(refetch);
61
+ }, [refreshAggregates, grouped, infinite, cursor, resetKey, refetch]);
62
+ const retry = useCallback(async () => {
63
+ if (grouped)
64
+ refreshAggregates();
65
+ else
66
+ await refetch();
67
+ }, [grouped, refreshAggregates, refetch]);
68
+ if (grouped) {
69
+ return {
70
+ rows: aggregates.rows,
71
+ page: null,
72
+ firstLoad: aggregates.loading,
73
+ error: aggregates.error,
74
+ hasData: aggregates.serverGroups !== undefined,
75
+ retry,
76
+ refreshAfterWrite,
77
+ };
78
+ }
79
+ return {
80
+ rows: infinite ? state.rows : (data?.rows ?? []),
81
+ page: data,
82
+ firstLoad: loading && data === null,
83
+ error: rowsQuery.error,
84
+ hasData: data !== null,
85
+ retry,
86
+ ...(infinite && { infinite: { onReachEnd, loadingMore: loading, hasMore: state.hasMore } }),
87
+ refreshAfterWrite,
88
+ };
89
+ }
90
+ /** Pager props for `pagination: "pages"`; none until the server total arrives. */
91
+ export function listPager(args) {
92
+ const { usePager, page, limit, urlState } = args;
93
+ const total = page?.total;
94
+ if (!usePager || total === undefined)
95
+ return undefined;
96
+ return {
97
+ page: urlState.page,
98
+ limit,
99
+ total,
100
+ onPageChange: urlState.setPage,
101
+ pageSizeOptions: pageSizeOptionsFor(limit),
102
+ onPageSizeChange: urlState.setPageSize,
103
+ };
104
+ }
@@ -0,0 +1,24 @@
1
+ import type { EntityListScreenDefinition, ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-types/screen";
2
+ import type { Translate } from "../headless/index.ts";
3
+ import type { ListGroupPaging } from "../hooks/list-group-pages.ts";
4
+ import { type ListAggregates } from "../hooks/use-list-aggregates.ts";
5
+ import { type ListTableProps } from "../hooks/use-list-table.ts";
6
+ import type { DataTableTableBlockProps } from "../primitives.tsx";
7
+ type ScreenListTableArgs = {
8
+ readonly screen: EntityListScreenDefinition | ProjectionListScreenDefinition;
9
+ readonly featureName: string;
10
+ /** The list query payload as sent; paging keys are stripped for the export. */
11
+ readonly payload: Readonly<Record<string, unknown>>;
12
+ readonly filterFields: readonly string[];
13
+ readonly queryType: string;
14
+ readonly pageSize: number;
15
+ readonly paging: ListGroupPaging;
16
+ readonly translate: Translate;
17
+ };
18
+ export type ScreenListTable = {
19
+ readonly props: ListTableProps & Pick<DataTableTableBlockProps, "serverGroups" | "totals" | "notice">;
20
+ readonly aggregates: ListAggregates;
21
+ };
22
+ /** Table-block wiring of a URL-backed list screen (entityList, projectionList). */
23
+ export declare function useScreenListTable({ screen, featureName, payload, filterFields, queryType, pageSize, paging, translate, }: ScreenListTableArgs): ScreenListTable;
24
+ export {};
@@ -0,0 +1,46 @@
1
+ import { useMemo } from "react";
2
+ import { useListStateStore } from "../components/list-state-store.js";
3
+ import { useListAggregates } from "../hooks/use-list-aggregates.js";
4
+ import { useListTable } from "../hooks/use-list-table.js";
5
+ import { listViewSchemaColumns } from "../hooks/use-list-view-state.js";
6
+ import { useRememberedListParams } from "../hooks/use-remembered-list-params.js";
7
+ /** Table-block wiring of a URL-backed list screen (entityList, projectionList). */
8
+ export function useScreenListTable({ screen, featureName, payload, filterFields, queryType, pageSize, paging, translate, }) {
9
+ const { id: screenId, table, columns } = screen;
10
+ const store = useListStateStore();
11
+ const schemaColumns = useMemo(() => listViewSchemaColumns(columns), [columns]);
12
+ const known = useMemo(() => ({ columnFields: schemaColumns.map((column) => column.field), filterFields }), [schemaColumns, filterFields]);
13
+ useRememberedListParams({
14
+ screenId,
15
+ enabled: table?.rememberState === true,
16
+ store,
17
+ known,
18
+ });
19
+ const exportSource = useMemo(() => ({ screen: `${featureName}:screen:${screenId}`, screenShortId: screenId, payload }), [featureName, screenId, payload]);
20
+ const { props, groupField } = useListTable({
21
+ storeKey: screenId,
22
+ table,
23
+ schemaColumns,
24
+ url: { screenId },
25
+ exportSource,
26
+ });
27
+ const aggregates = useListAggregates({
28
+ queryType,
29
+ payload,
30
+ table,
31
+ groupField,
32
+ pageSize,
33
+ paging,
34
+ translate,
35
+ });
36
+ const { serverGroups, totals, notice } = aggregates;
37
+ return {
38
+ props: {
39
+ ...props,
40
+ ...(serverGroups !== undefined && { serverGroups }),
41
+ ...(totals !== undefined && { totals }),
42
+ ...(notice !== undefined && { notice }),
43
+ },
44
+ aggregates,
45
+ };
46
+ }
@@ -0,0 +1,3 @@
1
+ import { type ReactNode } from "react";
2
+ import type { ListGroupPagesProps } from "../hooks/use-list-aggregates.ts";
3
+ export declare function ListGroupPageBridges({ queryType, requests, onResult, refreshNonce, }: ListGroupPagesProps): ReactNode;
@@ -0,0 +1,31 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ // One invisible bridge per requested group page: the number of open groups and
3
+ // loaded pages varies, so useQuery can't be called in a loop. The pool caps
4
+ // group page requests at four in flight.
5
+ import { useEffect, useRef } from "react";
6
+ import { useQuery } from "../hooks/use-query.js";
7
+ const GROUP_PAGE_CONCURRENCY = 4;
8
+ function GroupPageBridge({ pageKey, queryType, payload, onResult, refreshNonce, }) {
9
+ const result = useQuery(queryType, payload, {
10
+ live: true,
11
+ concurrency: GROUP_PAGE_CONCURRENCY,
12
+ });
13
+ const { data, loading, refetch } = result;
14
+ useEffect(() => {
15
+ if (data === null)
16
+ return;
17
+ onResult(pageKey, { rows: data.rows, nextCursor: data.nextCursor ?? null, loading });
18
+ }, [data, loading, pageKey, onResult]);
19
+ const seenNonce = useRef(refreshNonce);
20
+ useEffect(() => {
21
+ // skip: the mount fetch already covers the nonce the bridge started with
22
+ if (seenNonce.current === refreshNonce)
23
+ return;
24
+ seenNonce.current = refreshNonce;
25
+ void refetch();
26
+ }, [refreshNonce, refetch]);
27
+ return null;
28
+ }
29
+ export function ListGroupPageBridges({ queryType, requests, onResult, refreshNonce, }) {
30
+ return (_jsx(_Fragment, { children: requests.map((request) => (_jsx(GroupPageBridge, { pageKey: request.pageKey, queryType: queryType, payload: request.payload, onResult: onResult, refreshNonce: refreshNonce }, request.pageKey))) }));
31
+ }
@@ -0,0 +1,16 @@
1
+ import { type ReactNode } from "react";
2
+ import type { StoredListState } from "../hooks/list-state-schema.ts";
3
+ export type ListStateStore = {
4
+ read(key: string): StoredListState | undefined;
5
+ write(key: string, state: StoredListState): void;
6
+ remove(key: string): void;
7
+ clear(): void;
8
+ };
9
+ export declare function createMemoryListStateStore(): ListStateStore;
10
+ export declare function ListStateStoreProvider({ store, children, }: {
11
+ readonly store: ListStateStore;
12
+ readonly children: ReactNode;
13
+ }): ReactNode;
14
+ export declare function useListStateStore(): ListStateStore;
15
+ /** The shared in-memory default; the web boot clears it when the identity changes. */
16
+ export declare function getDefaultMemoryListStateStore(): ListStateStore;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from "react";
3
+ export function createMemoryListStateStore() {
4
+ const entries = new Map();
5
+ return {
6
+ read: (key) => entries.get(key),
7
+ write: (key, state) => {
8
+ entries.set(key, state);
9
+ },
10
+ remove: (key) => {
11
+ entries.delete(key);
12
+ },
13
+ clear: () => {
14
+ entries.clear();
15
+ },
16
+ };
17
+ }
18
+ // Module-wide so remembered list state survives route changes in the SPA
19
+ // without any provider mounted.
20
+ const defaultMemoryStore = createMemoryListStateStore();
21
+ const ListStateStoreContext = createContext(defaultMemoryStore);
22
+ export function ListStateStoreProvider({ store, children, }) {
23
+ return _jsx(ListStateStoreContext.Provider, { value: store, children: children });
24
+ }
25
+ export function useListStateStore() {
26
+ return useContext(ListStateStoreContext);
27
+ }
28
+ /** The shared in-memory default; the web boot clears it when the identity changes. */
29
+ export function getDefaultMemoryListStateStore() {
30
+ return defaultMemoryStore;
31
+ }
@@ -1,9 +1,10 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { evalFieldCondition, normalizeListColumn, parseRefTarget, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { evalFieldCondition, normalizeListColumn, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { useCallback, useMemo, useState } from "react";
4
4
  import { useAppFeatures } from "../app/app-features-context.js";
5
5
  import { useClientActionRuntime } from "../app/client-actions.js";
6
6
  import { ErrorReference } from "../app/error-reference.js";
7
+ import { useListBulk } from "../app/list-bulk-actions.js";
7
8
  import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
8
9
  import { useNav } from "../app/nav.js";
9
10
  import { SYNTHESIZED_FIELD_PII_REASON } from "../app/projection-list-shim.js";
@@ -15,11 +16,12 @@ import { dispatcherErrorText } from "../app/write-failed-error.js";
15
16
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
16
17
  import { useUserRoles } from "../context/user-roles-context.js";
17
18
  import { rendererWarn } from "../dev-console.js";
18
- import { useQuery } from "../hooks/use-query.js";
19
+ import { useRelatedListRows } from "../hooks/use-related-list-rows.js";
20
+ import { useRelatedListState } from "../hooks/use-related-list-state.js";
19
21
  import { useOptionalLocale, useTranslation } from "../i18n.js";
20
22
  import { PageHeaderSlotAvailableProvider } from "../page-header-slot.js";
21
- import { usePrimitives, } from "../primitives.js";
22
- import { sortByAccessor } from "../sort-by-accessor.js";
23
+ import { usePrimitives } from "../primitives.js";
24
+ import { ListGroupPageBridges } from "./list-group-page-bridges.js";
23
25
  import { RenderEditActionButton } from "./render-edit-action-button.js";
24
26
  import { RenderList } from "./render-list.js";
25
27
  // Keyed on the type, not on key presence: most factories omit `sortable`
@@ -85,36 +87,6 @@ function valueTypeField(valueType, sortable) {
85
87
  function warnRelatedListEmptyStateDrawerDropped(actionId) {
86
88
  rendererWarn(`[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`);
87
89
  }
88
- function buildRowGrouping(groupBy, translate, locale) {
89
- return {
90
- keyOf: (row) => relatedListGroupKey(row.values[groupBy.field]),
91
- startsCollapsed: (key) => groupBy.collapsedWhen !== undefined && key === relatedListGroupKey(groupBy.collapsedWhen),
92
- headerLabel: (key, rows) => {
93
- const labelKey = relatedListGroupHeaderLabel(groupBy, key);
94
- if (labelKey === undefined)
95
- return undefined;
96
- const dates = groupBy.dateField === undefined
97
- ? []
98
- : rows
99
- .map((row) => String(row.values[groupBy.dateField ?? ""] ?? ""))
100
- .filter((value) => value !== "")
101
- .sort();
102
- const latest = dates[dates.length - 1];
103
- return translate(labelKey, {
104
- count: rows.length,
105
- value: key,
106
- lastDate: latest === undefined
107
- ? ""
108
- : new Date(latest).toLocaleDateString(locale, {
109
- year: "numeric",
110
- month: "2-digit",
111
- day: "2-digit",
112
- timeZone: "UTC",
113
- }),
114
- });
115
- },
116
- };
117
- }
118
90
  function buildRowTone(rule) {
119
91
  if (rule === undefined)
120
92
  return undefined;
@@ -126,9 +98,6 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
126
98
  const t = useTranslation();
127
99
  const effectiveTranslate = translate ?? t;
128
100
  const locale = useOptionalLocale();
129
- const rowGrouping = useMemo(() => section.groupBy === undefined
130
- ? undefined
131
- : buildRowGrouping(section.groupBy, effectiveTranslate, locale ?? "en"), [section.groupBy, effectiveTranslate, locale]);
132
101
  const rowTone = useMemo(() => buildRowTone(section.rowTone), [section.rowTone]);
133
102
  const nav = useNav();
134
103
  const host = useReturnHost();
@@ -165,8 +134,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
165
134
  // Local state, not URL state: a section `id` is optional, so there is no
166
135
  // stable URL key to namespace against — the section's sort is local for the
167
136
  // same reason. Consequence: search/filters reset on reload.
168
- const [search, setSearch] = useState("");
169
- const [filters, setFilters] = useState({});
137
+ const { search, filters, sort, setSearch, setFilters, setSort, listTable, groupField } = useRelatedListState(section, nav.route?.screenId);
170
138
  const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(section.facets, effectiveTranslate, featureName), [section.facets, effectiveTranslate, featureName]);
171
139
  const [referenceFacetOptions, setReferenceFacetOptions] = useState({});
172
140
  const handleFacetOptions = useCallback((field, options) => setReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
@@ -198,39 +166,19 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
198
166
  search,
199
167
  filterPayload,
200
168
  ]);
201
- const rowsQuery = useQuery(section.query, payload);
202
- const ownRefetch = rowsQuery.refetch;
203
- const lastRefreshTokenRef = useRef(refreshToken);
204
- useEffect(() => {
205
- if (lastRefreshTokenRef.current === refreshToken)
206
- return;
207
- lastRefreshTokenRef.current = refreshToken;
208
- void ownRefetch();
209
- }, [refreshToken, ownRefetch]);
210
- const refetchSelfAndHost = useCallback(async () => {
211
- await ownRefetch();
212
- await onAfterWrite?.();
213
- }, [ownRefetch, onAfterWrite]);
214
- const onFilterChange = useCallback((field, values) => setFilters((prev) => ({ ...prev, [field]: values })), []);
215
- const onFilterReset = useCallback(() => setFilters({}), []);
216
- // Sorted client-side over the already-loaded rows only. The section has no
217
- // pager (one-shot fetch); when that fetch is truncated (`nextCursor` set) the
218
- // truncation banner below tells the user the sorted set is partial.
219
- const [sort, setSort] = useState(section.defaultSort ?? null);
220
- const sortAccessors = useMemo(() => {
221
- const accessors = {};
222
- for (const col of section.columns) {
223
- const field = normalizeListColumn(col).field;
224
- accessors[field] = (row) => {
225
- const value = row[field];
226
- if (value === null || value === undefined)
227
- return null;
228
- return typeof value === "number" ? value : String(value);
229
- };
230
- }
231
- return accessors;
232
- }, [section.columns]);
233
- const sortedRows = useMemo(() => sortByAccessor(rowsQuery.data?.rows ?? [], sort, sortAccessors, locale), [rowsQuery.data, sort, sortAccessors, locale]);
169
+ const listRows = useRelatedListRows({
170
+ section,
171
+ payload,
172
+ groupField,
173
+ sort,
174
+ locale,
175
+ refreshToken,
176
+ onAfterWrite,
177
+ translate: effectiveTranslate,
178
+ });
179
+ const { refetchSelfAndHost, aggregates } = listRows;
180
+ const onFilterChange = useCallback((field, values) => setFilters((prev) => ({ ...prev, [field]: values })), [setFilters]);
181
+ const onFilterReset = useCallback(() => setFilters({}), [setFilters]);
234
182
  const rowClick = section.rowClick;
235
183
  // A row-body click target comes from EITHER the legacy `rowClick` field OR
236
184
  // a `rowActions` entry marked rowClick:true — the boot-validator rejects
@@ -278,6 +226,13 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
278
226
  defaultEditRowAction,
279
227
  host,
280
228
  ]);
229
+ const bulk = useListBulk({
230
+ bulkActions: section.bulkActions,
231
+ rowActions: section.rowActions,
232
+ translate: effectiveTranslate,
233
+ dispatcher,
234
+ reload: refetchSelfAndHost,
235
+ });
281
236
  // Toolbar actions (e.g. "+ Create" above the tab table) — same schema
282
237
  // and dispatch semantics as entityList/projectionList.
283
238
  const toolbarActionButtons = useMemo(() => buildProjectionToolbarActions({
@@ -348,20 +303,11 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
348
303
  refetchSelfAndHost,
349
304
  ]);
350
305
  const emptyStateContent = section.emptyState !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: section.emptyState.title }), section.emptyState.description !== undefined && (_jsx(Text, { variant: "small", children: section.emptyState.description })), emptyStateActionButton !== undefined && (_jsx(RenderEditActionButton, { action: emptyStateActionButton, Button: Button, Dialog: Dialog, onError: setEmptyStateActionError }))] })) : undefined;
351
- // A truncated fetch means `sortedRows` is a sort of a partial set, not of
352
- // the full related-row set — the client-side sort above (or even plain
353
- // unsorted display) would silently claim "these are the top N" when they
354
- // are really just "the first N in the server's own order".
355
- // `nextCursor` is exactly how the paged envelope marks that: non-null means
356
- // more rows exist server-side beyond what was fetched.
357
- // A handler that omits `nextCursor` (or reports a `total` the fetched rows
358
- // already cover) has no further rows — only a real cursor marks truncation.
359
- const truncated = rowsQuery.data !== null &&
360
- typeof rowsQuery.data.nextCursor === "string" &&
361
- (rowsQuery.data.total === undefined || rowsQuery.data.total > rowsQuery.data.rows.length);
362
- const content = rowsQuery.loading && rowsQuery.data === null ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: t("kumiko.widget.loading") })) : rowsQuery.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
363
- count: sortedRows.length,
364
- }) })), emptyStateActionError !== null && (_jsxs(Banner, { variant: "error", testId: "related-list-empty-state-action-error", children: [emptyStateActionError.message, _jsx(ErrorReference, { requestId: emptyStateActionError.requestId })] })), _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(RenderList, { screen: listScreen, entity: entity, rows: sortedRows, featureName: listFeatureName ?? featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
306
+ // Without a cursor-driven pager the client-side sort covers only the fetched
307
+ // rows; the banner keeps it from claiming "these are the top N".
308
+ const content = listRows.firstLoad ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: t("kumiko.widget.loading") })) : listRows.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(listRows.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [listRows.truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
309
+ count: listRows.rows.length,
310
+ }) })), emptyStateActionError !== null && (_jsxs(Banner, { variant: "error", testId: "related-list-empty-state-action-error", children: [emptyStateActionError.message, _jsx(ErrorReference, { requestId: emptyStateActionError.requestId })] })), _jsx(ListGroupPageBridges, { ...aggregates.groupPages }), _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(RenderList, { screen: listScreen, entity: entity, rows: listRows.rows, featureName: listFeatureName ?? featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
365
311
  searchable: true,
366
312
  searchValue: search,
367
313
  onSearchChange: setSearch,
@@ -370,7 +316,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
370
316
  filterValues: filters,
371
317
  onFilterChange,
372
318
  onFilterReset,
373
- }), ...(onRowClick !== undefined && { onRowClick }), ...(rowActions !== undefined && { rowActions }), ...(section.rowActionMode !== undefined && { rowActionMode: section.rowActionMode }), ...(rowGrouping !== undefined && { rowGrouping }), ...(rowTone !== undefined && { rowTone }), ...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons }), ...(emptyStateContent !== undefined && { emptyState: emptyStateContent }), screenPadding: false, ...(hideTitle === true && { scrollBody: true, chromeless: true }), ...(embedded === true && { chromeless: true }) }) })] }));
319
+ }), ...(onRowClick !== undefined && { onRowClick }), ...(rowActions !== undefined && { rowActions }), ...(section.rowActionMode !== undefined && { rowActionMode: section.rowActionMode }), ...(bulk !== undefined && { bulk }), ...(rowTone !== undefined && { rowTone }), ...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons }), ...(emptyStateContent !== undefined && { emptyState: emptyStateContent }), ...listTable, ...(aggregates.serverGroups !== undefined && { serverGroups: aggregates.serverGroups }), ...(aggregates.totals !== undefined && { totals: aggregates.totals }), ...(aggregates.notice !== undefined && { notice: aggregates.notice }), screenPadding: false, ...(hideTitle === true && { scrollBody: true, chromeless: true }), ...(embedded === true && { chromeless: true }) }) })] }));
374
320
  // hideTitle (tabs mode) → the caller (render-edit.tsx) already wraps this
375
321
  // component's own return value in the same Card frame every other tab
376
322
  // kind gets — no card here, or the two would nest. `scrollBody` caps the
@@ -382,7 +328,9 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
382
328
  // and aren't confined to a tab panel.
383
329
  const bridges = _jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleFacetOptions });
384
330
  if (embedded === true) {
385
- const countSuffix = rowsQuery.data !== null ? ` · ${sortedRows.length}` : "";
331
+ const countSuffix = listRows.hasData
332
+ ? ` · ${aggregates.serverGroups?.totalCount ?? listRows.rows.length}`
333
+ : "";
386
334
  return (_jsxs(_Fragment, { children: [bridges, _jsx(Card, { options: { framed: false, padded: false },
387
335
  // Trim the default header inset: the host row already pads the area.
388
336
  className: "[&>div:first-child]:px-0 [&>div:first-child]:pt-1 [&>div:first-child]:pb-2", slots: {
@@ -285,6 +285,7 @@ function inputId(field) {
285
285
  function FieldRendererOutput({ field, renderer, row, appLocale, }) {
286
286
  const { Text, JsonView, FileValueList } = usePrimitives();
287
287
  const t = useTranslation();
288
+ const valueTestId = `field-value-${field.field}`;
288
289
  const componentName = !isFormatSpec(renderer) && typeof renderer === "object" && renderer !== null
289
290
  ? extensionSectionName(renderer)
290
291
  : undefined;
@@ -294,20 +295,20 @@ function FieldRendererOutput({ field, renderer, row, appLocale, }) {
294
295
  // itself) — applyFormatSpec's already-indented string is only the
295
296
  // fallback for primitives-providers without JsonView.
296
297
  if (renderer.format === "json" && JsonView !== undefined) {
297
- return (_jsx(JsonView, { value: field.value, indent: renderer.indent, testId: `field-value-${field.field}` }));
298
+ return _jsx(JsonView, { value: field.value, indent: renderer.indent, testId: valueTestId });
298
299
  }
299
300
  if (renderer.format === "file" && FileValueList !== undefined) {
300
301
  const fileIds = fileIdsOf(field.value);
301
302
  if (fileIds.length === 0)
302
- return _jsx(Text, { testId: `field-value-${field.field}`, children: EMPTY_VALUE_PLACEHOLDER });
303
- return _jsx(FileValueList, { fileIds: fileIds, testId: `field-value-${field.field}` });
303
+ return _jsx(Text, { testId: valueTestId, children: EMPTY_VALUE_PLACEHOLDER });
304
+ return (_jsx(FileValueList, { fileIds: fileIds, previewOpenByDefault: renderer.previewOpenByDefault === true, testId: valueTestId }));
304
305
  }
305
306
  // App locale as default when the FormatSpec declares none of its own —
306
307
  // otherwise locale-sensitive formats (timestamp/date/number/decimal/
307
308
  // bigInt/unit) fell back to Intl's runtime default instead of the app
308
309
  // language chosen via LocaleProvider. Prefer renderer.locale
309
310
  // when set; coalesce undefined (spread override) back to appLocale.
310
- return (_jsx(Text, { testId: `field-value-${field.field}`, children: applyFormatSpec({
311
+ return (_jsx(Text, { testId: valueTestId, children: applyFormatSpec({
311
312
  ...renderer,
312
313
  locale: stringPropertyOf(renderer, "locale") ?? appLocale,
313
314
  }, field.value, t) }));
@@ -318,7 +319,7 @@ function FieldRendererOutput({ field, renderer, row, appLocale, }) {
318
319
  }
319
320
  rendererWarn(`[kumiko] fieldRenderer "${componentName}" not registered`);
320
321
  }
321
- return _jsx(Text, { testId: `field-value-${field.field}`, children: stringValue(field.value) });
322
+ return _jsx(Text, { testId: valueTestId, children: stringValue(field.value) });
322
323
  }
323
324
  // Read-only text for `type: "reference"` — resolves the referenced row's
324
325
  // label via the same lookup query as ReferenceInput's combobox, but shows
@@ -0,0 +1,27 @@
1
+ import type { IconKey } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { type ReactNode } from "react";
3
+ import type { usePrimitives } from "../primitives.tsx";
4
+ export type ToolbarActionButton = {
5
+ readonly id: string;
6
+ readonly label: string;
7
+ readonly style?: "primary" | "secondary" | "danger";
8
+ readonly confirm?: string;
9
+ readonly confirmLabel?: string;
10
+ /** Overrides the default "danger implies a confirm dialog" rule. Schema-driven
11
+ * navigate/drawer actions set it to false: the colour marks the action as
12
+ * destructive, but the target form is the confirmation. */
13
+ readonly confirmRequired?: boolean;
14
+ readonly onTrigger: () => Promise<void> | void;
15
+ /** Id-derived default icon (ACTION_ICON_BY_ID in kumiko-screen.tsx) —
16
+ * ToolbarAction has no author-declared icon field, unlike RowAction. */
17
+ readonly icon?: IconKey;
18
+ };
19
+ export declare function ToolbarActionView({ action, iconOnly, Button, Dialog, Banner, }: {
20
+ readonly action: ToolbarActionButton;
21
+ /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
22
+ * effect when this action actually resolved an icon. */
23
+ readonly iconOnly?: boolean;
24
+ readonly Button: ReturnType<typeof usePrimitives>["Button"];
25
+ readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
26
+ readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
27
+ }): ReactNode;
@@ -0,0 +1,40 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { needsActionConfirm } from "./render-edit-action-button.js";
4
+ // ToolbarActionView renders one button per toolbar action, with a confirm
5
+ // dialog when required. It follows the inline RowAction pattern but has no
6
+ // row context. The busy state covers async onTrigger; the dialog opens before
7
+ // the trigger when confirmation is required for confirm/danger actions.
8
+ export function ToolbarActionView({ action, iconOnly = false, Button, Dialog, Banner, }) {
9
+ const [busy, setBusy] = useState(false);
10
+ const [confirmOpen, setConfirmOpen] = useState(false);
11
+ // Toolbar actions do not have access to the toast primitive, unlike
12
+ // rowActions routed through the DataTable primitive, so an inline error
13
+ // banner is used to surface failures. Without it, a swallowed failure result
14
+ // would look to the user as though nothing happened.
15
+ const [errorText, setErrorText] = useState(null);
16
+ const trigger = async () => {
17
+ setBusy(true);
18
+ setErrorText(null);
19
+ try {
20
+ await action.onTrigger();
21
+ }
22
+ catch (e) {
23
+ setErrorText(e instanceof Error ? e.message : String(e));
24
+ }
25
+ finally {
26
+ setBusy(false);
27
+ }
28
+ };
29
+ const variant = action.style ?? "secondary";
30
+ const needsConfirm = needsActionConfirm(action);
31
+ const showIconOnly = iconOnly && action.icon !== undefined;
32
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: variant, loading: busy, ...(action.icon !== undefined && { icon: action.icon }), size: showIconOnly ? "icon" : "md", ...(showIconOnly && { ariaLabel: action.label, title: action.label }), onClick: () => {
33
+ if (needsConfirm) {
34
+ setConfirmOpen(true);
35
+ }
36
+ else {
37
+ void trigger();
38
+ }
39
+ }, testId: `render-list-toolbar-action-${action.id}`, children: showIconOnly ? null : action.label }), _jsx(Dialog, { open: confirmOpen, onOpenChange: setConfirmOpen, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: trigger, testId: `render-list-toolbar-action-${action.id}-dialog` }), errorText !== null && (_jsx(Banner, { variant: "error", testId: `render-list-toolbar-action-${action.id}-error`, children: errorText }))] }));
40
+ }