@protobase/ui 0.12.22 → 0.13.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/displays/card-layout.js +19 -0
- package/dist/app/displays/cards-display.js +16 -11
- package/dist/app/displays/list-cards.js +50 -0
- package/dist/app/displays/list-displays.js +4 -3
- package/dist/app/displays/map-display.js +3 -3
- package/dist/app/displays/virtual-cards.js +138 -0
- package/dist/app/embedded-list/embedded-columns.js +52 -0
- package/dist/app/embedded-list/embedded-display.js +26 -0
- package/dist/app/embedded-list/embedded-list.js +155 -0
- package/dist/app/embedded-list/use-embedded-display.js +23 -0
- package/dist/app/embedded-list/use-embedded-rows.js +33 -0
- package/dist/app/list-page.js +14 -5
- package/dist/app/list-scroll.js +25 -0
- package/dist/app/pages/blocks/table.js +21 -41
- package/dist/app/record-page.js +2 -1
- package/dist/app/related-section.js +30 -52
- package/dist/app/router.js +1 -0
- package/dist/data-grid/data-grid.js +19 -39
- package/dist/data-grid/geometry.js +7 -9
- package/dist/data-grid/use-grid-scroll.js +8 -1
- package/dist/data-grid/use-virtual-rows.js +31 -0
- package/dist/live/columns-from-model.js +6 -4
- package/dist/live/grid-loader.js +26 -10
- package/dist/package.js +1 -1
- package/dist/record-view/section.js +24 -17
- package/dist/shell/assets/app-DLjaMVJD.js +66 -0
- package/dist/shell/assets/index-BYPphaEm.css +2 -0
- package/dist/shell/assets/{index-B8MhIaLr.js → index-D7IcSr6n.js} +3 -3
- package/dist/shell/assets/maplibre-gl-Bt-3iOM-.js +1 -0
- package/dist/shell/assets/record-map-DL4b9dZO.js +814 -0
- package/dist/shell/assets/records-map-D0EOO5kZ.js +1 -0
- package/dist/shell/assets/shared-protobase-ui-B8UXOIeW.js +1 -0
- package/dist/shell/index.html +5 -5
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -5
- package/dist/app/pages/record-table.js +0 -114
- package/dist/app/pages/use-relation-labels.js +0 -30
- package/dist/shell/assets/app-BYw-8qpE.js +0 -66
- package/dist/shell/assets/index-CdSIk0BQ.css +0 -2
- package/dist/shell/assets/navigation-control-CGS0pamh.js +0 -814
- package/dist/shell/assets/record-map-9Y3IL_dg.js +0 -1
- package/dist/shell/assets/records-map-DXIWowJ3.js +0 -1
- package/dist/shell/assets/shared-protobase-ui-Bol6wL_S.js +0 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useRouter } from "./router.js";
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
//#region src/app/list-scroll.ts
|
|
4
|
+
/** The first record in view of the list each history entry last showed, for this tab; a page load starts empty. */
|
|
5
|
+
const remembered = /* @__PURE__ */ new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Where a list starts and how it remembers where it is, so going back to it shows the same records. Only the list the
|
|
8
|
+
* history entry showed last comes back: after a change of sort or filter, which replaces the entry, the list starts at
|
|
9
|
+
* the top again.
|
|
10
|
+
*/
|
|
11
|
+
const useListScroll = () => {
|
|
12
|
+
const { entry, path, params } = useRouter();
|
|
13
|
+
const url = `${path}?${params.toString()}`;
|
|
14
|
+
const last = remembered.get(entry);
|
|
15
|
+
const remember = useCallback((index) => void remembered.set(entry, {
|
|
16
|
+
url,
|
|
17
|
+
index
|
|
18
|
+
}), [entry, url]);
|
|
19
|
+
return {
|
|
20
|
+
initialIndex: last?.url === url ? last.index : 0,
|
|
21
|
+
remember
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
export { useListScroll };
|
|
@@ -1,54 +1,34 @@
|
|
|
1
|
-
import { Skeleton } from "../../../primitives/skeleton.js";
|
|
2
1
|
import { useAdminMeta } from "../../meta-gate.js";
|
|
3
|
-
import { ErrorBanner } from "../../error-banner.js";
|
|
4
|
-
import { useListPage } from "../use-layout-data.js";
|
|
5
2
|
import { propsOf } from "./block-props.js";
|
|
6
|
-
import { CardFrame
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { CardFrame } from "./card-frame.js";
|
|
4
|
+
import { EmbeddedDisplaySwitch, EmbeddedList } from "../../embedded-list/embedded-list.js";
|
|
5
|
+
import { useEmbeddedDisplay } from "../../embedded-list/use-embedded-display.js";
|
|
6
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
10
7
|
//#region src/app/pages/blocks/table.tsx
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const LoadedTable = ({ model, view, filter, sort, columns, pageSize = 10, title, description, empty, actions }) => {
|
|
14
|
-
const shown = (columns ?? defaultColumns(model, view)).filter((name) => Object.hasOwn(model.fields, name));
|
|
15
|
-
const [tokens, setTokens] = useState([""]);
|
|
16
|
-
const page = useListPage(model, {
|
|
17
|
-
filter,
|
|
18
|
-
sort,
|
|
19
|
-
pageSize,
|
|
20
|
-
pageToken: tokens.at(-1)
|
|
21
|
-
});
|
|
22
|
-
const rows = page.data?.items ?? [];
|
|
23
|
-
const next = page.data?.nextPageToken;
|
|
8
|
+
const LoadedTable = ({ model, view, filter = "", sort = "", columns, limit = 10, display: preferred, title, description, empty, actions }) => {
|
|
9
|
+
const [display, pick] = useEmbeddedDisplay(view, `page:${model.name}:${title ?? ""}:${filter}`, preferred);
|
|
24
10
|
return /* @__PURE__ */ jsx(CardFrame, {
|
|
25
11
|
title,
|
|
26
12
|
description,
|
|
27
|
-
actions,
|
|
13
|
+
actions: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(EmbeddedDisplaySwitch, {
|
|
14
|
+
view,
|
|
15
|
+
value: display,
|
|
16
|
+
onChange: pick
|
|
17
|
+
}), actions] }),
|
|
28
18
|
className: "h-full",
|
|
29
|
-
children:
|
|
30
|
-
className: "flex flex-col gap-2",
|
|
31
|
-
children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
|
|
32
|
-
}) : page.error ? /* @__PURE__ */ jsx(ErrorBanner, {
|
|
33
|
-
title: "Could not load",
|
|
34
|
-
error: page.error
|
|
35
|
-
}) : rows.length === 0 && tokens.length === 1 ? /* @__PURE__ */ jsx(Quiet, { children: empty ?? "Nothing here yet." }) : /* @__PURE__ */ jsx(RecordTable, {
|
|
19
|
+
children: /* @__PURE__ */ jsx(EmbeddedList, {
|
|
36
20
|
model,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
busy: page.isFetching,
|
|
45
|
-
previous: () => setTokens((stack) => stack.slice(0, -1)),
|
|
46
|
-
next: () => next && setTokens((stack) => [...stack, next])
|
|
47
|
-
}
|
|
21
|
+
view,
|
|
22
|
+
filter,
|
|
23
|
+
sort,
|
|
24
|
+
columns,
|
|
25
|
+
limit,
|
|
26
|
+
display,
|
|
27
|
+
empty
|
|
48
28
|
})
|
|
49
29
|
});
|
|
50
30
|
};
|
|
51
|
-
/**
|
|
31
|
+
/** The first `limit` records in `sort` order, in the list page's table or display, with a link to the whole list. */
|
|
52
32
|
const TableBlock = ({ node, slots }) => {
|
|
53
33
|
const props = propsOf(node);
|
|
54
34
|
const { resources, views } = useAdminMeta();
|
|
@@ -62,4 +42,4 @@ const TableBlock = ({ node, slots }) => {
|
|
|
62
42
|
});
|
|
63
43
|
};
|
|
64
44
|
//#endregion
|
|
65
|
-
export { TableBlock
|
|
45
|
+
export { TableBlock };
|
package/dist/app/record-page.js
CHANGED
|
@@ -292,7 +292,8 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
292
292
|
children: [layout.flatMap((item, index) => {
|
|
293
293
|
if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
|
|
294
294
|
item,
|
|
295
|
-
recordKey
|
|
295
|
+
recordKey,
|
|
296
|
+
owner: model.name
|
|
296
297
|
}, `related:${item.title}`)];
|
|
297
298
|
if (item.kind === "custom") return item.sidebar ? [] : [/* @__PURE__ */ jsx(CustomSection, {
|
|
298
299
|
item,
|
|
@@ -4,17 +4,20 @@ import { useClient } from "../data/api-provider.js";
|
|
|
4
4
|
import { useAdminMeta } from "./meta-gate.js";
|
|
5
5
|
import { ErrorBanner } from "./error-banner.js";
|
|
6
6
|
import { Section } from "../record-view/section.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { listColumnNames } from "../live/columns-from-model.js";
|
|
8
|
+
import { EmbeddedDisplaySwitch, EmbeddedList } from "./embedded-list/embedded-list.js";
|
|
9
|
+
import { useEmbeddedDisplay } from "./embedded-list/use-embedded-display.js";
|
|
10
10
|
import { jsx } from "react/jsx-runtime";
|
|
11
|
-
import {
|
|
11
|
+
import { useQuery } from "@tanstack/react-query";
|
|
12
12
|
import { createWhere, printFilter } from "@protobase/schema";
|
|
13
13
|
//#region src/app/related-section.tsx
|
|
14
14
|
const where = createWhere();
|
|
15
15
|
/** The most join records a section reads to find its records; the list API's largest page. */
|
|
16
16
|
const maxThrough = 500;
|
|
17
|
-
const and = (...parts) =>
|
|
17
|
+
const and = (...parts) => {
|
|
18
|
+
const given = parts.filter((part) => Boolean(part));
|
|
19
|
+
return given.length === 1 ? given[0] : given.map((part) => `(${part})`).join(" AND ");
|
|
20
|
+
};
|
|
18
21
|
/**
|
|
19
22
|
* The filter on the section's resource: its `field` is this record's key, or, through a join resource, one of the keys
|
|
20
23
|
* the join records that point at this record hold. `null` when no join record does, so nothing can match.
|
|
@@ -54,47 +57,6 @@ const useMatch = (item, recordKey) => {
|
|
|
54
57
|
error: null
|
|
55
58
|
};
|
|
56
59
|
};
|
|
57
|
-
const RelatedRows = ({ item, model, filter }) => {
|
|
58
|
-
const client = useClient();
|
|
59
|
-
const { views } = useAdminMeta();
|
|
60
|
-
const [tokens, setTokens] = useState([""]);
|
|
61
|
-
const pageSize = item.pageSize ?? 10;
|
|
62
|
-
const sort = item.sort ?? "";
|
|
63
|
-
const token = tokens.at(-1) ?? "";
|
|
64
|
-
const page = useQuery({
|
|
65
|
-
queryKey: keys.list(model.name, filter, sort, pageSize, token),
|
|
66
|
-
placeholderData: keepPreviousData,
|
|
67
|
-
queryFn: () => client.search(model.name, {
|
|
68
|
-
filter,
|
|
69
|
-
orderBy: sort || void 0,
|
|
70
|
-
pageSize,
|
|
71
|
-
pageToken: token || void 0
|
|
72
|
-
})
|
|
73
|
-
});
|
|
74
|
-
if (page.isPending) return /* @__PURE__ */ jsx(Loading, {});
|
|
75
|
-
if (page.error) return /* @__PURE__ */ jsx(ErrorBanner, {
|
|
76
|
-
title: "Could not load",
|
|
77
|
-
error: page.error
|
|
78
|
-
});
|
|
79
|
-
const rows = page.data.items;
|
|
80
|
-
if (rows.length === 0 && tokens.length === 1) return /* @__PURE__ */ jsx(Empty, { text: item.empty });
|
|
81
|
-
const columns = (item.columns ?? defaultColumns(model, views[model.name]).filter((name) => name !== item.field)).filter((name) => name.includes(".") || Object.hasOwn(model.fields, name));
|
|
82
|
-
const next = page.data.nextPageToken;
|
|
83
|
-
return /* @__PURE__ */ jsx(RecordTable, {
|
|
84
|
-
model,
|
|
85
|
-
columns,
|
|
86
|
-
rows,
|
|
87
|
-
label: item.title,
|
|
88
|
-
pager: {
|
|
89
|
-
page: tokens.length,
|
|
90
|
-
hasPrevious: tokens.length > 1,
|
|
91
|
-
hasNext: Boolean(next),
|
|
92
|
-
busy: page.isFetching,
|
|
93
|
-
previous: () => setTokens((stack) => stack.slice(0, -1)),
|
|
94
|
-
next: () => next && setTokens((stack) => [...stack, next])
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
};
|
|
98
60
|
const Loading = () => /* @__PURE__ */ jsx("div", {
|
|
99
61
|
className: "flex flex-col gap-2",
|
|
100
62
|
children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
|
|
@@ -103,22 +65,38 @@ const Empty = ({ text }) => /* @__PURE__ */ jsx("p", {
|
|
|
103
65
|
className: "text-[13px] text-muted-foreground",
|
|
104
66
|
children: text ?? "Nothing here yet."
|
|
105
67
|
});
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
68
|
+
/**
|
|
69
|
+
* A record page section listing the first records of another resource that point at this record, directly or through
|
|
70
|
+
* a join resource, in the list page's table or display, with a link to them all in the list.
|
|
71
|
+
*/
|
|
72
|
+
const RelatedSection = ({ item, recordKey, owner }) => {
|
|
73
|
+
const { resources, views } = useAdminMeta();
|
|
109
74
|
const model = resources[item.resource];
|
|
75
|
+
const view = model && views[model.name];
|
|
110
76
|
const match = useMatch(item, recordKey);
|
|
77
|
+
const [display, pick] = useEmbeddedDisplay(view, `related:${owner}:${item.title}`, item.display);
|
|
111
78
|
if (!model) return null;
|
|
79
|
+
const columns = item.columns ?? listColumnNames(model, view).filter((name) => name !== item.field);
|
|
112
80
|
return /* @__PURE__ */ jsx(Section, {
|
|
113
81
|
title: item.title,
|
|
114
82
|
help: item.help,
|
|
83
|
+
actions: /* @__PURE__ */ jsx(EmbeddedDisplaySwitch, {
|
|
84
|
+
view,
|
|
85
|
+
value: display,
|
|
86
|
+
onChange: pick
|
|
87
|
+
}),
|
|
115
88
|
children: match.isPending ? /* @__PURE__ */ jsx(Loading, {}) : match.error ? /* @__PURE__ */ jsx(ErrorBanner, {
|
|
116
89
|
title: "Could not load",
|
|
117
90
|
error: match.error
|
|
118
|
-
}) : match.filter === null || match.filter === void 0 ? /* @__PURE__ */ jsx(Empty, { text: item.empty }) : /* @__PURE__ */ jsx(
|
|
119
|
-
item,
|
|
91
|
+
}) : match.filter === null || match.filter === void 0 ? /* @__PURE__ */ jsx(Empty, { text: item.empty }) : /* @__PURE__ */ jsx(EmbeddedList, {
|
|
120
92
|
model,
|
|
121
|
-
|
|
93
|
+
view,
|
|
94
|
+
filter: match.filter,
|
|
95
|
+
sort: item.sort ?? "",
|
|
96
|
+
columns,
|
|
97
|
+
limit: item.limit ?? 10,
|
|
98
|
+
display,
|
|
99
|
+
empty: item.empty
|
|
122
100
|
}, match.filter)
|
|
123
101
|
});
|
|
124
102
|
};
|
package/dist/app/router.js
CHANGED
|
@@ -8,16 +8,14 @@ import { useHorizontalScroll } from "./use-horizontal-scroll.js";
|
|
|
8
8
|
import { usePinnedColumns } from "./use-pinned-columns.js";
|
|
9
9
|
import { GridHeader } from "./grid-header.js";
|
|
10
10
|
import { DataRow, SkeletonRow } from "./grid-row.js";
|
|
11
|
-
import { windowFor } from "./geometry.js";
|
|
12
|
-
import { useGridScroll } from "./use-grid-scroll.js";
|
|
13
11
|
import { usePagedRows } from "./use-paged-rows.js";
|
|
14
|
-
import {
|
|
12
|
+
import { useVirtualRows } from "./use-virtual-rows.js";
|
|
13
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
15
14
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
16
|
-
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
17
15
|
import { useTable } from "@tanstack/react-table";
|
|
18
16
|
//#region src/data-grid/data-grid.tsx
|
|
19
17
|
const headerHeight = 32;
|
|
20
|
-
const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0, latencyMs = 150, sort: controlledSort, onSortChange, onOpenRow, rowActions, bulkActions, pinKey, defaultPinned, rowNumbers = true, compact = false, selectable = true, className }) => {
|
|
18
|
+
const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0, onScrollIndex, latencyMs = 150, sort: controlledSort, onSortChange, onOpenRow, rowActions, bulkActions, pinKey, defaultPinned, rowNumbers = true, compact = false, selectable = true, fit = false, className }) => {
|
|
21
19
|
const rowHeight = compact ? 28 : 36;
|
|
22
20
|
const [pinned, setPinned] = usePinnedColumns(pinKey, defaultPinned);
|
|
23
21
|
const columns = useMemo(() => {
|
|
@@ -52,45 +50,27 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
52
50
|
reverse,
|
|
53
51
|
total
|
|
54
52
|
]);
|
|
55
|
-
const { ref, onScroll, viewport, top, geometry } =
|
|
53
|
+
const { ref, onScroll, viewport, top, geometry, rows: virtualRows, first: firstIndex, last: lastIndex } = useVirtualRows({
|
|
54
|
+
total,
|
|
55
|
+
rowHeight,
|
|
56
|
+
initialIndex: initialScrollIndex,
|
|
57
|
+
inset: headerHeight
|
|
58
|
+
});
|
|
56
59
|
const narrow = !useIsDesktop();
|
|
57
60
|
const gutter = narrow || !rowNumbers ? 40 : gutterWidthFor(total);
|
|
58
61
|
const bodyHeight = Math.max(0, viewport - headerHeight);
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
const { base, count } = windowFor(total, top, rowHeight);
|
|
62
|
-
const localOffset = top - base * rowHeight;
|
|
63
|
-
const virtualizer = useVirtualizer({
|
|
64
|
-
count,
|
|
65
|
-
getScrollElement: () => bodyRef.current,
|
|
66
|
-
estimateSize: () => rowHeight,
|
|
67
|
-
overscan: 6,
|
|
68
|
-
initialRect: {
|
|
69
|
-
width: 0,
|
|
70
|
-
height: bodyHeight
|
|
71
|
-
},
|
|
72
|
-
observeElementOffset: (_, callback) => {
|
|
73
|
-
offsetListener.current = callback;
|
|
74
|
-
callback(localOffset, false);
|
|
75
|
-
return () => {
|
|
76
|
-
offsetListener.current = null;
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
useLayoutEffect(() => offsetListener.current?.(localOffset, false), [localOffset, base]);
|
|
81
|
-
const items = virtualizer.getVirtualItems();
|
|
82
|
-
const firstIndex = base + (items[0]?.index ?? 0);
|
|
83
|
-
const lastIndex = base + (items[items.length - 1]?.index ?? 0);
|
|
62
|
+
const topIndex = Math.floor(top / rowHeight);
|
|
63
|
+
useEffect(() => onScrollIndex?.(topIndex), [topIndex, onScrollIndex]);
|
|
84
64
|
const getRow = usePagedRows({
|
|
85
65
|
total,
|
|
86
66
|
rowAt: sourceRowAt ? rowAt : void 0,
|
|
87
67
|
loadRows: source.loadRows,
|
|
88
68
|
latencyMs
|
|
89
69
|
}, firstIndex, lastIndex);
|
|
90
|
-
const loaded =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
row: getRow(
|
|
70
|
+
const loaded = virtualRows.map(({ index, offset }) => ({
|
|
71
|
+
index,
|
|
72
|
+
offset,
|
|
73
|
+
row: getRow(index)
|
|
94
74
|
}));
|
|
95
75
|
const data = useMemo(() => loaded.flatMap(({ row }) => row ? [row] : []), [
|
|
96
76
|
getRow,
|
|
@@ -143,6 +123,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
143
123
|
const last = Math.min(total, first + visibleRows - 1);
|
|
144
124
|
return /* @__PURE__ */ jsxs("div", {
|
|
145
125
|
className: cn("flex min-h-0 flex-col overflow-hidden rounded-lg border bg-background", className),
|
|
126
|
+
style: fit ? { height: headerHeight + total * rowHeight + 2 } : void 0,
|
|
146
127
|
children: [/* @__PURE__ */ jsx("div", {
|
|
147
128
|
ref,
|
|
148
129
|
onScroll,
|
|
@@ -175,14 +156,13 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
175
156
|
selectedCount,
|
|
176
157
|
onClearSelection: () => setRowSelection({})
|
|
177
158
|
}), /* @__PURE__ */ jsx("div", {
|
|
178
|
-
ref: bodyRef,
|
|
179
159
|
className: "relative min-h-0 flex-1 overflow-clip",
|
|
180
|
-
children: loaded.map(({
|
|
160
|
+
children: loaded.map(({ index, offset, row }) => {
|
|
181
161
|
const frame = {
|
|
182
162
|
specs: columns,
|
|
183
163
|
gutter,
|
|
184
164
|
index,
|
|
185
|
-
offset
|
|
165
|
+
offset,
|
|
186
166
|
height: rowHeight,
|
|
187
167
|
selectable,
|
|
188
168
|
trailing,
|
|
@@ -217,7 +197,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
217
197
|
})] })
|
|
218
198
|
})
|
|
219
199
|
})
|
|
220
|
-
}), /* @__PURE__ */ jsx(GridFooter, {
|
|
200
|
+
}), !fit && /* @__PURE__ */ jsx(GridFooter, {
|
|
221
201
|
total,
|
|
222
202
|
first,
|
|
223
203
|
last,
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
//#region src/data-grid/geometry.ts
|
|
2
2
|
/** Browsers cap element height (about 17M px in the strictest); stay well below it. */
|
|
3
3
|
const maxContentHeight = 1e7;
|
|
4
|
-
const chunk = 2e3;
|
|
5
|
-
const windowSize = 5e3;
|
|
6
4
|
const gridGeometry = (total, rowHeight, viewportHeight) => {
|
|
7
5
|
const trueHeight = total * rowHeight;
|
|
8
6
|
const contentHeight = Math.min(trueHeight, maxContentHeight);
|
|
@@ -19,14 +17,14 @@ const gridGeometry = (total, rowHeight, viewportHeight) => {
|
|
|
19
17
|
const topFromScroll = (geometry, scrollTop) => geometry.maxScroll === 0 ? 0 : scrollTop / geometry.maxScroll * geometry.maxTop;
|
|
20
18
|
const scrollFromTop = (geometry, top) => geometry.maxTop === 0 ? 0 : top / geometry.maxTop * geometry.maxScroll;
|
|
21
19
|
const clampTop = (geometry, top) => Math.min(Math.max(0, top), geometry.maxTop);
|
|
22
|
-
/** The
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
20
|
+
/** The rows drawn for a scroll offset: those in a viewport of `height`, and `overscan` more on each side. */
|
|
21
|
+
const rowsInView = (total, top, rowHeight, height, overscan) => {
|
|
22
|
+
const first = Math.max(0, Math.floor(top / rowHeight) - overscan);
|
|
23
|
+
const last = Math.min(total - 1, Math.floor((top + height) / rowHeight) + overscan);
|
|
26
24
|
return {
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
first,
|
|
26
|
+
last: Math.max(first - 1, last)
|
|
29
27
|
};
|
|
30
28
|
};
|
|
31
29
|
//#endregion
|
|
32
|
-
export { clampTop, gridGeometry, maxContentHeight, scrollFromTop, topFromScroll
|
|
30
|
+
export { clampTop, gridGeometry, maxContentHeight, rowsInView, scrollFromTop, topFromScroll };
|
|
@@ -64,6 +64,12 @@ const useGridScroll = (total, rowHeight, initialIndex) => {
|
|
|
64
64
|
element.addEventListener("wheel", onWheel, { passive: false });
|
|
65
65
|
return () => element.removeEventListener("wheel", onWheel);
|
|
66
66
|
}, [syncNative]);
|
|
67
|
+
/** Moves to a virtual offset, for a layout change that keeps a row in view. */
|
|
68
|
+
const scrollTo = useCallback((nextTop) => {
|
|
69
|
+
const next = clampTop(latest.current.geometry, nextTop);
|
|
70
|
+
setTop(next);
|
|
71
|
+
syncNative(next);
|
|
72
|
+
}, [syncNative]);
|
|
67
73
|
return {
|
|
68
74
|
ref,
|
|
69
75
|
onScroll: useCallback(() => {
|
|
@@ -73,7 +79,8 @@ const useGridScroll = (total, rowHeight, initialIndex) => {
|
|
|
73
79
|
}, []),
|
|
74
80
|
viewport,
|
|
75
81
|
top,
|
|
76
|
-
geometry
|
|
82
|
+
geometry,
|
|
83
|
+
scrollTo
|
|
77
84
|
};
|
|
78
85
|
};
|
|
79
86
|
//#endregion
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { rowsInView } from "./geometry.js";
|
|
2
|
+
import { useGridScroll } from "./use-grid-scroll.js";
|
|
3
|
+
//#region src/data-grid/use-virtual-rows.ts
|
|
4
|
+
const overscan = 6;
|
|
5
|
+
/**
|
|
6
|
+
* Rows of a fixed height over a proxy scroller, so any number of rows scrolls at native speed: `ref` and `onScroll` go
|
|
7
|
+
* on the scrolling element, and each drawn row sits at `offset` from the top of the viewport's rows, below `inset`.
|
|
8
|
+
* `first` and `last` are the indexes of the drawn rows, overscan included.
|
|
9
|
+
*/
|
|
10
|
+
const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle }) => {
|
|
11
|
+
const scroll = useGridScroll(total, rowHeight, initialIndex);
|
|
12
|
+
const { ref, onScroll, viewport, geometry, scrollTo } = scroll;
|
|
13
|
+
const top = settle ? settle(scroll.top) : scroll.top;
|
|
14
|
+
const { first, last } = rowsInView(total, top, rowHeight, Math.max(0, viewport - inset), overscan);
|
|
15
|
+
return {
|
|
16
|
+
ref,
|
|
17
|
+
onScroll,
|
|
18
|
+
viewport,
|
|
19
|
+
top,
|
|
20
|
+
geometry,
|
|
21
|
+
scrollTo,
|
|
22
|
+
rows: Array.from({ length: last - first + 1 }, (_, offset) => ({
|
|
23
|
+
index: first + offset,
|
|
24
|
+
offset: (first + offset) * rowHeight - top
|
|
25
|
+
})),
|
|
26
|
+
first,
|
|
27
|
+
last
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { useVirtualRows };
|
|
@@ -61,9 +61,11 @@ const kindOf = (field, view, index) => {
|
|
|
61
61
|
}
|
|
62
62
|
};
|
|
63
63
|
const currencyCents = (value) => Math.round(Number.parseFloat(String(value)) * 100);
|
|
64
|
-
/**
|
|
65
|
-
const
|
|
66
|
-
|
|
64
|
+
/** The fields a view's list shows as columns: its list columns, else the resource's first six fields. */
|
|
65
|
+
const listColumnNames = (resource, view) => view?.list?.columns ?? Object.keys(resource.fields).slice(0, 6);
|
|
66
|
+
/** Grid columns for `names`, by default the view's list: kinds follow the field types and the view's format hints. */
|
|
67
|
+
const columnsFromModel = (resource, view, highlight = [], names = listColumnNames(resource, view)) => {
|
|
68
|
+
return names.flatMap((name, index) => {
|
|
67
69
|
const field = resource.fields[name];
|
|
68
70
|
if (!field) return [];
|
|
69
71
|
const viewField = view?.fields[name];
|
|
@@ -97,4 +99,4 @@ const columnsFromModel = (resource, view, highlight = []) => {
|
|
|
97
99
|
});
|
|
98
100
|
};
|
|
99
101
|
//#endregion
|
|
100
|
-
export { columnsFromModel, headerWidth, toneFor };
|
|
102
|
+
export { columnsFromModel, headerWidth, listColumnNames, toneFor };
|
package/dist/live/grid-loader.js
CHANGED
|
@@ -4,11 +4,12 @@ import { withLabels } from "./relation-labels.js";
|
|
|
4
4
|
//#region src/live/grid-loader.ts
|
|
5
5
|
const exactBelow = 5e3;
|
|
6
6
|
/**
|
|
7
|
-
* Loads fixed-size pages by number.
|
|
8
|
-
* page at that row position
|
|
7
|
+
* Loads fixed-size pages by number. The page after a cached or loading one follows that page's `next_page_token`; a
|
|
8
|
+
* jump to a page whose predecessor is neither asks `:seek` for the page at that row position, so the pages of one jump
|
|
9
|
+
* are read on from a single seek. Every page is cached by TanStack Query.
|
|
9
10
|
*/
|
|
10
11
|
const createGridLoader = ({ queryClient, client, resources, views, model, columns, filter, orderBy }) => {
|
|
11
|
-
const
|
|
12
|
+
const pageKey = (number) => keys.page(model.name, filter, orderBy, 40, number);
|
|
12
13
|
/** The server's total is a planner estimate, off by a lot for small results; settle those exactly. */
|
|
13
14
|
const firstPageTotal = async (result) => {
|
|
14
15
|
if (result.nextPageToken === "") return result.items.length;
|
|
@@ -20,36 +21,51 @@ const createGridLoader = ({ queryClient, client, resources, views, model, column
|
|
|
20
21
|
count: "exact"
|
|
21
22
|
})).totalSize ?? result.totalSizeEstimate;
|
|
22
23
|
};
|
|
24
|
+
/** The token that continues after the page before `number`, or undefined when that page is neither cached nor loading. */
|
|
25
|
+
const tokenBefore = async (number) => {
|
|
26
|
+
if (number === 0) return "";
|
|
27
|
+
const key = pageKey(number - 1);
|
|
28
|
+
const cached = queryClient.getQueryData(key);
|
|
29
|
+
if (cached) return cached.nextPageToken;
|
|
30
|
+
if (queryClient.getQueryState(key)?.fetchStatus !== "fetching") return void 0;
|
|
31
|
+
return (await page(number - 1)).nextPageToken;
|
|
32
|
+
};
|
|
23
33
|
const fetchPage = async (page) => {
|
|
24
|
-
const token =
|
|
34
|
+
const token = await tokenBefore(page);
|
|
35
|
+
if (page > 0 && token === "") return {
|
|
36
|
+
rows: [],
|
|
37
|
+
total: 0,
|
|
38
|
+
nextPageToken: ""
|
|
39
|
+
};
|
|
25
40
|
const params = {
|
|
26
41
|
filter,
|
|
27
42
|
orderBy,
|
|
28
43
|
pageSize: 40
|
|
29
44
|
};
|
|
30
|
-
const result =
|
|
45
|
+
const result = token !== void 0 ? await client.list(model.name, {
|
|
31
46
|
...params,
|
|
32
|
-
pageToken: token
|
|
47
|
+
pageToken: token
|
|
33
48
|
}) : await client.seek(model.name, {
|
|
34
49
|
...params,
|
|
35
50
|
position: page * 40
|
|
36
51
|
});
|
|
37
52
|
if (result.items.length === 0 && page > 0) return {
|
|
38
53
|
rows: [],
|
|
39
|
-
total: 0
|
|
54
|
+
total: 0,
|
|
55
|
+
nextPageToken: ""
|
|
40
56
|
};
|
|
41
|
-
tokens.set(page + 1, result.nextPageToken);
|
|
42
57
|
const ids = result.items.map((record) => recordId(model, record));
|
|
43
58
|
return {
|
|
44
59
|
rows: (await withLabels(queryClient, client, resources, views, model, columns, result.items)).map((record, index) => ({
|
|
45
60
|
...record,
|
|
46
61
|
id: ids[index]
|
|
47
62
|
})),
|
|
48
|
-
total: page === 0 ? await firstPageTotal(result) : result.totalSizeEstimate
|
|
63
|
+
total: page === 0 ? await firstPageTotal(result) : result.totalSizeEstimate,
|
|
64
|
+
nextPageToken: result.nextPageToken
|
|
49
65
|
};
|
|
50
66
|
};
|
|
51
67
|
const page = (number) => queryClient.fetchQuery({
|
|
52
|
-
queryKey:
|
|
68
|
+
queryKey: pageKey(number),
|
|
53
69
|
staleTime: 6e4,
|
|
54
70
|
queryFn: () => fetchPage(number)
|
|
55
71
|
});
|
package/dist/package.js
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
2
|
//#region src/record-view/section.tsx
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
})
|
|
3
|
+
/** A titled part of the record page; `actions` sit at the end of the title's line. */
|
|
4
|
+
const Section = ({ title, help, actions, children }) => {
|
|
5
|
+
const heading = /* @__PURE__ */ jsx("h2", {
|
|
6
|
+
className: "text-[15px] font-semibold text-foreground",
|
|
7
|
+
children: title
|
|
8
|
+
});
|
|
9
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
10
|
+
className: "py-8 first:pt-8",
|
|
11
|
+
children: [
|
|
12
|
+
actions ? /* @__PURE__ */ jsxs("div", {
|
|
13
|
+
className: "flex flex-wrap items-center justify-between gap-x-4 gap-y-2",
|
|
14
|
+
children: [heading, actions]
|
|
15
|
+
}) : heading,
|
|
16
|
+
help && /* @__PURE__ */ jsx("p", {
|
|
17
|
+
className: "mt-1 max-w-xl text-[13px] leading-relaxed text-muted-foreground",
|
|
18
|
+
children: help
|
|
19
|
+
}),
|
|
20
|
+
/* @__PURE__ */ jsx("div", {
|
|
21
|
+
className: "mt-5",
|
|
22
|
+
children
|
|
23
|
+
})
|
|
24
|
+
]
|
|
25
|
+
});
|
|
26
|
+
};
|
|
20
27
|
//#endregion
|
|
21
28
|
export { Section };
|