@protobase/ui 0.19.15 → 0.20.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.
Files changed (80) hide show
  1. package/dist/app/app.js +2 -2
  2. package/dist/app/charts/chart-groups.js +52 -0
  3. package/dist/app/charts/chart-view.js +75 -0
  4. package/dist/app/charts/group-bars.js +49 -0
  5. package/dist/app/charts/group-donut.js +84 -0
  6. package/dist/app/charts/period-chart.js +174 -0
  7. package/dist/app/displays/board-column.js +114 -0
  8. package/dist/app/displays/board-columns.js +45 -0
  9. package/dist/app/displays/board-display.js +96 -0
  10. package/dist/app/displays/board-dnd.js +32 -0
  11. package/dist/app/displays/list-displays.js +13 -1
  12. package/dist/app/displays/record-card.js +16 -5
  13. package/dist/app/displays/use-board-column.js +23 -0
  14. package/dist/app/displays/use-board-move.js +60 -0
  15. package/dist/app/displays/virtual-cards.js +1 -0
  16. package/dist/app/embedded-list/embedded-list.js +9 -0
  17. package/dist/app/export/export-options.js +2 -2
  18. package/dist/app/gallery/use-gallery-photos.js +2 -2
  19. package/dist/app/import/lookups.js +2 -2
  20. package/dist/app/list-edit/cell-field-editor.js +61 -0
  21. package/dist/app/list-edit/use-list-cell-editor.js +30 -0
  22. package/dist/app/list-edit/use-list-edits.js +75 -0
  23. package/dist/app/list-group-chart.js +37 -0
  24. package/dist/app/list-page.js +38 -13
  25. package/dist/app/map/map-area.js +6 -6
  26. package/dist/app/pages/action-href.js +4 -2
  27. package/dist/app/pages/blocks/chart.js +26 -0
  28. package/dist/app/pages/render-node.js +2 -0
  29. package/dist/app/pages/use-run-action.js +4 -3
  30. package/dist/app/record-page.js +30 -16
  31. package/dist/app/record-preview.js +25 -0
  32. package/dist/app/record-row/record-row-body.js +2 -2
  33. package/dist/app/record-row/record-row.js +1 -0
  34. package/dist/app/related-dots/card-dots-lines.js +16 -0
  35. package/dist/app/related-dots/card-dots.js +34 -0
  36. package/dist/app/related-dots/dot-card.js +3 -3
  37. package/dist/app/related-dots/related-dots-columns.js +3 -1
  38. package/dist/app/related-dots/related-dots.js +22 -9
  39. package/dist/app/related-section.js +4 -4
  40. package/dist/app/relation-options.js +44 -0
  41. package/dist/app/relation-picker.js +114 -35
  42. package/dist/app/saved/saved-records.js +2 -2
  43. package/dist/app/search/search-model.js +2 -2
  44. package/dist/app/searches/saved-search.js +3 -3
  45. package/dist/app/searches/search-diff.js +8 -8
  46. package/dist/app/use-nav-recent.js +1 -2
  47. package/dist/data/api-provider.d.ts +4 -1
  48. package/dist/data/api-provider.js +12 -5
  49. package/dist/data/focus-refresh.js +44 -0
  50. package/dist/data/query-keys.js +18 -0
  51. package/dist/data/use-aggregates.js +14 -1
  52. package/dist/data/use-record.js +17 -2
  53. package/dist/data-grid/cell-moves.js +26 -0
  54. package/dist/data-grid/data-grid.js +32 -7
  55. package/dist/data-grid/grid-header.js +1 -1
  56. package/dist/data-grid/grid-row.js +17 -13
  57. package/dist/data-grid/page-cache.js +3 -0
  58. package/dist/data-grid/use-cell-editing.js +113 -0
  59. package/dist/data-grid/use-paged-rows.js +25 -4
  60. package/dist/index.js +2 -2
  61. package/dist/live/field-values.js +3 -1
  62. package/dist/live/related-column-rows.js +3 -3
  63. package/dist/live/relation-labels.js +2 -2
  64. package/dist/live/use-live-rows.js +17 -4
  65. package/dist/live/use-reference-search.js +2 -2
  66. package/dist/package.js +1 -1
  67. package/dist/shell/assets/app-B5h_q_JL.js +69 -0
  68. package/dist/shell/assets/{index-CzV9U_NJ.js → index-B0veahji.js} +1 -1
  69. package/dist/shell/assets/index-BZmb28OT.css +2 -0
  70. package/dist/shell/assets/{maplibre-G35v6TYU.js → maplibre-CbjT6RKG.js} +1 -1
  71. package/dist/shell/assets/{markdown-content-DGFnggSX.js → markdown-content-BjRGtSOA.js} +1 -1
  72. package/dist/shell/assets/{record-map-ZAC_U52Y.js → record-map-rmkTnAs8.js} +1 -1
  73. package/dist/shell/assets/{records-map-DLXOQVpM.js → records-map-D1vLGY06.js} +1 -1
  74. package/dist/shell/assets/{shared-protobase-ui-xByMDSPg.js → shared-protobase-ui-PnkJqBqB.js} +1 -1
  75. package/dist/shell/index.html +4 -4
  76. package/dist/shell/protobase.shell.json +1 -1
  77. package/dist/tokens.css +17 -0
  78. package/package.json +4 -4
  79. package/dist/shell/assets/app-WromtADN.js +0 -69
  80. package/dist/shell/assets/index-DEEIUcYb.css +0 -2
