@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.
- package/dist/app/app.js +2 -2
- package/dist/app/charts/chart-groups.js +52 -0
- package/dist/app/charts/chart-view.js +75 -0
- package/dist/app/charts/group-bars.js +49 -0
- package/dist/app/charts/group-donut.js +84 -0
- package/dist/app/charts/period-chart.js +174 -0
- package/dist/app/displays/board-column.js +114 -0
- package/dist/app/displays/board-columns.js +45 -0
- package/dist/app/displays/board-display.js +96 -0
- package/dist/app/displays/board-dnd.js +32 -0
- package/dist/app/displays/list-displays.js +13 -1
- package/dist/app/displays/record-card.js +16 -5
- package/dist/app/displays/use-board-column.js +23 -0
- package/dist/app/displays/use-board-move.js +60 -0
- package/dist/app/displays/virtual-cards.js +1 -0
- package/dist/app/embedded-list/embedded-list.js +9 -0
- package/dist/app/export/export-options.js +2 -2
- package/dist/app/gallery/use-gallery-photos.js +2 -2
- package/dist/app/import/lookups.js +2 -2
- package/dist/app/list-edit/cell-field-editor.js +61 -0
- package/dist/app/list-edit/use-list-cell-editor.js +30 -0
- package/dist/app/list-edit/use-list-edits.js +75 -0
- package/dist/app/list-group-chart.js +37 -0
- package/dist/app/list-page.js +38 -13
- package/dist/app/map/map-area.js +6 -6
- package/dist/app/pages/action-href.js +4 -2
- package/dist/app/pages/blocks/chart.js +26 -0
- package/dist/app/pages/render-node.js +2 -0
- package/dist/app/pages/use-run-action.js +4 -3
- package/dist/app/record-page.js +30 -16
- package/dist/app/record-preview.js +25 -0
- package/dist/app/record-row/record-row-body.js +2 -2
- package/dist/app/record-row/record-row.js +1 -0
- package/dist/app/related-dots/card-dots-lines.js +16 -0
- package/dist/app/related-dots/card-dots.js +34 -0
- package/dist/app/related-dots/dot-card.js +3 -3
- package/dist/app/related-dots/related-dots-columns.js +3 -1
- package/dist/app/related-dots/related-dots.js +22 -9
- package/dist/app/related-section.js +4 -4
- package/dist/app/relation-options.js +44 -0
- package/dist/app/relation-picker.js +114 -35
- package/dist/app/saved/saved-records.js +2 -2
- package/dist/app/search/search-model.js +2 -2
- package/dist/app/searches/saved-search.js +3 -3
- package/dist/app/searches/search-diff.js +8 -8
- package/dist/app/use-nav-recent.js +1 -2
- package/dist/data/api-provider.d.ts +4 -1
- package/dist/data/api-provider.js +12 -5
- package/dist/data/focus-refresh.js +44 -0
- package/dist/data/query-keys.js +18 -0
- package/dist/data/use-aggregates.js +14 -1
- package/dist/data/use-record.js +17 -2
- package/dist/data-grid/cell-moves.js +26 -0
- package/dist/data-grid/data-grid.js +32 -7
- package/dist/data-grid/grid-header.js +1 -1
- package/dist/data-grid/grid-row.js +17 -13
- package/dist/data-grid/page-cache.js +3 -0
- package/dist/data-grid/use-cell-editing.js +113 -0
- package/dist/data-grid/use-paged-rows.js +25 -4
- package/dist/index.js +2 -2
- package/dist/live/field-values.js +3 -1
- package/dist/live/related-column-rows.js +3 -3
- package/dist/live/relation-labels.js +2 -2
- package/dist/live/use-live-rows.js +17 -4
- package/dist/live/use-reference-search.js +2 -2
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-B5h_q_JL.js +69 -0
- package/dist/shell/assets/{index-CzV9U_NJ.js → index-B0veahji.js} +1 -1
- package/dist/shell/assets/index-BZmb28OT.css +2 -0
- package/dist/shell/assets/{maplibre-G35v6TYU.js → maplibre-CbjT6RKG.js} +1 -1
- package/dist/shell/assets/{markdown-content-DGFnggSX.js → markdown-content-BjRGtSOA.js} +1 -1
- package/dist/shell/assets/{record-map-ZAC_U52Y.js → record-map-rmkTnAs8.js} +1 -1
- package/dist/shell/assets/{records-map-DLXOQVpM.js → records-map-D1vLGY06.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-xByMDSPg.js → shared-protobase-ui-PnkJqBqB.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/tokens.css +17 -0
- package/package.json +4 -4
- package/dist/shell/assets/app-WromtADN.js +0 -69
- 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
|
|
35
|
-
* value, joined by bullets
|
|
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 };
|