@protobase/ui 0.19.15 → 0.19.16

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 (68) hide show
  1. package/dist/app/app.js +2 -2
  2. package/dist/app/displays/board-column.js +114 -0
  3. package/dist/app/displays/board-columns.js +45 -0
  4. package/dist/app/displays/board-display.js +96 -0
  5. package/dist/app/displays/board-dnd.js +32 -0
  6. package/dist/app/displays/list-displays.js +13 -1
  7. package/dist/app/displays/record-card.js +16 -5
  8. package/dist/app/displays/use-board-column.js +23 -0
  9. package/dist/app/displays/use-board-move.js +60 -0
  10. package/dist/app/displays/virtual-cards.js +1 -0
  11. package/dist/app/embedded-list/embedded-list.js +9 -0
  12. package/dist/app/export/export-options.js +2 -2
  13. package/dist/app/gallery/use-gallery-photos.js +2 -2
  14. package/dist/app/import/lookups.js +2 -2
  15. package/dist/app/list-edit/cell-field-editor.js +61 -0
  16. package/dist/app/list-edit/use-list-cell-editor.js +30 -0
  17. package/dist/app/list-edit/use-list-edits.js +75 -0
  18. package/dist/app/list-page.js +28 -10
  19. package/dist/app/map/map-area.js +6 -6
  20. package/dist/app/pages/action-href.js +4 -2
  21. package/dist/app/pages/use-run-action.js +4 -3
  22. package/dist/app/record-page.js +30 -16
  23. package/dist/app/record-preview.js +25 -0
  24. package/dist/app/record-row/record-row-body.js +2 -2
  25. package/dist/app/record-row/record-row.js +1 -0
  26. package/dist/app/related-dots/card-dots-lines.js +16 -0
  27. package/dist/app/related-dots/card-dots.js +34 -0
  28. package/dist/app/related-dots/dot-card.js +3 -3
  29. package/dist/app/related-dots/related-dots-columns.js +3 -1
  30. package/dist/app/related-dots/related-dots.js +22 -9
  31. package/dist/app/related-section.js +4 -4
  32. package/dist/app/relation-picker.js +2 -2
  33. package/dist/app/saved/saved-records.js +2 -2
  34. package/dist/app/search/search-model.js +2 -2
  35. package/dist/app/searches/saved-search.js +3 -3
  36. package/dist/app/searches/search-diff.js +8 -8
  37. package/dist/app/use-nav-recent.js +1 -2
  38. package/dist/data/api-provider.d.ts +2 -1
  39. package/dist/data/api-provider.js +12 -5
  40. package/dist/data/focus-refresh.js +44 -0
  41. package/dist/data/query-keys.js +13 -0
  42. package/dist/data/use-record.js +17 -2
  43. package/dist/data-grid/cell-moves.js +26 -0
  44. package/dist/data-grid/data-grid.js +32 -7
  45. package/dist/data-grid/grid-header.js +1 -1
  46. package/dist/data-grid/grid-row.js +17 -13
  47. package/dist/data-grid/page-cache.js +3 -0
  48. package/dist/data-grid/use-cell-editing.js +113 -0
  49. package/dist/data-grid/use-paged-rows.js +25 -4
  50. package/dist/live/field-values.js +3 -1
  51. package/dist/live/related-column-rows.js +3 -3
  52. package/dist/live/relation-labels.js +2 -2
  53. package/dist/live/use-live-rows.js +17 -4
  54. package/dist/live/use-reference-search.js +2 -2
  55. package/dist/package.js +1 -1
  56. package/dist/shell/assets/app-xQmLJM07.js +69 -0
  57. package/dist/shell/assets/index-CmJl5dIO.css +2 -0
  58. package/dist/shell/assets/{index-CzV9U_NJ.js → index-CmmYBR7Y.js} +1 -1
  59. package/dist/shell/assets/{maplibre-G35v6TYU.js → maplibre-Siom2Zwj.js} +1 -1
  60. package/dist/shell/assets/{markdown-content-DGFnggSX.js → markdown-content-ChyGEubR.js} +1 -1
  61. package/dist/shell/assets/{record-map-ZAC_U52Y.js → record-map-B_QnHF99.js} +1 -1
  62. package/dist/shell/assets/{records-map-DLXOQVpM.js → records-map-CcHhEDBC.js} +1 -1
  63. package/dist/shell/assets/{shared-protobase-ui-xByMDSPg.js → shared-protobase-ui-zRmreKZS.js} +1 -1
  64. package/dist/shell/index.html +4 -4
  65. package/dist/shell/protobase.shell.json +1 -1
  66. package/package.json +4 -4
  67. package/dist/shell/assets/app-WromtADN.js +0 -69
  68. package/dist/shell/assets/index-DEEIUcYb.css +0 -2
