@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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Badge } from "../../primitives/badge.js";
|
|
2
|
+
import { humanize } from "../../live/naming.js";
|
|
3
|
+
import { useProjectUi } from "../pages/project-ui.js";
|
|
4
|
+
import { fileShown, isPreviewable } from "../../live/file-values.js";
|
|
5
|
+
import { displayValue } from "../../live/display-value.js";
|
|
6
|
+
import { amountParts, cardFields, filledAmount, filledMeta } from "./list-displays.js";
|
|
7
|
+
import { AppLink } from "../use-app-address.js";
|
|
8
|
+
import { shownWidget } from "../field-types/shown-widget.js";
|
|
9
|
+
import { CustomBoundary } from "../pages/custom-boundary.js";
|
|
10
|
+
import { WidgetPart } from "../field-types/widget-part.js";
|
|
11
|
+
import { namedIcon } from "../named-icon.js";
|
|
12
|
+
import { ValueBadge } from "../value-badge.js";
|
|
13
|
+
import { CardCarousel } from "./card-carousel.js";
|
|
14
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
15
|
+
//#region src/app/displays/record-card.tsx
|
|
16
|
+
const CardImage = ({ value }) => {
|
|
17
|
+
const file = fileShown(value);
|
|
18
|
+
const url = file && "url" in file && typeof file.url === "string" && isPreviewable(file.type) ? file.url : void 0;
|
|
19
|
+
return /* @__PURE__ */ jsx("span", {
|
|
20
|
+
className: "-mx-3 -mt-3 mb-1.5 block aspect-[4/3] overflow-hidden rounded-t-lg border-b border-border bg-muted",
|
|
21
|
+
children: url && /* @__PURE__ */ jsx("img", {
|
|
22
|
+
src: url,
|
|
23
|
+
alt: "",
|
|
24
|
+
loading: "lazy",
|
|
25
|
+
className: "size-full object-cover"
|
|
26
|
+
})
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
const MetaValue = ({ icon, children }) => {
|
|
30
|
+
const Icon = namedIcon(icon);
|
|
31
|
+
return /* @__PURE__ */ jsxs("dd", {
|
|
32
|
+
className: "flex items-center gap-1 tabular-nums",
|
|
33
|
+
children: [Icon && /* @__PURE__ */ jsx(Icon, {
|
|
34
|
+
className: "size-3.5 shrink-0",
|
|
35
|
+
"aria-hidden": true
|
|
36
|
+
}), children]
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* A record's card of a cards display, opening its page: its image across the top, with arrows through its photos when
|
|
41
|
+
* the display names them, the title over the subtitle, the badge beside it, the first amount with a value in bold, small
|
|
42
|
+
* meta fields below, those with a value, the labels of its flags that are true and the footer's fields with a value,
|
|
43
|
+
* joined by bullets; or, with a `card` component, what it draws of the row, the built-in content again when it is
|
|
44
|
+
* missing or throws.
|
|
45
|
+
*/
|
|
46
|
+
const RecordCard = ({ model, view, display, row }) => {
|
|
47
|
+
const { image, title, subtitle, amount, badge, meta, footer, flags } = cardFields(model, view, display);
|
|
48
|
+
const label = (name) => view?.fields[name]?.label ?? humanize(name);
|
|
49
|
+
const text = (name) => {
|
|
50
|
+
const field = model.fields[name];
|
|
51
|
+
if (!field) return "";
|
|
52
|
+
return row.labels[name] ?? displayValue(field, row.record[name], view?.fields[name]);
|
|
53
|
+
};
|
|
54
|
+
const { fields: fieldTypes, components } = useProjectUi();
|
|
55
|
+
const Card = display.card === void 0 ? void 0 : components?.[display.card];
|
|
56
|
+
const shownValue = (name) => {
|
|
57
|
+
const field = model.fields[name];
|
|
58
|
+
const shown = field && shownWidget(fieldTypes, field, view?.fields[name]);
|
|
59
|
+
return shown ? /* @__PURE__ */ jsx(WidgetPart, {
|
|
60
|
+
shown,
|
|
61
|
+
part: "display",
|
|
62
|
+
field,
|
|
63
|
+
value: row.record[name],
|
|
64
|
+
fallback: text(name)
|
|
65
|
+
}) : text(name);
|
|
66
|
+
};
|
|
67
|
+
const metaValue = (name) => {
|
|
68
|
+
const field = model.fields[name];
|
|
69
|
+
const hints = view?.fields[name];
|
|
70
|
+
const value = row.record[name];
|
|
71
|
+
if (!field || hints?.format !== "badge" || shownWidget(fieldTypes, field, hints) || value === null || value === void 0 || value === "") return shownValue(name);
|
|
72
|
+
return /* @__PURE__ */ jsx(ValueBadge, {
|
|
73
|
+
value,
|
|
74
|
+
hints,
|
|
75
|
+
children: text(name)
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
const amountValue = (name) => {
|
|
79
|
+
const field = model.fields[name];
|
|
80
|
+
if (!field) return void 0;
|
|
81
|
+
if (row.labels[name] !== void 0 || shownWidget(fieldTypes, field, view?.fields[name])) return /* @__PURE__ */ jsx("strong", {
|
|
82
|
+
className: "font-semibold text-foreground",
|
|
83
|
+
children: shownValue(name)
|
|
84
|
+
});
|
|
85
|
+
const parts = amountParts(field, row.record[name], view?.fields[name]);
|
|
86
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("strong", {
|
|
87
|
+
className: "font-semibold text-foreground",
|
|
88
|
+
children: parts.amount
|
|
89
|
+
}), parts.suffix] });
|
|
90
|
+
};
|
|
91
|
+
const photos = Card ? void 0 : display.photos;
|
|
92
|
+
const status = badge === void 0 ? void 0 : row.record[badge];
|
|
93
|
+
const raised = flags.filter((name) => row.record[name] === true);
|
|
94
|
+
const cover = image === void 0 ? void 0 : row.record[image];
|
|
95
|
+
const shownAmount = filledAmount(row, amount);
|
|
96
|
+
const footing = filledMeta(row, footer);
|
|
97
|
+
const builtIn = (photo) => /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
98
|
+
(image !== void 0 || photos !== void 0) && /* @__PURE__ */ jsx(CardImage, { value: photo }),
|
|
99
|
+
/* @__PURE__ */ jsxs("span", {
|
|
100
|
+
className: "flex items-start justify-between gap-2",
|
|
101
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
102
|
+
className: "min-w-0 truncate font-medium",
|
|
103
|
+
children: shownValue(title)
|
|
104
|
+
}), badge !== void 0 && status !== null && status !== void 0 && /* @__PURE__ */ jsx(ValueBadge, {
|
|
105
|
+
value: status,
|
|
106
|
+
hints: view?.fields[badge],
|
|
107
|
+
children: text(badge)
|
|
108
|
+
})]
|
|
109
|
+
}),
|
|
110
|
+
subtitle !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
111
|
+
className: "truncate text-muted-foreground",
|
|
112
|
+
children: shownValue(subtitle)
|
|
113
|
+
}),
|
|
114
|
+
shownAmount !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
115
|
+
className: "truncate tabular-nums text-muted-foreground",
|
|
116
|
+
children: amountValue(shownAmount)
|
|
117
|
+
}),
|
|
118
|
+
meta.length > 0 && /* @__PURE__ */ jsx("dl", {
|
|
119
|
+
className: "mt-auto flex flex-wrap items-center gap-x-3 gap-y-0.5 pt-1 text-xs text-muted-foreground",
|
|
120
|
+
children: filledMeta(row, meta).map((name) => /* @__PURE__ */ jsxs("div", {
|
|
121
|
+
className: "flex gap-1",
|
|
122
|
+
children: [/* @__PURE__ */ jsx("dt", {
|
|
123
|
+
className: "sr-only",
|
|
124
|
+
children: label(name)
|
|
125
|
+
}), /* @__PURE__ */ jsx(MetaValue, {
|
|
126
|
+
icon: view?.fields[name]?.icon,
|
|
127
|
+
children: metaValue(name)
|
|
128
|
+
})]
|
|
129
|
+
}, name))
|
|
130
|
+
}),
|
|
131
|
+
raised.length > 0 && /* @__PURE__ */ jsx("ul", {
|
|
132
|
+
className: "flex flex-wrap gap-1",
|
|
133
|
+
"aria-label": "Features",
|
|
134
|
+
children: raised.map((name) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(Badge, {
|
|
135
|
+
dot: false,
|
|
136
|
+
children: label(name)
|
|
137
|
+
}) }, name))
|
|
138
|
+
}),
|
|
139
|
+
footing.length > 0 && /* @__PURE__ */ jsx("span", {
|
|
140
|
+
className: `truncate text-xs text-muted-foreground ${meta.length > 0 ? "" : "mt-auto"}`,
|
|
141
|
+
children: footing.map((name, index) => /* @__PURE__ */ jsxs("span", { children: [index > 0 && /* @__PURE__ */ jsx("span", {
|
|
142
|
+
"aria-hidden": true,
|
|
143
|
+
children: " • "
|
|
144
|
+
}), shownValue(name)] }, name))
|
|
145
|
+
})
|
|
146
|
+
] });
|
|
147
|
+
const card = (photo) => /* @__PURE__ */ jsx(AppLink, {
|
|
148
|
+
to: {
|
|
149
|
+
kind: "record",
|
|
150
|
+
resource: model.name,
|
|
151
|
+
key: row.key
|
|
152
|
+
},
|
|
153
|
+
className: "flex h-full flex-col gap-1.5 rounded-lg border border-border bg-background p-3 text-[13px] hover:border-border-strong hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
|
|
154
|
+
children: Card ? /* @__PURE__ */ jsx(CustomBoundary, {
|
|
155
|
+
name: display.card,
|
|
156
|
+
fallback: builtIn(photo),
|
|
157
|
+
children: /* @__PURE__ */ jsx(Card, { row })
|
|
158
|
+
}) : builtIn(photo)
|
|
159
|
+
});
|
|
160
|
+
return /* @__PURE__ */ jsx("div", {
|
|
161
|
+
className: photos ? "group/card relative h-full" : "h-full",
|
|
162
|
+
children: photos ? /* @__PURE__ */ jsx(CardCarousel, {
|
|
163
|
+
photos,
|
|
164
|
+
recordKey: row.key,
|
|
165
|
+
cover,
|
|
166
|
+
children: card
|
|
167
|
+
}) : card(cover)
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
//#endregion
|
|
171
|
+
export { RecordCard };
|
|
@@ -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/field-editor.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
2
|
import { Input } from "../primitives/input.js";
|
|
3
3
|
import { Switch } from "../primitives/switch.js";
|
|
4
|
+
import { displayValue } from "../live/display-value.js";
|
|
4
5
|
import { AppLink } from "./use-app-address.js";
|
|
5
6
|
import { RelationPicker } from "./relation-picker.js";
|
|
6
|
-
import { displayValue } from "../live/display-value.js";
|
|
7
7
|
import { fromDraft, isEditable, toFieldDraft } from "../live/field-values.js";
|
|
8
8
|
import { SensitiveField } from "./sensitive-field.js";
|
|
9
9
|
import { FileField } from "./file-field.js";
|