@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.
- package/dist/app/base-path-context.d.ts +9 -0
- package/dist/app/base-path-context.js +13 -0
- package/dist/app/base-path.d.ts +3 -0
- package/dist/app/base-path.js +27 -0
- package/dist/app/config-edit-body.d.ts +1 -1
- package/dist/app/config-edit-body.js +15 -6
- package/dist/app/config-edit-without-secret-fields.d.ts +2 -0
- package/dist/app/config-edit-without-secret-fields.js +28 -0
- package/dist/app/entity-list-facet-specs.d.ts +3 -0
- package/dist/app/entity-list-facet-specs.js +65 -0
- package/dist/app/entity-list-screen.js +74 -206
- package/dist/app/kumiko-screen.js +6 -14
- package/dist/app/list-bulk-actions.d.ts +18 -0
- package/dist/app/list-bulk-actions.js +54 -0
- package/dist/app/locked-screen-header.d.ts +9 -0
- package/dist/app/locked-screen-header.js +33 -0
- package/dist/app/projection-list-body.js +51 -46
- package/dist/app/row-actions.d.ts +3 -0
- package/dist/app/row-actions.js +10 -5
- package/dist/app/screen-shared.js +9 -2
- package/dist/app/use-entity-range-facets.d.ts +24 -0
- package/dist/app/use-entity-range-facets.js +64 -0
- package/dist/app/use-projection-date-range.d.ts +12 -0
- package/dist/app/use-projection-date-range.js +21 -0
- package/dist/app/use-screen-list-rows.d.ts +37 -0
- package/dist/app/use-screen-list-rows.js +104 -0
- package/dist/app/use-screen-list-table.d.ts +24 -0
- package/dist/app/use-screen-list-table.js +46 -0
- package/dist/components/list-group-page-bridges.d.ts +3 -0
- package/dist/components/list-group-page-bridges.js +31 -0
- package/dist/components/list-state-store.d.ts +16 -0
- package/dist/components/list-state-store.js +31 -0
- package/dist/components/related-list-section.js +37 -89
- package/dist/components/render-field.js +6 -5
- package/dist/components/render-list-toolbar-action.d.ts +27 -0
- package/dist/components/render-list-toolbar-action.js +40 -0
- package/dist/components/render-list.d.ts +13 -29
- package/dist/components/render-list.js +42 -50
- package/dist/current-user.d.ts +2 -0
- package/dist/data-table-aggregate-types.d.ts +47 -0
- package/dist/data-table-aggregate-types.js +1 -0
- package/dist/data-table-bulk-types.d.ts +27 -0
- package/dist/data-table-bulk-types.js +1 -0
- package/dist/data-table-facet-types.d.ts +36 -0
- package/dist/data-table-facet-types.js +1 -0
- package/dist/data-table-view-types.d.ts +73 -0
- package/dist/data-table-view-types.js +1 -0
- package/dist/headless/view-model/edit.js +3 -1
- package/dist/headless/view-model/types.d.ts +4 -2
- package/dist/hooks/list-column-layout.d.ts +19 -0
- package/dist/hooks/list-column-layout.js +36 -0
- package/dist/hooks/list-group-pages.d.ts +26 -0
- package/dist/hooks/list-group-pages.js +37 -0
- package/dist/hooks/list-reset.d.ts +8 -0
- package/dist/hooks/list-reset.js +15 -0
- package/dist/hooks/list-server-groups.d.ts +39 -0
- package/dist/hooks/list-server-groups.js +84 -0
- package/dist/hooks/list-state-schema.d.ts +28 -0
- package/dist/hooks/list-state-schema.js +76 -0
- package/dist/hooks/use-bulk-run.d.ts +41 -0
- package/dist/hooks/use-bulk-run.js +65 -0
- package/dist/hooks/use-group-load-state.d.ts +11 -0
- package/dist/hooks/use-group-load-state.js +31 -0
- package/dist/hooks/use-list-aggregates.d.ts +38 -0
- package/dist/hooks/use-list-aggregates.js +107 -0
- package/dist/hooks/use-list-table.d.ts +38 -0
- package/dist/hooks/use-list-table.js +91 -0
- package/dist/hooks/use-list-view-state.d.ts +43 -0
- package/dist/hooks/use-list-view-state.js +161 -0
- package/dist/hooks/use-related-list-rows.d.ts +27 -0
- package/dist/hooks/use-related-list-rows.js +81 -0
- package/dist/hooks/use-related-list-state.d.ts +15 -0
- package/dist/hooks/use-related-list-state.js +31 -0
- package/dist/hooks/use-remembered-list-params.d.ts +13 -0
- package/dist/hooks/use-remembered-list-params.js +53 -0
- package/dist/hooks/use-remembered-local-list-state.d.ts +26 -0
- package/dist/hooks/use-remembered-local-list-state.js +63 -0
- package/dist/i18n-defaults.js +53 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/primitives.d.ts +11 -47
- package/dist/web/app/create-app.d.ts +5 -0
- package/dist/web/app/create-app.js +7 -4
- package/dist/web/app/history-leave-guard.d.ts +6 -0
- package/dist/web/app/history-leave-guard.js +115 -0
- package/dist/web/app/list-state-store-boot.d.ts +6 -0
- package/dist/web/app/list-state-store-boot.js +15 -0
- package/dist/web/app/nav.js +7 -54
- package/dist/web/app/session-list-state-store.d.ts +15 -0
- package/dist/web/app/session-list-state-store.js +117 -0
- package/dist/web/index.d.ts +1 -0
- package/dist/web/index.js +1 -0
- package/dist/web/lib/export-xlsx-request.d.ts +11 -0
- package/dist/web/lib/export-xlsx-request.js +46 -0
- package/dist/web/primitives/data-table/actions-column.d.ts +16 -0
- package/dist/web/primitives/data-table/actions-column.js +34 -0
- package/dist/web/primitives/data-table/bulk-selection-bar.d.ts +20 -0
- package/dist/web/primitives/data-table/bulk-selection-bar.js +37 -0
- package/dist/web/primitives/data-table/card-sort-select.d.ts +14 -0
- package/dist/web/primitives/data-table/card-sort-select.js +40 -0
- package/dist/web/primitives/data-table/column-layout-mode.d.ts +5 -0
- package/dist/web/primitives/data-table/column-layout-mode.js +10 -0
- package/dist/web/primitives/data-table/column-picker-section.d.ts +7 -0
- package/dist/web/primitives/data-table/column-picker-section.js +35 -0
- package/dist/web/primitives/data-table/column-resize-handle.d.ts +14 -0
- package/dist/web/primitives/data-table/column-resize-handle.js +46 -0
- package/dist/web/primitives/data-table/density.d.ts +14 -0
- package/dist/web/primitives/data-table/density.js +20 -0
- package/dist/web/primitives/data-table/export-button.d.ts +5 -0
- package/dist/web/primitives/data-table/export-button.js +37 -0
- package/dist/web/primitives/data-table/facet-cluster.d.ts +8 -0
- package/dist/web/primitives/data-table/facet-cluster.js +37 -0
- package/dist/web/primitives/data-table/group-band.d.ts +28 -0
- package/dist/web/primitives/data-table/group-band.js +53 -0
- package/dist/web/primitives/data-table/measure-value.d.ts +10 -0
- package/dist/web/primitives/data-table/measure-value.js +28 -0
- package/dist/web/primitives/data-table/pin-section.d.ts +8 -0
- package/dist/web/primitives/data-table/pin-section.js +20 -0
- package/dist/web/primitives/data-table/reset-all-button.d.ts +4 -0
- package/dist/web/primitives/data-table/reset-all-button.js +8 -0
- package/dist/web/primitives/data-table/server-group-header.d.ts +11 -0
- package/dist/web/primitives/data-table/server-group-header.js +51 -0
- package/dist/web/primitives/data-table/sortable-header.d.ts +17 -0
- package/dist/web/primitives/data-table/sortable-header.js +41 -0
- package/dist/web/primitives/data-table/table-chrome.d.ts +13 -0
- package/dist/web/primitives/data-table/table-chrome.js +30 -0
- package/dist/web/primitives/data-table/table-footer.d.ts +7 -0
- package/dist/web/primitives/data-table/table-footer.js +20 -0
- package/dist/web/primitives/data-table/toolbar-end-cluster.d.ts +10 -0
- package/dist/web/primitives/data-table/toolbar-end-cluster.js +13 -0
- package/dist/web/primitives/data-table/totals-footer.d.ts +24 -0
- package/dist/web/primitives/data-table/totals-footer.js +48 -0
- package/dist/web/primitives/data-table/use-bulk-selection.d.ts +21 -0
- package/dist/web/primitives/data-table/use-bulk-selection.js +66 -0
- package/dist/web/primitives/data-table/use-column-pins.d.ts +32 -0
- package/dist/web/primitives/data-table/use-column-pins.js +117 -0
- package/dist/web/primitives/data-table/use-column-sizing.d.ts +22 -0
- package/dist/web/primitives/data-table/use-column-sizing.js +44 -0
- package/dist/web/primitives/data-table/use-measured-column-widths.d.ts +15 -0
- package/dist/web/primitives/data-table/use-measured-column-widths.js +83 -0
- package/dist/web/primitives/data-table/use-row-keyboard-navigation.d.ts +22 -0
- package/dist/web/primitives/data-table/use-row-keyboard-navigation.js +65 -0
- package/dist/web/primitives/data-table/use-row-selection.d.ts +14 -0
- package/dist/web/primitives/data-table/use-row-selection.js +48 -0
- package/dist/web/primitives/data-table/use-view-columns.d.ts +6 -0
- package/dist/web/primitives/data-table/use-view-columns.js +17 -0
- package/dist/web/primitives/data-table/view-menu.d.ts +8 -0
- package/dist/web/primitives/data-table/view-menu.js +45 -0
- package/dist/web/primitives/data-table-cell.d.ts +0 -22
- package/dist/web/primitives/data-table-cell.js +1 -58
- package/dist/web/primitives/data-table.d.ts +1 -1
- package/dist/web/primitives/data-table.js +122 -144
- package/dist/web/primitives/file-value-list.d.ts +1 -1
- package/dist/web/primitives/file-value-list.js +5 -5
- package/dist/web/primitives/number-range-facet.d.ts +7 -0
- package/dist/web/primitives/number-range-facet.js +33 -0
- package/dist/web/widgets/bounded-load-cache.d.ts +5 -1
- package/dist/web/widgets/bounded-load-cache.js +48 -26
- package/dist/web/widgets/pdf-preview.js +9 -4
- package/package.json +5 -5
- 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,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,
|
|
3
|
-
import { useCallback,
|
|
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 {
|
|
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
|
|
22
|
-
import {
|
|
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
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}, [
|
|
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
|
-
//
|
|
352
|
-
// the
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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 }), ...(
|
|
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 =
|
|
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
|
|
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:
|
|
303
|
-
return _jsx(FileValueList, { fileIds: fileIds,
|
|
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:
|
|
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:
|
|
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
|
+
}
|