package/dist/app/app.js CHANGED
@@ -12,7 +12,7 @@ import { CustomBoundary } from "./pages/custom-boundary.js";
12
12
  import { PluginSourceProvider } from "../plugins/plugin-source.js";
13
13
  import { AuthGate } from "../auth/auth-gate.js";
14
14
  import { isAdmin, userToShell } from "../auth/user-to-shell.js";
15
- import { useRecord } from "../data/use-record.js";
15
+ import { useOpenedRecord } from "../data/use-record.js";
16
16
  import { recordTitle } from "../live/model-helpers.js";
17
17
  import { humanize } from "../live/naming.js";
18
18
  import { ThemeProvider, useTheme } from "../theme/theme-provider.js";
@@ -92,7 +92,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
92
92
  const view = resource ? meta.views[resource] : void 0;
93
93
  const group = groups.find((entry) => entry.items.some((item) => item.id === place));
94
94
  const model = resource ? meta.resources[resource] : void 0;
95
- const record = useRecord(shown?.resource ?? "", shown?.key ?? "", Boolean(model && shown));
95
+ const record = useOpenedRecord(shown?.resource ?? "", shown?.key ?? "", Boolean(model && shown));
96
96
  const title = model && shown && record.data ? recordTitle(model, view, record.data.record, shown.key) : void 0;
97
97
  const titleLoading = Boolean(shown) && (record.isLoading || drawnFrom?.state === "loading");
98
98
  const { shell } = useProjectUi();