@@ -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 };
@@ -0,0 +1,30 @@
1
+ import { isEditable } from "../../live/field-values.js";
2
+ import { can, rowAllows } from "../permissions.js";
3
+ import { CellFieldEditor } from "./cell-field-editor.js";
4
+ import { useListEdits } from "./use-list-edits.js";
5
+ import { jsx } from "react/jsx-runtime";
6
+ //#region src/app/list-edit/use-list-cell-editor.tsx
7
+ /**
8
+ * The list's cells edited in place: the view's `editable` columns, which `/meta` keeps to the fields the caller may
9
+ * write, of the rows the caller may change. `patches` are the values saved since the rows loaded, for the grid to show.
10
+ */
11
+ const useListCellEditor = (model, view, permissions) => {
12
+ const edits = useListEdits(model);
13
+ const columns = view?.list?.editable ?? [];
14
+ return {
15
+ editor: columns.length > 0 && can(permissions, "update") ? {
16
+ editable: (row, column) => columns.includes(column) && model.fields[column] !== void 0 && isEditable(model.fields[column]) && rowAllows(row, "update"),
17
+ render: (row, column, close) => /* @__PURE__ */ jsx(CellFieldEditor, {
18
+ field: model.fields[column],
19
+ hints: view?.fields[column],
20
+ resource: model.name,
21
+ stored: row[column],
22
+ onSave: (value) => void edits.save(row, column, value),
23
+ close
24
+ })
25
+ } : void 0,
26
+ patches: edits.patches
27
+ };
28
+ };
29
+ //#endregion
30
+ export { useListCellEditor };
@@ -0,0 +1,75 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { recordKey } from "../../live/model-helpers.js";
4
+ import { useToast } from "../../toasts/toast-provider.js";
5
+ import { useRef, useState } from "react";
6
+ import { isApiError } from "@protobase/client";
7
+ import { useQueryClient } from "@tanstack/react-query";
8
+ import { encodeKey } from "@protobase/schema/internal";
9
+ //#region src/app/list-edit/use-list-edits.ts
10
+ /**
11
+ * The list's edits in place: `patches` holds, by row id, the values saving or saved since the rows loaded, with the
12
+ * ETag each saved row has now. `save` sends one field as a PATCH with the row's ETag, after any save of the same row
13
+ * still under way. A refusal puts the old value back, or the record's current one when someone else changed it, and
14
+ * says why in a toast.
15
+ */
16
+ const useListEdits = (model) => {
17
+ const client = useClient();
18
+ const queryClient = useQueryClient();
19
+ const toast = useToast();
20
+ const [patches, setPatches] = useState(/* @__PURE__ */ new Map());
21
+ const current = useRef(patches);
22
+ const queues = useRef(/* @__PURE__ */ new Map());
23
+ const patch = (id, values) => {
24
+ current.current = new Map(current.current).set(id, {
25
+ ...current.current.get(id),
26
+ ...values
27
+ });
28
+ setPatches(current.current);
29
+ };
30
+ const send = async (row, name, value) => {
31
+ const key = recordKey(model, row);
32
+ const etag = String(current.current.get(row.id)?.etag ?? row.etag);
33
+ try {
34
+ const stored = await client.update(model.name, key, { [name]: value }, etag);
35
+ patch(row.id, {
36
+ [name]: stored.record[name],
37
+ etag: stored.etag
38
+ });
39
+ queryClient.setQueryData(keys.record(model.name, encodeKey(key)), stored);
40
+ queryClient.invalidateQueries({ queryKey: ["page", model.name] });
41
+ queryClient.invalidateQueries({ queryKey: ["list", model.name] });
42
+ queryClient.invalidateQueries({ queryKey: ["map", model.name] });
43
+ } catch (error) {
44
+ if (!isApiError(error)) {
45
+ patch(row.id, { [name]: row[name] });
46
+ throw error;
47
+ }
48
+ const changed = error.status === 412;
49
+ const latest = changed ? await client.get(model.name, key) : void 0;
50
+ patch(row.id, latest ? {
51
+ [name]: latest.record[name],
52
+ etag: latest.etag
53
+ } : { [name]: row[name] });
54
+ const reason = error.errors.find((entry) => entry.field === name)?.message ?? error.message;
55
+ toast.show({
56
+ state: "error",
57
+ title: changed ? "Changed by someone else" : "Could not save",
58
+ description: reason
59
+ });
60
+ }
61
+ };
62
+ /** Saves `value` as the field `name` of `row`, as the grid shows it before the edit. */
63
+ const save = (row, name, value) => {
64
+ patch(row.id, { [name]: value });
65
+ const queued = (queues.current.get(row.id) ?? Promise.resolve()).then(() => send(row, name, value));
66
+ queues.current.set(row.id, queued.catch(() => void 0));
67
+ return queued;
68
+ };
69
+ return {
70
+ patches,
71
+ save
72
+ };
73
+ };
74
+ //#endregion
75
+ export { useListEdits };
@@ -0,0 +1,37 @@
1
+ import { humanize } from "../live/naming.js";
2
+ import { ChartView } from "./charts/chart-view.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app/list-group-chart.tsx
5
+ const measureTitle = (chart, view, plural) => {
6
+ const name = chart.sum ?? chart.average;
7
+ if (name === void 0) return plural;
8
+ const label = view?.fields[name]?.label ?? humanize(name);
9
+ return chart.sum !== void 0 ? `${label}` : `Average ${label.toLowerCase()}`;
10
+ };
11
+ /** The view's chart per value of a field, above the list and following its filter. */
12
+ const ListGroupChart = ({ model, view, chart, plural, filter }) => {
13
+ const by = view?.fields[chart.groupBy]?.label ?? humanize(chart.groupBy);
14
+ return /* @__PURE__ */ jsxs("section", {
15
+ className: "rounded-lg border bg-background px-4 pb-3 pt-3",
16
+ "aria-label": `${measureTitle(chart, view, plural)} by ${by.toLowerCase()}`,
17
+ children: [/* @__PURE__ */ jsxs("h2", {
18
+ className: "mb-2 font-semibold",
19
+ children: [
20
+ measureTitle(chart, view, plural),
21
+ " by ",
22
+ by.toLowerCase()
23
+ ]
24
+ }), /* @__PURE__ */ jsx(ChartView, {
25
+ model,
26
+ view,
27
+ groupBy: chart.groupBy,
28
+ type: chart.type,
29
+ sum: chart.sum,
30
+ average: chart.average,
31
+ limit: chart.limit,
32
+ filter
33
+ })]
34
+ });
35
+ };
36
+ //#endregion
37
+ export { ListGroupChart };