@protobase/ui 0.23.14 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 with that column's value. When the
33
- * resource has a position, its records ordered as one, the columns are in its order and a card is dropped at a place:
34
- * the up and down arrow keys move it within its column, and the drop renumbers the cards before it.
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 movable = can(permissions, "update") && !model.fields[display.by]?.readOnly;
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 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." }
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 { useState } from "react";
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 refusal puts it back and says why, and either way every list of the
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
- return client.update(model.name, recordKey(model, row.record), { [by]: to.value }, etag);
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}`;
@@ -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 };
@@ -17,24 +17,26 @@ import { jsx } from "react/jsx-runtime";
17
17
  * row, or moving it with the keyboard from its handle, renumbers them all. The page scrolls them, and a row still opens
18
18
  * its record.
19
19
  */
20
- const HandSortedList = ({ model, view, field, filter, columns }) => {
20
+ const HandSortedList = ({ model, view, field, filter, columns, sort }) => {
21
21
  const { resources, views } = useAdminMeta();
22
22
  const { fields: fieldTypes } = useProjectUi();
23
23
  const { go, href } = useAppAddress();
24
+ const sorts = sort !== void 0;
24
25
  const specs = useMemo(() => embeddedColumns(model, view, columns, {
25
26
  resources,
26
27
  views,
27
28
  fieldTypes
28
- }).map((spec) => ({
29
+ }).map((spec) => sorts ? spec : {
29
30
  ...spec,
30
31
  sortable: false
31
- })), [
32
+ }), [
32
33
  model,
33
34
  view,
34
35
  columns.join(","),
35
36
  resources,
36
37
  views,
37
- fieldTypes
38
+ fieldTypes,
39
+ sorts
38
40
  ]);
39
41
  const fields = useMemo(() => loadedFields(model, view, specs.map((spec) => spec.id)), [
40
42
  model,
@@ -85,6 +87,10 @@ const HandSortedList = ({ model, view, field, filter, columns }) => {
85
87
  latencyMs: 0,
86
88
  onOpenRow: openRecord,
87
89
  rowLink: recordLink,
90
+ ...sort && {
91
+ sort: sort.value,
92
+ onSortChange: sort.onChange
93
+ },
88
94
  rowNumbers: false,
89
95
  reorder: {
90
96
  label: (row) => recordTitle(model, view, row, row.id),
@@ -6,14 +6,20 @@ 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"
10
13
  },
11
14
  nl: {
12
15
  done: "Klaar",
13
16
  reorder: "Volgorde wijzigen",
14
17
  couldNotLoad: "Kon niet laden",
15
18
  nothingYet: "Hier staat nog niets.",
16
- couldNotChange: "Kon de volgorde niet wijzigen"
19
+ couldNotChange: "Kon de volgorde niet wijzigen",
20
+ orderChanged: "Volgorde gewijzigd",
21
+ undo: "Ongedaan maken",
22
+ couldNotUndo: "Kon niet ongedaan maken"
17
23
  }
18
24
  });
19
25
  //#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 };
@@ -7,6 +7,7 @@ import { useAdminMeta } from "../meta-gate.js";
7
7
  import { useRefreshResource } from "../pages/use-layout-data.js";
8
8
  import { listLabels, withLabels } from "../../live/relation-labels.js";
9
9
  import { reorderSpan } from "./reorder-span.js";
10
+ import { undoOps } from "./undo-ops.js";
10
11
  import { text } from "./messages.js";
11
12
  import { useRef, useState } from "react";
12
13
  import { encodeKey } from "@protobase/schema/internal";
@@ -17,7 +18,8 @@ import { arrayMove } from "@dnd-kit/sortable";
17
18
  * The records of `filter` in the order of `field`, all of them, a page of `handOrderPage` at a time, and `move` to put
18
19
  * one elsewhere. A move shows at once and saves only the records whose place changed (see `reorderSpan`) in one batch
19
20
  * `reorder` with their ETags; moves made while one saves are saved after it, with the values and ETags it gave back.
20
- * A refusal puts them back as the server has them and says why; either way every list of the resource reloads.
21
+ * Each save says so with **Undo**, which puts back the values it changed. A refusal puts them back as the server has
22
+ * them and says why; either way every list of the resource reloads.
21
23
  */
22
24
  const useHandOrder = ({ model, field, filter, fields }) => {
23
25
  const client = useClient();
@@ -53,6 +55,7 @@ const useHandOrder = ({ model, field, filter, fields }) => {
53
55
  const pending = useRef(void 0);
54
56
  const saving = useRef(Promise.resolve());
55
57
  const waiting = useRef(0);
58
+ const undoToast = useRef(void 0);
56
59
  const save = async () => {
57
60
  const rows = latest.current;
58
61
  const changed = pending.current;
@@ -69,7 +72,22 @@ const useHandOrder = ({ model, field, filter, fields }) => {
69
72
  start: span.start,
70
73
  etags
71
74
  }])).results[0];
72
- if (written?.op !== "reorder" || !latest.current) return;
75
+ if (written?.op !== "reorder") return;
76
+ const before = new Map(sent.map((row) => [row.id, { [field]: row[field] }]));
77
+ const undo = undoOps(model, written.records.map((entry) => ({
78
+ ...entry,
79
+ before: before.get(recordId(model, entry.record)) ?? {}
80
+ })), [field]);
81
+ if (undoToast.current !== void 0) toast.dismiss(undoToast.current);
82
+ undoToast.current = toast.show({
83
+ state: "success",
84
+ title: text("orderChanged"),
85
+ action: {
86
+ label: text("undo"),
87
+ onClick: () => undoSave(undo)
88
+ }
89
+ });
90
+ if (!latest.current) return;
73
91
  const stored = new Map(written.records.map((entry) => [recordId(model, entry.record), entry]));
74
92
  latest.current = latest.current.map((row) => {
75
93
  const entry = stored.get(row.id);
@@ -80,13 +98,13 @@ const useHandOrder = ({ model, field, filter, fields }) => {
80
98
  } : row;
81
99
  });
82
100
  };
83
- const refused = (error) => {
101
+ const refused = (title) => (error) => {
84
102
  latest.current = void 0;
85
103
  pending.current = void 0;
86
104
  setMoved(void 0);
87
105
  toast.show({
88
106
  state: "error",
89
- title: text("couldNotChange"),
107
+ title,
90
108
  description: refusalReason(error)
91
109
  });
92
110
  };
@@ -113,7 +131,18 @@ const useHandOrder = ({ model, field, filter, fields }) => {
113
131
  };
114
132
  setMoved(latest.current);
115
133
  waiting.current += 1;
116
- saving.current = saving.current.then(save).catch(refused).then(settle);
134
+ saving.current = saving.current.then(save).catch(refused(text("couldNotChange"))).then(settle);
135
+ };
136
+ const undoSave = (ops) => {
137
+ if (undoToast.current !== void 0) toast.dismiss(undoToast.current);
138
+ undoToast.current = void 0;
139
+ waiting.current += 1;
140
+ saving.current = saving.current.then(async () => {
141
+ await client.batchWrite(ops);
142
+ latest.current = void 0;
143
+ pending.current = void 0;
144
+ setMoved(void 0);
145
+ }).catch(refused(text("couldNotUndo"))).then(settle);
117
146
  };
118
147
  return {
119
148
  rows: moved ?? loaded.data,
@@ -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,
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.23.14";
2
+ var version = "0.24.0";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };