@protobase/ui 0.22.8 → 0.22.10

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.
Files changed (30) hide show
  1. package/dist/app/current-address.d.ts +1 -0
  2. package/dist/app/current-address.js +2 -1
  3. package/dist/app/displays/board-column.js +63 -21
  4. package/dist/app/displays/board-columns.js +3 -1
  5. package/dist/app/displays/board-display.js +62 -11
  6. package/dist/app/displays/board-dnd.js +41 -5
  7. package/dist/app/displays/board-place.js +45 -0
  8. package/dist/app/displays/use-board-move.js +18 -9
  9. package/dist/app/list-page.js +3 -2
  10. package/dist/app/record-mode/shown-layout.js +16 -10
  11. package/dist/app/record-mode/use-shown-layout.js +2 -2
  12. package/dist/app/record-page.js +39 -14
  13. package/dist/app/record-tabs.js +31 -0
  14. package/dist/app/related-section.js +1 -1
  15. package/dist/app/use-focus-first-invalid.js +10 -3
  16. package/dist/package.js +1 -1
  17. package/dist/record-view/record-tabs.js +22 -0
  18. package/dist/shell/assets/app-CrmCFhlg.js +69 -0
  19. package/dist/shell/assets/index-BflGBHJH.css +2 -0
  20. package/dist/shell/assets/{index-BU2imrOR.js → index-Wkeo9_-S.js} +1 -1
  21. package/dist/shell/assets/{maplibre-CpkT3k-d.js → maplibre-BPlEFxat.js} +1 -1
  22. package/dist/shell/assets/{markdown-content-Cu8Q0N0V.js → markdown-content-DD9stPkw.js} +1 -1
  23. package/dist/shell/assets/{record-map-DMFFj00J.js → record-map-BGWjTC-n.js} +1 -1
  24. package/dist/shell/assets/{records-map-C1VrhxtO.js → records-map-Rt00G5XI.js} +1 -1
  25. package/dist/shell/assets/{shared-protobase-ui-DyjCtT2d.js → shared-protobase-ui-BdPdyvgu.js} +1 -1
  26. package/dist/shell/index.html +4 -4
  27. package/dist/shell/protobase.shell.json +1 -1
  28. package/package.json +4 -4
  29. package/dist/shell/assets/app-DivHWgft.js +0 -69
  30. package/dist/shell/assets/index-BtU8c1DC.css +0 -2
@@ -23,6 +23,7 @@ export type CurrentAddress = ListAddress | {
23
23
  key: string;
24
24
  edit?: true;
25
25
  open?: readonly string[];
26
+ tab?: string;
26
27
  } | {
27
28
  kind: 'create';
28
29
  resource: string;
@@ -35,7 +35,8 @@ const currentAddress = (read, defaults) => {
35
35
  resource: read.resource,
36
36
  key: encodeKey(read.key),
37
37
  ...read.edit && { edit: true },
38
- ...read.open && { open: read.open }
38
+ ...read.open && { open: read.open },
39
+ ...read.tab && { tab: read.tab }
39
40
  };
40
41
  if (read?.kind !== "list") return read;
41
42
  return {
@@ -10,10 +10,20 @@ import { RecordCard } from "./record-card.js";
10
10
  import { columnId } from "./board-dnd.js";
11
11
  import { useBoardColumn } from "./use-board-column.js";
12
12
  import { jsx, jsxs } from "react/jsx-runtime";
13
- import { useDraggable, useDroppable } from "@dnd-kit/core";
13
+ import { useDroppable } from "@dnd-kit/core";
14
+ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
14
15
  //#region src/app/displays/board-column.tsx
15
- /** One column of a board: its loaded records, those on their way out shown in their new column, and those on their way in at its top. */
16
- const BoardColumnView = ({ model, view, display, column, fields, filter, orderBy, limit, moves, movable, dragged }) => {
16
+ const inOrder = (rows, order) => {
17
+ if (!order) return rows;
18
+ const byKey = new Map(rows.map((row) => [row.key, row]));
19
+ const named = new Set(order);
20
+ return [...order.flatMap((key) => byKey.get(key) ?? []), ...rows.filter((row) => !named.has(row.key))];
21
+ };
22
+ /**
23
+ * One column of a board: its loaded records, those on their way out shown in their new column, and those on their way
24
+ * in at its top, or on a ranked board at the place they were dropped.
25
+ */
26
+ const BoardColumnView = ({ model, view, display, column, fields, filter, orderBy, limit, moves, movable, placeable, dragged }) => {
17
27
  const list = useBoardColumn({
18
28
  model,
19
29
  by: display.by,
@@ -22,14 +32,21 @@ const BoardColumnView = ({ model, view, display, column, fields, filter, orderBy
22
32
  filter,
23
33
  orderBy
24
34
  });
25
- const { setNodeRef, isOver } = useDroppable({ id: columnId(column.value) });
26
35
  const card = boardCard(model, view, display);
27
36
  const loaded = new Set(list.rows.map((row) => row.key));
28
37
  const moving = new Map(moves.map((entry) => [entry.row.key, entry]));
29
38
  const leaving = list.rows.filter((row) => moving.has(row.key) && moving.get(row.key).to !== column.value);
30
39
  const arriving = moves.filter((entry) => entry.to === column.value && !loaded.has(entry.row.key)).map((entry) => entry.row);
31
40
  const staying = list.rows.filter((row) => !leaving.includes(row)).map((row) => moving.has(row.key) ? moving.get(row.key).row : row);
32
- const rows = [...arriving, ...staying].slice(0, limit);
41
+ const placing = moves.findLast((entry) => entry.to === column.value && entry.order);
42
+ const rows = inOrder([...arriving, ...staying], placing?.order).slice(0, limit);
43
+ const { setNodeRef, isOver } = useDroppable({
44
+ id: columnId(column.value),
45
+ data: {
46
+ column,
47
+ rows
48
+ }
49
+ });
33
50
  const count = list.total - leaving.length + arriving.length;
34
51
  if (column.value === null && !list.loading && count === 0 && !isOver) return null;
35
52
  const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
@@ -62,16 +79,24 @@ const BoardColumnView = ({ model, view, display, column, fields, filter, orderBy
62
79
  }) : rows.length === 0 ? /* @__PURE__ */ jsxs("p", {
63
80
  className: "px-1 py-4 text-center text-xs text-muted-foreground",
64
81
  children: ["No ", plural]
65
- }) : /* @__PURE__ */ jsx("ul", {
66
- className: "flex flex-col gap-2",
67
- children: rows.map((row) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(BoardCard, {
68
- model,
69
- view,
70
- card,
71
- row,
72
- movable: movable && rowAllows(row.record, "update"),
73
- dragged
74
- }) }, row.key))
82
+ }) : /* @__PURE__ */ jsx(SortableContext, {
83
+ id: columnId(column.value),
84
+ items: rows.map((row) => row.key),
85
+ strategy: verticalListSortingStrategy,
86
+ children: /* @__PURE__ */ jsx("ul", {
87
+ className: "-mb-2 flex flex-col",
88
+ children: rows.map((row) => /* @__PURE__ */ jsx(BoardCard, {
89
+ model,
90
+ view,
91
+ card,
92
+ row,
93
+ column,
94
+ rows,
95
+ draggable: (movable || placeable) && rowAllows(row.record, "update"),
96
+ placeable,
97
+ dragged
98
+ }, row.key))
99
+ })
75
100
  }),
76
101
  limit === void 0 && list.more && /* @__PURE__ */ jsx(Button, {
77
102
  size: "sm",
@@ -82,13 +107,26 @@ const BoardColumnView = ({ model, view, display, column, fields, filter, orderBy
82
107
  ]
83
108
  });
84
109
  };
85
- const BoardCard = ({ model, view, card, row, movable, dragged }) => {
86
- const { setNodeRef, setActivatorNodeRef, listeners, isDragging } = useDraggable({
110
+ /**
111
+ * A record's card, picked up by dragging it or by Space on its link; one the user may not update stays put. Where the
112
+ * user may change the order of a ranked board, a card is dropped on too: in its own column the cards make room as it
113
+ * moves, in another a bar marks its place.
114
+ */
115
+ const BoardCard = ({ model, view, card, row, column, rows, draggable, placeable, dragged }) => {
116
+ const { setNodeRef, setActivatorNodeRef, listeners, isDragging, isOver, active, transform, transition } = useSortable({
87
117
  id: row.key,
88
- data: { row },
89
- disabled: !movable
118
+ data: {
119
+ row,
120
+ column,
121
+ rows
122
+ },
123
+ disabled: {
124
+ draggable: !draggable,
125
+ droppable: !placeable
126
+ }
90
127
  });
91
- return /* @__PURE__ */ jsx("div", {
128
+ const arriving = isOver && (active?.data.current?.column)?.value !== column.value;
129
+ return /* @__PURE__ */ jsx("li", {
92
130
  ref: (element) => {
93
131
  setNodeRef(element);
94
132
  setActivatorNodeRef(element?.querySelector("a") ?? element);
@@ -100,7 +138,11 @@ const BoardCard = ({ model, view, card, row, movable, dragged }) => {
100
138
  event.preventDefault();
101
139
  event.stopPropagation();
102
140
  },
103
- className: cn(movable && "cursor-grab touch-manipulation", isDragging && "opacity-40"),
141
+ style: {
142
+ transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : void 0,
143
+ transition
144
+ },
145
+ className: cn("relative pb-2", draggable && "cursor-grab touch-manipulation", isDragging && "opacity-40", arriving && "before:absolute before:inset-x-0 before:-top-1 before:h-0.5 before:rounded-full before:bg-primary"),
104
146
  children: /* @__PURE__ */ jsx(RecordCard, {
105
147
  model,
106
148
  view,
@@ -21,6 +21,8 @@ const boardColumns = (model, view, display) => {
21
21
  label: `No ${(hints?.label ?? humanize(display.by)).toLowerCase()}`
22
22
  }];
23
23
  };
24
+ /** The field a board orders each column by: the resource's `.position()`, when its records are ordered as one rather than within a parent. */
25
+ const boardRank = (model) => model.position && model.position.within === void 0 ? model.position.field : void 0;
24
26
  /** The list's filter, its search included, narrowed to the records of one column. */
25
27
  const columnFilter = (filter, by, value) => {
26
28
  const column = printFilter(value === null ? where.isNull(by) : where.eq(by, value));
@@ -42,4 +44,4 @@ const refusalReason = (error) => {
42
44
  return reasons.length > 0 ? reasons.join("; ") : error.message;
43
45
  };
44
46
  //#endregion
45
- export { boardCard, boardColumns, columnFilter, refusalReason };
47
+ export { boardCard, boardColumns, boardRank, columnFilter, refusalReason };
@@ -1,25 +1,47 @@
1
- import { boardCard, boardColumns } from "./board-columns.js";
1
+ import { boardCard, boardColumns, boardRank } from "./board-columns.js";
2
2
  import { usePermissions } from "../meta-gate.js";
3
3
  import { can } from "../permissions.js";
4
4
  import { RecordCard } from "./record-card.js";
5
- import { collision, columnCoordinates, columnId } from "./board-dnd.js";
5
+ import { listHandOrder } from "../hand-order/hand-order.js";
6
+ import { cardCoordinates, collision, columnId } from "./board-dnd.js";
6
7
  import { BoardColumnView } from "./board-column.js";
7
8
  import { columnsAllowed } from "./board-allowed.js";
9
+ import { placedRows } from "./board-place.js";
8
10
  import { useBoardMove } from "./use-board-move.js";
9
11
  import { useRef, useState } from "react";
10
12
  import { jsx, jsxs } from "react/jsx-runtime";
11
13
  import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
12
14
  //#region src/app/displays/board-display.tsx
15
+ const placeOver = (over, row) => {
16
+ const data = over.data.current;
17
+ if (!data?.column || !data.rows) return void 0;
18
+ if (data.sortable) return {
19
+ column: data.column,
20
+ rows: data.rows,
21
+ index: data.sortable.index
22
+ };
23
+ const own = data.rows.some((entry) => entry.key === row.key);
24
+ return {
25
+ column: data.column,
26
+ rows: data.rows,
27
+ index: own ? data.rows.length - 1 : data.rows.length
28
+ };
29
+ };
13
30
  /**
14
31
  * A column per value of the board's enum field, each loading its own records through the list API, with their count;
15
- * a card dragged, or moved with the keyboard, to another column saves the record with that column's value.
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.
16
35
  */
17
36
  const BoardDisplay = ({ model, view, display, fields, filter, orderBy, limit }) => {
18
37
  const columns = columnsAllowed(filter, display.by, boardColumns(model, view, display));
19
38
  const card = boardCard(model, view, display);
20
39
  const permissions = usePermissions(model.name);
21
40
  const movable = can(permissions, "update") && !model.fields[display.by]?.readOnly;
22
- const { moves, move } = useBoardMove(model, display.by);
41
+ const rank = boardRank(model);
42
+ const placeable = rank !== void 0 && listHandOrder(model, permissions) !== void 0;
43
+ const columnOrder = rank ? `${rank} asc` : orderBy;
44
+ const { moves, move } = useBoardMove(model, display.by, rank);
23
45
  const [active, setActive] = useState();
24
46
  const dragged = useRef(false);
25
47
  const sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: 4 } }), useSensor(TouchSensor, { activationConstraint: {
@@ -31,15 +53,30 @@ const BoardDisplay = ({ model, view, display, fields, filter, orderBy, limit })
31
53
  cancel: ["Escape"],
32
54
  end: ["Space", "Enter"]
33
55
  },
34
- coordinateGetter: columnCoordinates
56
+ coordinateGetter: cardCoordinates
35
57
  }));
36
58
  const title = (row) => String(row.labels[card.title] ?? row.record[card.title] ?? row.key);
37
59
  const columnOf = (id) => columns.find((column) => columnId(column.value) === id);
38
60
  const valueOf = (row) => row.record[display.by] ?? null;
61
+ const announced = (row, over) => {
62
+ if (!over) return { over: "no column" };
63
+ const place = placeable ? placeOver(over, row) : void 0;
64
+ if (!place) return { over: columnOf(over.id)?.label ?? "no column" };
65
+ const count = place.rows.some((entry) => entry.key === row.key) ? place.rows.length : place.rows.length + 1;
66
+ return {
67
+ over: `${place.column.label}, place ${place.index + 1} of ${count}`,
68
+ place: `${place.column.label}, place ${place.index + 1}`
69
+ };
70
+ };
39
71
  const announcements = {
40
72
  onDragStart: ({ active: picked }) => `Picked up ${title(picked.data.current.row)}.`,
41
- onDragOver: ({ active: picked, over }) => `${title(picked.data.current.row)} is over ${over ? columnOf(over.id)?.label : "no column"}.`,
42
- onDragEnd: ({ active: picked, over }) => over ? `${title(picked.data.current.row)} was moved to ${columnOf(over.id)?.label}.` : `${title(picked.data.current.row)} was put back.`,
73
+ onDragOver: ({ active: picked, over }) => `${title(picked.data.current.row)} is over ${announced(picked.data.current.row, over).over}.`,
74
+ onDragEnd: ({ active: picked, over }) => {
75
+ const row = picked.data.current.row;
76
+ if (!over) return `${title(row)} was put back.`;
77
+ const said = announced(row, over);
78
+ return `${title(row)} was moved to ${said.place ?? said.over}.`;
79
+ },
43
80
  onDragCancel: ({ active: picked }) => `Moving ${title(picked.data.current.row)} was cancelled.`
44
81
  };
45
82
  const start = ({ active: picked }) => {
@@ -50,8 +87,21 @@ const BoardDisplay = ({ model, view, display, fields, filter, orderBy, limit })
50
87
  setActive(void 0);
51
88
  setTimeout(() => dragged.current = false);
52
89
  const row = picked.data.current.row;
53
- const target = over ? columnOf(over.id) : void 0;
54
- if (target && target.value !== valueOf(row)) move(row, target);
90
+ if (!over) return;
91
+ const place = placeable ? placeOver(over, row) : void 0;
92
+ if (!place) {
93
+ const target = columnOf(over.id);
94
+ if (target && target.value !== valueOf(row) && movable) move(row, target);
95
+ return;
96
+ }
97
+ const from = place.rows.findIndex((entry) => entry.key === row.key);
98
+ if (from === -1 && !movable) return;
99
+ if (from === place.index) return;
100
+ const order = placedRows(place.rows, row, place.index);
101
+ move(row, place.column, {
102
+ order,
103
+ changed: from === -1 ? place.index : Math.max(from, place.index)
104
+ });
55
105
  };
56
106
  return /* @__PURE__ */ jsxs(DndContext, {
57
107
  sensors,
@@ -64,7 +114,7 @@ const BoardDisplay = ({ model, view, display, fields, filter, orderBy, limit })
64
114
  },
65
115
  accessibility: {
66
116
  announcements,
67
- screenReaderInstructions: { draggable: "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." }
117
+ 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." }
68
118
  },
69
119
  children: [/* @__PURE__ */ jsx("div", {
70
120
  className: "relative min-w-0 flex-1 overflow-x-auto",
@@ -77,10 +127,11 @@ const BoardDisplay = ({ model, view, display, fields, filter, orderBy, limit })
77
127
  column,
78
128
  fields,
79
129
  filter,
80
- orderBy,
130
+ orderBy: columnOrder,
81
131
  limit,
82
132
  moves,
83
133
  movable,
134
+ placeable,
84
135
  dragged: () => dragged.current
85
136
  }, columnId(column.value)))
86
137
  })
@@ -2,17 +2,24 @@ import { pointerWithin, rectIntersection } from "@dnd-kit/core";
2
2
  //#region src/app/displays/board-dnd.ts
3
3
  /** The id a board's column is dropped on by. */
4
4
  const columnId = (value) => value === null ? "none" : `value:${value}`;
5
- /** A pointer drops a card on the column under it; the keyboard, which has no pointer, on the column the card overlaps. */
5
+ const isCard = (container) => container.data.current?.sortable !== void 0;
6
+ /**
7
+ * A pointer drops a card on the card under it, on a ranked board, else on the column under it; the keyboard, which has
8
+ * no pointer, on what the card overlaps.
9
+ */
6
10
  const collision = (args) => {
7
11
  const under = pointerWithin(args);
12
+ const card = under.find((entry) => entry.data?.droppableContainer && isCard(entry.data.droppableContainer));
13
+ if (card) return [card];
8
14
  return under.length > 0 ? under : rectIntersection(args);
9
15
  };
10
- /** The arrow keys move a picked-up card to the top of the next column to the left or right. */
16
+ /** The left and right arrow keys move a picked-up card to the top of the next column, onto its first card when it has one. */
11
17
  const columnCoordinates = (event, { context: { collisionRect, droppableRects, droppableContainers, over } }) => {
12
18
  const step = event.code === "ArrowRight" ? 1 : event.code === "ArrowLeft" ? -1 : 0;
13
19
  if (step === 0 || !collisionRect) return void 0;
14
20
  event.preventDefault();
15
- const columns = droppableContainers.getEnabled().flatMap((container) => {
21
+ const enabled = droppableContainers.getEnabled();
22
+ const columns = enabled.filter((container) => !isCard(container)).flatMap((container) => {
16
23
  const rect = droppableRects.get(container.id);
17
24
  return rect ? [{
18
25
  id: container.id,
@@ -20,13 +27,42 @@ const columnCoordinates = (event, { context: { collisionRect, droppableRects, dr
20
27
  }] : [];
21
28
  }).sort((a, b) => a.rect.left - b.rect.left);
22
29
  const center = collisionRect.left + collisionRect.width / 2;
23
- const at = over ? columns.findIndex((column) => column.id === over.id) : columns.findIndex((column) => column.rect.left <= center && center <= column.rect.right);
30
+ const current = over?.data.current?.sortable?.containerId ?? over?.id;
31
+ const at = current !== void 0 ? columns.findIndex((column) => column.id === current) : columns.findIndex((column) => column.rect.left <= center && center <= column.rect.right);
24
32
  const target = columns[at + step];
25
33
  if (at === -1 || !target) return void 0;
34
+ const first = cardRects(enabled, droppableRects, target.id)[0];
26
35
  return {
27
36
  x: target.rect.left + (target.rect.width - collisionRect.width) / 2,
37
+ y: first?.rect.top ?? target.rect.top
38
+ };
39
+ };
40
+ const cardRects = (enabled, rects, column) => enabled.filter((container) => container.data.current?.sortable?.containerId === column).flatMap((container) => {
41
+ const rect = rects.get(container.id);
42
+ return rect ? [{
43
+ id: container.id,
44
+ rect
45
+ }] : [];
46
+ }).sort((a, b) => a.rect.top - b.rect.top);
47
+ /**
48
+ * On a ranked board the up and down arrow keys move a picked-up card to the place of the card above or below it in its
49
+ * column; the left and right ones as `columnCoordinates` does. An unranked board has no cards to drop on, so up and down
50
+ * do nothing there.
51
+ */
52
+ const cardCoordinates = (event, args) => {
53
+ const step = event.code === "ArrowDown" ? 1 : event.code === "ArrowUp" ? -1 : 0;
54
+ if (step === 0) return columnCoordinates(event, args);
55
+ const { droppableRects, droppableContainers, over } = args.context;
56
+ const column = over?.data.current?.sortable?.containerId ?? over?.id;
57
+ if (column === void 0) return void 0;
58
+ const cards = cardRects(droppableContainers.getEnabled(), droppableRects, column);
59
+ const target = cards[(over?.id === column ? cards.length : cards.findIndex((card) => card.id === over?.id)) + step];
60
+ if (!target) return void 0;
61
+ event.preventDefault();
62
+ return {
63
+ x: target.rect.left,
28
64
  y: target.rect.top
29
65
  };
30
66
  };
31
67
  //#endregion
32
- export { collision, columnCoordinates, columnId };
68
+ export { cardCoordinates, collision, columnCoordinates, columnId };
@@ -0,0 +1,45 @@
1
+ import { recordKey } from "../../live/model-helpers.js";
2
+ import { encodeKey } from "@protobase/schema/internal";
3
+ import { arrayMove } from "@dnd-kit/sortable";
4
+ //#region src/app/displays/board-place.ts
5
+ /**
6
+ * The column's cards once `row` is dropped at `index`. In its own column it moves there, as a sortable list moves an
7
+ * item; in another it goes in before the card at `index`, or last.
8
+ */
9
+ const placedRows = (rows, row, index) => {
10
+ const from = rows.findIndex((entry) => entry.key === row.key);
11
+ if (from !== -1) return arrayMove(rows, from, index);
12
+ return [
13
+ ...rows.slice(0, index),
14
+ row,
15
+ ...rows.slice(index)
16
+ ];
17
+ };
18
+ const etagOf = (row) => typeof row.record.etag === "string" ? row.record.etag : "*";
19
+ /**
20
+ * The batch that saves a drop on a ranked board: in another column first the update of `by` with the card's ETag, then
21
+ * a `reorder` numbering the column's cards 1, 2, 3, … up to and including the one at `changed`, the last whose place
22
+ * changed. The cards after it already rank higher, so they keep their place unwritten, loaded or not.
23
+ */
24
+ const placeOps = (model, by, rank, row, column, order, changed) => {
25
+ const moving = (row.record[by] ?? null) !== column.value;
26
+ const prefix = order.slice(0, changed + 1);
27
+ const etags = Object.fromEntries(prefix.map((entry) => [encodeKey(recordKey(model, entry.record)), moving && entry.key === row.key ? "*" : etagOf(entry)]));
28
+ const reorder = {
29
+ op: "reorder",
30
+ resource: model.name,
31
+ field: rank,
32
+ keys: prefix.map((entry) => recordKey(model, entry.record)),
33
+ etags
34
+ };
35
+ if (!moving) return [reorder];
36
+ return [{
37
+ op: "update",
38
+ resource: model.name,
39
+ key: recordKey(model, row.record),
40
+ data: { [by]: column.value },
41
+ etag: etagOf(row)
42
+ }, reorder];
43
+ };
44
+ //#endregion
45
+ export { placeOps, placedRows };
@@ -3,26 +3,31 @@ 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
5
  import { useRefreshResource } from "../pages/use-layout-data.js";
6
+ import { placeOps } from "./board-place.js";
6
7
  import { useState } from "react";
7
8
  import { useMutation } from "@tanstack/react-query";
8
9
  //#region src/app/displays/use-board-move.ts
9
10
  /**
10
11
  * Moving a board's card to another column: one update of the record with the column's value and the ETag it was listed
11
- * with, as a named update action sends, so the resource's access rules and `validate` decide. The card shows in its new
12
- * column at once; a refusal puts it back and says why, and either way every list of the resource reloads.
12
+ * with, as a named update action sends, so the resource's access rules and `validate` decide. On a ranked board a drop
13
+ * 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.
13
16
  */
14
- const useBoardMove = (model, by) => {
17
+ const useBoardMove = (model, by, rank) => {
15
18
  const client = useClient();
16
19
  const toast = useToast();
17
20
  const refresh = useRefreshResource();
18
21
  const [moves, setMoves] = useState([]);
19
22
  const settle = (key) => setMoves((current) => current.filter((move) => move.row.key !== key));
20
23
  const mutation = useMutation({
21
- mutationFn: ({ row, to }) => {
24
+ mutationFn: async ({ row, to, place }) => {
25
+ 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.changed));
22
27
  const etag = typeof row.record.etag === "string" ? row.record.etag : "*";
23
28
  return client.update(model.name, recordKey(model, row.record), { [by]: to.value }, etag);
24
29
  },
25
- onMutate: ({ row, to }) => {
30
+ onMutate: ({ row, to, place }) => {
26
31
  const moved = {
27
32
  ...row,
28
33
  record: {
@@ -30,16 +35,19 @@ const useBoardMove = (model, by) => {
30
35
  [by]: to.value
31
36
  }
32
37
  };
38
+ const order = place?.order.map((entry) => entry.key);
33
39
  setMoves((current) => [...current.filter((move) => move.row.key !== row.key), {
34
40
  row: moved,
35
- to: to.value
41
+ to: to.value,
42
+ ...order && { order }
36
43
  }]);
37
44
  },
38
45
  onError: (error, { row, to }) => {
39
46
  settle(row.key);
47
+ const title = (row.record[by] ?? null) === to.value ? "Could not change the order" : `Could not move it to ${to.label}`;
40
48
  toast.show({
41
49
  state: "error",
42
- title: `Could not move it to ${to.label}`,
50
+ title,
43
51
  description: refusalReason(error)
44
52
  });
45
53
  },
@@ -50,9 +58,10 @@ const useBoardMove = (model, by) => {
50
58
  });
51
59
  return {
52
60
  moves,
53
- move: (row, to) => mutation.mutate({
61
+ move: (row, to, place) => mutation.mutate({
54
62
  row,
55
- to
63
+ to,
64
+ ...place && { place }
56
65
  })
57
66
  };
58
67
  };
@@ -6,6 +6,7 @@ import { Button, buttonClass } from "../primitives/button.js";
6
6
  import { humanize } from "../live/naming.js";
7
7
  import { recordKey, recordTitleField } from "../live/model-helpers.js";
8
8
  import { columnsFromModel, listColumnNames } from "../live/columns-from-model.js";
9
+ import { boardRank } from "./displays/board-columns.js";
9
10
  import { displayRow, listDisplayChoices, loadedFields, shownListDisplay } from "./displays/list-displays.js";
10
11
  import { parseSort, sortToOrderBy } from "./list-order.js";
11
12
  import { listedResource } from "./current-address.js";
@@ -29,6 +30,7 @@ import { facetNames, filterChips } from "./searches/saved-search.js";
29
30
  import { useSeedRecord } from "./record-preview.js";
30
31
  import { SignInPrompt } from "../auth/sign-in-prompt.js";
31
32
  import { relatedColumnLoads } from "./related-dots/dot-card.js";
33
+ import { listHandOrder } from "./hand-order/hand-order.js";
32
34
  import { useDisplayRows } from "./displays/use-display-rows.js";
33
35
  import { BoardDisplay } from "./displays/board-display.js";
34
36
  import { CalendarDisplay } from "./displays/calendar-display.js";
@@ -56,7 +58,6 @@ import { SavedList } from "./saved/saved-list.js";
56
58
  import { SaveSearchButton } from "./searches/save-search-button.js";
57
59
  import { SavedSearches } from "./searches/saved-searches.js";
58
60
  import { useListCellEditor } from "./list-edit/use-list-cell-editor.js";
59
- import { listHandOrder } from "./hand-order/hand-order.js";
60
61
  import { HandSortedList } from "./hand-order/hand-sorted-list.js";
61
62
  import { SortButton } from "./hand-order/sort-button.js";
62
63
  import { useMemo, useState } from "react";
@@ -212,7 +213,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
212
213
  setFilter(state, text ? [...others, where$1.search(text)] : others);
213
214
  };
214
215
  const plural = view?.names?.plural ?? humanize(resource);
215
- const sorted = display?.kind === "calendar" ? void 0 : display;
216
+ const sorted = display?.kind === "calendar" || display?.kind === "board" && boardRank(model) ? void 0 : display;
216
217
  const sortMenu = sorted && /* @__PURE__ */ jsx(SortMenu, {
217
218
  model,
218
219
  view,
@@ -1,25 +1,31 @@
1
1
  import { whenShown } from "./record-mode.js";
2
2
  import { isRecordCondition } from "@protobase/schema";
3
3
  //#region src/app/record-mode/shown-layout.ts
4
- /** The items with a record condition in `when`, those inside groups too, whose conditions the page evaluates: a group before its items. */
5
- const conditionalItems = (items) => items.flatMap((item) => item.kind === "group" ? [item, ...item.items] : [item]).filter((item) => isRecordCondition(item.when));
4
+ const innerItems = (item) => item.kind === "group" || item.kind === "tab" ? item.items : [];
5
+ const whenOf = (item) => item.kind === "tab" ? void 0 : item.when;
6
+ /** The items with a record condition in `when`, those inside tabs and groups too, whose conditions the page evaluates: a group before its items. */
7
+ const conditionalItems = (items) => items.flatMap((item) => [item, ...conditionalItems(innerItems(item))]).filter((item) => isRecordCondition(whenOf(item)));
6
8
  /**
7
9
  * The items that show: those with a record condition only when it holds (`held`, one answer per conditional item in
8
10
  * the order `conditionalItems` lists them, `undefined` while loading) and the page is no preview; the others by the
9
- * form or read view. A group keeps the items inside it that show, and goes when it or all of them do not.
11
+ * form or read view. A tab or group keeps the items inside it that show, and goes when it or all of them do not.
10
12
  */
11
13
  const shownLayout = (items, held, editing, preview) => {
12
14
  let next = 0;
13
- const shown = (item) => isRecordCondition(item.when) ? !preview && held[next++] === true : whenShown(item.when, editing);
14
- return items.flatMap((item) => {
15
- if (item.kind !== "group") return shown(item) ? [item] : [];
16
- const groupShown = shown(item);
17
- const inner = item.items.filter(shown);
18
- return groupShown && inner.length > 0 ? [{
15
+ const shown = (item) => {
16
+ const when = whenOf(item);
17
+ return isRecordCondition(when) ? !preview && held[next++] === true : whenShown(when, editing);
18
+ };
19
+ const keep = (list) => list.flatMap((item) => {
20
+ const itself = shown(item);
21
+ if (item.kind !== "group" && item.kind !== "tab") return itself ? [item] : [];
22
+ const inner = keep(item.items);
23
+ return itself && inner.length > 0 ? [{
19
24
  ...item,
20
25
  items: inner
21
26
  }] : [];
22
27
  });
28
+ return keep(items);
23
29
  };
24
30
  //#endregion
25
- export { conditionalItems, shownLayout };
31
+ export { conditionalItems, shownLayout, whenOf };
@@ -1,12 +1,12 @@
1
1
  import { useConditions } from "../pages/use-conditions.js";
2
- import { conditionalItems, shownLayout } from "./shown-layout.js";
2
+ import { conditionalItems, shownLayout, whenOf } from "./shown-layout.js";
3
3
  //#region src/app/record-mode/use-shown-layout.ts
4
4
  /**
5
5
  * The layout items to draw: those meant for the form or the read view as the page is, and those with a record
6
6
  * condition while it holds for the saved record. Conditional items stay out while their values load and on a preview.
7
7
  */
8
8
  const useShownLayout = (items, scope, editing, preview) => {
9
- const conditions = preview ? [] : conditionalItems(items).map((item) => item.when);
9
+ const conditions = preview ? [] : conditionalItems(items).map((item) => whenOf(item));
10
10
  const held = useConditions(conditions, scope);
11
11
  return shownLayout(items, held, editing, preview);
12
12
  };