@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,19 @@
|
|
|
1
|
+
/** How many cards fit across `width`, as a grid of `repeat(auto-fill, minmax(15rem, 1fr))` would place them. */
|
|
2
|
+
const cardColumns = (width) => Math.max(1, Math.floor((width + 12) / 252));
|
|
3
|
+
const cardWidth = (width, columns) => Math.max(0, (width - (columns - 1) * 12) / columns);
|
|
4
|
+
/** The records the card rows `first` to `last` hold, as a first and last record index. */
|
|
5
|
+
const recordRange = (first, last, columns, total) => ({
|
|
6
|
+
start: first * columns,
|
|
7
|
+
end: Math.max(0, Math.min(total, (last + 1) * columns) - 1)
|
|
8
|
+
});
|
|
9
|
+
/**
|
|
10
|
+
* The scroll offset after a layout change that keeps the same records in view: the same place in the same row when
|
|
11
|
+
* only the row height changed, else the row now holding the first record of the row that was at the top.
|
|
12
|
+
*/
|
|
13
|
+
const anchoredTop = (top, before, after) => {
|
|
14
|
+
const row = top / before.rowHeight;
|
|
15
|
+
if (before.columns === after.columns) return row * after.rowHeight;
|
|
16
|
+
return Math.floor(Math.floor(row) * before.columns / after.columns) * after.rowHeight;
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { anchoredTop, cardColumns, cardWidth, recordRange };
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn.js";
|
|
1
2
|
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
3
|
import { humanize } from "../../live/naming.js";
|
|
3
|
-
import { DisplayFooter, DisplayState } from "./display-frame.js";
|
|
4
4
|
import { RecordCard } from "./record-card.js";
|
|
5
|
+
import { DisplayFooter, DisplayState } from "./display-frame.js";
|
|
5
6
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
7
|
//#region src/app/displays/cards-display.tsx
|
|
7
8
|
const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const scroller = "flex snap-x snap-mandatory gap-3 overflow-x-auto pb-1";
|
|
10
|
+
/** A card per record (see `RecordCard`), in a grid or a row, with how many match and a button for more below. */
|
|
11
|
+
const CardsDisplay = ({ model, view, display, list, row = false, footer = true }) => {
|
|
10
12
|
const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
|
|
11
13
|
return /* @__PURE__ */ jsxs(DisplayState, {
|
|
12
14
|
list,
|
|
@@ -16,15 +18,18 @@ const CardsDisplay = ({ model, view, display, list }) => {
|
|
|
16
18
|
children: Array.from({ length: 6 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24" }, index))
|
|
17
19
|
}),
|
|
18
20
|
children: [/* @__PURE__ */ jsx("ul", {
|
|
19
|
-
className: grid,
|
|
21
|
+
className: row ? scroller : grid,
|
|
20
22
|
"aria-label": view?.names?.plural ?? humanize(model.name),
|
|
21
|
-
children: list.rows.map((
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
children: list.rows.map((record) => /* @__PURE__ */ jsx("li", {
|
|
24
|
+
className: cn(row && "w-64 shrink-0 snap-start"),
|
|
25
|
+
children: /* @__PURE__ */ jsx(RecordCard, {
|
|
26
|
+
model,
|
|
27
|
+
view,
|
|
28
|
+
display,
|
|
29
|
+
row: record
|
|
30
|
+
})
|
|
31
|
+
}, record.key))
|
|
32
|
+
}), footer && /* @__PURE__ */ jsx(DisplayFooter, { list })]
|
|
28
33
|
});
|
|
29
34
|
};
|
|
30
35
|
//#endregion
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { humanize } from "../../live/naming.js";
|
|
3
|
+
import { displayRow } from "./list-displays.js";
|
|
4
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
5
|
+
import { RecordCard } from "./record-card.js";
|
|
6
|
+
import { VirtualCards } from "./virtual-cards.js";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
//#region src/app/displays/list-cards.tsx
|
|
9
|
+
const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
|
|
10
|
+
const pictureHeight = (width) => width * .75;
|
|
11
|
+
const plainHeight = 44;
|
|
12
|
+
/**
|
|
13
|
+
* The list page's cards display: a card per record (see `RecordCard`), scrolled like the table, with which of the
|
|
14
|
+
* matching records are in view below. An embedded list, which shows a few cards whole, draws them with `CardsDisplay`.
|
|
15
|
+
*/
|
|
16
|
+
const ListCards = ({ model, view, display, list }) => {
|
|
17
|
+
const plural = view?.names?.plural ?? humanize(model.name);
|
|
18
|
+
const pictured = display.card === void 0 && (display.image !== void 0 || display.photos !== void 0);
|
|
19
|
+
if (list.isLoading) return /* @__PURE__ */ jsx("div", {
|
|
20
|
+
className: `min-h-0 flex-1 overflow-hidden ${grid}`,
|
|
21
|
+
children: Array.from({ length: 6 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24" }, index))
|
|
22
|
+
});
|
|
23
|
+
if (list.error) return /* @__PURE__ */ jsx(ErrorBanner, {
|
|
24
|
+
title: "Could not load rows",
|
|
25
|
+
error: list.error
|
|
26
|
+
});
|
|
27
|
+
if (!list.source || list.source.total === 0) return /* @__PURE__ */ jsxs("p", {
|
|
28
|
+
className: "py-8 text-center text-[13px] text-muted-foreground",
|
|
29
|
+
children: [
|
|
30
|
+
"No ",
|
|
31
|
+
plural.toLowerCase(),
|
|
32
|
+
" match."
|
|
33
|
+
]
|
|
34
|
+
});
|
|
35
|
+
return /* @__PURE__ */ jsx(VirtualCards, {
|
|
36
|
+
source: list.source,
|
|
37
|
+
label: plural,
|
|
38
|
+
initialIndex: list.initialIndex,
|
|
39
|
+
onScrollIndex: list.onScrollIndex,
|
|
40
|
+
minHeight: (width) => (pictured ? pictureHeight(width) : 0) + plainHeight,
|
|
41
|
+
children: (row) => /* @__PURE__ */ jsx(RecordCard, {
|
|
42
|
+
model,
|
|
43
|
+
view,
|
|
44
|
+
display,
|
|
45
|
+
row: displayRow(model, row)
|
|
46
|
+
})
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
//#endregion
|
|
50
|
+
export { ListCards };
|
|
@@ -6,10 +6,11 @@ import { encodeKey, tableDisplay } from "@protobase/schema";
|
|
|
6
6
|
const displaysOf = (view) => view?.list?.displays ?? [];
|
|
7
7
|
/**
|
|
8
8
|
* The display a list opens in when its address names none. Below the `sm` breakpoint a list with a cards display opens
|
|
9
|
-
* in one: the view's default when that is a cards display, else the first. Otherwise
|
|
9
|
+
* in one: `preferred` or the view's default when that is a cards display, else the first. Otherwise `preferred`, the
|
|
10
|
+
* view's default, or the table.
|
|
10
11
|
*/
|
|
11
|
-
const defaultDisplay = (view, xs) => {
|
|
12
|
-
const fallback = view?.list?.default ?? tableDisplay;
|
|
12
|
+
const defaultDisplay = (view, xs, preferred) => {
|
|
13
|
+
const fallback = preferred ?? view?.list?.default ?? tableDisplay;
|
|
13
14
|
if (!xs) return fallback;
|
|
14
15
|
const cards = displaysOf(view).filter((display) => display.kind === "cards");
|
|
15
16
|
return cards.find((display) => display.name === fallback)?.name ?? cards[0]?.name ?? fallback;
|
|
@@ -3,8 +3,8 @@ import { humanize } from "../../live/naming.js";
|
|
|
3
3
|
import { mapCards } from "./list-displays.js";
|
|
4
4
|
import { useAdminMeta } from "../meta-gate.js";
|
|
5
5
|
import { Problem } from "../pages/problem.js";
|
|
6
|
-
import { DisplayFooter, DisplayState } from "./display-frame.js";
|
|
7
6
|
import { RecordCard } from "./record-card.js";
|
|
7
|
+
import { DisplayFooter, DisplayState } from "./display-frame.js";
|
|
8
8
|
import { keptSelection, mapPoints, stepSelection } from "./map-groups.js";
|
|
9
9
|
import { Suspense, lazy, useLayoutEffect, useRef, useState } from "react";
|
|
10
10
|
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
|
@@ -17,7 +17,7 @@ const arrow = "flex size-8 items-center justify-center rounded-full border borde
|
|
|
17
17
|
* record at the map's bottom, with arrows through the records the marker stands for; closing it or choosing the map
|
|
18
18
|
* elsewhere clears it. Below the map, how many of the matching records are on it and a button for more.
|
|
19
19
|
*/
|
|
20
|
-
const MapDisplay = ({ model, view, display, list }) => {
|
|
20
|
+
const MapDisplay = ({ model, view, display, list, footer = true }) => {
|
|
21
21
|
const style = useAdminMeta().maps?.style;
|
|
22
22
|
const [chosen, setChosen] = useState();
|
|
23
23
|
const cards = mapCards(view, display);
|
|
@@ -112,7 +112,7 @@ const MapDisplay = ({ model, view, display, list }) => {
|
|
|
112
112
|
})]
|
|
113
113
|
})
|
|
114
114
|
})]
|
|
115
|
-
}), /* @__PURE__ */ jsx(DisplayFooter, { list })]
|
|
115
|
+
}), footer && /* @__PURE__ */ jsx(DisplayFooter, { list })]
|
|
116
116
|
})
|
|
117
117
|
});
|
|
118
118
|
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { usePagedRows } from "../../data-grid/use-paged-rows.js";
|
|
3
|
+
import { useVirtualRows } from "../../data-grid/use-virtual-rows.js";
|
|
4
|
+
import { anchoredTop, cardColumns, cardWidth, recordRange } from "./card-layout.js";
|
|
5
|
+
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/app/displays/virtual-cards.tsx
|
|
8
|
+
/**
|
|
9
|
+
* Cards in rows as wide as the list, scrolled like the table: only the rows in view are drawn, over a scroller that
|
|
10
|
+
* holds any number of records, and the records are loaded in the table's pages, so a change of width keeps the loaded
|
|
11
|
+
* pages and the first record in view. Every card in a width is as tall as the tallest drawn so far.
|
|
12
|
+
*/
|
|
13
|
+
const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, children }) => {
|
|
14
|
+
const { total } = source;
|
|
15
|
+
const [width, setWidth] = useState(0);
|
|
16
|
+
const columns = cardColumns(width);
|
|
17
|
+
const itemWidth = cardWidth(width, columns);
|
|
18
|
+
const [measured, setMeasured] = useState({
|
|
19
|
+
width: 0,
|
|
20
|
+
height: 0
|
|
21
|
+
});
|
|
22
|
+
const itemHeight = Math.ceil(Math.max(minHeight(itemWidth), measured.width === itemWidth ? measured.height : 0));
|
|
23
|
+
const rowHeight = itemHeight + 12;
|
|
24
|
+
const layout = {
|
|
25
|
+
columns,
|
|
26
|
+
rowHeight
|
|
27
|
+
};
|
|
28
|
+
const anchor = useRef({
|
|
29
|
+
layout,
|
|
30
|
+
top: 0
|
|
31
|
+
});
|
|
32
|
+
const changed = anchor.current.layout.columns !== columns || anchor.current.layout.rowHeight !== rowHeight;
|
|
33
|
+
const settle = (top) => changed ? anchoredTop(anchor.current.top, anchor.current.layout, layout) : top;
|
|
34
|
+
const scroll = useVirtualRows({
|
|
35
|
+
total: Math.ceil(total / columns),
|
|
36
|
+
rowHeight,
|
|
37
|
+
initialIndex: Math.floor(initialIndex / columns),
|
|
38
|
+
settle
|
|
39
|
+
});
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
if (changed) scroll.scrollTo(scroll.top);
|
|
42
|
+
anchor.current = {
|
|
43
|
+
layout,
|
|
44
|
+
top: scroll.top
|
|
45
|
+
};
|
|
46
|
+
if (!changed) onScrollIndex(Math.floor(scroll.top / rowHeight) * columns);
|
|
47
|
+
});
|
|
48
|
+
const range = recordRange(scroll.first, scroll.last, columns, total);
|
|
49
|
+
const getRow = usePagedRows({
|
|
50
|
+
total,
|
|
51
|
+
rowAt: source.rowAt,
|
|
52
|
+
loadRows: source.loadRows,
|
|
53
|
+
latencyMs: 0
|
|
54
|
+
}, range.start, range.end);
|
|
55
|
+
const bodyRef = useRef(null);
|
|
56
|
+
useLayoutEffect(() => {
|
|
57
|
+
const body = bodyRef.current;
|
|
58
|
+
if (!body) return;
|
|
59
|
+
const observer = new ResizeObserver(() => setWidth(body.clientWidth));
|
|
60
|
+
observer.observe(body);
|
|
61
|
+
setWidth(body.clientWidth);
|
|
62
|
+
return () => observer.disconnect();
|
|
63
|
+
}, []);
|
|
64
|
+
const widthRef = useRef(itemWidth);
|
|
65
|
+
widthRef.current = itemWidth;
|
|
66
|
+
const [observer] = useState(() => new ResizeObserver((entries) => {
|
|
67
|
+
const tallest = Math.max(...entries.map((entry) => entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height));
|
|
68
|
+
const at = widthRef.current;
|
|
69
|
+
setMeasured((current) => current.width === at && current.height >= tallest ? current : {
|
|
70
|
+
width: at,
|
|
71
|
+
height: Math.max(tallest, current.width === at ? current.height : 0)
|
|
72
|
+
});
|
|
73
|
+
}));
|
|
74
|
+
useLayoutEffect(() => () => observer.disconnect(), [observer]);
|
|
75
|
+
const observe = useCallback((item) => {
|
|
76
|
+
if (!item) return;
|
|
77
|
+
observer.observe(item);
|
|
78
|
+
return () => observer.unobserve(item);
|
|
79
|
+
}, [observer]);
|
|
80
|
+
const firstShown = Math.min(total, Math.floor(scroll.top / rowHeight) * columns + 1);
|
|
81
|
+
const lastShown = Math.min(total, (Math.floor((scroll.top + Math.max(0, scroll.viewport - 1)) / rowHeight) + 1) * columns);
|
|
82
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
83
|
+
className: "flex min-h-0 flex-1 flex-col",
|
|
84
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
85
|
+
ref: scroll.ref,
|
|
86
|
+
onScroll: scroll.onScroll,
|
|
87
|
+
className: "relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
|
|
88
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
89
|
+
style: { height: scroll.geometry.contentHeight || void 0 },
|
|
90
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
91
|
+
ref: bodyRef,
|
|
92
|
+
className: "sticky top-0 overflow-clip",
|
|
93
|
+
style: { height: scroll.viewport },
|
|
94
|
+
children: /* @__PURE__ */ jsx("ul", {
|
|
95
|
+
className: "relative",
|
|
96
|
+
"aria-label": label,
|
|
97
|
+
children: scroll.rows.flatMap(({ index: rowIndex, offset }) => Array.from({ length: columns }, (_, column) => {
|
|
98
|
+
const index = rowIndex * columns + column;
|
|
99
|
+
if (index >= total) return [];
|
|
100
|
+
const row = getRow(index);
|
|
101
|
+
const place = {
|
|
102
|
+
top: offset,
|
|
103
|
+
left: column * (itemWidth + 12),
|
|
104
|
+
width: itemWidth,
|
|
105
|
+
minHeight: itemHeight
|
|
106
|
+
};
|
|
107
|
+
if (!row) return /* @__PURE__ */ jsx("li", {
|
|
108
|
+
"aria-hidden": true,
|
|
109
|
+
className: "absolute grid grid-cols-[minmax(0,1fr)]",
|
|
110
|
+
style: place,
|
|
111
|
+
children: /* @__PURE__ */ jsx(Skeleton, { className: "h-full rounded-lg" })
|
|
112
|
+
}, `skeleton-${index}`);
|
|
113
|
+
return /* @__PURE__ */ jsx("li", {
|
|
114
|
+
ref: observe,
|
|
115
|
+
"aria-posinset": index + 1,
|
|
116
|
+
"aria-setsize": total,
|
|
117
|
+
className: "absolute grid grid-cols-[minmax(0,1fr)]",
|
|
118
|
+
style: place,
|
|
119
|
+
children: children(row)
|
|
120
|
+
}, row.id);
|
|
121
|
+
}))
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
126
|
+
className: "py-3 text-xs tabular-nums text-muted-foreground",
|
|
127
|
+
children: [
|
|
128
|
+
firstShown.toLocaleString("en-IE"),
|
|
129
|
+
"–",
|
|
130
|
+
lastShown.toLocaleString("en-IE"),
|
|
131
|
+
" of ",
|
|
132
|
+
total.toLocaleString("en-IE")
|
|
133
|
+
]
|
|
134
|
+
})]
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
//#endregion
|
|
138
|
+
export { VirtualCards };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { humanize } from "../../live/naming.js";
|
|
2
|
+
import { displayRow } from "../displays/list-displays.js";
|
|
3
|
+
import { columnsFromModel } from "../../live/columns-from-model.js";
|
|
4
|
+
import { withFieldTypeCells } from "../field-types/field-type-cells.js";
|
|
5
|
+
import { scopeOf } from "../pages/use-layout-data.js";
|
|
6
|
+
import { FieldValue } from "../pages/field-value.js";
|
|
7
|
+
import { usePathValue } from "../pages/use-path-value.js";
|
|
8
|
+
import { jsx } from "react/jsx-runtime";
|
|
9
|
+
import { resolveFieldPath } from "@protobase/layout";
|
|
10
|
+
//#region src/app/embedded-list/embedded-columns.tsx
|
|
11
|
+
const isPath = (column) => column.includes(".");
|
|
12
|
+
/** A value through relations (`unit_id.property_id`), fetched like the record page fetches it and cached. */
|
|
13
|
+
const PathCell = ({ model, row, column }) => {
|
|
14
|
+
const value = usePathValue(scopeOf(model, displayRow(model, row).record), column);
|
|
15
|
+
if (value.state === "loading") return /* @__PURE__ */ jsx("span", {
|
|
16
|
+
className: "text-muted-foreground",
|
|
17
|
+
children: "…"
|
|
18
|
+
});
|
|
19
|
+
if (value.state === "missing") return /* @__PURE__ */ jsx("span", {
|
|
20
|
+
className: "text-muted-foreground",
|
|
21
|
+
children: "—"
|
|
22
|
+
});
|
|
23
|
+
return /* @__PURE__ */ jsx(FieldValue, {
|
|
24
|
+
model: value.model,
|
|
25
|
+
field: value.field,
|
|
26
|
+
value: value.value
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
/** A column through relations, headed by the label of the field it ends at. */
|
|
30
|
+
const pathColumn = (model, column, { resources, views }) => {
|
|
31
|
+
const resolved = resolveFieldPath(resources, model.name, column);
|
|
32
|
+
const last = resolved.ok ? resolved.steps.at(-1) : void 0;
|
|
33
|
+
const name = last?.field.name ?? column;
|
|
34
|
+
return {
|
|
35
|
+
id: column,
|
|
36
|
+
header: views[last?.model.name ?? model.name]?.fields[name]?.label ?? humanize(name),
|
|
37
|
+
kind: "text",
|
|
38
|
+
width: 200,
|
|
39
|
+
render: (row) => /* @__PURE__ */ jsx(PathCell, {
|
|
40
|
+
model,
|
|
41
|
+
row,
|
|
42
|
+
column
|
|
43
|
+
})
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
/** The list page's columns for `names`: fields of `model` as its table draws them, and paths through relations. */
|
|
47
|
+
const embeddedColumns = (model, view, names, meta) => {
|
|
48
|
+
const fields = new Map(withFieldTypeCells(columnsFromModel(model, view, [], names.filter((name) => !isPath(name))), model, view, meta.fieldTypes).map((spec) => [spec.id, spec]));
|
|
49
|
+
return names.flatMap((name) => isPath(name) ? [pathColumn(model, name, meta)] : fields.get(name) ?? []);
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { embeddedColumns };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { guarded } from "../../lib/guarded.js";
|
|
2
|
+
import { defaultDisplay, displayChoices } from "../displays/list-displays.js";
|
|
3
|
+
import { changedList, listDefaults } from "../current-address.js";
|
|
4
|
+
//#region src/app/embedded-list/embedded-display.ts
|
|
5
|
+
const prefix = "protobase:embedded-display:";
|
|
6
|
+
/** Where a viewer's pick of display is kept for one embedded list, named by the place it is shown. */
|
|
7
|
+
const embeddedDisplayKey = (place) => `${prefix}${place}`;
|
|
8
|
+
const readPickedDisplay = (storage, key) => guarded(() => storage.getItem(key) ?? void 0, void 0);
|
|
9
|
+
const writePickedDisplay = (storage, key, display) => guarded(() => storage.setItem(key, display), void 0);
|
|
10
|
+
/**
|
|
11
|
+
* The display an embedded list shows: the viewer's pick while the view still has it, else what the list opens in for
|
|
12
|
+
* the screen, with the display the page names in place of the view's default (below `sm`, a cards display when the
|
|
13
|
+
* view has one).
|
|
14
|
+
*/
|
|
15
|
+
const embeddedDisplay = (view, xs, { picked, preferred }) => {
|
|
16
|
+
const names = displayChoices(view).map((choice) => choice.name);
|
|
17
|
+
if (picked !== void 0 && names.includes(picked)) return picked;
|
|
18
|
+
return defaultDisplay(view, xs, preferred !== void 0 && names.includes(preferred) ? preferred : void 0);
|
|
19
|
+
};
|
|
20
|
+
/** The resource's list with the embedded list's filter, order and display, leaving out what the list does by default. */
|
|
21
|
+
const viewAllAddress = (model, view, xs, list) => changedList({
|
|
22
|
+
kind: "list",
|
|
23
|
+
resource: model.name
|
|
24
|
+
}, list, listDefaults(model, view, xs));
|
|
25
|
+
//#endregion
|
|
26
|
+
export { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, viewAllAddress, writePickedDisplay };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useIsDesktop, useIsXs } from "../../lib/use-media-query.js";
|
|
2
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
3
|
+
import { recordKey } from "../../live/model-helpers.js";
|
|
4
|
+
import { humanize } from "../../live/naming.js";
|
|
5
|
+
import { useProjectUi } from "../pages/project-ui.js";
|
|
6
|
+
import { displayRow, loadedFields, shownDisplay } from "../displays/list-displays.js";
|
|
7
|
+
import { defaultOrderBy, parseSort, sortToOrderBy } from "../list-order.js";
|
|
8
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
9
|
+
import { AppLink, useAppAddress } from "../use-app-address.js";
|
|
10
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
11
|
+
import { DataGrid } from "../../data-grid/data-grid.js";
|
|
12
|
+
import { listColumnNames } from "../../live/columns-from-model.js";
|
|
13
|
+
import { MapDisplay } from "../displays/map-display.js";
|
|
14
|
+
import { CustomDisplay } from "../displays/custom-display.js";
|
|
15
|
+
import { DisplaySwitch } from "../displays/display-controls.js";
|
|
16
|
+
import { CardsDisplay } from "../displays/cards-display.js";
|
|
17
|
+
import { embeddedColumns } from "./embedded-columns.js";
|
|
18
|
+
import { viewAllAddress } from "./embedded-display.js";
|
|
19
|
+
import { useEmbeddedRows } from "./use-embedded-rows.js";
|
|
20
|
+
import { useMemo, useState } from "react";
|
|
21
|
+
import { ArrowRight } from "lucide-react";
|
|
22
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
23
|
+
//#region src/app/embedded-list/embedded-list.tsx
|
|
24
|
+
/** The switch between the table and the view's displays, for an embedded list's heading; nothing with only a table. */
|
|
25
|
+
const EmbeddedDisplaySwitch = ({ view, value, onChange }) => /* @__PURE__ */ jsx(DisplaySwitch, {
|
|
26
|
+
view,
|
|
27
|
+
value,
|
|
28
|
+
onChange,
|
|
29
|
+
menu: !useIsDesktop()
|
|
30
|
+
});
|
|
31
|
+
const Loading = () => /* @__PURE__ */ jsx("div", {
|
|
32
|
+
className: "flex flex-col gap-2",
|
|
33
|
+
children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* The first `limit` records of a list, drawn by the list page's own table (sized to its rows) or display, and a link
|
|
37
|
+
* to the whole list with the same filter, order and display. On a phone a cards display is one row that scrolls sideways.
|
|
38
|
+
*/
|
|
39
|
+
const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty }) => {
|
|
40
|
+
const { resources, views } = useAdminMeta();
|
|
41
|
+
const { fields: fieldTypes } = useProjectUi();
|
|
42
|
+
const { go } = useAppAddress();
|
|
43
|
+
const xs = useIsXs();
|
|
44
|
+
const [orderBy, setOrderBy] = useState(sort || defaultOrderBy(model, view));
|
|
45
|
+
const names = columns ?? listColumnNames(model, view);
|
|
46
|
+
const specs = useMemo(() => embeddedColumns(model, view, names, {
|
|
47
|
+
resources,
|
|
48
|
+
views,
|
|
49
|
+
fieldTypes
|
|
50
|
+
}), [
|
|
51
|
+
model,
|
|
52
|
+
view,
|
|
53
|
+
names.join(","),
|
|
54
|
+
resources,
|
|
55
|
+
views,
|
|
56
|
+
fieldTypes
|
|
57
|
+
]);
|
|
58
|
+
const fields = useMemo(() => loadedFields(model, view, specs.map((spec) => spec.id)), [
|
|
59
|
+
model,
|
|
60
|
+
view,
|
|
61
|
+
specs
|
|
62
|
+
]);
|
|
63
|
+
const rows = useEmbeddedRows({
|
|
64
|
+
model,
|
|
65
|
+
filter,
|
|
66
|
+
orderBy,
|
|
67
|
+
limit,
|
|
68
|
+
fields
|
|
69
|
+
});
|
|
70
|
+
const loaded = rows.data;
|
|
71
|
+
const source = useMemo(() => loaded && {
|
|
72
|
+
total: loaded.length,
|
|
73
|
+
rowAt: (index) => loaded[index]
|
|
74
|
+
}, [loaded]);
|
|
75
|
+
const shown = shownDisplay(view, display);
|
|
76
|
+
if (rows.isPending) return /* @__PURE__ */ jsx(Loading, {});
|
|
77
|
+
if (rows.error) return /* @__PURE__ */ jsx(ErrorBanner, {
|
|
78
|
+
title: "Could not load",
|
|
79
|
+
error: rows.error
|
|
80
|
+
});
|
|
81
|
+
if (!source || source.total === 0) return /* @__PURE__ */ jsx("p", {
|
|
82
|
+
className: "text-[13px] text-muted-foreground",
|
|
83
|
+
children: empty ?? "Nothing here yet."
|
|
84
|
+
});
|
|
85
|
+
const list = {
|
|
86
|
+
resource: model.name,
|
|
87
|
+
rows: rows.data.map((row) => displayRow(model, row)),
|
|
88
|
+
total: rows.data.length,
|
|
89
|
+
loading: false,
|
|
90
|
+
error: null,
|
|
91
|
+
more: false,
|
|
92
|
+
loadMore: () => void 0
|
|
93
|
+
};
|
|
94
|
+
const openRecord = (row) => go({
|
|
95
|
+
kind: "record",
|
|
96
|
+
resource: model.name,
|
|
97
|
+
key: recordKey(model, row)
|
|
98
|
+
});
|
|
99
|
+
const singular = view?.names?.singular ?? humanize(model.name);
|
|
100
|
+
const plural = view?.names?.plural ?? humanize(model.name);
|
|
101
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
102
|
+
className: "flex flex-col gap-3",
|
|
103
|
+
children: [!shown ? /* @__PURE__ */ jsx(DataGrid, {
|
|
104
|
+
fit: true,
|
|
105
|
+
columns: specs,
|
|
106
|
+
source,
|
|
107
|
+
latencyMs: 0,
|
|
108
|
+
sort: parseSort(orderBy),
|
|
109
|
+
onSortChange: (next) => setOrderBy(sortToOrderBy(next)),
|
|
110
|
+
onOpenRow: openRecord,
|
|
111
|
+
rowActions: [{
|
|
112
|
+
label: `Open ${singular.toLowerCase()}`,
|
|
113
|
+
onSelect: openRecord
|
|
114
|
+
}],
|
|
115
|
+
rowNumbers: false,
|
|
116
|
+
selectable: false
|
|
117
|
+
}) : shown.kind === "cards" ? /* @__PURE__ */ jsx(CardsDisplay, {
|
|
118
|
+
model,
|
|
119
|
+
view,
|
|
120
|
+
display: shown,
|
|
121
|
+
list,
|
|
122
|
+
row: xs,
|
|
123
|
+
footer: false
|
|
124
|
+
}) : shown.kind === "map" ? /* @__PURE__ */ jsx(MapDisplay, {
|
|
125
|
+
model,
|
|
126
|
+
view,
|
|
127
|
+
display: shown,
|
|
128
|
+
list,
|
|
129
|
+
footer: false
|
|
130
|
+
}) : /* @__PURE__ */ jsx(CustomDisplay, {
|
|
131
|
+
display: shown,
|
|
132
|
+
list
|
|
133
|
+
}), /* @__PURE__ */ jsxs(AppLink, {
|
|
134
|
+
to: viewAllAddress(model, view, xs, {
|
|
135
|
+
filter,
|
|
136
|
+
orderBy,
|
|
137
|
+
display
|
|
138
|
+
}),
|
|
139
|
+
className: "flex items-center gap-1 self-end text-[13px] font-medium text-primary-text hover:underline",
|
|
140
|
+
children: [
|
|
141
|
+
"View all ",
|
|
142
|
+
/* @__PURE__ */ jsx("span", {
|
|
143
|
+
className: "sr-only",
|
|
144
|
+
children: plural.toLowerCase()
|
|
145
|
+
}),
|
|
146
|
+
/* @__PURE__ */ jsx(ArrowRight, {
|
|
147
|
+
className: "size-3.5",
|
|
148
|
+
"aria-hidden": true
|
|
149
|
+
})
|
|
150
|
+
]
|
|
151
|
+
})]
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
//#endregion
|
|
155
|
+
export { EmbeddedDisplaySwitch, EmbeddedList };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useIsXs } from "../../lib/use-media-query.js";
|
|
2
|
+
import { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, writePickedDisplay } from "./embedded-display.js";
|
|
3
|
+
import { useCallback, useState } from "react";
|
|
4
|
+
//#region src/app/embedded-list/use-embedded-display.ts
|
|
5
|
+
/**
|
|
6
|
+
* The display an embedded list shows and the way to pick another, which is kept in local storage for the viewer under
|
|
7
|
+
* `place`, the list's place in the app.
|
|
8
|
+
*/
|
|
9
|
+
const useEmbeddedDisplay = (view, place, preferred) => {
|
|
10
|
+
const xs = useIsXs();
|
|
11
|
+
const key = embeddedDisplayKey(place);
|
|
12
|
+
const [picked, setPicked] = useState(() => readPickedDisplay(localStorage, key));
|
|
13
|
+
const pick = useCallback((display) => {
|
|
14
|
+
setPicked(display);
|
|
15
|
+
writePickedDisplay(localStorage, key, display);
|
|
16
|
+
}, [key]);
|
|
17
|
+
return [embeddedDisplay(view, xs, {
|
|
18
|
+
picked,
|
|
19
|
+
preferred
|
|
20
|
+
}), pick];
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { useEmbeddedDisplay };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../../data/api-provider.js";
|
|
3
|
+
import { recordId } from "../../live/model-helpers.js";
|
|
4
|
+
import { withLabels } from "../../live/relation-labels.js";
|
|
5
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
6
|
+
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
7
|
+
//#region src/app/embedded-list/use-embedded-rows.ts
|
|
8
|
+
/**
|
|
9
|
+
* The first `limit` records in `orderBy` order, as the list page's table and displays get them: relations among
|
|
10
|
+
* `fields` as `{ id, name }`. A filter through a join resource may hold hundreds of keys, so it goes in a JSON body.
|
|
11
|
+
*/
|
|
12
|
+
const useEmbeddedRows = ({ model, filter, orderBy, limit, fields }) => {
|
|
13
|
+
const client = useClient();
|
|
14
|
+
const queryClient = useQueryClient();
|
|
15
|
+
const { resources, views } = useAdminMeta();
|
|
16
|
+
return useQuery({
|
|
17
|
+
queryKey: [...keys.list(model.name, filter, orderBy, limit, "embedded"), fields.join(",")],
|
|
18
|
+
placeholderData: keepPreviousData,
|
|
19
|
+
queryFn: async () => {
|
|
20
|
+
const page = await client.search(model.name, {
|
|
21
|
+
filter: filter || void 0,
|
|
22
|
+
orderBy: orderBy || void 0,
|
|
23
|
+
pageSize: limit
|
|
24
|
+
});
|
|
25
|
+
return (await withLabels(queryClient, client, resources, views, model, fields, page.items)).map((row, index) => ({
|
|
26
|
+
...row,
|
|
27
|
+
id: recordId(model, page.items[index])
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { useEmbeddedRows };
|
package/dist/app/list-page.js
CHANGED
|
@@ -26,11 +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
30
|
import { MapDisplay } from "./displays/map-display.js";
|
|
31
31
|
import { CustomDisplay } from "./displays/custom-display.js";
|
|
32
32
|
import { DisplaySwitch, SortMenu } from "./displays/display-controls.js";
|
|
33
33
|
import { useDisplayRows } from "./displays/use-display-rows.js";
|
|
34
|
+
import { useListScroll } from "./list-scroll.js";
|
|
34
35
|
import { ListChart } from "./list-chart.js";
|
|
35
36
|
import { ListSearch } from "./list-search.js";
|
|
36
37
|
import { withFieldTypeCells } from "./field-types/field-type-cells.js";
|
|
@@ -98,7 +99,8 @@ const LoadedList = ({ model, address }) => {
|
|
|
98
99
|
orderBy
|
|
99
100
|
});
|
|
100
101
|
const display = shownDisplay(view, address.display);
|
|
101
|
-
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();
|
|
102
104
|
const titleField = recordTitleField(model, view);
|
|
103
105
|
const target = (row) => ({
|
|
104
106
|
key: row.id,
|
|
@@ -234,6 +236,8 @@ const LoadedList = ({ model, address }) => {
|
|
|
234
236
|
sort: parseSort(orderBy),
|
|
235
237
|
onSortChange: (sort) => set({ orderBy: sortToOrderBy(sort) }),
|
|
236
238
|
onOpenRow: openRecord,
|
|
239
|
+
initialScrollIndex: scroll.initialIndex,
|
|
240
|
+
onScrollIndex: scroll.remember,
|
|
237
241
|
rowActions: [{
|
|
238
242
|
label: `Open ${singular.toLowerCase()}`,
|
|
239
243
|
onSelect: openRecord
|
|
@@ -252,14 +256,19 @@ const LoadedList = ({ model, address }) => {
|
|
|
252
256
|
pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
|
|
253
257
|
className: "min-h-0 flex-1"
|
|
254
258
|
}) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
|
|
259
|
+
const cardsList = {
|
|
260
|
+
...rows,
|
|
261
|
+
initialIndex: scroll.initialIndex,
|
|
262
|
+
onScrollIndex: scroll.remember
|
|
263
|
+
};
|
|
255
264
|
const drawDisplay = () => {
|
|
256
265
|
if (!display) return table;
|
|
257
|
-
if (display.kind === "cards") return /* @__PURE__ */ jsx(
|
|
266
|
+
if (display.kind === "cards") return /* @__PURE__ */ jsx(ListCards, {
|
|
258
267
|
model,
|
|
259
268
|
view,
|
|
260
269
|
display,
|
|
261
|
-
list:
|
|
262
|
-
});
|
|
270
|
+
list: cardsList
|
|
271
|
+
}, rows.key.join(" "));
|
|
263
272
|
if (display.kind === "map") return /* @__PURE__ */ jsx(MapDisplay, {
|
|
264
273
|
model,
|
|
265
274
|
view,
|