@protobase/ui 0.12.22 → 0.13.3
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/create/create-page.js +1 -0
- package/dist/app/current-address.d.ts +3 -2
- package/dist/app/current-address.js +2 -1
- package/dist/app/delete/use-delete-flow.js +2 -1
- package/dist/app/displays/card-carousel.js +4 -1
- package/dist/app/displays/card-layout.js +19 -0
- package/dist/app/displays/card-photos.js +6 -3
- 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 +83 -23
- package/dist/app/displays/map-groups.js +1 -8
- package/dist/app/displays/record-card.js +8 -6
- package/dist/app/displays/use-map-records.js +44 -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 +157 -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/file-field.js +16 -13
- package/dist/app/list-page.js +19 -6
- package/dist/app/list-scroll.js +25 -0
- package/dist/app/map/map-area.js +53 -0
- package/dist/app/map/records-map.js +91 -38
- package/dist/app/pages/blocks/table.js +21 -41
- package/dist/app/pages/field-value.js +22 -9
- 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/app/use-record-editor.js +2 -1
- package/dist/data/api-provider.d.ts +2 -0
- package/dist/data/query-keys.js +9 -0
- package/dist/data/use-update-record.js +1 -0
- package/dist/data-grid/cells.js +8 -5
- 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/file-image.js +35 -0
- package/dist/live/file-values.js +17 -1
- 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-CfuMqqAY.js +66 -0
- package/dist/shell/assets/index-CYWfU_c1.css +2 -0
- package/dist/shell/assets/{index-B8MhIaLr.js → index-DCTyUhH-.js} +3 -3
- package/dist/shell/assets/maplibre-gl-DP9hPAtI.js +1 -0
- package/dist/shell/assets/record-map-Dgn4pFlE.js +814 -0
- package/dist/shell/assets/records-map-3O6u05bx.js +1 -0
- package/dist/shell/assets/shared-protobase-ui-C84DU2o6.js +1 -0
- package/dist/shell/index.html +5 -5
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +5 -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
|
@@ -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,14 +2,15 @@ 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 { fileShown } from "../../live/file-values.js";
|
|
5
|
+
import { fileShown, shownImage } from "../../live/file-values.js";
|
|
6
6
|
import { displayValue } from "../../live/display-value.js";
|
|
7
7
|
import { useAdminMeta } from "../meta-gate.js";
|
|
8
8
|
import { AppLink } from "../use-app-address.js";
|
|
9
|
+
import { FileImage } from "../../live/file-image.js";
|
|
9
10
|
import { useShownWidget } from "../field-types/shown-widget.js";
|
|
10
11
|
import { WidgetPart } from "../field-types/widget-part.js";
|
|
11
12
|
import { ValueBadge } from "../value-badge.js";
|
|
12
|
-
import { jsx } from "react/jsx-runtime";
|
|
13
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
13
14
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
14
15
|
//#region src/app/pages/field-value.tsx
|
|
15
16
|
const RelationValue = ({ field, value, known }) => {
|
|
@@ -50,13 +51,25 @@ const BuiltInValue = ({ model, field, value, format, relationLabel }) => {
|
|
|
50
51
|
known: relationLabel
|
|
51
52
|
});
|
|
52
53
|
const file = field.type === "file" ? fileShown(value) : void 0;
|
|
53
|
-
if (file && "url" in file && file.url)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
if (file && "url" in file && file.url) {
|
|
55
|
+
const image = shownImage(value, {
|
|
56
|
+
width: 40,
|
|
57
|
+
height: 40
|
|
58
|
+
});
|
|
59
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
60
|
+
className: "inline-flex min-w-0 items-center gap-2",
|
|
61
|
+
children: [image && /* @__PURE__ */ jsx(FileImage, {
|
|
62
|
+
image,
|
|
63
|
+
className: "size-10 shrink-0 rounded border border-border object-cover"
|
|
64
|
+
}), /* @__PURE__ */ jsx("a", {
|
|
65
|
+
href: file.url,
|
|
66
|
+
target: "_blank",
|
|
67
|
+
rel: "noreferrer",
|
|
68
|
+
className: "truncate font-medium text-primary-text hover:underline",
|
|
69
|
+
children: file.name
|
|
70
|
+
})]
|
|
71
|
+
});
|
|
72
|
+
}
|
|
60
73
|
const text = displayValue(field, value, hints);
|
|
61
74
|
if (field.type === "enum" || hints.format === "badge") return /* @__PURE__ */ jsx(ValueBadge, {
|
|
62
75
|
value,
|
package/dist/app/record-page.js
CHANGED
|
@@ -292,7 +292,8 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
292
292
|
children: [layout.flatMap((item, index) => {
|
|
293
293
|
if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
|
|
294
294
|
item,
|
|
295
|
-
recordKey
|
|
295
|
+
recordKey,
|
|
296
|
+
owner: model.name
|
|
296
297
|
}, `related:${item.title}`)];
|
|
297
298
|
if (item.kind === "custom") return item.sidebar ? [] : [/* @__PURE__ */ jsx(CustomSection, {
|
|
298
299
|
item,
|
|
@@ -4,17 +4,20 @@ import { useClient } from "../data/api-provider.js";
|
|
|
4
4
|
import { useAdminMeta } from "./meta-gate.js";
|
|
5
5
|
import { ErrorBanner } from "./error-banner.js";
|
|
6
6
|
import { Section } from "../record-view/section.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { listColumnNames } from "../live/columns-from-model.js";
|
|
8
|
+
import { EmbeddedDisplaySwitch, EmbeddedList } from "./embedded-list/embedded-list.js";
|
|
9
|
+
import { useEmbeddedDisplay } from "./embedded-list/use-embedded-display.js";
|
|
10
10
|
import { jsx } from "react/jsx-runtime";
|
|
11
|
-
import {
|
|
11
|
+
import { useQuery } from "@tanstack/react-query";
|
|
12
12
|
import { createWhere, printFilter } from "@protobase/schema";
|
|
13
13
|
//#region src/app/related-section.tsx
|
|
14
14
|
const where = createWhere();
|
|
15
15
|
/** The most join records a section reads to find its records; the list API's largest page. */
|
|
16
16
|
const maxThrough = 500;
|
|
17
|
-
const and = (...parts) =>
|
|
17
|
+
const and = (...parts) => {
|
|
18
|
+
const given = parts.filter((part) => Boolean(part));
|
|
19
|
+
return given.length === 1 ? given[0] : given.map((part) => `(${part})`).join(" AND ");
|
|
20
|
+
};
|
|
18
21
|
/**
|
|
19
22
|
* The filter on the section's resource: its `field` is this record's key, or, through a join resource, one of the keys
|
|
20
23
|
* the join records that point at this record hold. `null` when no join record does, so nothing can match.
|
|
@@ -54,47 +57,6 @@ const useMatch = (item, recordKey) => {
|
|
|
54
57
|
error: null
|
|
55
58
|
};
|
|
56
59
|
};
|
|
57
|
-
const RelatedRows = ({ item, model, filter }) => {
|
|
58
|
-
const client = useClient();
|
|
59
|
-
const { views } = useAdminMeta();
|
|
60
|
-
const [tokens, setTokens] = useState([""]);
|
|
61
|
-
const pageSize = item.pageSize ?? 10;
|
|
62
|
-
const sort = item.sort ?? "";
|
|
63
|
-
const token = tokens.at(-1) ?? "";
|
|
64
|
-
const page = useQuery({
|
|
65
|
-
queryKey: keys.list(model.name, filter, sort, pageSize, token),
|
|
66
|
-
placeholderData: keepPreviousData,
|
|
67
|
-
queryFn: () => client.search(model.name, {
|
|
68
|
-
filter,
|
|
69
|
-
orderBy: sort || void 0,
|
|
70
|
-
pageSize,
|
|
71
|
-
pageToken: token || void 0
|
|
72
|
-
})
|
|
73
|
-
});
|
|
74
|
-
if (page.isPending) return /* @__PURE__ */ jsx(Loading, {});
|
|
75
|
-
if (page.error) return /* @__PURE__ */ jsx(ErrorBanner, {
|
|
76
|
-
title: "Could not load",
|
|
77
|
-
error: page.error
|
|
78
|
-
});
|
|
79
|
-
const rows = page.data.items;
|
|
80
|
-
if (rows.length === 0 && tokens.length === 1) return /* @__PURE__ */ jsx(Empty, { text: item.empty });
|
|
81
|
-
const columns = (item.columns ?? defaultColumns(model, views[model.name]).filter((name) => name !== item.field)).filter((name) => name.includes(".") || Object.hasOwn(model.fields, name));
|
|
82
|
-
const next = page.data.nextPageToken;
|
|
83
|
-
return /* @__PURE__ */ jsx(RecordTable, {
|
|
84
|
-
model,
|
|
85
|
-
columns,
|
|
86
|
-
rows,
|
|
87
|
-
label: item.title,
|
|
88
|
-
pager: {
|
|
89
|
-
page: tokens.length,
|
|
90
|
-
hasPrevious: tokens.length > 1,
|
|
91
|
-
hasNext: Boolean(next),
|
|
92
|
-
busy: page.isFetching,
|
|
93
|
-
previous: () => setTokens((stack) => stack.slice(0, -1)),
|
|
94
|
-
next: () => next && setTokens((stack) => [...stack, next])
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
};
|
|
98
60
|
const Loading = () => /* @__PURE__ */ jsx("div", {
|
|
99
61
|
className: "flex flex-col gap-2",
|
|
100
62
|
children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
|
|
@@ -103,22 +65,38 @@ const Empty = ({ text }) => /* @__PURE__ */ jsx("p", {
|
|
|
103
65
|
className: "text-[13px] text-muted-foreground",
|
|
104
66
|
children: text ?? "Nothing here yet."
|
|
105
67
|
});
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
68
|
+
/**
|
|
69
|
+
* A record page section listing the first records of another resource that point at this record, directly or through
|
|
70
|
+
* a join resource, in the list page's table or display, with a link to them all in the list.
|
|
71
|
+
*/
|
|
72
|
+
const RelatedSection = ({ item, recordKey, owner }) => {
|
|
73
|
+
const { resources, views } = useAdminMeta();
|
|
109
74
|
const model = resources[item.resource];
|
|
75
|
+
const view = model && views[model.name];
|
|
110
76
|
const match = useMatch(item, recordKey);
|
|
77
|
+
const [display, pick] = useEmbeddedDisplay(view, `related:${owner}:${item.title}`, item.display);
|
|
111
78
|
if (!model) return null;
|
|
79
|
+
const columns = item.columns ?? listColumnNames(model, view).filter((name) => name !== item.field);
|
|
112
80
|
return /* @__PURE__ */ jsx(Section, {
|
|
113
81
|
title: item.title,
|
|
114
82
|
help: item.help,
|
|
83
|
+
actions: /* @__PURE__ */ jsx(EmbeddedDisplaySwitch, {
|
|
84
|
+
view,
|
|
85
|
+
value: display,
|
|
86
|
+
onChange: pick
|
|
87
|
+
}),
|
|
115
88
|
children: match.isPending ? /* @__PURE__ */ jsx(Loading, {}) : match.error ? /* @__PURE__ */ jsx(ErrorBanner, {
|
|
116
89
|
title: "Could not load",
|
|
117
90
|
error: match.error
|
|
118
|
-
}) : match.filter === null || match.filter === void 0 ? /* @__PURE__ */ jsx(Empty, { text: item.empty }) : /* @__PURE__ */ jsx(
|
|
119
|
-
item,
|
|
91
|
+
}) : match.filter === null || match.filter === void 0 ? /* @__PURE__ */ jsx(Empty, { text: item.empty }) : /* @__PURE__ */ jsx(EmbeddedList, {
|
|
120
92
|
model,
|
|
121
|
-
|
|
93
|
+
view,
|
|
94
|
+
filter: match.filter,
|
|
95
|
+
sort: item.sort ?? "",
|
|
96
|
+
columns,
|
|
97
|
+
limit: item.limit ?? 10,
|
|
98
|
+
display,
|
|
99
|
+
empty: item.empty
|
|
122
100
|
}, match.filter)
|
|
123
101
|
});
|
|
124
102
|
};
|
package/dist/app/router.js
CHANGED
|
@@ -106,7 +106,8 @@ const useRecordEditor = ({ model, stored, recordKey, mode, subject, draftKey, li
|
|
|
106
106
|
await Promise.all([
|
|
107
107
|
queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) }),
|
|
108
108
|
queryClient.invalidateQueries({ queryKey: ["list"] }),
|
|
109
|
-
queryClient.invalidateQueries({ queryKey: ["page"] })
|
|
109
|
+
queryClient.invalidateQueries({ queryKey: ["page"] }),
|
|
110
|
+
queryClient.invalidateQueries({ queryKey: ["map"] })
|
|
110
111
|
]);
|
|
111
112
|
lines?.reset();
|
|
112
113
|
};
|
|
@@ -28,6 +28,7 @@ export declare const useClient: () => {
|
|
|
28
28
|
filter?: import("@protobase/client").FilterInput;
|
|
29
29
|
buckets?: number;
|
|
30
30
|
}) => Promise<import("@protobase/client").Histogram>;
|
|
31
|
+
map: <R extends import("@protobase/client").ResourceRef>(resource: R, params: import("@protobase/client").MapParams) => Promise<import("@protobase/client").MapResult<import("@protobase/client").RecordOf<R>>>;
|
|
31
32
|
seek: <R extends import("@protobase/client").ResourceRef>(resource: R, params: import("@protobase/client").ListParams & {
|
|
32
33
|
position: number;
|
|
33
34
|
}) => Promise<import("@protobase/client").ListPage<import("@protobase/client").RecordOf<R>>>;
|
|
@@ -66,6 +67,7 @@ export declare const useResourceClient: (resource: string) => {
|
|
|
66
67
|
filter?: import("@protobase/client").FilterInput;
|
|
67
68
|
buckets?: number;
|
|
68
69
|
}) => Promise<import("@protobase/client").Histogram>;
|
|
70
|
+
map: <R extends import("@protobase/client").ResourceRef>(resource: R, params: import("@protobase/client").MapParams) => Promise<import("@protobase/client").MapResult<import("@protobase/client").RecordOf<R>>>;
|
|
69
71
|
seek: <R extends import("@protobase/client").ResourceRef>(resource: R, params: import("@protobase/client").ListParams & {
|
|
70
72
|
position: number;
|
|
71
73
|
}) => Promise<import("@protobase/client").ListPage<import("@protobase/client").RecordOf<R>>>;
|
package/dist/data/query-keys.js
CHANGED
|
@@ -66,6 +66,15 @@ const keys = {
|
|
|
66
66
|
buckets,
|
|
67
67
|
filter
|
|
68
68
|
],
|
|
69
|
+
/** The records or buckets a map shows; `filter` holds its bounding box. */
|
|
70
|
+
map: (resource, position, zoom, filter, orderBy) => [
|
|
71
|
+
"map",
|
|
72
|
+
resource,
|
|
73
|
+
position,
|
|
74
|
+
zoom,
|
|
75
|
+
filter,
|
|
76
|
+
orderBy
|
|
77
|
+
],
|
|
69
78
|
relatedLabel: (resource, id) => [
|
|
70
79
|
"related-label",
|
|
71
80
|
resource,
|
|
@@ -18,6 +18,7 @@ const useUpdateRecord = (resource, key) => {
|
|
|
18
18
|
queryClient.setQueryData(keys.record(resource, encoded), stored);
|
|
19
19
|
queryClient.invalidateQueries({ queryKey: ["page", resource] });
|
|
20
20
|
queryClient.invalidateQueries({ queryKey: ["list", resource] });
|
|
21
|
+
queryClient.invalidateQueries({ queryKey: ["map", resource] });
|
|
21
22
|
}
|
|
22
23
|
});
|
|
23
24
|
};
|
package/dist/data-grid/cells.js
CHANGED
|
@@ -4,7 +4,8 @@ import { Badge } from "../primitives/badge.js";
|
|
|
4
4
|
import { formatMoney } from "../format/money.js";
|
|
5
5
|
import { formatInt, formatPercent, formatSigned } from "../format/number.js";
|
|
6
6
|
import { formatDate, formatDateTime } from "../format/date.js";
|
|
7
|
-
import { fileShown,
|
|
7
|
+
import { fileShown, shownImage } from "../live/file-values.js";
|
|
8
|
+
import { FileImage } from "../live/file-image.js";
|
|
8
9
|
import { userById } from "../mocks/users.js";
|
|
9
10
|
import { valueColor } from "../live/value-color.js";
|
|
10
11
|
import { Check, Paperclip } from "lucide-react";
|
|
@@ -75,12 +76,14 @@ const renderCell = (spec, value) => {
|
|
|
75
76
|
className: "truncate text-danger-text",
|
|
76
77
|
children: file.name
|
|
77
78
|
});
|
|
78
|
-
const
|
|
79
|
+
const image = shownImage(value, {
|
|
80
|
+
width: 20,
|
|
81
|
+
height: 20
|
|
82
|
+
});
|
|
79
83
|
return /* @__PURE__ */ jsxs("span", {
|
|
80
84
|
className: "inline-flex min-w-0 items-center gap-2",
|
|
81
|
-
children: [
|
|
82
|
-
|
|
83
|
-
alt: "",
|
|
85
|
+
children: [image ? /* @__PURE__ */ jsx(FileImage, {
|
|
86
|
+
image,
|
|
84
87
|
className: "size-5 shrink-0 rounded-sm object-cover"
|
|
85
88
|
}) : /* @__PURE__ */ jsx(Paperclip, { className: "size-3.5 shrink-0 text-muted-foreground" }), /* @__PURE__ */ jsx("span", {
|
|
86
89
|
className: "truncate",
|
|
@@ -8,16 +8,14 @@ import { useHorizontalScroll } from "./use-horizontal-scroll.js";
|
|
|
8
8
|
import { usePinnedColumns } from "./use-pinned-columns.js";
|
|
9
9
|
import { GridHeader } from "./grid-header.js";
|
|
10
10
|
import { DataRow, SkeletonRow } from "./grid-row.js";
|
|
11
|
-
import { windowFor } from "./geometry.js";
|
|
12
|
-
import { useGridScroll } from "./use-grid-scroll.js";
|
|
13
11
|
import { usePagedRows } from "./use-paged-rows.js";
|
|
14
|
-
import {
|
|
12
|
+
import { useVirtualRows } from "./use-virtual-rows.js";
|
|
13
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
15
14
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
16
|
-
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
17
15
|
import { useTable } from "@tanstack/react-table";
|
|
18
16
|
//#region src/data-grid/data-grid.tsx
|
|
19
17
|
const headerHeight = 32;
|
|
20
|
-
const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0, latencyMs = 150, sort: controlledSort, onSortChange, onOpenRow, rowActions, bulkActions, pinKey, defaultPinned, rowNumbers = true, compact = false, selectable = true, className }) => {
|
|
18
|
+
const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0, onScrollIndex, latencyMs = 150, sort: controlledSort, onSortChange, onOpenRow, rowActions, bulkActions, pinKey, defaultPinned, rowNumbers = true, compact = false, selectable = true, fit = false, className }) => {
|
|
21
19
|
const rowHeight = compact ? 28 : 36;
|
|
22
20
|
const [pinned, setPinned] = usePinnedColumns(pinKey, defaultPinned);
|
|
23
21
|
const columns = useMemo(() => {
|
|
@@ -52,45 +50,27 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
52
50
|
reverse,
|
|
53
51
|
total
|
|
54
52
|
]);
|
|
55
|
-
const { ref, onScroll, viewport, top, geometry } =
|
|
53
|
+
const { ref, onScroll, viewport, top, geometry, rows: virtualRows, first: firstIndex, last: lastIndex } = useVirtualRows({
|
|
54
|
+
total,
|
|
55
|
+
rowHeight,
|
|
56
|
+
initialIndex: initialScrollIndex,
|
|
57
|
+
inset: headerHeight
|
|
58
|
+
});
|
|
56
59
|
const narrow = !useIsDesktop();
|
|
57
60
|
const gutter = narrow || !rowNumbers ? 40 : gutterWidthFor(total);
|
|
58
61
|
const bodyHeight = Math.max(0, viewport - headerHeight);
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
const { base, count } = windowFor(total, top, rowHeight);
|
|
62
|
-
const localOffset = top - base * rowHeight;
|
|
63
|
-
const virtualizer = useVirtualizer({
|
|
64
|
-
count,
|
|
65
|
-
getScrollElement: () => bodyRef.current,
|
|
66
|
-
estimateSize: () => rowHeight,
|
|
67
|
-
overscan: 6,
|
|
68
|
-
initialRect: {
|
|
69
|
-
width: 0,
|
|
70
|
-
height: bodyHeight
|
|
71
|
-
},
|
|
72
|
-
observeElementOffset: (_, callback) => {
|
|
73
|
-
offsetListener.current = callback;
|
|
74
|
-
callback(localOffset, false);
|
|
75
|
-
return () => {
|
|
76
|
-
offsetListener.current = null;
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
useLayoutEffect(() => offsetListener.current?.(localOffset, false), [localOffset, base]);
|
|
81
|
-
const items = virtualizer.getVirtualItems();
|
|
82
|
-
const firstIndex = base + (items[0]?.index ?? 0);
|
|
83
|
-
const lastIndex = base + (items[items.length - 1]?.index ?? 0);
|
|
62
|
+
const topIndex = Math.floor(top / rowHeight);
|
|
63
|
+
useEffect(() => onScrollIndex?.(topIndex), [topIndex, onScrollIndex]);
|
|
84
64
|
const getRow = usePagedRows({
|
|
85
65
|
total,
|
|
86
66
|
rowAt: sourceRowAt ? rowAt : void 0,
|
|
87
67
|
loadRows: source.loadRows,
|
|
88
68
|
latencyMs
|
|
89
69
|
}, firstIndex, lastIndex);
|
|
90
|
-
const loaded =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
row: getRow(
|
|
70
|
+
const loaded = virtualRows.map(({ index, offset }) => ({
|
|
71
|
+
index,
|
|
72
|
+
offset,
|
|
73
|
+
row: getRow(index)
|
|
94
74
|
}));
|
|
95
75
|
const data = useMemo(() => loaded.flatMap(({ row }) => row ? [row] : []), [
|
|
96
76
|
getRow,
|
|
@@ -143,6 +123,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
143
123
|
const last = Math.min(total, first + visibleRows - 1);
|
|
144
124
|
return /* @__PURE__ */ jsxs("div", {
|
|
145
125
|
className: cn("flex min-h-0 flex-col overflow-hidden rounded-lg border bg-background", className),
|
|
126
|
+
style: fit ? { height: headerHeight + total * rowHeight + 2 } : void 0,
|
|
146
127
|
children: [/* @__PURE__ */ jsx("div", {
|
|
147
128
|
ref,
|
|
148
129
|
onScroll,
|
|
@@ -175,14 +156,13 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
175
156
|
selectedCount,
|
|
176
157
|
onClearSelection: () => setRowSelection({})
|
|
177
158
|
}), /* @__PURE__ */ jsx("div", {
|
|
178
|
-
ref: bodyRef,
|
|
179
159
|
className: "relative min-h-0 flex-1 overflow-clip",
|
|
180
|
-
children: loaded.map(({
|
|
160
|
+
children: loaded.map(({ index, offset, row }) => {
|
|
181
161
|
const frame = {
|
|
182
162
|
specs: columns,
|
|
183
163
|
gutter,
|
|
184
164
|
index,
|
|
185
|
-
offset
|
|
165
|
+
offset,
|
|
186
166
|
height: rowHeight,
|
|
187
167
|
selectable,
|
|
188
168
|
trailing,
|
|
@@ -217,7 +197,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
217
197
|
})] })
|
|
218
198
|
})
|
|
219
199
|
})
|
|
220
|
-
}), /* @__PURE__ */ jsx(GridFooter, {
|
|
200
|
+
}), !fit && /* @__PURE__ */ jsx(GridFooter, {
|
|
221
201
|
total,
|
|
222
202
|
first,
|
|
223
203
|
last,
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
//#region src/data-grid/geometry.ts
|
|
2
2
|
/** Browsers cap element height (about 17M px in the strictest); stay well below it. */
|
|
3
3
|
const maxContentHeight = 1e7;
|
|
4
|
-
const chunk = 2e3;
|
|
5
|
-
const windowSize = 5e3;
|
|
6
4
|
const gridGeometry = (total, rowHeight, viewportHeight) => {
|
|
7
5
|
const trueHeight = total * rowHeight;
|
|
8
6
|
const contentHeight = Math.min(trueHeight, maxContentHeight);
|
|
@@ -19,14 +17,14 @@ const gridGeometry = (total, rowHeight, viewportHeight) => {
|
|
|
19
17
|
const topFromScroll = (geometry, scrollTop) => geometry.maxScroll === 0 ? 0 : scrollTop / geometry.maxScroll * geometry.maxTop;
|
|
20
18
|
const scrollFromTop = (geometry, top) => geometry.maxTop === 0 ? 0 : top / geometry.maxTop * geometry.maxScroll;
|
|
21
19
|
const clampTop = (geometry, top) => Math.min(Math.max(0, top), geometry.maxTop);
|
|
22
|
-
/** The
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
20
|
+
/** The rows drawn for a scroll offset: those in a viewport of `height`, and `overscan` more on each side. */
|
|
21
|
+
const rowsInView = (total, top, rowHeight, height, overscan) => {
|
|
22
|
+
const first = Math.max(0, Math.floor(top / rowHeight) - overscan);
|
|
23
|
+
const last = Math.min(total - 1, Math.floor((top + height) / rowHeight) + overscan);
|
|
26
24
|
return {
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
first,
|
|
26
|
+
last: Math.max(first - 1, last)
|
|
29
27
|
};
|
|
30
28
|
};
|
|
31
29
|
//#endregion
|
|
32
|
-
export { clampTop, gridGeometry, maxContentHeight, scrollFromTop, topFromScroll
|
|
30
|
+
export { clampTop, gridGeometry, maxContentHeight, rowsInView, scrollFromTop, topFromScroll };
|
|
@@ -64,6 +64,12 @@ const useGridScroll = (total, rowHeight, initialIndex) => {
|
|
|
64
64
|
element.addEventListener("wheel", onWheel, { passive: false });
|
|
65
65
|
return () => element.removeEventListener("wheel", onWheel);
|
|
66
66
|
}, [syncNative]);
|
|
67
|
+
/** Moves to a virtual offset, for a layout change that keeps a row in view. */
|
|
68
|
+
const scrollTo = useCallback((nextTop) => {
|
|
69
|
+
const next = clampTop(latest.current.geometry, nextTop);
|
|
70
|
+
setTop(next);
|
|
71
|
+
syncNative(next);
|
|
72
|
+
}, [syncNative]);
|
|
67
73
|
return {
|
|
68
74
|
ref,
|
|
69
75
|
onScroll: useCallback(() => {
|
|
@@ -73,7 +79,8 @@ const useGridScroll = (total, rowHeight, initialIndex) => {
|
|
|
73
79
|
}, []),
|
|
74
80
|
viewport,
|
|
75
81
|
top,
|
|
76
|
-
geometry
|
|
82
|
+
geometry,
|
|
83
|
+
scrollTo
|
|
77
84
|
};
|
|
78
85
|
};
|
|
79
86
|
//#endregion
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { rowsInView } from "./geometry.js";
|
|
2
|
+
import { useGridScroll } from "./use-grid-scroll.js";
|
|
3
|
+
//#region src/data-grid/use-virtual-rows.ts
|
|
4
|
+
const overscan = 6;
|
|
5
|
+
/**
|
|
6
|
+
* Rows of a fixed height over a proxy scroller, so any number of rows scrolls at native speed: `ref` and `onScroll` go
|
|
7
|
+
* on the scrolling element, and each drawn row sits at `offset` from the top of the viewport's rows, below `inset`.
|
|
8
|
+
* `first` and `last` are the indexes of the drawn rows, overscan included.
|
|
9
|
+
*/
|
|
10
|
+
const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle }) => {
|
|
11
|
+
const scroll = useGridScroll(total, rowHeight, initialIndex);
|
|
12
|
+
const { ref, onScroll, viewport, geometry, scrollTo } = scroll;
|
|
13
|
+
const top = settle ? settle(scroll.top) : scroll.top;
|
|
14
|
+
const { first, last } = rowsInView(total, top, rowHeight, Math.max(0, viewport - inset), overscan);
|
|
15
|
+
return {
|
|
16
|
+
ref,
|
|
17
|
+
onScroll,
|
|
18
|
+
viewport,
|
|
19
|
+
top,
|
|
20
|
+
geometry,
|
|
21
|
+
scrollTo,
|
|
22
|
+
rows: Array.from({ length: last - first + 1 }, (_, offset) => ({
|
|
23
|
+
index: first + offset,
|
|
24
|
+
offset: (first + offset) * rowHeight - top
|
|
25
|
+
})),
|
|
26
|
+
first,
|
|
27
|
+
last
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { useVirtualRows };
|
|
@@ -61,9 +61,11 @@ const kindOf = (field, view, index) => {
|
|
|
61
61
|
}
|
|
62
62
|
};
|
|
63
63
|
const currencyCents = (value) => Math.round(Number.parseFloat(String(value)) * 100);
|
|
64
|
-
/**
|
|
65
|
-
const
|
|
66
|
-
|
|
64
|
+
/** The fields a view's list shows as columns: its list columns, else the resource's first six fields. */
|
|
65
|
+
const listColumnNames = (resource, view) => view?.list?.columns ?? Object.keys(resource.fields).slice(0, 6);
|
|
66
|
+
/** Grid columns for `names`, by default the view's list: kinds follow the field types and the view's format hints. */
|
|
67
|
+
const columnsFromModel = (resource, view, highlight = [], names = listColumnNames(resource, view)) => {
|
|
68
|
+
return names.flatMap((name, index) => {
|
|
67
69
|
const field = resource.fields[name];
|
|
68
70
|
if (!field) return [];
|
|
69
71
|
const viewField = view?.fields[name];
|
|
@@ -97,4 +99,4 @@ const columnsFromModel = (resource, view, highlight = []) => {
|
|
|
97
99
|
});
|
|
98
100
|
};
|
|
99
101
|
//#endregion
|
|
100
|
-
export { columnsFromModel, headerWidth, toneFor };
|
|
102
|
+
export { columnsFromModel, headerWidth, listColumnNames, toneFor };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { thumbHashToDataURL } from "thumbhash";
|
|
4
|
+
//#region src/live/file-image.tsx
|
|
5
|
+
const placeholderImage = (hash) => {
|
|
6
|
+
let binary;
|
|
7
|
+
try {
|
|
8
|
+
binary = atob(hash);
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (error instanceof DOMException && error.name === "InvalidCharacterError") return void 0;
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
return thumbHashToDataURL(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
|
|
14
|
+
};
|
|
15
|
+
/** An image of a file, its placeholder drawn blurred behind it until it has loaded. */
|
|
16
|
+
const FileImage = ({ image, className, loading }) => {
|
|
17
|
+
const [loaded, setLoaded] = useState();
|
|
18
|
+
const placeholder = useMemo(() => image.placeholder === void 0 ? void 0 : placeholderImage(image.placeholder), [image.placeholder]);
|
|
19
|
+
const waiting = placeholder !== void 0 && loaded !== image.url;
|
|
20
|
+
return /* @__PURE__ */ jsx("img", {
|
|
21
|
+
src: image.url,
|
|
22
|
+
alt: "",
|
|
23
|
+
loading,
|
|
24
|
+
decoding: "async",
|
|
25
|
+
onLoad: () => setLoaded(image.url),
|
|
26
|
+
className,
|
|
27
|
+
style: waiting ? {
|
|
28
|
+
backgroundImage: `url("${placeholder}")`,
|
|
29
|
+
backgroundSize: "cover",
|
|
30
|
+
backgroundPosition: "center"
|
|
31
|
+
} : void 0
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { FileImage };
|
package/dist/live/file-values.js
CHANGED
|
@@ -34,6 +34,22 @@ const formatSize = (bytes) => {
|
|
|
34
34
|
};
|
|
35
35
|
/** Raster images the browser shows; SVG is never shown, since it can carry scripts. */
|
|
36
36
|
const isPreviewable = (type) => type.startsWith("image/") && type !== "image/svg+xml";
|
|
37
|
+
const density = 2;
|
|
38
|
+
const unbounded = 1e5;
|
|
39
|
+
const covers = (variant, box) => (variant.width !== void 0 || variant.height !== void 0) && (variant.width ?? unbounded) >= box.width * density && (variant.height ?? unbounded) >= box.height * density;
|
|
40
|
+
const area = (variant) => (variant.width ?? unbounded) * (variant.height ?? unbounded);
|
|
41
|
+
/**
|
|
42
|
+
* The picture of a file for `box`: the link of its smallest variant large enough for it, else the file's own, with its
|
|
43
|
+
* placeholder; `undefined` for a file that is no raster image with a link.
|
|
44
|
+
*/
|
|
45
|
+
const shownImage = (value, box) => {
|
|
46
|
+
const file = fileShown(value);
|
|
47
|
+
if (!file || "missing" in file || !file.url || !isPreviewable(file.type)) return void 0;
|
|
48
|
+
return {
|
|
49
|
+
url: ("variants" in file && file.variants ? Object.values(file.variants) : []).filter((variant) => covers(variant, box)).sort((a, b) => area(a) - area(b))[0]?.url ?? file.url,
|
|
50
|
+
..."placeholder" in file && file.placeholder !== void 0 && { placeholder: file.placeholder }
|
|
51
|
+
};
|
|
52
|
+
};
|
|
37
53
|
/** The file input's `accept`: the field's allowed types, or nothing when it takes any. */
|
|
38
54
|
const acceptOf = (field) => {
|
|
39
55
|
const accept = field.file?.accept ?? [];
|
|
@@ -52,4 +68,4 @@ const fileDiffers = (draft, stored) => {
|
|
|
52
68
|
return uri(draft) !== uri(stored);
|
|
53
69
|
};
|
|
54
70
|
//#endregion
|
|
55
|
-
export { acceptOf, fileDiffers, filePatchValue, fileShown, formatSize, isFileDraft, isPreviewable, isStoredFile };
|
|
71
|
+
export { acceptOf, fileDiffers, filePatchValue, fileShown, formatSize, isFileDraft, isPreviewable, isStoredFile, shownImage };
|