@protobase/ui 0.23.14 → 0.24.1
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/board-display.js +9 -7
- package/dist/app/displays/board-place.js +23 -1
- package/dist/app/displays/messages.js +10 -2
- package/dist/app/displays/use-board-move.js +41 -6
- package/dist/app/hand-order/drop-rule.d.ts +13 -0
- package/dist/app/hand-order/drop-rule.js +28 -0
- package/dist/app/hand-order/hand-order.js +11 -1
- package/dist/app/hand-order/hand-sorted-list.js +24 -9
- package/dist/app/hand-order/messages.js +12 -2
- package/dist/app/hand-order/undo-ops.js +19 -0
- package/dist/app/hand-order/use-hand-order.js +51 -8
- package/dist/app/list-page.js +14 -2
- package/dist/app/pages/merge-ui.d.ts +1 -1
- package/dist/app/pages/merge-ui.js +3 -1
- package/dist/app/pages/project-ui.d.ts +6 -3
- package/dist/index.d.ts +2 -1
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-DH7x4kWy.js +69 -0
- package/dist/shell/assets/{index-DL-zk94Z.js → index-Ce3tnjxv.js} +1 -1
- package/dist/shell/assets/{index-Br0ZlvVS.css → index-Ihcp7yPR.css} +1 -1
- package/dist/shell/assets/{maplibre-BqnpnW9w.js → maplibre-CjYd33uD.js} +1 -1
- package/dist/shell/assets/{markdown-content-0PAPwJ3B.js → markdown-content-Cvb4nqTn.js} +1 -1
- package/dist/shell/assets/{record-map-e0DqjuCs.js → record-map-3K382XcL.js} +1 -1
- package/dist/shell/assets/{records-map-CA-lAL9M.js → records-map-Dolr6LVy.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-BZ2UBlKm.js → shared-protobase-ui-Cv8dHbzX.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/shell/protobase.sw.js +2 -2
- package/dist/toasts/toast-provider.js +1 -0
- package/dist/toasts/toast.js +3 -2
- package/package.json +4 -4
- package/dist/shell/assets/app-CXlnPi2R.js +0 -69
|
@@ -28,18 +28,20 @@ const placeOver = (over, row) => {
|
|
|
28
28
|
};
|
|
29
29
|
};
|
|
30
30
|
/**
|
|
31
|
-
* A column per value of the board's enum field, each loading its own records through the list API, with their count
|
|
32
|
-
* a card dragged, or moved with the keyboard, to another column saves the record
|
|
33
|
-
* resource has a position, its records ordered as one, the columns are in its order
|
|
34
|
-
* the up and down arrow keys move it within its column, and
|
|
31
|
+
* A column per value of the board's enum field, each loading its own records through the list API, with their count.
|
|
32
|
+
* When the list's `drag.board` is on, a card dragged, or moved with the keyboard, to another column saves the record
|
|
33
|
+
* with that column's value. When the resource has a position, its records ordered as one, the columns are in its order,
|
|
34
|
+
* and when `drag.rank` is on a card is dropped at a place: the up and down arrow keys move it within its column, and
|
|
35
|
+
* the drop renumbers the cards before it. Each change says so with **Undo**.
|
|
35
36
|
*/
|
|
36
37
|
const BoardDisplay = ({ model, view, display, fields, filter, orderBy, limit }) => {
|
|
37
38
|
const columns = columnsAllowed(filter, display.by, boardColumns(model, view, display));
|
|
38
39
|
const card = boardCard(model, view, display);
|
|
39
40
|
const permissions = usePermissions(model.name);
|
|
40
|
-
const
|
|
41
|
+
const drag = view?.list?.drag;
|
|
42
|
+
const movable = drag?.board === true && can(permissions, "update") && !model.fields[display.by]?.readOnly;
|
|
41
43
|
const rank = boardRank(model);
|
|
42
|
-
const placeable = rank !== void 0 && listHandOrder(model, permissions) !== void 0;
|
|
44
|
+
const placeable = drag?.rank === true && rank !== void 0 && listHandOrder(model, permissions) !== void 0;
|
|
43
45
|
const columnOrder = rank ? `${rank} asc` : orderBy;
|
|
44
46
|
const { moves, move } = useBoardMove(model, display.by, rank);
|
|
45
47
|
const [active, setActive] = useState();
|
|
@@ -115,7 +117,7 @@ const BoardDisplay = ({ model, view, display, fields, filter, orderBy, limit })
|
|
|
115
117
|
},
|
|
116
118
|
accessibility: {
|
|
117
119
|
announcements,
|
|
118
|
-
screenReaderInstructions: { draggable: placeable ? "To move a card, press Space, move it with the up and down arrow keys within its column and the left and right arrow keys to another column, and press Space again to drop it there. Escape puts it back." : "To move a card
|
|
120
|
+
screenReaderInstructions: { draggable: !placeable ? "To move a card to another column, press Space, move it with the left and right arrow keys, and press Space again to drop it there. Escape puts it back." : movable ? "To move a card, press Space, move it with the up and down arrow keys within its column and the left and right arrow keys to another column, and press Space again to drop it there. Escape puts it back." : "To move a card, press Space, move it with the up and down arrow keys within its column, and press Space again to drop it there. Escape puts it back." }
|
|
119
121
|
},
|
|
120
122
|
children: [/* @__PURE__ */ jsx("div", {
|
|
121
123
|
className: "relative min-w-0 flex-1 overflow-x-auto",
|
|
@@ -44,5 +44,27 @@ const placeOps = (model, by, rank, row, column, order, first, last) => {
|
|
|
44
44
|
etag: etagOf(row)
|
|
45
45
|
}, reorder];
|
|
46
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* What the batch of a drop saved: each record it wrote, as it stored it last, with its values in `rows`, the column's
|
|
49
|
+
* cards as listed before the drop, so the drop can be undone.
|
|
50
|
+
*/
|
|
51
|
+
const boardWrites = (model, rows, result) => {
|
|
52
|
+
const before = new Map(rows.map((row) => [encodeKey(recordKey(model, row.record)), row.record]));
|
|
53
|
+
const stored = /* @__PURE__ */ new Map();
|
|
54
|
+
for (const entry of result.results) {
|
|
55
|
+
const written = entry.op === "reorder" ? entry.records : entry.op === "update" ? [entry] : [];
|
|
56
|
+
for (const { record, etag } of written) stored.set(encodeKey(recordKey(model, record)), {
|
|
57
|
+
record,
|
|
58
|
+
etag
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return [...stored].flatMap(([key, written]) => {
|
|
62
|
+
const was = before.get(key);
|
|
63
|
+
return was ? [{
|
|
64
|
+
before: was,
|
|
65
|
+
...written
|
|
66
|
+
}] : [];
|
|
67
|
+
});
|
|
68
|
+
};
|
|
47
69
|
//#endregion
|
|
48
|
-
export { placeOps, placedRows };
|
|
70
|
+
export { boardWrites, placeOps, placedRows };
|
|
@@ -6,14 +6,22 @@ const text = defineMessages({
|
|
|
6
6
|
display: "Display",
|
|
7
7
|
sort: "Sort",
|
|
8
8
|
ascending: "ascending",
|
|
9
|
-
descending: "descending"
|
|
9
|
+
descending: "descending",
|
|
10
|
+
movedTo: (column) => `Moved to ${column}`,
|
|
11
|
+
orderChanged: "Order changed",
|
|
12
|
+
undo: "Undo",
|
|
13
|
+
couldNotUndo: "Could not undo"
|
|
10
14
|
},
|
|
11
15
|
nl: {
|
|
12
16
|
table: "Tabel",
|
|
13
17
|
display: "Weergave",
|
|
14
18
|
sort: "Sorteren",
|
|
15
19
|
ascending: "oplopend",
|
|
16
|
-
descending: "aflopend"
|
|
20
|
+
descending: "aflopend",
|
|
21
|
+
movedTo: (column) => `Verplaatst naar ${column}`,
|
|
22
|
+
orderChanged: "Volgorde gewijzigd",
|
|
23
|
+
undo: "Ongedaan maken",
|
|
24
|
+
couldNotUndo: "Kon niet ongedaan maken"
|
|
17
25
|
}
|
|
18
26
|
});
|
|
19
27
|
//#endregion
|
|
@@ -2,30 +2,48 @@ import { useClient } from "../../data/api-provider.js";
|
|
|
2
2
|
import { useToast } from "../../toasts/toast-provider.js";
|
|
3
3
|
import { recordKey } from "../../live/model-helpers.js";
|
|
4
4
|
import { refusalReason } from "./board-columns.js";
|
|
5
|
+
import { text } from "./messages.js";
|
|
5
6
|
import { useRefreshResource } from "../pages/use-layout-data.js";
|
|
6
|
-
import { placeOps } from "./board-place.js";
|
|
7
|
-
import {
|
|
7
|
+
import { boardWrites, placeOps } from "./board-place.js";
|
|
8
|
+
import { undoOps } from "../hand-order/undo-ops.js";
|
|
9
|
+
import { useRef, useState } from "react";
|
|
8
10
|
import { useMutation } from "@tanstack/react-query";
|
|
9
11
|
//#region src/app/displays/use-board-move.ts
|
|
10
12
|
/**
|
|
11
13
|
* Moving a board's card to another column: one update of the record with the column's value and the ETag it was listed
|
|
12
14
|
* with, as a named update action sends, so the resource's access rules and `validate` decide. On a ranked board a drop
|
|
13
15
|
* at a place is one batch instead, that update when the column changes and a `reorder` by `rank` of the column's cards.
|
|
14
|
-
* The card shows in its new place at once; a
|
|
15
|
-
* resource reloads.
|
|
16
|
+
* The card shows in its new place at once; a saved move says so with **Undo**, which puts back the values it changed,
|
|
17
|
+
* and a refusal puts it back and says why. Either way every list of the resource reloads.
|
|
16
18
|
*/
|
|
17
19
|
const useBoardMove = (model, by, rank) => {
|
|
18
20
|
const client = useClient();
|
|
19
21
|
const toast = useToast();
|
|
20
22
|
const refresh = useRefreshResource();
|
|
21
23
|
const [moves, setMoves] = useState([]);
|
|
24
|
+
const undoToast = useRef(void 0);
|
|
22
25
|
const settle = (key) => setMoves((current) => current.filter((move) => move.row.key !== key));
|
|
26
|
+
const fields = rank ? [by, rank] : [by];
|
|
27
|
+
const undo = useMutation({
|
|
28
|
+
mutationFn: (writes) => client.batchWrite(undoOps(model, writes, fields)),
|
|
29
|
+
onError: (error) => toast.show({
|
|
30
|
+
state: "error",
|
|
31
|
+
title: text("couldNotUndo"),
|
|
32
|
+
description: refusalReason(error)
|
|
33
|
+
}),
|
|
34
|
+
onSettled: () => refresh(model.name)
|
|
35
|
+
});
|
|
23
36
|
const mutation = useMutation({
|
|
24
37
|
mutationFn: async ({ row, to, place }) => {
|
|
25
38
|
if (place && !rank) throw new Error("A board places a card only by its rank");
|
|
26
|
-
if (place) return client.batchWrite(placeOps(model, by, rank, row, to, place.order, place.first, place.last));
|
|
39
|
+
if (place) return boardWrites(model, place.order, await client.batchWrite(placeOps(model, by, rank, row, to, place.order, place.first, place.last)));
|
|
27
40
|
const etag = typeof row.record.etag === "string" ? row.record.etag : "*";
|
|
28
|
-
|
|
41
|
+
const stored = await client.update(model.name, recordKey(model, row.record), { [by]: to.value }, etag);
|
|
42
|
+
return [{
|
|
43
|
+
before: row.record,
|
|
44
|
+
record: stored.record,
|
|
45
|
+
etag: stored.etag
|
|
46
|
+
}];
|
|
29
47
|
},
|
|
30
48
|
onMutate: ({ row, to, place }) => {
|
|
31
49
|
const moved = {
|
|
@@ -42,6 +60,23 @@ const useBoardMove = (model, by, rank) => {
|
|
|
42
60
|
...order && { order }
|
|
43
61
|
}]);
|
|
44
62
|
},
|
|
63
|
+
onSuccess: (writes, { row, to }) => {
|
|
64
|
+
if (undoToast.current !== void 0) toast.dismiss(undoToast.current);
|
|
65
|
+
const title = (row.record[by] ?? null) === to.value ? text("orderChanged") : text("movedTo", to.label);
|
|
66
|
+
const id = toast.show({
|
|
67
|
+
state: "success",
|
|
68
|
+
title,
|
|
69
|
+
action: {
|
|
70
|
+
label: text("undo"),
|
|
71
|
+
onClick: () => {
|
|
72
|
+
toast.dismiss(id);
|
|
73
|
+
undoToast.current = void 0;
|
|
74
|
+
undo.mutate(writes);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
undoToast.current = id;
|
|
79
|
+
},
|
|
45
80
|
onError: (error, { row, to }) => {
|
|
46
81
|
settle(row.key);
|
|
47
82
|
const title = (row.record[by] ?? null) === to.value ? "Could not change the order" : `Could not move it to ${to.label}`;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/app/hand-order/drop-rule.d.ts
|
|
2
|
+
/** A record dropped while re-ranking: the records as the API gives them, in the order the drop leaves them, and its place among them, 0 the first. */
|
|
3
|
+
export type Drop = {
|
|
4
|
+
record: Record<string, unknown>;
|
|
5
|
+
order: Record<string, unknown>[];
|
|
6
|
+
index: number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Where a re-ranked record may land, a view's `drag.rule`: why `drop.record` may not be at `drop.index`, in the app's own
|
|
10
|
+
* words, or nothing when it may.
|
|
11
|
+
*/
|
|
12
|
+
export type DropRule = (drop: Drop) => string | undefined;
|
|
13
|
+
//#endregion
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { arrayMove } from "@dnd-kit/sortable";
|
|
2
|
+
//#region src/app/hand-order/drop-rule.ts
|
|
3
|
+
/**
|
|
4
|
+
* Where the record at `from` of `records` lands when dropped at `to`: there when `rule` allows it, else at the first
|
|
5
|
+
* place below that it allows, with the reason it refused `to`. With none below, it stays at `from`, with that reason.
|
|
6
|
+
*/
|
|
7
|
+
const allowedDrop = (records, from, to, rule) => {
|
|
8
|
+
const record = records[from];
|
|
9
|
+
let reason;
|
|
10
|
+
for (let index = to; index < records.length; index++) {
|
|
11
|
+
const refused = rule({
|
|
12
|
+
record,
|
|
13
|
+
order: arrayMove(records, from, index),
|
|
14
|
+
index
|
|
15
|
+
});
|
|
16
|
+
if (refused === void 0) return {
|
|
17
|
+
to: index,
|
|
18
|
+
...reason !== void 0 && { reason }
|
|
19
|
+
};
|
|
20
|
+
reason ??= refused;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
to: from,
|
|
24
|
+
reason
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { allowedDrop };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseSort } from "../list-order.js";
|
|
1
2
|
import { can } from "../permissions.js";
|
|
2
3
|
//#region src/app/hand-order/hand-order.ts
|
|
3
4
|
/** The field a user may sort `model`'s records by hand in: its `position`, when they may update it. */
|
|
@@ -17,5 +18,14 @@ const listHandOrder = (model, permissions) => model.position?.within === void 0
|
|
|
17
18
|
* whose `within` relation points at the page's record, no filter or join resource leaving some out.
|
|
18
19
|
*/
|
|
19
20
|
const relatedHandOrder = (item, model, permissions) => model.position?.within !== void 0 && item.field === model.position.within && !item.through && !item.from && !item.filter ? writablePosition(model, permissions) : void 0;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the list page's table moves its rows by dragging at any time, without **Reorder**: its list turns
|
|
23
|
+
* `drag.rank` on, and it shows every record in the order of `field`, from `listHandOrder`, without a filter or search.
|
|
24
|
+
*/
|
|
25
|
+
const dragsRank = (view, field, orderBy, filter) => {
|
|
26
|
+
if (field === void 0 || view?.list?.drag?.rank !== true || filter !== "") return false;
|
|
27
|
+
const sort = parseSort(orderBy);
|
|
28
|
+
return sort.columnId === field && sort.direction === "asc";
|
|
29
|
+
};
|
|
20
30
|
//#endregion
|
|
21
|
-
export { listHandOrder, relatedHandOrder };
|
|
31
|
+
export { dragsRank, listHandOrder, relatedHandOrder };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { Problem } from "../pages/problem.js";
|
|
2
3
|
import { recordKey, recordTitle } from "../../live/model-helpers.js";
|
|
3
4
|
import { loadedFields } from "../displays/list-displays.js";
|
|
4
5
|
import { useAdminMeta } from "../meta-gate.js";
|
|
@@ -10,48 +11,58 @@ import { embeddedColumns } from "../embedded-list/embedded-columns.js";
|
|
|
10
11
|
import { text } from "./messages.js";
|
|
11
12
|
import { useHandOrder } from "./use-hand-order.js";
|
|
12
13
|
import { useMemo } from "react";
|
|
13
|
-
import { jsx } from "react/jsx-runtime";
|
|
14
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
14
15
|
//#region src/app/hand-order/hand-sorted-list.tsx
|
|
15
16
|
/**
|
|
16
17
|
* 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
|
|
18
|
-
* its record.
|
|
18
|
+
* row, or moving it with the keyboard from its handle, renumbers them all, landing where the list's `drag.rule` allows.
|
|
19
|
+
* The page scrolls them, and a row still opens its record.
|
|
19
20
|
*/
|
|
20
|
-
const HandSortedList = ({ model, view, field, filter, columns }) => {
|
|
21
|
+
const HandSortedList = ({ model, view, field, filter, columns, sort }) => {
|
|
21
22
|
const { resources, views } = useAdminMeta();
|
|
22
|
-
const { fields: fieldTypes } = useProjectUi();
|
|
23
|
+
const { fields: fieldTypes, dropRules } = useProjectUi();
|
|
23
24
|
const { go, href } = useAppAddress();
|
|
25
|
+
const sorts = sort !== void 0;
|
|
24
26
|
const specs = useMemo(() => embeddedColumns(model, view, columns, {
|
|
25
27
|
resources,
|
|
26
28
|
views,
|
|
27
29
|
fieldTypes
|
|
28
|
-
}).map((spec) =>
|
|
30
|
+
}).map((spec) => sorts ? spec : {
|
|
29
31
|
...spec,
|
|
30
32
|
sortable: false
|
|
31
|
-
})
|
|
33
|
+
}), [
|
|
32
34
|
model,
|
|
33
35
|
view,
|
|
34
36
|
columns.join(","),
|
|
35
37
|
resources,
|
|
36
38
|
views,
|
|
37
|
-
fieldTypes
|
|
39
|
+
fieldTypes,
|
|
40
|
+
sorts
|
|
38
41
|
]);
|
|
39
42
|
const fields = useMemo(() => loadedFields(model, view, specs.map((spec) => spec.id)), [
|
|
40
43
|
model,
|
|
41
44
|
view,
|
|
42
45
|
specs
|
|
43
46
|
]);
|
|
47
|
+
const ruleName = view?.list?.drag?.rule;
|
|
48
|
+
const rule = ruleName === void 0 ? void 0 : dropRules?.[ruleName];
|
|
44
49
|
const order = useHandOrder({
|
|
45
50
|
model,
|
|
46
51
|
field,
|
|
47
52
|
filter,
|
|
48
|
-
fields
|
|
53
|
+
fields,
|
|
54
|
+
...rule && { rule }
|
|
49
55
|
});
|
|
50
56
|
const rows = order.rows;
|
|
51
57
|
const source = useMemo(() => rows && {
|
|
52
58
|
total: rows.length,
|
|
53
59
|
rowAt: (index) => rows[index]
|
|
54
60
|
}, [rows]);
|
|
61
|
+
if (ruleName !== void 0 && !rule) return /* @__PURE__ */ jsxs(Problem, { children: [
|
|
62
|
+
"No drop rule is registered as ",
|
|
63
|
+
ruleName,
|
|
64
|
+
". Give it in dropRules in protobase.ui.tsx, or load the plugin that provides it."
|
|
65
|
+
] });
|
|
55
66
|
if (order.loading) return /* @__PURE__ */ jsx("div", {
|
|
56
67
|
className: "flex flex-col gap-2",
|
|
57
68
|
children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
|
|
@@ -85,6 +96,10 @@ const HandSortedList = ({ model, view, field, filter, columns }) => {
|
|
|
85
96
|
latencyMs: 0,
|
|
86
97
|
onOpenRow: openRecord,
|
|
87
98
|
rowLink: recordLink,
|
|
99
|
+
...sort && {
|
|
100
|
+
sort: sort.value,
|
|
101
|
+
onSortChange: sort.onChange
|
|
102
|
+
},
|
|
88
103
|
rowNumbers: false,
|
|
89
104
|
reorder: {
|
|
90
105
|
label: (row) => recordTitle(model, view, row, row.id),
|
|
@@ -6,14 +6,24 @@ const text = defineMessages({
|
|
|
6
6
|
reorder: "Reorder",
|
|
7
7
|
couldNotLoad: "Could not load",
|
|
8
8
|
nothingYet: "Nothing here yet.",
|
|
9
|
-
couldNotChange: "Could not change the order"
|
|
9
|
+
couldNotChange: "Could not change the order",
|
|
10
|
+
orderChanged: "Order changed",
|
|
11
|
+
undo: "Undo",
|
|
12
|
+
couldNotUndo: "Could not undo",
|
|
13
|
+
movedToPlace: (place) => `Moved to place ${place}`,
|
|
14
|
+
notMoved: "Left in its place"
|
|
10
15
|
},
|
|
11
16
|
nl: {
|
|
12
17
|
done: "Klaar",
|
|
13
18
|
reorder: "Volgorde wijzigen",
|
|
14
19
|
couldNotLoad: "Kon niet laden",
|
|
15
20
|
nothingYet: "Hier staat nog niets.",
|
|
16
|
-
couldNotChange: "Kon de volgorde niet wijzigen"
|
|
21
|
+
couldNotChange: "Kon de volgorde niet wijzigen",
|
|
22
|
+
orderChanged: "Volgorde gewijzigd",
|
|
23
|
+
undo: "Ongedaan maken",
|
|
24
|
+
couldNotUndo: "Kon niet ongedaan maken",
|
|
25
|
+
movedToPlace: (place) => `Verplaatst naar plaats ${place}`,
|
|
26
|
+
notMoved: "Op zijn plaats gelaten"
|
|
17
27
|
}
|
|
18
28
|
});
|
|
19
29
|
//#endregion
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { recordKey } from "../../live/model-helpers.js";
|
|
2
|
+
//#region src/app/hand-order/undo-ops.ts
|
|
3
|
+
/**
|
|
4
|
+
* The batch that undoes a drag: an update of each record it saved putting back those of `fields` it changed, with the
|
|
5
|
+
* ETag the save gave it, so a record changed again since is refused rather than overwritten.
|
|
6
|
+
*/
|
|
7
|
+
const undoOps = (model, writes, fields) => writes.flatMap(({ before, record, etag }) => {
|
|
8
|
+
const changed = fields.filter((field) => Object.hasOwn(before, field) && before[field] !== record[field]);
|
|
9
|
+
if (changed.length === 0) return [];
|
|
10
|
+
return [{
|
|
11
|
+
op: "update",
|
|
12
|
+
resource: model.name,
|
|
13
|
+
key: recordKey(model, record),
|
|
14
|
+
data: Object.fromEntries(changed.map((field) => [field, before[field]])),
|
|
15
|
+
etag
|
|
16
|
+
}];
|
|
17
|
+
});
|
|
18
|
+
//#endregion
|
|
19
|
+
export { undoOps };
|
|
@@ -3,10 +3,13 @@ import { useClient } from "../../data/api-provider.js";
|
|
|
3
3
|
import { useToast } from "../../toasts/toast-provider.js";
|
|
4
4
|
import { recordId, recordKey } from "../../live/model-helpers.js";
|
|
5
5
|
import { refusalReason } from "../displays/board-columns.js";
|
|
6
|
+
import { displayRow } from "../displays/list-displays.js";
|
|
6
7
|
import { useAdminMeta } from "../meta-gate.js";
|
|
7
8
|
import { useRefreshResource } from "../pages/use-layout-data.js";
|
|
8
9
|
import { listLabels, withLabels } from "../../live/relation-labels.js";
|
|
9
10
|
import { reorderSpan } from "./reorder-span.js";
|
|
11
|
+
import { undoOps } from "./undo-ops.js";
|
|
12
|
+
import { allowedDrop } from "./drop-rule.js";
|
|
10
13
|
import { text } from "./messages.js";
|
|
11
14
|
import { useRef, useState } from "react";
|
|
12
15
|
import { encodeKey } from "@protobase/schema/internal";
|
|
@@ -17,9 +20,11 @@ import { arrayMove } from "@dnd-kit/sortable";
|
|
|
17
20
|
* The records of `filter` in the order of `field`, all of them, a page of `handOrderPage` at a time, and `move` to put
|
|
18
21
|
* one elsewhere. A move shows at once and saves only the records whose place changed (see `reorderSpan`) in one batch
|
|
19
22
|
* `reorder` with their ETags; moves made while one saves are saved after it, with the values and ETags it gave back.
|
|
20
|
-
*
|
|
23
|
+
* Each save says so with **Undo**, which puts back the values it changed. A refusal puts them back as the server has
|
|
24
|
+
* them and says why; either way every list of the resource reloads. A drop `rule` refuses moves to the first place below
|
|
25
|
+
* that it allows, with a warning giving its reason.
|
|
21
26
|
*/
|
|
22
|
-
const useHandOrder = ({ model, field, filter, fields }) => {
|
|
27
|
+
const useHandOrder = ({ model, field, filter, fields, rule }) => {
|
|
23
28
|
const client = useClient();
|
|
24
29
|
const toast = useToast();
|
|
25
30
|
const refresh = useRefreshResource();
|
|
@@ -53,6 +58,7 @@ const useHandOrder = ({ model, field, filter, fields }) => {
|
|
|
53
58
|
const pending = useRef(void 0);
|
|
54
59
|
const saving = useRef(Promise.resolve());
|
|
55
60
|
const waiting = useRef(0);
|
|
61
|
+
const undoToast = useRef(void 0);
|
|
56
62
|
const save = async () => {
|
|
57
63
|
const rows = latest.current;
|
|
58
64
|
const changed = pending.current;
|
|
@@ -69,7 +75,22 @@ const useHandOrder = ({ model, field, filter, fields }) => {
|
|
|
69
75
|
start: span.start,
|
|
70
76
|
etags
|
|
71
77
|
}])).results[0];
|
|
72
|
-
if (written?.op !== "reorder"
|
|
78
|
+
if (written?.op !== "reorder") return;
|
|
79
|
+
const before = new Map(sent.map((row) => [row.id, { [field]: row[field] }]));
|
|
80
|
+
const undo = undoOps(model, written.records.map((entry) => ({
|
|
81
|
+
...entry,
|
|
82
|
+
before: before.get(recordId(model, entry.record)) ?? {}
|
|
83
|
+
})), [field]);
|
|
84
|
+
if (undoToast.current !== void 0) toast.dismiss(undoToast.current);
|
|
85
|
+
undoToast.current = toast.show({
|
|
86
|
+
state: "success",
|
|
87
|
+
title: text("orderChanged"),
|
|
88
|
+
action: {
|
|
89
|
+
label: text("undo"),
|
|
90
|
+
onClick: () => undoSave(undo)
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
if (!latest.current) return;
|
|
73
94
|
const stored = new Map(written.records.map((entry) => [recordId(model, entry.record), entry]));
|
|
74
95
|
latest.current = latest.current.map((row) => {
|
|
75
96
|
const entry = stored.get(row.id);
|
|
@@ -80,13 +101,13 @@ const useHandOrder = ({ model, field, filter, fields }) => {
|
|
|
80
101
|
} : row;
|
|
81
102
|
});
|
|
82
103
|
};
|
|
83
|
-
const refused = (error) => {
|
|
104
|
+
const refused = (title) => (error) => {
|
|
84
105
|
latest.current = void 0;
|
|
85
106
|
pending.current = void 0;
|
|
86
107
|
setMoved(void 0);
|
|
87
108
|
toast.show({
|
|
88
109
|
state: "error",
|
|
89
|
-
title
|
|
110
|
+
title,
|
|
90
111
|
description: refusalReason(error)
|
|
91
112
|
});
|
|
92
113
|
};
|
|
@@ -98,9 +119,11 @@ const useHandOrder = ({ model, field, filter, fields }) => {
|
|
|
98
119
|
latest.current = void 0;
|
|
99
120
|
setMoved(void 0);
|
|
100
121
|
};
|
|
101
|
-
const move = (from,
|
|
122
|
+
const move = (from, dropped) => {
|
|
102
123
|
const rows = latest.current ?? loaded.data;
|
|
103
|
-
if (!rows || from ===
|
|
124
|
+
if (!rows || from === dropped) return;
|
|
125
|
+
const to = rule ? placeAllowed(rows, from, dropped, rule) : dropped;
|
|
126
|
+
if (from === to) return;
|
|
104
127
|
latest.current = arrayMove(rows, from, to);
|
|
105
128
|
const first = Math.min(from, to);
|
|
106
129
|
const last = Math.max(from, to);
|
|
@@ -113,7 +136,27 @@ const useHandOrder = ({ model, field, filter, fields }) => {
|
|
|
113
136
|
};
|
|
114
137
|
setMoved(latest.current);
|
|
115
138
|
waiting.current += 1;
|
|
116
|
-
saving.current = saving.current.then(save).catch(refused).then(settle);
|
|
139
|
+
saving.current = saving.current.then(save).catch(refused(text("couldNotChange"))).then(settle);
|
|
140
|
+
};
|
|
141
|
+
const placeAllowed = (rows, from, dropped, rule) => {
|
|
142
|
+
const allowed = allowedDrop(rows.map((row) => displayRow(model, row).record), from, dropped, rule);
|
|
143
|
+
if (allowed.reason !== void 0) toast.show({
|
|
144
|
+
state: "warning",
|
|
145
|
+
title: allowed.to === from ? text("notMoved") : text("movedToPlace", allowed.to + 1),
|
|
146
|
+
description: allowed.reason
|
|
147
|
+
});
|
|
148
|
+
return allowed.to;
|
|
149
|
+
};
|
|
150
|
+
const undoSave = (ops) => {
|
|
151
|
+
if (undoToast.current !== void 0) toast.dismiss(undoToast.current);
|
|
152
|
+
undoToast.current = void 0;
|
|
153
|
+
waiting.current += 1;
|
|
154
|
+
saving.current = saving.current.then(async () => {
|
|
155
|
+
await client.batchWrite(ops);
|
|
156
|
+
latest.current = void 0;
|
|
157
|
+
pending.current = void 0;
|
|
158
|
+
setMoved(void 0);
|
|
159
|
+
}).catch(refused(text("couldNotUndo"))).then(settle);
|
|
117
160
|
};
|
|
118
161
|
return {
|
|
119
162
|
rows: moved ?? loaded.data,
|
package/dist/app/list-page.js
CHANGED
|
@@ -31,7 +31,7 @@ import { facetNames, filterChips } from "./searches/saved-search.js";
|
|
|
31
31
|
import { useSeedRecord } from "./record-preview.js";
|
|
32
32
|
import { SignInPrompt } from "../auth/sign-in-prompt.js";
|
|
33
33
|
import { relatedColumnLoads } from "./related-dots/dot-card.js";
|
|
34
|
-
import { listHandOrder } from "./hand-order/hand-order.js";
|
|
34
|
+
import { dragsRank, listHandOrder } from "./hand-order/hand-order.js";
|
|
35
35
|
import { useDisplayRows } from "./displays/use-display-rows.js";
|
|
36
36
|
import { BoardDisplay } from "./displays/board-display.js";
|
|
37
37
|
import { CalendarDisplay } from "./displays/calendar-display.js";
|
|
@@ -176,6 +176,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
176
176
|
const handOrder = scope ? void 0 : listHandOrder(model, permissions);
|
|
177
177
|
const [sorting, setSorting] = useState(false);
|
|
178
178
|
const handSorting = sorting && handOrder !== void 0;
|
|
179
|
+
const dragging = !display && !handSorting && dragsRank(view, handOrder, orderBy, loadFilter);
|
|
179
180
|
const pageScroll = handSorting || display?.kind !== "map";
|
|
180
181
|
usePageScroll(pageScroll);
|
|
181
182
|
const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
|
|
@@ -316,7 +317,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
316
317
|
},
|
|
317
318
|
children: [/* @__PURE__ */ jsx(Download, { className: "size-3.5" }), desktop && text$1("export")]
|
|
318
319
|
});
|
|
319
|
-
const sortButton = handOrder && /* @__PURE__ */ jsx(ReorderButton, {
|
|
320
|
+
const sortButton = handOrder && !dragging && /* @__PURE__ */ jsx(ReorderButton, {
|
|
320
321
|
sorting: handSorting,
|
|
321
322
|
onChange: setSorting,
|
|
322
323
|
iconOnly: !desktop
|
|
@@ -397,6 +398,17 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
397
398
|
create: createButton
|
|
398
399
|
});
|
|
399
400
|
const drawDisplay = () => {
|
|
401
|
+
if (dragging) return /* @__PURE__ */ jsx(HandSortedList, {
|
|
402
|
+
model,
|
|
403
|
+
view,
|
|
404
|
+
field: handOrder,
|
|
405
|
+
filter: "",
|
|
406
|
+
columns: listColumnNames(model, view),
|
|
407
|
+
sort: {
|
|
408
|
+
value: parseSort(orderBy),
|
|
409
|
+
onChange: (sort) => set({ orderBy: sortToOrderBy(sort) })
|
|
410
|
+
}
|
|
411
|
+
});
|
|
400
412
|
if (!display) return rows.source?.total === 0 && !rows.error ? empty : table;
|
|
401
413
|
if (display.kind === "cards") return /* @__PURE__ */ jsx(ListCards, {
|
|
402
414
|
model,
|
|
@@ -2,7 +2,7 @@ import { ProjectUi } from "./project-ui.js";
|
|
|
2
2
|
//#region src/app/pages/merge-ui.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
|
|
5
|
-
* handlers
|
|
5
|
+
* handlers, field types and drop rules are all kept, and one name defined by two configs is an error naming both; every shell slot
|
|
6
6
|
* and `beforeSignIn` component is drawn, the extended configs' first, except the logo, the last config's to set one.
|
|
7
7
|
*/
|
|
8
8
|
export declare const mergeUi: (...uis: ProjectUi[]) => ProjectUi;
|
|
@@ -35,7 +35,7 @@ const around = (wrappers) => {
|
|
|
35
35
|
};
|
|
36
36
|
/**
|
|
37
37
|
* One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
|
|
38
|
-
* handlers
|
|
38
|
+
* handlers, field types and drop rules are all kept, and one name defined by two configs is an error naming both; every shell slot
|
|
39
39
|
* and `beforeSignIn` component is drawn, the extended configs' first, except the logo, the last config's to set one.
|
|
40
40
|
*/
|
|
41
41
|
const mergeUi = (...uis) => {
|
|
@@ -43,6 +43,7 @@ const mergeUi = (...uis) => {
|
|
|
43
43
|
const components = union("The component", named, (ui) => ui.components);
|
|
44
44
|
const actions = union("The action handler", named, (ui) => ui.actions);
|
|
45
45
|
const fields = union("The field type", named, (ui) => ui.fields);
|
|
46
|
+
const dropRules = union("The drop rule", named, (ui) => ui.dropRules);
|
|
46
47
|
const actionSlot = slot(named.flatMap(({ ui }) => ui.shell?.actions ? [ui.shell.actions] : []));
|
|
47
48
|
const rightPanel = slot(named.flatMap(({ ui }) => ui.shell?.rightPanel ? [ui.shell.rightPanel] : []));
|
|
48
49
|
const logo = named.findLast(({ ui }) => ui.shell?.logo)?.ui.shell?.logo;
|
|
@@ -53,6 +54,7 @@ const mergeUi = (...uis) => {
|
|
|
53
54
|
components,
|
|
54
55
|
actions,
|
|
55
56
|
...Object.keys(fields).length > 0 && { fields },
|
|
57
|
+
...Object.keys(dropRules).length > 0 && { dropRules },
|
|
56
58
|
...(actionSlot || rightPanel || logo) && { shell: {
|
|
57
59
|
...actionSlot && { actions: actionSlot },
|
|
58
60
|
...rightPanel && { rightPanel },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ShellLogoProps } from "../../app-shell/logo.js";
|
|
2
2
|
import { FieldWidget } from "../field-types/field-widget.js";
|
|
3
|
+
import { DropRule } from "../hand-order/drop-rule.js";
|
|
3
4
|
import { ComponentType, ReactNode } from "react";
|
|
4
5
|
import { Client } from "@protobase/client";
|
|
5
6
|
import { AppAddress } from "@protobase/schema";
|
|
@@ -43,9 +44,9 @@ export type BeforeSignInProps = {
|
|
|
43
44
|
/**
|
|
44
45
|
* The project's own React code for composed pages: custom components by the name their layout declares
|
|
45
46
|
* (`component('UsageChart')`) or a widget part names, handlers for named actions that have no built-in behaviour,
|
|
46
|
-
* field types by the name a view's `.widget(name)` chooses,
|
|
47
|
-
* before it (see `mergeUi`), or whole `protobase.ui.tsx` modules
|
|
48
|
-
* components included; `name` names it in their errors.
|
|
47
|
+
* field types by the name a view's `.widget(name)` chooses, drop rules by the name a list's `drag.rule` gives, and the
|
|
48
|
+
* shell's slots. `extends` lists UI configs merged in before it (see `mergeUi`), or whole `protobase.ui.tsx` modules
|
|
49
|
+
* (`import * as tasksUi from '...'`), their exported components included; `name` names it in their errors.
|
|
49
50
|
*/
|
|
50
51
|
export type ProjectUi = {
|
|
51
52
|
name?: string;
|
|
@@ -54,6 +55,8 @@ export type ProjectUi = {
|
|
|
54
55
|
actions?: Record<string, ActionHandler>;
|
|
55
56
|
/** Field types by name, each with the storage types it shows and its components (see `FieldWidget`). */
|
|
56
57
|
fields?: Record<string, FieldWidget>;
|
|
58
|
+
/** Where re-ranked records may land, by the name a view's `drag.rule` gives (see `DropRule`). */
|
|
59
|
+
dropRules?: Record<string, DropRule>;
|
|
57
60
|
/**
|
|
58
61
|
* Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page, and `logo`
|
|
59
62
|
* at the top of the sidebar in place of Protobase's (see `ShellLogoProps`).
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LogoMark, ShellLogoProps } from "./app-shell/logo.js";
|
|
2
2
|
import { FieldWidget, FieldWidgetEditorProps, FieldWidgetProps } from "./app/field-types/field-widget.js";
|
|
3
|
+
import { Drop, DropRule } from "./app/hand-order/drop-rule.js";
|
|
3
4
|
import { ActionContext, ActionHandler, BeforeSignInProps, ProjectUi, definePlugin, defineUi } from "./app/pages/project-ui.js";
|
|
4
5
|
import { PluginInfo, usePlugin } from "./plugins/plugin-context.js";
|
|
5
6
|
import { App, AppProps } from "./app/app.js";
|
|
@@ -61,4 +62,4 @@ import { Section } from "./record-view/section.js";
|
|
|
61
62
|
import { Form, FormProps } from "./app/create/form.js";
|
|
62
63
|
import { RecordPicker, RecordPickerProps } from "./app/record-picker.js";
|
|
63
64
|
import { Spinner } from "./primitives/spinner.js";
|
|
64
|
-
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, type BackendData, type BackendRefresh, type BeforeSignInProps, Button, type ButtonProps, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, type ChoiceOption, Choices, type ChoicesProps, CodeForm, type CodeFormProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, Dialog, type DialogProps, type DiffLine, DiffView, type DiffViewProps, Field, FieldList, type FieldProps, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, Form, type FormProps, Input, type InputProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, LogoMark, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PluginInfo, type ProjectUi, type RecordCardProps, RecordLink, RecordPicker, type RecordPickerProps, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, Spinner, type Step, StepList, type StepState, Textarea, type TextareaProps, buttonClass, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
|
|
65
|
+
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, type BackendData, type BackendRefresh, type BeforeSignInProps, Button, type ButtonProps, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, type ChoiceOption, Choices, type ChoicesProps, CodeForm, type CodeFormProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, Dialog, type DialogProps, type DiffLine, DiffView, type DiffViewProps, type Drop, type DropRule, Field, FieldList, type FieldProps, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, Form, type FormProps, Input, type InputProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, LogoMark, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PluginInfo, type ProjectUi, type RecordCardProps, RecordLink, RecordPicker, type RecordPickerProps, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, Spinner, type Step, StepList, type StepState, Textarea, type TextareaProps, buttonClass, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
|
package/dist/package.js
CHANGED