@protobase/ui 0.21.1 → 0.21.2
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/hand-order/hand-order.js +21 -0
- package/dist/app/hand-order/hand-sorted-list.js +98 -0
- package/dist/app/hand-order/sort-button.js +20 -0
- package/dist/app/hand-order/use-hand-order.js +105 -0
- package/dist/app/list-page.js +34 -9
- package/dist/app/related-section.js +37 -8
- package/dist/data-grid/data-grid.js +49 -36
- package/dist/data-grid/grid-row.js +16 -4
- package/dist/data-grid/row-reorder.js +68 -0
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-BnuJWZWd.js +69 -0
- package/dist/shell/assets/{index-CW037BBI.js → index-7lQIOMrK.js} +1 -1
- package/dist/shell/assets/{index-Dc6xgHsz.css → index-B48ty05p.css} +1 -1
- package/dist/shell/assets/{maplibre-Ck9abcyu.js → maplibre-DEejTZ7I.js} +1 -1
- package/dist/shell/assets/{markdown-content-BDjSKrC7.js → markdown-content-ZzP9i4q4.js} +1 -1
- package/dist/shell/assets/{record-map-CKPZ2KJs.js → record-map-B-zsmXL2.js} +1 -1
- package/dist/shell/assets/{records-map-DadV3GNO.js → records-map-Lj1MQgsW.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-tJCYxj7E.js → shared-protobase-ui-Bnd7T1j4.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-DGVgRtZo.js +0 -69
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { can } from "../permissions.js";
|
|
2
|
+
//#region src/app/hand-order/hand-order.ts
|
|
3
|
+
/** The field a user may sort `model`'s records by hand in: its `position`, when they may update it. */
|
|
4
|
+
const writablePosition = (model, permissions) => {
|
|
5
|
+
const position = model.position;
|
|
6
|
+
if (!position || !can(permissions, "update")) return void 0;
|
|
7
|
+
const field = model.fields[position.field];
|
|
8
|
+
return field && !field.readOnly ? position.field : void 0;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The field the list page sorts all of `model`'s records by hand in. A resource whose records are ordered within a
|
|
12
|
+
* parent is sorted in the parent's related section instead, since its list mixes the parents.
|
|
13
|
+
*/
|
|
14
|
+
const listHandOrder = (model, permissions) => model.position?.within === void 0 ? writablePosition(model, permissions) : void 0;
|
|
15
|
+
/**
|
|
16
|
+
* The field a related section sorts its records by hand in: those of one parent, when the section lists every record
|
|
17
|
+
* whose `within` relation points at the page's record, no filter or join resource leaving some out.
|
|
18
|
+
*/
|
|
19
|
+
const relatedHandOrder = (item, model, permissions) => model.position?.within !== void 0 && item.field === model.position.within && !item.through && !item.filter ? writablePosition(model, permissions) : void 0;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { listHandOrder, relatedHandOrder };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { recordKey, recordTitle } from "../../live/model-helpers.js";
|
|
3
|
+
import { humanize } from "../../live/naming.js";
|
|
4
|
+
import { useProjectUi } from "../pages/project-ui.js";
|
|
5
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
6
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
7
|
+
import { loadedFields } from "../displays/list-displays.js";
|
|
8
|
+
import { useAppAddress } from "../use-app-address.js";
|
|
9
|
+
import { DataGrid } from "../../data-grid/data-grid.js";
|
|
10
|
+
import { embeddedColumns } from "../embedded-list/embedded-columns.js";
|
|
11
|
+
import { useHandOrder } from "./use-hand-order.js";
|
|
12
|
+
import { useMemo } from "react";
|
|
13
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
14
|
+
//#region src/app/hand-order/hand-sorted-list.tsx
|
|
15
|
+
/**
|
|
16
|
+
* The records in their order by hand, in the list's table with a drag handle in each row's checkbox place: dragging a
|
|
17
|
+
* row, or moving it with the keyboard from its handle, renumbers them all. The page scrolls them, and a row still opens
|
|
18
|
+
* its record.
|
|
19
|
+
*/
|
|
20
|
+
const HandSortedList = ({ model, view, field, filter, columns }) => {
|
|
21
|
+
const { resources, views } = useAdminMeta();
|
|
22
|
+
const { fields: fieldTypes } = useProjectUi();
|
|
23
|
+
const { go } = useAppAddress();
|
|
24
|
+
const specs = useMemo(() => embeddedColumns(model, view, columns, {
|
|
25
|
+
resources,
|
|
26
|
+
views,
|
|
27
|
+
fieldTypes
|
|
28
|
+
}).map((spec) => ({
|
|
29
|
+
...spec,
|
|
30
|
+
sortable: false
|
|
31
|
+
})), [
|
|
32
|
+
model,
|
|
33
|
+
view,
|
|
34
|
+
columns.join(","),
|
|
35
|
+
resources,
|
|
36
|
+
views,
|
|
37
|
+
fieldTypes
|
|
38
|
+
]);
|
|
39
|
+
const fields = useMemo(() => loadedFields(model, view, specs.map((spec) => spec.id)), [
|
|
40
|
+
model,
|
|
41
|
+
view,
|
|
42
|
+
specs
|
|
43
|
+
]);
|
|
44
|
+
const order = useHandOrder({
|
|
45
|
+
model,
|
|
46
|
+
field,
|
|
47
|
+
filter,
|
|
48
|
+
fields
|
|
49
|
+
});
|
|
50
|
+
const rows = order.rows;
|
|
51
|
+
const source = useMemo(() => rows && {
|
|
52
|
+
total: rows.length,
|
|
53
|
+
rowAt: (index) => rows[index]
|
|
54
|
+
}, [rows]);
|
|
55
|
+
const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
|
|
56
|
+
if (order.loading) return /* @__PURE__ */ jsx("div", {
|
|
57
|
+
className: "flex flex-col gap-2",
|
|
58
|
+
children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
|
|
59
|
+
});
|
|
60
|
+
if (order.error) return /* @__PURE__ */ jsx(ErrorBanner, {
|
|
61
|
+
title: "Could not load",
|
|
62
|
+
error: order.error
|
|
63
|
+
});
|
|
64
|
+
if (order.more) return /* @__PURE__ */ jsxs("p", {
|
|
65
|
+
className: "text-muted-foreground",
|
|
66
|
+
children: [
|
|
67
|
+
"There are more than ",
|
|
68
|
+
500 .toLocaleString("en-IE"),
|
|
69
|
+
" ",
|
|
70
|
+
plural,
|
|
71
|
+
", too many to sort by hand."
|
|
72
|
+
]
|
|
73
|
+
});
|
|
74
|
+
if (!source || source.total === 0) return /* @__PURE__ */ jsx("p", {
|
|
75
|
+
className: "text-muted-foreground",
|
|
76
|
+
children: "Nothing here yet."
|
|
77
|
+
});
|
|
78
|
+
const openRecord = (row) => go({
|
|
79
|
+
kind: "record",
|
|
80
|
+
resource: model.name,
|
|
81
|
+
key: recordKey(model, row)
|
|
82
|
+
});
|
|
83
|
+
return /* @__PURE__ */ jsx(DataGrid, {
|
|
84
|
+
fit: true,
|
|
85
|
+
scroll: "parent",
|
|
86
|
+
columns: specs,
|
|
87
|
+
source,
|
|
88
|
+
latencyMs: 0,
|
|
89
|
+
onOpenRow: openRecord,
|
|
90
|
+
rowNumbers: false,
|
|
91
|
+
reorder: {
|
|
92
|
+
label: (row) => recordTitle(model, view, row, row.id),
|
|
93
|
+
onMove: order.move
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
//#endregion
|
|
98
|
+
export { HandSortedList };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
|
+
import { ArrowUpDown, Check } from "lucide-react";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/app/hand-order/sort-button.tsx
|
|
5
|
+
/** The button that turns sorting by hand on, as **Sort**, and off again, as **Done**; with `iconOnly` its icon alone. */
|
|
6
|
+
const SortButton = ({ sorting, onChange, size, iconOnly = false }) => {
|
|
7
|
+
const label = sorting ? "Done" : "Sort";
|
|
8
|
+
return /* @__PURE__ */ jsxs(Button, {
|
|
9
|
+
size,
|
|
10
|
+
variant: sorting ? "primary" : "secondary",
|
|
11
|
+
onClick: () => onChange(!sorting),
|
|
12
|
+
...iconOnly && {
|
|
13
|
+
"aria-label": label,
|
|
14
|
+
title: label
|
|
15
|
+
},
|
|
16
|
+
children: [sorting ? /* @__PURE__ */ jsx(Check, { className: "size-3.5" }) : /* @__PURE__ */ jsx(ArrowUpDown, { className: "size-3.5" }), !iconOnly && label]
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
export { SortButton };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../../data/api-provider.js";
|
|
3
|
+
import { recordId, recordKey } from "../../live/model-helpers.js";
|
|
4
|
+
import { useToast } from "../../toasts/toast-provider.js";
|
|
5
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
6
|
+
import { refusalReason } from "../displays/board-columns.js";
|
|
7
|
+
import { useRefreshResource } from "../pages/use-layout-data.js";
|
|
8
|
+
import { listLabels, withLabels } from "../../live/relation-labels.js";
|
|
9
|
+
import { useRef, useState } from "react";
|
|
10
|
+
import { useQuery } from "@tanstack/react-query";
|
|
11
|
+
import { encodeKey } from "@protobase/schema/internal";
|
|
12
|
+
import { arrayMove } from "@dnd-kit/sortable";
|
|
13
|
+
//#region src/app/hand-order/use-hand-order.ts
|
|
14
|
+
/**
|
|
15
|
+
* The records of `filter` in the order of `field`, all of them up to `handOrderLimit`, with `more` when there are
|
|
16
|
+
* others, and `move` to put one elsewhere. A move shows at once and renumbers them all 1, 2, 3, ... in one batch
|
|
17
|
+
* `reorder` with their ETags; moves made while one saves are saved after it, with the ETags it gave back. A refusal
|
|
18
|
+
* puts them back as the server has them and says why; either way every list of the resource reloads.
|
|
19
|
+
*/
|
|
20
|
+
const useHandOrder = ({ model, field, filter, fields }) => {
|
|
21
|
+
const client = useClient();
|
|
22
|
+
const toast = useToast();
|
|
23
|
+
const refresh = useRefreshResource();
|
|
24
|
+
const { resources, views } = useAdminMeta();
|
|
25
|
+
const orderBy = `${field} asc`;
|
|
26
|
+
const loaded = useQuery({
|
|
27
|
+
queryKey: [...keys.list(model.name, filter, orderBy, 500, "hand-order"), fields.join(",")],
|
|
28
|
+
queryFn: async () => {
|
|
29
|
+
const labels = listLabels(resources, views, model, fields);
|
|
30
|
+
const page = await client.search(model.name, {
|
|
31
|
+
filter: filter || void 0,
|
|
32
|
+
orderBy,
|
|
33
|
+
pageSize: 500,
|
|
34
|
+
labels
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
rows: withLabels(resources, model, fields, page.items, page.labels).map((row, index) => ({
|
|
38
|
+
...row,
|
|
39
|
+
id: recordId(model, page.items[index])
|
|
40
|
+
})),
|
|
41
|
+
more: Boolean(page.nextPageToken)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const [moved, setMoved] = useState();
|
|
46
|
+
const latest = useRef(void 0);
|
|
47
|
+
const saving = useRef(Promise.resolve());
|
|
48
|
+
const waiting = useRef(0);
|
|
49
|
+
const save = async () => {
|
|
50
|
+
const rows = latest.current;
|
|
51
|
+
if (!rows) return;
|
|
52
|
+
const etags = Object.fromEntries(rows.flatMap((row) => typeof row.etag === "string" ? [[encodeKey(recordKey(model, row)), row.etag]] : []));
|
|
53
|
+
const written = (await client.batchWrite([{
|
|
54
|
+
op: "reorder",
|
|
55
|
+
resource: model.name,
|
|
56
|
+
field,
|
|
57
|
+
keys: rows.map((row) => recordKey(model, row)),
|
|
58
|
+
etags
|
|
59
|
+
}])).results[0];
|
|
60
|
+
if (written?.op !== "reorder" || !latest.current) return;
|
|
61
|
+
const stored = new Map(written.records.map((entry) => [recordId(model, entry.record), entry]));
|
|
62
|
+
latest.current = latest.current.map((row) => {
|
|
63
|
+
const entry = stored.get(row.id);
|
|
64
|
+
return entry ? {
|
|
65
|
+
...row,
|
|
66
|
+
[field]: entry.record[field],
|
|
67
|
+
etag: entry.etag
|
|
68
|
+
} : row;
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
const refused = (error) => {
|
|
72
|
+
latest.current = void 0;
|
|
73
|
+
setMoved(void 0);
|
|
74
|
+
toast.show({
|
|
75
|
+
state: "error",
|
|
76
|
+
title: "Could not change the order",
|
|
77
|
+
description: refusalReason(error)
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
const settle = async () => {
|
|
81
|
+
waiting.current -= 1;
|
|
82
|
+
if (waiting.current > 0) return;
|
|
83
|
+
await refresh(model.name);
|
|
84
|
+
if (waiting.current > 0) return;
|
|
85
|
+
latest.current = void 0;
|
|
86
|
+
setMoved(void 0);
|
|
87
|
+
};
|
|
88
|
+
const move = (from, to) => {
|
|
89
|
+
const rows = latest.current ?? loaded.data?.rows;
|
|
90
|
+
if (!rows || from === to) return;
|
|
91
|
+
latest.current = arrayMove(rows, from, to);
|
|
92
|
+
setMoved(latest.current);
|
|
93
|
+
waiting.current += 1;
|
|
94
|
+
saving.current = saving.current.then(save).catch(refused).then(settle);
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
rows: moved ?? loaded.data?.rows,
|
|
98
|
+
more: loaded.data?.more ?? false,
|
|
99
|
+
loading: loaded.isPending,
|
|
100
|
+
error: loaded.error,
|
|
101
|
+
move
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
//#endregion
|
|
105
|
+
export { useHandOrder };
|
package/dist/app/list-page.js
CHANGED
|
@@ -14,7 +14,7 @@ import { parseSort, sortToOrderBy } from "./list-order.js";
|
|
|
14
14
|
import { listedResource } from "./current-address.js";
|
|
15
15
|
import { AppLink, useAppAddress } from "./use-app-address.js";
|
|
16
16
|
import { can, rowAllows } from "./permissions.js";
|
|
17
|
-
import { columnsFromModel } from "../live/columns-from-model.js";
|
|
17
|
+
import { columnsFromModel, listColumnNames } from "../live/columns-from-model.js";
|
|
18
18
|
import { pinnedStorageKey } from "../data-grid/pinned-storage.js";
|
|
19
19
|
import { DataGrid } from "../data-grid/data-grid.js";
|
|
20
20
|
import { useLiveRows } from "../live/use-live-rows.js";
|
|
@@ -52,6 +52,9 @@ import { SavedList } from "./saved/saved-list.js";
|
|
|
52
52
|
import { SaveSearchButton } from "./searches/save-search-button.js";
|
|
53
53
|
import { SavedSearches } from "./searches/saved-searches.js";
|
|
54
54
|
import { useListCellEditor } from "./list-edit/use-list-cell-editor.js";
|
|
55
|
+
import { listHandOrder } from "./hand-order/hand-order.js";
|
|
56
|
+
import { HandSortedList } from "./hand-order/hand-sorted-list.js";
|
|
57
|
+
import { SortButton } from "./hand-order/sort-button.js";
|
|
55
58
|
import { useMemo, useState } from "react";
|
|
56
59
|
import { Download, Plus, SlidersHorizontal } from "lucide-react";
|
|
57
60
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -160,7 +163,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
160
163
|
related
|
|
161
164
|
});
|
|
162
165
|
const display = shownDisplay(view, address.display);
|
|
163
|
-
const
|
|
166
|
+
const permissions = usePermissions(resource);
|
|
167
|
+
const handOrder = scope ? void 0 : listHandOrder(model, permissions);
|
|
168
|
+
const [sorting, setSorting] = useState(false);
|
|
169
|
+
const handSorting = sorting && handOrder !== void 0;
|
|
170
|
+
const pageScroll = handSorting || display?.kind !== "map";
|
|
164
171
|
usePageScroll(pageScroll);
|
|
165
172
|
const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
|
|
166
173
|
const scroll = useListScroll();
|
|
@@ -172,7 +179,6 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
172
179
|
snapshot: row
|
|
173
180
|
});
|
|
174
181
|
const singular = view?.names?.singular ?? humanize(resource);
|
|
175
|
-
const permissions = usePermissions(resource);
|
|
176
182
|
const cellEditing = useListCellEditor(model, view, permissions);
|
|
177
183
|
const { state: auth } = useAuth();
|
|
178
184
|
const restore = useRestore(model);
|
|
@@ -308,7 +314,12 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
308
314
|
},
|
|
309
315
|
children: [/* @__PURE__ */ jsx(Download, { className: "size-3.5" }), desktop && "Export"]
|
|
310
316
|
});
|
|
311
|
-
const
|
|
317
|
+
const sortButton = handOrder && /* @__PURE__ */ jsx(SortButton, {
|
|
318
|
+
sorting: handSorting,
|
|
319
|
+
onChange: setSorting,
|
|
320
|
+
iconOnly: !desktop
|
|
321
|
+
});
|
|
322
|
+
const displaySwitch = (menu) => !handSorting && /* @__PURE__ */ jsx(DisplaySwitch, {
|
|
312
323
|
view,
|
|
313
324
|
value: display?.name ?? tableDisplay,
|
|
314
325
|
onChange: (name) => set({ display: name }),
|
|
@@ -392,7 +403,19 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
392
403
|
list: displayRows
|
|
393
404
|
});
|
|
394
405
|
};
|
|
395
|
-
const shown =
|
|
406
|
+
const shown = handSorting ? /* @__PURE__ */ jsxs("div", {
|
|
407
|
+
className: "flex flex-col gap-3",
|
|
408
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
409
|
+
className: "text-muted-foreground",
|
|
410
|
+
children: "Drag a row by its handle to move it. Every move is saved as you drop it."
|
|
411
|
+
}), /* @__PURE__ */ jsx(HandSortedList, {
|
|
412
|
+
model,
|
|
413
|
+
view,
|
|
414
|
+
field: handOrder,
|
|
415
|
+
filter: "",
|
|
416
|
+
columns: listColumnNames(model, view)
|
|
417
|
+
})]
|
|
418
|
+
}) : drawDisplay();
|
|
396
419
|
return /* @__PURE__ */ jsxs("div", {
|
|
397
420
|
className: "flex min-h-0 flex-1 flex-col gap-3 px-5 py-4",
|
|
398
421
|
children: [
|
|
@@ -404,11 +427,13 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
404
427
|
aside: !desktop && displaySwitch(true),
|
|
405
428
|
actions: desktop ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
406
429
|
displaySwitch(false),
|
|
430
|
+
sortButton,
|
|
407
431
|
saveSearch,
|
|
408
432
|
importButton,
|
|
409
433
|
exportButton,
|
|
410
434
|
createButton
|
|
411
435
|
] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
436
|
+
sortButton,
|
|
412
437
|
saveSearch,
|
|
413
438
|
importButton,
|
|
414
439
|
exportButton,
|
|
@@ -419,11 +444,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
419
444
|
title: "The filter in the address could not be read",
|
|
420
445
|
error: new Error(parsed.errors.map((error) => error.message).join("; "))
|
|
421
446
|
}),
|
|
422
|
-
rows.error && !display && /* @__PURE__ */ jsx(ErrorBanner, {
|
|
447
|
+
rows.error && !display && !handSorting && /* @__PURE__ */ jsx(ErrorBanner, {
|
|
423
448
|
title: "Could not load rows",
|
|
424
449
|
error: rows.error
|
|
425
450
|
}),
|
|
426
|
-
view?.chart && (isGroupChart(view.chart) ? /* @__PURE__ */ jsx(ListGroupChart, {
|
|
451
|
+
view?.chart && !handSorting && (isGroupChart(view.chart) ? /* @__PURE__ */ jsx(ListGroupChart, {
|
|
427
452
|
model,
|
|
428
453
|
view,
|
|
429
454
|
chart: view.chart,
|
|
@@ -436,8 +461,8 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
436
461
|
noun: plural.toLowerCase(),
|
|
437
462
|
filter: loadFilter
|
|
438
463
|
})),
|
|
439
|
-
desktop ? desktopToolbar : phoneFilters,
|
|
440
|
-
desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
|
|
464
|
+
!handSorting && (desktop ? desktopToolbar : phoneFilters),
|
|
465
|
+
desktop && layout === "panel" && !handSorting ? /* @__PURE__ */ jsxs("div", {
|
|
441
466
|
className: "flex min-h-0 flex-1 gap-4",
|
|
442
467
|
children: [/* @__PURE__ */ jsx("div", {
|
|
443
468
|
className: pageScroll ? "sticky top-[calc(var(--pb-sticky-top,0px)+1rem)] flex max-h-[calc(100dvh-var(--pb-sticky-top,0px)-var(--pb-sticky-bottom,0px)-2rem)] shrink-0 self-start" : "flex shrink-0",
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { Skeleton } from "../primitives/skeleton.js";
|
|
2
2
|
import { keys } from "../data/query-keys.js";
|
|
3
3
|
import { useClient } from "../data/api-provider.js";
|
|
4
|
-
import { useAdminMeta } from "./meta-gate.js";
|
|
4
|
+
import { useAdminMeta, usePermissions } from "./meta-gate.js";
|
|
5
5
|
import { Section } from "../record-view/section.js";
|
|
6
6
|
import { ErrorBanner } from "./error-banner.js";
|
|
7
7
|
import { listColumnNames } from "../live/columns-from-model.js";
|
|
8
8
|
import { EmbeddedDisplaySwitch, EmbeddedList } from "./embedded-list/embedded-list.js";
|
|
9
9
|
import { useEmbeddedDisplay } from "./embedded-list/use-embedded-display.js";
|
|
10
|
-
import {
|
|
10
|
+
import { relatedHandOrder } from "./hand-order/hand-order.js";
|
|
11
|
+
import { HandSortedList } from "./hand-order/hand-sorted-list.js";
|
|
12
|
+
import { SortButton } from "./hand-order/sort-button.js";
|
|
13
|
+
import { useState } from "react";
|
|
14
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
11
15
|
import { useQuery } from "@tanstack/react-query";
|
|
12
16
|
import { createWhere, printFilter } from "@protobase/schema";
|
|
13
17
|
//#region src/app/related-section.tsx
|
|
@@ -67,7 +71,8 @@ const Empty = ({ text }) => /* @__PURE__ */ jsx("p", {
|
|
|
67
71
|
});
|
|
68
72
|
/**
|
|
69
73
|
* 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.
|
|
74
|
+
* a join resource, in the list page's table or display, with a link to them all in the list. When they are ordered by
|
|
75
|
+
* hand within this record, **Sort** shows them all in their order, to be moved by their drag handles.
|
|
71
76
|
*/
|
|
72
77
|
const RelatedSection = ({ item, recordKey, owner }) => {
|
|
73
78
|
const { resources, views } = useAdminMeta();
|
|
@@ -75,16 +80,40 @@ const RelatedSection = ({ item, recordKey, owner }) => {
|
|
|
75
80
|
const view = model && views[model.name];
|
|
76
81
|
const match = useMatch(item, recordKey);
|
|
77
82
|
const [display, pick] = useEmbeddedDisplay(view, `related:${owner}:${item.title}`, item.display);
|
|
83
|
+
const permissions = usePermissions(item.resource);
|
|
84
|
+
const [sorting, setSorting] = useState(false);
|
|
78
85
|
if (!model) return null;
|
|
79
86
|
const columns = item.columns ?? listColumnNames(model, view).filter((name) => name !== item.field);
|
|
80
|
-
|
|
87
|
+
const handOrder = relatedHandOrder(item, model, permissions);
|
|
88
|
+
const displaySwitch = /* @__PURE__ */ jsx(EmbeddedDisplaySwitch, {
|
|
89
|
+
view,
|
|
90
|
+
value: display,
|
|
91
|
+
onChange: pick
|
|
92
|
+
});
|
|
93
|
+
const actions = handOrder ? /* @__PURE__ */ jsxs("div", {
|
|
94
|
+
className: "flex items-center gap-2",
|
|
95
|
+
children: [!sorting && displaySwitch, /* @__PURE__ */ jsx(SortButton, {
|
|
96
|
+
size: "sm",
|
|
97
|
+
sorting,
|
|
98
|
+
onChange: setSorting
|
|
99
|
+
})]
|
|
100
|
+
}) : displaySwitch;
|
|
101
|
+
if (sorting && handOrder) return /* @__PURE__ */ jsx(Section, {
|
|
81
102
|
title: item.title,
|
|
82
103
|
help: item.help,
|
|
83
|
-
actions
|
|
104
|
+
actions,
|
|
105
|
+
children: /* @__PURE__ */ jsx(HandSortedList, {
|
|
106
|
+
model,
|
|
84
107
|
view,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
108
|
+
field: handOrder,
|
|
109
|
+
filter: printFilter(where$1.eq(item.field, recordKey)),
|
|
110
|
+
columns
|
|
111
|
+
})
|
|
112
|
+
});
|
|
113
|
+
return /* @__PURE__ */ jsx(Section, {
|
|
114
|
+
title: item.title,
|
|
115
|
+
help: item.help,
|
|
116
|
+
actions,
|
|
88
117
|
children: match.isPending ? /* @__PURE__ */ jsx(Loading, {}) : match.error ? /* @__PURE__ */ jsx(ErrorBanner, {
|
|
89
118
|
title: "Could not load",
|
|
90
119
|
error: match.error
|
|
@@ -11,12 +11,14 @@ import { DataRow, SkeletonRow } from "./grid-row.js";
|
|
|
11
11
|
import { usePagedRows } from "./use-paged-rows.js";
|
|
12
12
|
import { useVirtualRows } from "./use-virtual-rows.js";
|
|
13
13
|
import { useCellEditing } from "./use-cell-editing.js";
|
|
14
|
+
import { ReorderRows, SortableRow } from "./row-reorder.js";
|
|
14
15
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
15
16
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
16
17
|
import { useTable } from "@tanstack/react-table";
|
|
17
18
|
//#region src/data-grid/data-grid.tsx
|
|
18
19
|
const headerHeight = 32;
|
|
19
|
-
const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0, onScrollIndex, latencyMs = 150, sort: controlledSort, onSortChange, onOpenRow, rowActions, cellEditor, rowPatches, bulkActions, pinKey, defaultPinned, rowNumbers = true, compact = false, selectable = true, fit = false, scroll = "own", className }) => {
|
|
20
|
+
const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0, onScrollIndex, latencyMs = 150, sort: controlledSort, onSortChange, onOpenRow, rowActions, cellEditor, rowPatches, bulkActions, pinKey, defaultPinned, rowNumbers = true, compact = false, selectable: selecting = true, reorder, fit = false, scroll = "own", className }) => {
|
|
21
|
+
const selectable = selecting && !reorder;
|
|
20
22
|
const rowHeight = compact ? 28 : 36;
|
|
21
23
|
const [pinned, setPinned] = usePinnedColumns(pinKey, defaultPinned);
|
|
22
24
|
const columns = useMemo(() => {
|
|
@@ -166,6 +168,46 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
166
168
|
})
|
|
167
169
|
})
|
|
168
170
|
});
|
|
171
|
+
const rowsInView = loaded.map(({ index, offset, row }) => {
|
|
172
|
+
const frame = {
|
|
173
|
+
specs: columns,
|
|
174
|
+
gutter,
|
|
175
|
+
index,
|
|
176
|
+
offset,
|
|
177
|
+
height: rowHeight,
|
|
178
|
+
selectable,
|
|
179
|
+
trailing,
|
|
180
|
+
minScale,
|
|
181
|
+
scrolled: horizontal.scrolled,
|
|
182
|
+
pins,
|
|
183
|
+
lastPinned
|
|
184
|
+
};
|
|
185
|
+
const tableRow = row ? tableRows.get(row.id) : void 0;
|
|
186
|
+
if (!tableRow || !row) return /* @__PURE__ */ jsx(SkeletonRow, { ...frame }, `skeleton-${index}`);
|
|
187
|
+
const actions = trailing ? availableActions(rowActions, row) : [];
|
|
188
|
+
const props = {
|
|
189
|
+
...frame,
|
|
190
|
+
selected: tableRow.getIsSelected(),
|
|
191
|
+
onToggle: () => tableRow.toggleSelected(),
|
|
192
|
+
onOpen: onOpenRow ? () => onOpenRow(row) : void 0,
|
|
193
|
+
menu: actions.length > 0 ? /* @__PURE__ */ jsx(RowMenu, {
|
|
194
|
+
row,
|
|
195
|
+
actions
|
|
196
|
+
}) : void 0,
|
|
197
|
+
cells: tableRow.getAllCells().map((cell) => {
|
|
198
|
+
const content = /* @__PURE__ */ jsx(table.FlexRender, { cell });
|
|
199
|
+
return {
|
|
200
|
+
spec: columns.find((spec) => spec.id === cell.column.id),
|
|
201
|
+
...cellEditing ? cellEditing.cell(index, row, cell.column.id, content) : { content }
|
|
202
|
+
};
|
|
203
|
+
})
|
|
204
|
+
};
|
|
205
|
+
return reorder ? /* @__PURE__ */ jsx(SortableRow, {
|
|
206
|
+
...props,
|
|
207
|
+
row,
|
|
208
|
+
label: reorder.label(row)
|
|
209
|
+
}, row.id) : /* @__PURE__ */ jsx(DataRow, { ...props }, row.id);
|
|
210
|
+
});
|
|
169
211
|
const body = /* @__PURE__ */ jsx("div", {
|
|
170
212
|
ref: horizontal.attach,
|
|
171
213
|
onScroll: onBodyScroll,
|
|
@@ -177,41 +219,12 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
177
219
|
minWidth,
|
|
178
220
|
height: geometry.contentHeight
|
|
179
221
|
},
|
|
180
|
-
children:
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
height: rowHeight,
|
|
187
|
-
selectable,
|
|
188
|
-
trailing,
|
|
189
|
-
minScale,
|
|
190
|
-
scrolled: horizontal.scrolled,
|
|
191
|
-
pins,
|
|
192
|
-
lastPinned
|
|
193
|
-
};
|
|
194
|
-
const tableRow = row ? tableRows.get(row.id) : void 0;
|
|
195
|
-
if (!tableRow) return /* @__PURE__ */ jsx(SkeletonRow, { ...frame }, `skeleton-${index}`);
|
|
196
|
-
const actions = trailing && row ? availableActions(rowActions, row) : [];
|
|
197
|
-
return /* @__PURE__ */ jsx(DataRow, {
|
|
198
|
-
...frame,
|
|
199
|
-
selected: tableRow.getIsSelected(),
|
|
200
|
-
onToggle: () => tableRow.toggleSelected(),
|
|
201
|
-
onOpen: onOpenRow && row ? () => onOpenRow(row) : void 0,
|
|
202
|
-
menu: row && actions.length > 0 ? /* @__PURE__ */ jsx(RowMenu, {
|
|
203
|
-
row,
|
|
204
|
-
actions
|
|
205
|
-
}) : void 0,
|
|
206
|
-
cells: tableRow.getAllCells().map((cell) => {
|
|
207
|
-
const content = /* @__PURE__ */ jsx(table.FlexRender, { cell });
|
|
208
|
-
return {
|
|
209
|
-
spec: columns.find((spec) => spec.id === cell.column.id),
|
|
210
|
-
...cellEditing && row ? cellEditing.cell(index, row, cell.column.id, content) : { content }
|
|
211
|
-
};
|
|
212
|
-
})
|
|
213
|
-
}, row?.id);
|
|
214
|
-
})
|
|
222
|
+
children: reorder ? /* @__PURE__ */ jsx(ReorderRows, {
|
|
223
|
+
ids: Array.from({ length: total }, (_, index) => patchedRow(index)?.id ?? `missing-${index}`),
|
|
224
|
+
rowAt: patchedRow,
|
|
225
|
+
reorder,
|
|
226
|
+
children: rowsInView
|
|
227
|
+
}) : rowsInView
|
|
215
228
|
})
|
|
216
229
|
});
|
|
217
230
|
return /* @__PURE__ */ jsxs("div", {
|
|
@@ -33,13 +33,25 @@ const Gutter = ({ width, index, selectable, selected, onToggle, edge }) => /* @_
|
|
|
33
33
|
children: formatInt(index + 1)
|
|
34
34
|
})]
|
|
35
35
|
});
|
|
36
|
-
const
|
|
36
|
+
const dragStyle = (frame, dragY, transition) => ({
|
|
37
|
+
gridTemplateColumns: gridTemplate(frame.specs, frame.gutter, frame.trailing, frame.minScale),
|
|
38
|
+
height: frame.height,
|
|
39
|
+
top: frame.offset,
|
|
40
|
+
transform: dragY === 0 ? void 0 : `translateY(${dragY}px)`,
|
|
41
|
+
transition
|
|
42
|
+
});
|
|
43
|
+
const DataRow = ({ cells, selected, onToggle, onOpen, menu, handle, rowRef, dragY, transition, dragging, ...frame }) => /* @__PURE__ */ jsxs("div", {
|
|
44
|
+
ref: rowRef,
|
|
37
45
|
role: "row",
|
|
38
46
|
onClick: onOpen,
|
|
39
|
-
className: cn("absolute inset-x-0 top-0 grid items-center border-b", selected ? "bg-primary-soft" : "bg-background hover:bg-surface", onOpen && "cursor-pointer"),
|
|
40
|
-
style: frameStyle(frame),
|
|
47
|
+
className: cn("absolute inset-x-0 top-0 grid items-center border-b", selected ? "bg-primary-soft" : "bg-background hover:bg-surface", onOpen && "cursor-pointer", dragging && "z-30 bg-surface shadow-pop"),
|
|
48
|
+
style: dragY === void 0 ? frameStyle(frame) : dragStyle(frame, dragY, transition),
|
|
41
49
|
children: [
|
|
42
|
-
/* @__PURE__ */ jsx(
|
|
50
|
+
handle ? /* @__PURE__ */ jsx("div", {
|
|
51
|
+
className: cn("sticky left-0 z-10 flex h-full items-center bg-inherit pl-1.5", frame.scrolled && !frame.lastPinned && "shadow-[1px_0_0_var(--border),4px_0_6px_-2px_rgb(0_0_0/0.12)]"),
|
|
52
|
+
style: { width: frame.gutter },
|
|
53
|
+
children: handle
|
|
54
|
+
}) : /* @__PURE__ */ jsx(Gutter, {
|
|
43
55
|
width: frame.gutter,
|
|
44
56
|
index: frame.index,
|
|
45
57
|
selectable: frame.selectable,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { DataRow } from "./grid-row.js";
|
|
2
|
+
import { GripVertical } from "lucide-react";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
import { DndContext, KeyboardSensor, MouseSensor, TouchSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
|
|
5
|
+
import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
|
6
|
+
//#region src/data-grid/row-reorder.tsx
|
|
7
|
+
const instructions = "To move a row, press Space on its handle, move it with the up and down arrow keys, and press Space again to drop it there. Escape puts it back.";
|
|
8
|
+
/** The rows of a grid that are moved by hand, `ids` in their order, each loaded or not, so the drop's index is the row's place in the whole list. */
|
|
9
|
+
const ReorderRows = ({ ids, rowAt, reorder, children }) => {
|
|
10
|
+
const sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: 4 } }), useSensor(TouchSensor, { activationConstraint: {
|
|
11
|
+
delay: 250,
|
|
12
|
+
tolerance: 5
|
|
13
|
+
} }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
|
14
|
+
const place = (id) => ids.indexOf(String(id));
|
|
15
|
+
const name = (id) => {
|
|
16
|
+
const row = rowAt(place(id));
|
|
17
|
+
return row ? reorder.label(row) : "row";
|
|
18
|
+
};
|
|
19
|
+
const announcements = {
|
|
20
|
+
onDragStart: ({ active }) => `Picked up ${name(active.id)}, row ${place(active.id) + 1} of ${ids.length}.`,
|
|
21
|
+
onDragOver: ({ active, over }) => over ? `${name(active.id)} is over row ${place(over.id) + 1} of ${ids.length}.` : void 0,
|
|
22
|
+
onDragEnd: ({ active, over }) => over ? `${name(active.id)} was dropped at row ${place(over.id) + 1} of ${ids.length}.` : `${name(active.id)} was put back.`,
|
|
23
|
+
onDragCancel: ({ active }) => `Moving ${name(active.id)} was cancelled. It is back at row ${place(active.id) + 1}.`
|
|
24
|
+
};
|
|
25
|
+
const end = ({ active, over }) => {
|
|
26
|
+
if (!over || active.id === over.id) return;
|
|
27
|
+
reorder.onMove(place(active.id), place(over.id));
|
|
28
|
+
};
|
|
29
|
+
return /* @__PURE__ */ jsx(DndContext, {
|
|
30
|
+
sensors,
|
|
31
|
+
collisionDetection: closestCenter,
|
|
32
|
+
onDragEnd: end,
|
|
33
|
+
accessibility: {
|
|
34
|
+
announcements,
|
|
35
|
+
screenReaderInstructions: { draggable: instructions }
|
|
36
|
+
},
|
|
37
|
+
children: /* @__PURE__ */ jsx(SortableContext, {
|
|
38
|
+
items: ids,
|
|
39
|
+
strategy: verticalListSortingStrategy,
|
|
40
|
+
children
|
|
41
|
+
})
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
/** A row moved by its handle, which takes the checkbox's place; it follows the pointer up and down only. */
|
|
45
|
+
const SortableRow = ({ row, label, ...props }) => {
|
|
46
|
+
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id: row.id });
|
|
47
|
+
const handle = /* @__PURE__ */ jsx("button", {
|
|
48
|
+
ref: setActivatorNodeRef,
|
|
49
|
+
type: "button",
|
|
50
|
+
"aria-label": `Move ${label}`,
|
|
51
|
+
...attributes,
|
|
52
|
+
...listeners,
|
|
53
|
+
onClick: (event) => event.stopPropagation(),
|
|
54
|
+
style: { touchAction: "none" },
|
|
55
|
+
className: "flex size-7 cursor-grab items-center justify-center rounded text-faint-foreground hover:bg-muted hover:text-foreground focus-visible:text-foreground active:cursor-grabbing pointer-coarse:size-9",
|
|
56
|
+
children: /* @__PURE__ */ jsx(GripVertical, { className: "size-4" })
|
|
57
|
+
});
|
|
58
|
+
return /* @__PURE__ */ jsx(DataRow, {
|
|
59
|
+
...props,
|
|
60
|
+
rowRef: setNodeRef,
|
|
61
|
+
handle,
|
|
62
|
+
dragY: transform?.y ?? 0,
|
|
63
|
+
transition,
|
|
64
|
+
dragging: isDragging
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
//#endregion
|
|
68
|
+
export { ReorderRows, SortableRow };
|
package/dist/package.js
CHANGED