@@ -0,0 +1,114 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { Skeleton } from "../../primitives/skeleton.js";
3
+ import { Button } from "../../primitives/button.js";
4
+ import { humanize } from "../../live/naming.js";
5
+ import { ErrorBanner } from "../error-banner.js";
6
+ import { boardCard } from "./board-columns.js";
7
+ import { rowAllows } from "../permissions.js";
8
+ import { ValueBadge } from "../value-badge.js";
9
+ import { RecordCard } from "./record-card.js";
10
+ import { columnId } from "./board-dnd.js";
11
+ import { useBoardColumn } from "./use-board-column.js";
12
+ import { jsx, jsxs } from "react/jsx-runtime";
13
+ import { useDraggable, useDroppable } from "@dnd-kit/core";
14
+ //#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 }) => {
17
+ const list = useBoardColumn({
18
+ model,
19
+ by: display.by,
20
+ value: column.value,
21
+ fields,
22
+ filter,
23
+ orderBy
24
+ });
25
+ const { setNodeRef, isOver } = useDroppable({ id: columnId(column.value) });
26
+ const card = boardCard(model, view, display);
27
+ const loaded = new Set(list.rows.map((row) => row.key));
28
+ const moving = new Map(moves.map((entry) => [entry.row.key, entry]));
29
+ const leaving = list.rows.filter((row) => moving.has(row.key) && moving.get(row.key).to !== column.value);
30
+ const arriving = moves.filter((entry) => entry.to === column.value && !loaded.has(entry.row.key)).map((entry) => entry.row);
31
+ 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);
33
+ const count = list.total - leaving.length + arriving.length;
34
+ if (column.value === null && !list.loading && count === 0 && !isOver) return null;
35
+ const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
36
+ const hints = view?.fields[display.by];
37
+ return /* @__PURE__ */ jsxs("section", {
38
+ ref: setNodeRef,
39
+ "aria-label": `${column.label}, ${count}`,
40
+ className: cn("flex w-72 shrink-0 snap-start flex-col gap-2 rounded-lg bg-surface p-2", isOver && "ring-2 ring-primary/30"),
41
+ children: [
42
+ /* @__PURE__ */ jsxs("header", {
43
+ className: "flex items-center justify-between gap-2 px-1 pt-0.5",
44
+ children: [/* @__PURE__ */ jsx("h2", {
45
+ className: "min-w-0 truncate text-xs font-medium",
46
+ children: column.value === null ? /* @__PURE__ */ jsx("span", {
47
+ className: "text-muted-foreground",
48
+ children: column.label
49
+ }) : /* @__PURE__ */ jsx(ValueBadge, {
50
+ value: column.value,
51
+ hints,
52
+ children: column.label
53
+ })
54
+ }), !list.loading && /* @__PURE__ */ jsx("span", {
55
+ className: "text-xs tabular-nums text-muted-foreground",
56
+ children: count.toLocaleString("en-IE")
57
+ })]
58
+ }),
59
+ list.loading ? Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-20" }, index)) : list.error ? /* @__PURE__ */ jsx(ErrorBanner, {
60
+ title: "Could not load rows",
61
+ error: list.error
62
+ }) : rows.length === 0 ? /* @__PURE__ */ jsxs("p", {
63
+ className: "px-1 py-4 text-center text-xs text-muted-foreground",
64
+ 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))
75
+ }),
76
+ limit === void 0 && list.more && /* @__PURE__ */ jsx(Button, {
77
+ size: "sm",
78
+ variant: "ghost",
79
+ onClick: list.loadMore,
80
+ children: "Show more"
81
+ })
82
+ ]
83
+ });
84
+ };
85
+ const BoardCard = ({ model, view, card, row, movable, dragged }) => {
86
+ const { setNodeRef, setActivatorNodeRef, listeners, isDragging } = useDraggable({
87
+ id: row.key,
88
+ data: { row },
89
+ disabled: !movable
90
+ });
91
+ return /* @__PURE__ */ jsx("div", {
92
+ ref: (element) => {
93
+ setNodeRef(element);
94
+ setActivatorNodeRef(element?.querySelector("a") ?? element);
95
+ },
96
+ ...listeners,
97
+ onDragStart: (event) => event.preventDefault(),
98
+ onClickCapture: (event) => {
99
+ if (!dragged()) return;
100
+ event.preventDefault();
101
+ event.stopPropagation();
102
+ },
103
+ className: cn(movable && "cursor-grab touch-manipulation", isDragging && "opacity-40"),
104
+ children: /* @__PURE__ */ jsx(RecordCard, {
105
+ model,
106
+ view,
107
+ display: card,
108
+ row,
109
+ swipe: false
110
+ })
111
+ });
112
+ };
113
+ //#endregion
114
+ export { BoardColumnView };
@@ -0,0 +1,45 @@
1
+ import { recordTitleField } from "../../live/model-helpers.js";
2
+ import { humanize } from "../../live/naming.js";
3
+ import { isApiError } from "@protobase/client";
4
+ import { enumLabel, printFilter, where } from "@protobase/schema";
5
+ //#region src/app/displays/board-columns.ts
6
+ /**
7
+ * A board's columns: one per value of its enum field, in the order the field lists them, headed by the view's value
8
+ * label; then, when the field may be empty, one for the records without a value.
9
+ */
10
+ const boardColumns = (model, view, display) => {
11
+ const field = model.fields[display.by];
12
+ if (!field) return [];
13
+ const hints = view?.fields[display.by];
14
+ const values = (field.enumValues ?? []).map((value) => ({
15
+ value,
16
+ label: enumLabel(value, hints?.valueLabels)
17
+ }));
18
+ if (!field.nullable) return values;
19
+ return [...values, {
20
+ value: null,
21
+ label: `No ${(hints?.label ?? humanize(display.by)).toLowerCase()}`
22
+ }];
23
+ };
24
+ /** The list's filter, its search included, narrowed to the records of one column. */
25
+ const columnFilter = (filter, by, value) => {
26
+ const column = printFilter(value === null ? where.isNull(by) : where.eq(by, value));
27
+ return filter === "" ? column : `(${filter}) AND ${column}`;
28
+ };
29
+ /** The built-in card a board draws for each record: that of a cards display of the board's fields. */
30
+ const boardCard = (model, view, display) => ({
31
+ kind: "cards",
32
+ name: display.name,
33
+ title: display.title ?? recordTitleField(model, view),
34
+ ...display.subtitle !== void 0 && { subtitle: display.subtitle },
35
+ ...display.badge !== void 0 && { badge: display.badge },
36
+ ...display.meta && { meta: display.meta }
37
+ });
38
+ /** Why the server refused a move: the reasons it gives per field, such as a `validate` message, else its detail. */
39
+ const refusalReason = (error) => {
40
+ if (!isApiError(error)) return error instanceof Error ? error.message : "Something went wrong";
41
+ const reasons = error.errors.map((entry) => entry.message);
42
+ return reasons.length > 0 ? reasons.join("; ") : error.message;
43
+ };
44
+ //#endregion
45
+ export { boardCard, boardColumns, columnFilter, refusalReason };
@@ -0,0 +1,96 @@
1
+ import { usePermissions } from "../meta-gate.js";
2
+ import { boardCard, boardColumns } from "./board-columns.js";
3
+ import { can } from "../permissions.js";
4
+ import { RecordCard } from "./record-card.js";
5
+ import { collision, columnCoordinates, columnId } from "./board-dnd.js";
6
+ import { BoardColumnView } from "./board-column.js";
7
+ import { useBoardMove } from "./use-board-move.js";
8
+ import { useRef, useState } from "react";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
11
+ //#region src/app/displays/board-display.tsx
12
+ /**
13
+ * A column per value of the board's enum field, each loading its own records through the list API, with their count;
14
+ * a card dragged, or moved with the keyboard, to another column saves the record with that column's value.
15
+ */
16
+ const BoardDisplay = ({ model, view, display, fields, filter, orderBy, limit }) => {
17
+ const columns = boardColumns(model, view, display);
18
+ const card = boardCard(model, view, display);
19
+ const permissions = usePermissions(model.name);
20
+ const movable = can(permissions, "update") && !model.fields[display.by]?.readOnly;
21
+ const { moves, move } = useBoardMove(model, display.by);
22
+ const [active, setActive] = useState();
23
+ const dragged = useRef(false);
24
+ const sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: 4 } }), useSensor(TouchSensor, { activationConstraint: {
25
+ delay: 250,
26
+ tolerance: 5
27
+ } }), useSensor(KeyboardSensor, {
28
+ keyboardCodes: {
29
+ start: ["Space"],
30
+ cancel: ["Escape"],
31
+ end: ["Space", "Enter"]
32
+ },
33
+ coordinateGetter: columnCoordinates
34
+ }));
35
+ const title = (row) => String(row.labels[card.title] ?? row.record[card.title] ?? row.key);
36
+ const columnOf = (id) => columns.find((column) => columnId(column.value) === id);
37
+ const valueOf = (row) => row.record[display.by] ?? null;
38
+ const announcements = {
39
+ onDragStart: ({ active: picked }) => `Picked up ${title(picked.data.current.row)}.`,
40
+ onDragOver: ({ active: picked, over }) => `${title(picked.data.current.row)} is over ${over ? columnOf(over.id)?.label : "no column"}.`,
41
+ 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.`,
42
+ onDragCancel: ({ active: picked }) => `Moving ${title(picked.data.current.row)} was cancelled.`
43
+ };
44
+ const start = ({ active: picked }) => {
45
+ dragged.current = true;
46
+ setActive(picked.data.current.row);
47
+ };
48
+ const end = ({ active: picked, over }) => {
49
+ setActive(void 0);
50
+ setTimeout(() => dragged.current = false);
51
+ const row = picked.data.current.row;
52
+ const target = over ? columnOf(over.id) : void 0;
53
+ if (target && target.value !== valueOf(row)) move(row, target);
54
+ };
55
+ return /* @__PURE__ */ jsxs(DndContext, {
56
+ sensors,
57
+ collisionDetection: collision,
58
+ onDragStart: start,
59
+ onDragEnd: end,
60
+ onDragCancel: () => {
61
+ setActive(void 0);
62
+ setTimeout(() => dragged.current = false);
63
+ },
64
+ accessibility: {
65
+ announcements,
66
+ 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." }
67
+ },
68
+ children: [/* @__PURE__ */ jsx("div", {
69
+ className: "min-w-0 flex-1 overflow-x-auto",
70
+ children: /* @__PURE__ */ jsx("div", {
71
+ className: "flex snap-x items-start gap-3 pb-2",
72
+ children: columns.map((column) => /* @__PURE__ */ jsx(BoardColumnView, {
73
+ model,
74
+ view,
75
+ display,
76
+ column,
77
+ fields,
78
+ filter,
79
+ orderBy,
80
+ limit,
81
+ moves,
82
+ movable,
83
+ dragged: () => dragged.current
84
+ }, columnId(column.value)))
85
+ })
86
+ }), /* @__PURE__ */ jsx(DragOverlay, { children: active && /* @__PURE__ */ jsx(RecordCard, {
87
+ model,
88
+ view,
89
+ display: card,
90
+ row: active,
91
+ swipe: false
92
+ }) })]
93
+ });
94
+ };
95
+ //#endregion
96
+ export { BoardDisplay };
@@ -0,0 +1,32 @@
1
+ import { pointerWithin, rectIntersection } from "@dnd-kit/core";
2
+ //#region src/app/displays/board-dnd.ts
3
+ /** The id a board's column is dropped on by. */
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. */
6
+ const collision = (args) => {
7
+ const under = pointerWithin(args);
8
+ return under.length > 0 ? under : rectIntersection(args);
9
+ };
10
+ /** The arrow keys move a picked-up card to the top of the next column to the left or right. */
11
+ const columnCoordinates = (event, { context: { collisionRect, droppableRects, droppableContainers, over } }) => {
12
+ const step = event.code === "ArrowRight" ? 1 : event.code === "ArrowLeft" ? -1 : 0;
13
+ if (step === 0 || !collisionRect) return void 0;
14
+ event.preventDefault();
15
+ const columns = droppableContainers.getEnabled().flatMap((container) => {
16
+ const rect = droppableRects.get(container.id);
17
+ return rect ? [{
18
+ id: container.id,
19
+ rect
20
+ }] : [];
21
+ }).sort((a, b) => a.rect.left - b.rect.left);
22
+ 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);
24
+ const target = columns[at + step];
25
+ if (at === -1 || !target) return void 0;
26
+ return {
27
+ x: target.rect.left + (target.rect.width - collisionRect.width) / 2,
28
+ y: target.rect.top
29
+ };
30
+ };
31
+ //#endregion
32
+ export { collision, columnCoordinates, columnId };
@@ -3,6 +3,7 @@ import { humanize } from "../../live/naming.js";
3
3
  import { displayValue } from "../../live/display-value.js";
4
4
  import { photosKey } from "../../live/row-photos.js";
5
5
  import { filledFields } from "../filled-fields.js";
6
+ import { boardCard } from "./board-columns.js";
6
7
  import { encodeKey, tableDisplay } from "@protobase/schema/internal";
7
8
  //#region src/app/displays/list-displays.ts
8
9
  const displaysOf = (view) => view?.list?.displays ?? [];
@@ -25,7 +26,8 @@ const shownDisplay = (view, name) => {
25
26
  };
26
27
  const defaultIcons = {
27
28
  cards: "cards",
28
- map: "map"
29
+ map: "map",
30
+ board: "kanban"
29
31
  };
30
32
  const displayChoices = (view) => [{
31
33
  name: tableDisplay,
@@ -76,6 +78,16 @@ const shownFields = (model, view, display) => {
76
78
  display.longitude,
77
79
  ...shownFields(model, view, mapCards(view, display))
78
80
  ];
81
+ if (display.kind === "board") {
82
+ const card = boardCard(model, view, display);
83
+ return [
84
+ display.by,
85
+ card.title,
86
+ card.subtitle,
87
+ card.badge,
88
+ ...card.meta ?? []
89
+ ].filter((name) => name !== void 0);
90
+ }
79
91
  const { image, title, subtitle, amount, badge, meta, footer, flags } = cardFields(model, view, display);
80
92
  return [
81
93
  image,
@@ -12,8 +12,11 @@ import { Markdown } from "../../markdown/markdown.js";
12
12
  import { isMarkdown } from "../../markdown/markdown-hint.js";
13
13
  import { markdownExcerpt } from "../../markdown/markdown-excerpt.js";
14
14
  import { ValueBadge } from "../value-badge.js";
15
+ import { useSeedRecord } from "../record-preview.js";
15
16
  import { namedIcon } from "../named-icon.js";
16
17
  import { FavoriteButton } from "../saved/favorite-button.js";
18
+ import { CardDots, CardDotsRoom } from "../related-dots/card-dots.js";
19
+ import { cardDotsLines } from "../related-dots/card-dots-lines.js";
17
20
  import { CardImage } from "./card-image.js";
18
21
  import { CardCarousel } from "./card-carousel.js";
19
22
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -31,9 +34,9 @@ const MetaValue = ({ icon, children }) => {
31
34
  /**
32
35
  * A record's card of a cards display, opening its page: its image across the top, swiped or stepped through its photos
33
36
  * when the display names them, the title over the subtitle, the badge beside it, the first amount with a value in bold,
34
- * small meta fields below, those with a value, the labels of its flags that are true and the footer's fields with a
35
- * value, joined by bullets; or, with a `card` component, what it draws of the row, the built-in content again when it
36
- * is missing or throws. With a favorite button, a heart in the top right corner saves the record.
37
+ * small meta fields below, those with a value, the labels of its flags that are true, the footer's fields with a
38
+ * value, joined by bullets, and a line of dots for each related column of the list it names; or, with a `card`
39
+ * component, what it draws of the row, the built-in content again when it is missing or throws. With a favorite button, a heart in the top right corner saves the record.
37
40
  */
38
41
  const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
39
42
  const { image, title, subtitle, amount, badge, meta, footer, flags } = cardFields(model, view, display);
@@ -46,6 +49,7 @@ const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
46
49
  return row.labels[name] ?? displayValue(field, value, view?.fields[name]);
47
50
  };
48
51
  const { fields: fieldTypes, components } = useProjectUi();
52
+ const seedRecord = useSeedRecord(model);
49
53
  const Card = display.card === void 0 ? void 0 : components?.[display.card];
50
54
  const shownValue = (name) => {
51
55
  const field = model.fields[name];
@@ -99,6 +103,7 @@ const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
99
103
  const footing = filledMeta(row, footer);
100
104
  const pictured = image !== void 0 || photos !== void 0;
101
105
  const favorite = display.favorite;
106
+ const dots = Card ? [] : cardDotsLines(view, display, row);
102
107
  const builtIn = (picture) => /* @__PURE__ */ jsxs(Fragment, { children: [
103
108
  pictured && picture,
104
109
  /* @__PURE__ */ jsxs("span", {
@@ -147,7 +152,8 @@ const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
147
152
  "aria-hidden": true,
148
153
  children: " • "
149
154
  }), shownValue(name)] }, name))
150
- })
155
+ }),
156
+ dots.length > 0 && /* @__PURE__ */ jsx(CardDotsRoom, { dots })
151
157
  ] });
152
158
  const card = (picture) => /* @__PURE__ */ jsxs(AppLink, {
153
159
  to: {
@@ -155,6 +161,7 @@ const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
155
161
  resource: model.name,
156
162
  key: row.key
157
163
  },
164
+ onClick: () => seedRecord(row.record),
158
165
  className: "flex h-full flex-col gap-1.5 rounded-lg border border-border bg-background p-3 hover:border-border-strong hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
159
166
  children: [mark && !pictured && /* @__PURE__ */ jsx(Badge, {
160
167
  tone: "blue",
@@ -167,7 +174,7 @@ const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
167
174
  }) : builtIn(picture)]
168
175
  });
169
176
  return /* @__PURE__ */ jsxs("div", {
170
- className: photos ? "group/card relative h-full" : favorite || mark ? "relative h-full" : "h-full",
177
+ className: photos ? "group/card relative h-full" : favorite || mark || dots.length > 0 ? "relative h-full" : "h-full",
171
178
  children: [
172
179
  photos ? /* @__PURE__ */ jsx(CardCarousel, {
173
180
  photos: row.photos ?? [],
@@ -180,6 +187,10 @@ const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
180
187
  className: "pointer-events-none absolute top-2 left-2 shadow-sm",
181
188
  children: mark
182
189
  }),
190
+ dots.length > 0 && /* @__PURE__ */ jsx(CardDots, {
191
+ dots,
192
+ Card: RecordCard
193
+ }),
183
194
  favorite && /* @__PURE__ */ jsx(FavoriteButton, {
184
195
  favorite,
185
196
  recordKey: row.key,
@@ -0,0 +1,23 @@
1
+ import { useAdminMeta } from "../meta-gate.js";
2
+ import { columnFilter } from "./board-columns.js";
3
+ import { useLiveRows } from "../../live/use-live-rows.js";
4
+ import { useDisplayRows } from "./use-display-rows.js";
5
+ //#region src/app/displays/use-board-column.ts
6
+ /**
7
+ * The records of one column of a board, page by page through the list API: the list's filter with the column's value
8
+ * added, in the list's order, with how many there are. A change to the resource reloads them, as it does the table.
9
+ */
10
+ const useBoardColumn = ({ model, by, value, fields, filter, orderBy }) => {
11
+ const { resources, views } = useAdminMeta();
12
+ const rows = useLiveRows({
13
+ resources,
14
+ views,
15
+ model,
16
+ columns: fields,
17
+ filter: columnFilter(filter, by, value),
18
+ orderBy
19
+ });
20
+ return useDisplayRows(model, rows.page, rows.key, true);
21
+ };
22
+ //#endregion
23
+ export { useBoardColumn };
@@ -0,0 +1,60 @@
1
+ import { useClient } from "../../data/api-provider.js";
2
+ import { recordKey } from "../../live/model-helpers.js";
3
+ import { useToast } from "../../toasts/toast-provider.js";
4
+ import { refusalReason } from "./board-columns.js";
5
+ import { useRefreshResource } from "../pages/use-layout-data.js";
6
+ import { useState } from "react";
7
+ import { useMutation } from "@tanstack/react-query";
8
+ //#region src/app/displays/use-board-move.ts
9
+ /**
10
+ * 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.
13
+ */
14
+ const useBoardMove = (model, by) => {
15
+ const client = useClient();
16
+ const toast = useToast();
17
+ const refresh = useRefreshResource();
18
+ const [moves, setMoves] = useState([]);
19
+ const settle = (key) => setMoves((current) => current.filter((move) => move.row.key !== key));
20
+ const mutation = useMutation({
21
+ mutationFn: ({ row, to }) => {
22
+ const etag = typeof row.record.etag === "string" ? row.record.etag : "*";
23
+ return client.update(model.name, recordKey(model, row.record), { [by]: to.value }, etag);
24
+ },
25
+ onMutate: ({ row, to }) => {
26
+ const moved = {
27
+ ...row,
28
+ record: {
29
+ ...row.record,
30
+ [by]: to.value
31
+ }
32
+ };
33
+ setMoves((current) => [...current.filter((move) => move.row.key !== row.key), {
34
+ row: moved,
35
+ to: to.value
36
+ }]);
37
+ },
38
+ onError: (error, { row, to }) => {
39
+ settle(row.key);
40
+ toast.show({
41
+ state: "error",
42
+ title: `Could not move it to ${to.label}`,
43
+ description: refusalReason(error)
44
+ });
45
+ },
46
+ onSettled: async (_result, _error, { row }) => {
47
+ await refresh(model.name);
48
+ settle(row.key);
49
+ }
50
+ });
51
+ return {
52
+ moves,
53
+ move: (row, to) => mutation.mutate({
54
+ row,
55
+ to
56
+ })
57
+ };
58
+ };
59
+ //#endregion
60
+ export { useBoardMove };
@@ -53,6 +53,7 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
53
53
  total,
54
54
  rowAt: source.rowAt,
55
55
  loadRows: source.loadRows,
56
+ revision: source.revision,
56
57
  latencyMs: 0
57
58
  }, range.start, range.end);
58
59
  const [guard] = useState(() => createFlipGuard());
@@ -11,6 +11,7 @@ import { AppLink, useAppAddress } from "../use-app-address.js";
11
11
  import { listColumnNames } from "../../live/columns-from-model.js";
12
12
  import { DataGrid } from "../../data-grid/data-grid.js";
13
13
  import { viewAllAddress } from "./embedded-display.js";
14
+ import { BoardDisplay } from "../displays/board-display.js";
14
15
  import { CardsDisplay } from "../displays/cards-display.js";
15
16
  import { CustomDisplay } from "../displays/custom-display.js";
16
17
  import { MapDisplay } from "../displays/map-display.js";
@@ -156,6 +157,14 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
156
157
  orderBy,
157
158
  fields,
158
159
  footer: false
160
+ }) : shown.kind === "board" ? /* @__PURE__ */ jsx(BoardDisplay, {
161
+ model,
162
+ view,
163
+ display: shown,
164
+ fields,
165
+ filter,
166
+ orderBy,
167
+ limit
159
168
  }) : /* @__PURE__ */ jsx(CustomDisplay, {
160
169
  display: shown,
161
170
  list
@@ -2,7 +2,7 @@ import { columnsFromModel, listColumnNames } from "../../live/columns-from-model
2
2
  import { xlsxMaxRows } from "./xlsx.js";
3
3
  import { createWhere, printFilter } from "@protobase/schema";
4
4
  //#region src/app/export/export-options.ts
5
- const where = createWhere();
5
+ const where$1 = createWhere();
6
6
  /** Whether the view's list offers Export: unless the view takes it away. */
7
7
  const exportOffered = (view) => view?.export !== false;
8
8
  /**
@@ -42,7 +42,7 @@ const selectionFilters = (model, keys) => {
42
42
  const [keyField] = model.primaryKey;
43
43
  if (!keyField || model.primaryKey.length !== 1) throw new Error("Not implemented: selected rows are exported by a single-field key");
44
44
  const filters = [];
45
- for (let start = 0; start < keys.length; start += 500) filters.push(printFilter(where.in(keyField, keys.slice(start, start + 500))));
45
+ for (let start = 0; start < keys.length; start += 500) filters.push(printFilter(where$1.in(keyField, keys.slice(start, start + 500))));
46
46
  return filters;
47
47
  };
48
48
  //#endregion
@@ -5,7 +5,7 @@ import { galleryItems } from "./gallery-items.js";
5
5
  import { useQuery } from "@tanstack/react-query";
6
6
  import { createWhere, printFilter } from "@protobase/schema";
7
7
  //#region src/app/gallery/use-gallery-photos.ts
8
- const where = createWhere();
8
+ const where$1 = createWhere();
9
9
  /**
10
10
  * A gallery's photos: the record's own image, then the photo records that point at it, read with the list API under
11
11
  * the user's own access, so its row filters and readable fields apply. Without the photos' resource in `/meta`, the
@@ -15,7 +15,7 @@ const useGalleryPhotos = (item, record, recordKey) => {
15
15
  const client = useClient();
16
16
  const { resources } = useAdminMeta();
17
17
  const holder = item.photos ? resources[item.photos.resource] : void 0;
18
- const filter = item.photos ? printFilter(where.eq(item.photos.field, recordKey)) : "";
18
+ const filter = item.photos ? printFilter(where$1.eq(item.photos.field, recordKey)) : "";
19
19
  const sort = item.photos?.sort ?? "";
20
20
  const rows = useQuery({
21
21
  queryKey: keys.list(holder?.name ?? "", filter, sort, 500, "gallery"),
@@ -1,6 +1,6 @@
1
1
  import { createWhere, printFilter } from "@protobase/schema";
2
2
  //#region src/app/import/lookups.ts
3
- const where = createWhere();
3
+ const where$1 = createWhere();
4
4
  const valuesPerRequest = 100;
5
5
  /** A value as the lookups compare it: the API answers numbers as numbers and the rest as text. */
6
6
  const lookupKey = (value) => String(value);
@@ -23,7 +23,7 @@ const createLookups = (clientFor) => {
23
23
  for (let at = 0; at < missing.length; at += valuesPerRequest) {
24
24
  const asked = missing.slice(at, at + valuesPerRequest);
25
25
  for (const value of asked) found.set(lookupKey(value), []);
26
- const filter = printFilter(where.in(field, asked));
26
+ const filter = printFilter(where$1.in(field, asked));
27
27
  let pageToken;
28
28
  do {
29
29
  const page = await clientFor(model.name).list(model.name, {
@@ -0,0 +1,61 @@
1
+ import { differs, toFieldDraft, toPatchValue } from "../../live/field-values.js";
2
+ import { FieldEditor } from "../field-editor.js";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/app/list-edit/cell-field-editor.tsx
6
+ /**
7
+ * A field's editor in a list cell, the one the record page uses: Enter, Tab and leaving the cell save a change, and Tab
8
+ * and Shift+Tab go on to the next or previous editable cell; Escape leaves it as it was. A built-in switch or choice
9
+ * saves as soon as it changes.
10
+ */
11
+ const CellFieldEditor = ({ field, hints, resource, stored, onSave, close }) => {
12
+ const [draft, setDraft] = useState(() => toFieldDraft(field, stored));
13
+ const ref = useRef(null);
14
+ const finished = useRef(false);
15
+ const instant = !hints?.widget && (field.type === "boolean" || field.type === "enum");
16
+ useEffect(() => {
17
+ ref.current?.querySelector("input, select, textarea, button, [tabindex]")?.focus();
18
+ }, []);
19
+ const finish = (save, move, value = draft) => {
20
+ if (finished.current) return;
21
+ finished.current = true;
22
+ if (save && differs(field, value, stored)) onSave(toPatchValue(field, value));
23
+ close(move);
24
+ };
25
+ const onKeyDown = (event) => {
26
+ if (event.key === "Enter") {
27
+ event.preventDefault();
28
+ finish(true);
29
+ } else if (event.key === "Tab") {
30
+ event.preventDefault();
31
+ finish(true, event.shiftKey ? -1 : 1);
32
+ } else if (event.key === "Escape") {
33
+ event.preventDefault();
34
+ event.stopPropagation();
35
+ finish(false);
36
+ }
37
+ };
38
+ const onBlur = (event) => {
39
+ if (!ref.current?.contains(event.relatedTarget)) finish(true);
40
+ };
41
+ const onChange = (next) => {
42
+ setDraft(next);
43
+ if (instant) finish(true, void 0, next);
44
+ };
45
+ return /* @__PURE__ */ jsx("div", {
46
+ ref,
47
+ className: "w-full min-w-0",
48
+ onKeyDown,
49
+ onBlur,
50
+ children: /* @__PURE__ */ jsx(FieldEditor, {
51
+ field,
52
+ hints,
53
+ value: draft,
54
+ stored,
55
+ resource,
56
+ onChange
57
+ })
58
+ });
59
+ };
60
+ //#endregion
61
+ export { CellFieldEditor };