@protobase/ui 0.12.21 → 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 +17 -143
- package/dist/app/displays/display-controls.js +3 -2
- package/dist/app/displays/list-cards.js +50 -0
- package/dist/app/displays/list-displays.d.ts +1 -1
- package/dist/app/displays/list-displays.js +40 -6
- package/dist/app/displays/map-display.js +120 -0
- package/dist/app/displays/map-groups.js +55 -0
- package/dist/app/displays/record-card.js +171 -0
- 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/field-editor.js +1 -1
- package/dist/app/list-page.js +32 -11
- package/dist/app/list-scroll.js +25 -0
- package/dist/app/map/record-map.js +2 -2
- package/dist/app/map/records-map.js +106 -0
- package/dist/app/named-icon.js +3 -2
- package/dist/app/pages/blocks/table.js +21 -41
- package/dist/app/pages/field-value.js +2 -2
- package/dist/app/record-page.js +3 -2
- 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-BNrginzc.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-CdxyCTXE.js +0 -66
- package/dist/shell/assets/index-UCjnDdFX.css +0 -2
- package/dist/shell/assets/maplibre-gl-B1f3q0xK.js +0 -1
- package/dist/shell/assets/record-map-HYe7IrpM.js +0 -814
- package/dist/shell/assets/shared-protobase-ui-DkwITozF.js +0 -1
package/dist/app/list-page.js
CHANGED
|
@@ -26,10 +26,12 @@ import { useLiveRows } from "../live/use-live-rows.js";
|
|
|
26
26
|
import { Sheet } from "../primitives/sheet.js";
|
|
27
27
|
import { useDeleteFlow } from "./delete/use-delete-flow.js";
|
|
28
28
|
import { useRestore } from "./delete/use-restore.js";
|
|
29
|
-
import {
|
|
29
|
+
import { ListCards } from "./displays/list-cards.js";
|
|
30
|
+
import { MapDisplay } from "./displays/map-display.js";
|
|
30
31
|
import { CustomDisplay } from "./displays/custom-display.js";
|
|
31
32
|
import { DisplaySwitch, SortMenu } from "./displays/display-controls.js";
|
|
32
33
|
import { useDisplayRows } from "./displays/use-display-rows.js";
|
|
34
|
+
import { useListScroll } from "./list-scroll.js";
|
|
33
35
|
import { ListChart } from "./list-chart.js";
|
|
34
36
|
import { ListSearch } from "./list-search.js";
|
|
35
37
|
import { withFieldTypeCells } from "./field-types/field-type-cells.js";
|
|
@@ -97,7 +99,8 @@ const LoadedList = ({ model, address }) => {
|
|
|
97
99
|
orderBy
|
|
98
100
|
});
|
|
99
101
|
const display = shownDisplay(view, address.display);
|
|
100
|
-
const displayRows = useDisplayRows(model, rows.page, rows.key, display !== void 0);
|
|
102
|
+
const displayRows = useDisplayRows(model, rows.page, rows.key, display !== void 0 && display.kind !== "cards");
|
|
103
|
+
const scroll = useListScroll();
|
|
101
104
|
const titleField = recordTitleField(model, view);
|
|
102
105
|
const target = (row) => ({
|
|
103
106
|
key: row.id,
|
|
@@ -233,6 +236,8 @@ const LoadedList = ({ model, address }) => {
|
|
|
233
236
|
sort: parseSort(orderBy),
|
|
234
237
|
onSortChange: (sort) => set({ orderBy: sortToOrderBy(sort) }),
|
|
235
238
|
onOpenRow: openRecord,
|
|
239
|
+
initialScrollIndex: scroll.initialIndex,
|
|
240
|
+
onScrollIndex: scroll.remember,
|
|
236
241
|
rowActions: [{
|
|
237
242
|
label: `Open ${singular.toLowerCase()}`,
|
|
238
243
|
onSelect: openRecord
|
|
@@ -251,15 +256,31 @@ const LoadedList = ({ model, address }) => {
|
|
|
251
256
|
pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
|
|
252
257
|
className: "min-h-0 flex-1"
|
|
253
258
|
}) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
display
|
|
261
|
-
|
|
262
|
-
|
|
259
|
+
const cardsList = {
|
|
260
|
+
...rows,
|
|
261
|
+
initialIndex: scroll.initialIndex,
|
|
262
|
+
onScrollIndex: scroll.remember
|
|
263
|
+
};
|
|
264
|
+
const drawDisplay = () => {
|
|
265
|
+
if (!display) return table;
|
|
266
|
+
if (display.kind === "cards") return /* @__PURE__ */ jsx(ListCards, {
|
|
267
|
+
model,
|
|
268
|
+
view,
|
|
269
|
+
display,
|
|
270
|
+
list: cardsList
|
|
271
|
+
}, rows.key.join(" "));
|
|
272
|
+
if (display.kind === "map") return /* @__PURE__ */ jsx(MapDisplay, {
|
|
273
|
+
model,
|
|
274
|
+
view,
|
|
275
|
+
display,
|
|
276
|
+
list: displayRows
|
|
277
|
+
});
|
|
278
|
+
return /* @__PURE__ */ jsx(CustomDisplay, {
|
|
279
|
+
display,
|
|
280
|
+
list: displayRows
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
const shown = drawDisplay();
|
|
263
284
|
return /* @__PURE__ */ jsxs("div", {
|
|
264
285
|
className: "flex min-h-0 flex-1 flex-col gap-3 px-5 py-4",
|
|
265
286
|
children: [
|
|
@@ -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 };
|
|
@@ -4,7 +4,7 @@ import * as maplibre from "maplibre-gl";
|
|
|
4
4
|
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
|
|
5
5
|
import "maplibre-gl/dist/maplibre-gl.css";
|
|
6
6
|
import { Protocol } from "pmtiles";
|
|
7
|
-
import { Map, Marker, NavigationControl } from "react-map-gl/maplibre";
|
|
7
|
+
import { Map as Map$1, Marker, NavigationControl } from "react-map-gl/maplibre";
|
|
8
8
|
//#region src/app/map/record-map.tsx
|
|
9
9
|
maplibre.setWorkerUrl(workerUrl);
|
|
10
10
|
maplibre.addProtocol("pmtiles", new Protocol().tile);
|
|
@@ -12,7 +12,7 @@ maplibre.addProtocol("pmtiles", new Protocol().tile);
|
|
|
12
12
|
* A map of `style` centred on `position` at `zoom`, with a pin on it. It pans and zooms with two fingers on a phone and
|
|
13
13
|
* with Ctrl or Cmd and the wheel, so scrolling the page past it does not move it.
|
|
14
14
|
*/
|
|
15
|
-
const RecordMap = ({ style, position, zoom }) => /* @__PURE__ */ jsxs(Map, {
|
|
15
|
+
const RecordMap = ({ style, position, zoom }) => /* @__PURE__ */ jsxs(Map$1, {
|
|
16
16
|
mapLib: maplibre,
|
|
17
17
|
mapStyle: style,
|
|
18
18
|
initialViewState: {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn.js";
|
|
2
|
+
import { groupPoints, pointBounds } from "../displays/map-groups.js";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { MapPin } from "lucide-react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import * as maplibre from "maplibre-gl";
|
|
7
|
+
import { Map as Map$1, Marker, NavigationControl } from "react-map-gl/maplibre";
|
|
8
|
+
//#region src/app/map/records-map.tsx
|
|
9
|
+
const groupRadius = 28;
|
|
10
|
+
const fitOptions = {
|
|
11
|
+
padding: 48,
|
|
12
|
+
maxZoom: 15,
|
|
13
|
+
duration: 0
|
|
14
|
+
};
|
|
15
|
+
const markerHeight = 32;
|
|
16
|
+
/**
|
|
17
|
+
* A map of `style` with a marker for each group of records that lie close together at the zoom, showing how many it
|
|
18
|
+
* stands for. It fits the records when they change. Choosing a marker selects its records; choosing the map elsewhere
|
|
19
|
+
* clears the selection. A chosen marker the covered bottom would hide moves up into the open part of the map.
|
|
20
|
+
*/
|
|
21
|
+
const RecordsMap = ({ style, points, selected, covered, onSelect, onClear, markerLabel }) => {
|
|
22
|
+
const ref = useRef(null);
|
|
23
|
+
const [groups, setGroups] = useState([]);
|
|
24
|
+
const byKey = new globalThis.Map(points.map((point) => [point.key, point]));
|
|
25
|
+
const pointKeys = points.map((point) => point.key).join(",");
|
|
26
|
+
const bounds = pointBounds(points);
|
|
27
|
+
const regroup = () => {
|
|
28
|
+
const map = ref.current?.getMap();
|
|
29
|
+
if (!map) return;
|
|
30
|
+
setGroups(groupPoints(points.map((point) => ({
|
|
31
|
+
key: point.key,
|
|
32
|
+
...map.project([point.longitude, point.latitude])
|
|
33
|
+
})), groupRadius));
|
|
34
|
+
};
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const map = ref.current?.getMap();
|
|
37
|
+
if (!map) return;
|
|
38
|
+
if (bounds) map.fitBounds(bounds, fitOptions);
|
|
39
|
+
regroup();
|
|
40
|
+
}, [pointKeys]);
|
|
41
|
+
const chosenAt = groups.find((group) => selected !== void 0 && group.keys.some((key) => selected.includes(key)))?.at;
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const map = ref.current?.getMap();
|
|
44
|
+
const at = chosenAt === void 0 ? void 0 : byKey.get(chosenAt);
|
|
45
|
+
if (!map || !at || covered === 0) return;
|
|
46
|
+
const { y } = map.project([at.longitude, at.latitude]);
|
|
47
|
+
const open = map.getContainer().clientHeight - covered;
|
|
48
|
+
if (y > markerHeight && y < open) return;
|
|
49
|
+
map.easeTo({
|
|
50
|
+
center: [at.longitude, at.latitude],
|
|
51
|
+
offset: [0, (markerHeight - covered) / 2],
|
|
52
|
+
duration: 300
|
|
53
|
+
});
|
|
54
|
+
}, [chosenAt, covered]);
|
|
55
|
+
return /* @__PURE__ */ jsxs(Map$1, {
|
|
56
|
+
ref,
|
|
57
|
+
mapLib: maplibre,
|
|
58
|
+
mapStyle: style,
|
|
59
|
+
initialViewState: bounds ? {
|
|
60
|
+
bounds,
|
|
61
|
+
fitBoundsOptions: fitOptions
|
|
62
|
+
} : {
|
|
63
|
+
latitude: 0,
|
|
64
|
+
longitude: 0,
|
|
65
|
+
zoom: 1
|
|
66
|
+
},
|
|
67
|
+
style: {
|
|
68
|
+
width: "100%",
|
|
69
|
+
height: "100%"
|
|
70
|
+
},
|
|
71
|
+
onLoad: regroup,
|
|
72
|
+
onZoomEnd: regroup,
|
|
73
|
+
onClick: onClear,
|
|
74
|
+
children: [/* @__PURE__ */ jsx(NavigationControl, { showCompass: false }), groups.map((group) => {
|
|
75
|
+
const at = byKey.get(group.at);
|
|
76
|
+
if (!at) return null;
|
|
77
|
+
const chosen = selected !== void 0 && group.keys.some((key) => selected.includes(key));
|
|
78
|
+
return /* @__PURE__ */ jsx(Marker, {
|
|
79
|
+
latitude: at.latitude,
|
|
80
|
+
longitude: at.longitude,
|
|
81
|
+
anchor: "bottom",
|
|
82
|
+
style: { zIndex: chosen ? 1 : 0 },
|
|
83
|
+
children: /* @__PURE__ */ jsxs("button", {
|
|
84
|
+
type: "button",
|
|
85
|
+
"aria-label": markerLabel(group.keys),
|
|
86
|
+
"aria-pressed": chosen,
|
|
87
|
+
className: "relative block cursor-pointer",
|
|
88
|
+
onClick: (event) => {
|
|
89
|
+
event.stopPropagation();
|
|
90
|
+
onSelect(group.keys);
|
|
91
|
+
},
|
|
92
|
+
children: [/* @__PURE__ */ jsx(MapPin, {
|
|
93
|
+
"aria-hidden": true,
|
|
94
|
+
className: cn("size-8 drop-shadow", chosen ? "fill-foreground text-background" : "fill-primary text-primary-foreground"),
|
|
95
|
+
strokeWidth: 1.5
|
|
96
|
+
}), group.keys.length > 1 && /* @__PURE__ */ jsx("span", {
|
|
97
|
+
className: "absolute -right-2 -top-1 min-w-5 rounded-full border border-background bg-foreground px-1 text-center text-[11px] font-medium leading-4 text-background tabular-nums",
|
|
98
|
+
children: group.keys.length
|
|
99
|
+
})]
|
|
100
|
+
})
|
|
101
|
+
}, group.at);
|
|
102
|
+
})]
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
//#endregion
|
|
106
|
+
export { RecordsMap };
|
package/dist/app/named-icon.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { FileText, RulerDimensionLine, Send, Wallet } from "lucide-react";
|
|
1
|
+
import { Bed, FileText, RulerDimensionLine, Send, Wallet } from "lucide-react";
|
|
2
2
|
//#region src/app/named-icon.ts
|
|
3
3
|
/** The icons an action's or a field's `icon` may name. */
|
|
4
4
|
const byName = {
|
|
5
5
|
wallet: Wallet,
|
|
6
6
|
"file-text": FileText,
|
|
7
7
|
send: Send,
|
|
8
|
-
ruler: RulerDimensionLine
|
|
8
|
+
ruler: RulerDimensionLine,
|
|
9
|
+
bed: Bed
|
|
9
10
|
};
|
|
10
11
|
/** The icon a name stands for, or nothing for a name the app does not know. */
|
|
11
12
|
const namedIcon = (name) => name !== void 0 && Object.hasOwn(byName, name) ? byName[name] : void 0;
|
|
@@ -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 };
|
|
@@ -2,10 +2,10 @@ import { cn } from "../../lib/cn.js";
|
|
|
2
2
|
import { keys } from "../../data/query-keys.js";
|
|
3
3
|
import { useClient } from "../../data/api-provider.js";
|
|
4
4
|
import { fetchLabels } from "../../live/relation-labels.js";
|
|
5
|
-
import { useAdminMeta } from "../meta-gate.js";
|
|
6
|
-
import { AppLink } from "../use-app-address.js";
|
|
7
5
|
import { fileShown } from "../../live/file-values.js";
|
|
8
6
|
import { displayValue } from "../../live/display-value.js";
|
|
7
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
8
|
+
import { AppLink } from "../use-app-address.js";
|
|
9
9
|
import { useShownWidget } from "../field-types/shown-widget.js";
|
|
10
10
|
import { WidgetPart } from "../field-types/widget-part.js";
|
|
11
11
|
import { ValueBadge } from "../value-badge.js";
|
package/dist/app/record-page.js
CHANGED
|
@@ -7,10 +7,10 @@ import { useRecord } from "../data/use-record.js";
|
|
|
7
7
|
import { recordTitle } from "../live/model-helpers.js";
|
|
8
8
|
import { humanize } from "../live/naming.js";
|
|
9
9
|
import { useProjectUi } from "./pages/project-ui.js";
|
|
10
|
+
import { displayValue } from "../live/display-value.js";
|
|
10
11
|
import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
|
|
11
12
|
import { useNavigationGuard, useRouter } from "./router.js";
|
|
12
13
|
import { useAppAddress } from "./use-app-address.js";
|
|
13
|
-
import { displayValue } from "../live/display-value.js";
|
|
14
14
|
import { ErrorBanner } from "./error-banner.js";
|
|
15
15
|
import { shownWidget } from "./field-types/shown-widget.js";
|
|
16
16
|
import { WidgetPart } from "./field-types/widget-part.js";
|
|
@@ -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
|