@protobase/ui 0.25.4 → 0.25.6
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/displays/board-display.js +6 -7
- package/dist/app/displays/board-place.js +24 -11
- package/dist/app/displays/use-board-move.js +55 -19
- package/dist/app/hand-order/hand-sorted-list.js +2 -1
- package/dist/app/hand-order/use-hand-order.js +19 -5
- package/dist/app/named-icon.js +3 -2
- package/dist/app/pages/blocks/action.js +8 -4
- package/dist/app/rank-after/dependency-rule.js +17 -0
- package/dist/app/rank-after/messages.js +8 -0
- package/dist/app/rank-after/rank-bounds.js +54 -0
- package/dist/app/rank-after/ranked-place.js +45 -0
- package/dist/app/related-cards/clamped.js +43 -0
- package/dist/app/related-cards/messages.js +18 -0
- package/dist/app/related-cards/related-card.js +66 -0
- package/dist/app/related-cards/related-cards.js +85 -0
- package/dist/app/related-section.js +24 -7
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-G4GViEwd.js +69 -0
- package/dist/shell/assets/index-BrgzNBVN.css +2 -0
- package/dist/shell/assets/{index-HkjqX_O-.js → index-uYJB6y6I.js} +1 -1
- package/dist/shell/assets/{records-map-MfQEuiFB.js → records-map-BzzaFH1s.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-Bfa59vx6.js → shared-protobase-ui-CJc7TMx0.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/shell/protobase.sw.js +2 -2
- package/package.json +4 -4
- package/dist/shell/assets/app-yCMYvqMX.js +0 -69
- package/dist/shell/assets/index-Bkc7w8I4.css +0 -2
|
@@ -11,7 +11,6 @@ import { cardCoordinates, collision, columnId } from "./board-dnd.js";
|
|
|
11
11
|
import { BoardColumnView } from "./board-column.js";
|
|
12
12
|
import { columnsAllowed } from "./board-allowed.js";
|
|
13
13
|
import { dropWrite } from "./board-drop.js";
|
|
14
|
-
import { placedRows } from "./board-place.js";
|
|
15
14
|
import { useBoardMove } from "./use-board-move.js";
|
|
16
15
|
import { useRef, useState } from "react";
|
|
17
16
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -55,12 +54,15 @@ const BoardDisplay = ({ model, view, display, fields, filter, orderBy, limit })
|
|
|
55
54
|
const placeable = drag?.rank === true && rank !== void 0 && listHandOrder(model, permissions) !== void 0;
|
|
56
55
|
const columnOrder = rank ? `${rank} asc` : orderBy;
|
|
57
56
|
const labelOf = (value) => every.find((column) => column.value === value)?.label ?? String(value);
|
|
57
|
+
const title = (row) => String(row.labels[card.title] ?? row.record[card.title] ?? row.key);
|
|
58
58
|
const { moves, move } = useBoardMove({
|
|
59
59
|
model,
|
|
60
|
+
view,
|
|
60
61
|
by: display.by,
|
|
61
62
|
rank,
|
|
62
63
|
drop,
|
|
63
|
-
labelOf
|
|
64
|
+
labelOf,
|
|
65
|
+
titleOf: title
|
|
64
66
|
});
|
|
65
67
|
const [active, setActive] = useState();
|
|
66
68
|
const dragged = useRef(false);
|
|
@@ -76,7 +78,6 @@ const BoardDisplay = ({ model, view, display, fields, filter, orderBy, limit })
|
|
|
76
78
|
coordinateGetter: cardCoordinates
|
|
77
79
|
}));
|
|
78
80
|
if (dropName !== void 0 && !drop) return /* @__PURE__ */ jsx(Problem, { children: slotted(text("noDrop"), dropName) });
|
|
79
|
-
const title = (row) => String(row.labels[card.title] ?? row.record[card.title] ?? row.key);
|
|
80
81
|
const columnOf = (id) => columns.find((column) => columnId(column.value) === id);
|
|
81
82
|
const valueOf = (row) => row.record[display.by] ?? null;
|
|
82
83
|
const crosses = (row, to) => takes(valueOf(row)) && takes(to.value);
|
|
@@ -124,11 +125,9 @@ const BoardDisplay = ({ model, view, display, fields, filter, orderBy, limit })
|
|
|
124
125
|
if (from === -1 && !crosses(row, place.column)) return;
|
|
125
126
|
if (from === -1 && drop) return move(row, place.column);
|
|
126
127
|
if (from === place.index) return;
|
|
127
|
-
const order = placedRows(place.rows, row, place.index);
|
|
128
128
|
move(row, place.column, {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
last: from === -1 ? place.index : Math.max(from, place.index)
|
|
129
|
+
rows: place.rows,
|
|
130
|
+
index: place.index
|
|
132
131
|
});
|
|
133
132
|
};
|
|
134
133
|
return /* @__PURE__ */ jsxs(DndContext, {
|
|
@@ -16,33 +16,46 @@ const placedRows = (rows, row, index) => {
|
|
|
16
16
|
...rows.slice(index)
|
|
17
17
|
];
|
|
18
18
|
};
|
|
19
|
+
/** The column's cards once `row` is dropped at `index`, with the first and last place that changed. */
|
|
20
|
+
const placing = (rows, row, index) => {
|
|
21
|
+
const from = rows.findIndex((entry) => entry.key === row.key);
|
|
22
|
+
return {
|
|
23
|
+
order: placedRows(rows, row, index),
|
|
24
|
+
first: from === -1 ? index : Math.min(from, index),
|
|
25
|
+
last: from === -1 ? index : Math.max(from, index)
|
|
26
|
+
};
|
|
27
|
+
};
|
|
19
28
|
const etagOf = (row) => typeof row.record.etag === "string" ? row.record.etag : "*";
|
|
20
29
|
/**
|
|
21
30
|
* The batch that saves a drop on a ranked board: in another column first the update of `by` with the card's ETag, then
|
|
22
31
|
* a `reorder` of the column's cards whose place changed, `first` to `last`, reaching only as far as their ranks need
|
|
23
|
-
* (see `reorderSpan`). The cards after it already rank higher, so they keep their place unwritten, loaded or not.
|
|
32
|
+
* (see `reorderSpan`). The cards after it already rank higher, so they keep their place unwritten, loaded or not. With
|
|
33
|
+
* `bump`, the card, last in the column, takes that rank in a `reorder` of its own before the others are numbered, so
|
|
34
|
+
* it ranks above a record it depends on (see `rankedPlace`).
|
|
24
35
|
*/
|
|
25
|
-
const placeOps = (model, by, rank, row, column, order, first, last) => {
|
|
36
|
+
const placeOps = (model, by, rank, row, column, order, first, last, bump) => {
|
|
26
37
|
const moving = (row.record[by] ?? null) !== column.value;
|
|
27
38
|
const span = reorderSpan(order.map((entry) => entry.record[rank]), first, last);
|
|
28
39
|
const sent = order.slice(span.first, span.last + 1);
|
|
29
|
-
const
|
|
30
|
-
const reorder = {
|
|
40
|
+
const etagFor = (entry) => moving && entry.key === row.key ? "*" : etagOf(entry);
|
|
41
|
+
const reorder = (entries, start) => ({
|
|
31
42
|
op: "reorder",
|
|
32
43
|
resource: model.name,
|
|
33
44
|
field: rank,
|
|
34
|
-
keys:
|
|
35
|
-
start
|
|
36
|
-
etags
|
|
37
|
-
};
|
|
38
|
-
|
|
45
|
+
keys: entries.map((entry) => recordKey(model, entry.record)),
|
|
46
|
+
start,
|
|
47
|
+
etags: Object.fromEntries(entries.map((entry) => [encodeKey(recordKey(model, entry.record)), etagFor(entry)]))
|
|
48
|
+
});
|
|
49
|
+
const others = sent.filter((entry) => entry.key !== row.key);
|
|
50
|
+
const reorders = bump === void 0 ? [reorder(sent, span.start)] : [reorder([row], bump), ...others.length > 0 ? [reorder(others, span.start)] : []];
|
|
51
|
+
if (!moving) return reorders;
|
|
39
52
|
return [{
|
|
40
53
|
op: "update",
|
|
41
54
|
resource: model.name,
|
|
42
55
|
key: recordKey(model, row.record),
|
|
43
56
|
data: { [by]: column.value },
|
|
44
57
|
etag: etagOf(row)
|
|
45
|
-
},
|
|
58
|
+
}, ...reorders];
|
|
46
59
|
};
|
|
47
60
|
/**
|
|
48
61
|
* What the batch of a drop saved: each record it wrote, as it stored it last, with its values in `rows`, the column's
|
|
@@ -67,4 +80,4 @@ const boardWrites = (model, rows, result) => {
|
|
|
67
80
|
});
|
|
68
81
|
};
|
|
69
82
|
//#endregion
|
|
70
|
-
export { boardWrites, placeOps, placedRows };
|
|
83
|
+
export { boardWrites, placeOps, placedRows, placing };
|
|
@@ -5,8 +5,11 @@ import { text } from "./messages.js";
|
|
|
5
5
|
import { refusalReason } from "./board-columns.js";
|
|
6
6
|
import { useRefreshResource } from "../pages/use-layout-data.js";
|
|
7
7
|
import { dropWrite } from "./board-drop.js";
|
|
8
|
-
import {
|
|
8
|
+
import { text as text$1 } from "../hand-order/messages.js";
|
|
9
9
|
import { undoOps } from "../hand-order/undo-ops.js";
|
|
10
|
+
import { rankBounds } from "../rank-after/rank-bounds.js";
|
|
11
|
+
import { boardWrites, placeOps, placing } from "./board-place.js";
|
|
12
|
+
import { rankedPlace } from "../rank-after/ranked-place.js";
|
|
10
13
|
import { useRef, useState } from "react";
|
|
11
14
|
import { useMutation } from "@tanstack/react-query";
|
|
12
15
|
//#region src/app/displays/use-board-move.ts
|
|
@@ -17,9 +20,11 @@ import { useMutation } from "@tanstack/react-query";
|
|
|
17
20
|
* With the board's `drop`, a card dropped on another column runs that column's write instead, and then reads the record
|
|
18
21
|
* again to say which column it is in now. The card shows in its new place at once; a saved move says so with **Undo**,
|
|
19
22
|
* which puts back the values it changed or runs the undo the drop's write gave, and a refusal puts it back and says why.
|
|
23
|
+
* With the position's `after`, a card dropped above a record it depends on lands at the first place below that keeps
|
|
24
|
+
* the order, or stays where it was, with a warning saying why (see `rankedPlace`).
|
|
20
25
|
* Either way every list of the resource reloads, which puts the card where its record now is.
|
|
21
26
|
*/
|
|
22
|
-
const useBoardMove = ({ model, by, rank, drop, labelOf }) => {
|
|
27
|
+
const useBoardMove = ({ model, view, by, rank, drop, labelOf, titleOf }) => {
|
|
23
28
|
const client = useClient();
|
|
24
29
|
const toast = useToast();
|
|
25
30
|
const refresh = useRefreshResource();
|
|
@@ -58,12 +63,55 @@ const useBoardMove = ({ model, by, rank, drop, labelOf }) => {
|
|
|
58
63
|
landed: to.value,
|
|
59
64
|
undo: () => client.batchWrite(undoOps(model, writes, fields))
|
|
60
65
|
});
|
|
66
|
+
const show = (row, to, order) => {
|
|
67
|
+
const moved = {
|
|
68
|
+
...row,
|
|
69
|
+
record: {
|
|
70
|
+
...row.record,
|
|
71
|
+
[by]: to.value
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const keys = order?.map((entry) => entry.key);
|
|
75
|
+
setMoves((current) => [...current.filter((move) => move.row.key !== row.key), {
|
|
76
|
+
row: moved,
|
|
77
|
+
to: to.value,
|
|
78
|
+
...keys && { order: keys }
|
|
79
|
+
}]);
|
|
80
|
+
};
|
|
81
|
+
const allowed = async (row, to, place) => {
|
|
82
|
+
if (!model.position?.after) return { index: place.index };
|
|
83
|
+
const bounds = await rankBounds(client, model, view, row.record);
|
|
84
|
+
const ranked = rankedPlace({
|
|
85
|
+
model,
|
|
86
|
+
rank,
|
|
87
|
+
rows: place.rows,
|
|
88
|
+
row,
|
|
89
|
+
index: place.index,
|
|
90
|
+
bounds,
|
|
91
|
+
title: titleOf(row)
|
|
92
|
+
});
|
|
93
|
+
const stays = !ranked.place || ranked.place.bump === void 0 && ranked.place.index === place.rows.findIndex((entry) => entry.key === row.key);
|
|
94
|
+
if (ranked.reason !== void 0) toast.show({
|
|
95
|
+
state: "warning",
|
|
96
|
+
title: stays ? text$1("notMoved") : text$1("movedToPlace", ranked.place.index + 1),
|
|
97
|
+
description: ranked.reason
|
|
98
|
+
});
|
|
99
|
+
if (stays) return void 0;
|
|
100
|
+
if (ranked.place.index !== place.index) show(row, to, placing(place.rows, row, ranked.place.index).order);
|
|
101
|
+
return ranked.place;
|
|
102
|
+
};
|
|
103
|
+
const placed = async (row, to, place) => {
|
|
104
|
+
const landing = await allowed(row, to, place);
|
|
105
|
+
if (!landing) return void 0;
|
|
106
|
+
const { order, first, last } = placing(place.rows, row, landing.index);
|
|
107
|
+
return saved(boardWrites(model, order, await client.batchWrite(placeOps(model, by, rank, row, to, order, first, last, landing.bump))), to);
|
|
108
|
+
};
|
|
61
109
|
const mutation = useMutation({
|
|
62
110
|
mutationFn: async ({ row, to, place }) => {
|
|
63
111
|
if (place && !rank) throw new Error("A board places a card only by its rank");
|
|
64
112
|
const from = row.record[by] ?? null;
|
|
65
113
|
if (drop && to.value !== from) return dropped(row, to);
|
|
66
|
-
if (place) return
|
|
114
|
+
if (place) return placed(row, to, place);
|
|
67
115
|
const etag = typeof row.record.etag === "string" ? row.record.etag : "*";
|
|
68
116
|
const stored = await client.update(model.name, recordKey(model, row.record), { [by]: to.value }, etag);
|
|
69
117
|
return saved([{
|
|
@@ -72,22 +120,10 @@ const useBoardMove = ({ model, by, rank, drop, labelOf }) => {
|
|
|
72
120
|
etag: stored.etag
|
|
73
121
|
}], to);
|
|
74
122
|
},
|
|
75
|
-
onMutate: ({ row, to, place }) =>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
...row.record,
|
|
80
|
-
[by]: to.value
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
const order = place?.order.map((entry) => entry.key);
|
|
84
|
-
setMoves((current) => [...current.filter((move) => move.row.key !== row.key), {
|
|
85
|
-
row: moved,
|
|
86
|
-
to: to.value,
|
|
87
|
-
...order && { order }
|
|
88
|
-
}]);
|
|
89
|
-
},
|
|
90
|
-
onSuccess: ({ landed, undo: undoing }, { row, to }) => {
|
|
123
|
+
onMutate: ({ row, to, place }) => show(row, to, place && placing(place.rows, row, place.index).order),
|
|
124
|
+
onSuccess: (result, { row, to }) => {
|
|
125
|
+
if (!result) return;
|
|
126
|
+
const { landed, undo: undoing } = result;
|
|
91
127
|
if (undoToast.current !== void 0) toast.dismiss(undoToast.current);
|
|
92
128
|
undoToast.current = void 0;
|
|
93
129
|
const from = row.record[by] ?? null;
|
|
@@ -8,8 +8,8 @@ import { useAppAddress } from "../use-app-address.js";
|
|
|
8
8
|
import { ErrorBanner } from "../error-banner.js";
|
|
9
9
|
import { useProjectUi } from "../pages/project-ui.js";
|
|
10
10
|
import { DataGrid } from "../../data-grid/data-grid.js";
|
|
11
|
-
import { embeddedColumns } from "../embedded-list/embedded-columns.js";
|
|
12
11
|
import { text } from "./messages.js";
|
|
12
|
+
import { embeddedColumns } from "../embedded-list/embedded-columns.js";
|
|
13
13
|
import { useHandOrder } from "./use-hand-order.js";
|
|
14
14
|
import { useMemo } from "react";
|
|
15
15
|
import { jsx } from "react/jsx-runtime";
|
|
@@ -49,6 +49,7 @@ const HandSortedList = ({ model, view, field, filter, columns, sort }) => {
|
|
|
49
49
|
const rule = ruleName === void 0 ? void 0 : dropRules?.[ruleName];
|
|
50
50
|
const order = useHandOrder({
|
|
51
51
|
model,
|
|
52
|
+
view,
|
|
52
53
|
field,
|
|
53
54
|
filter,
|
|
54
55
|
fields,
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { keys } from "../../data/query-keys.js";
|
|
2
2
|
import { useClient } from "../../data/api-provider.js";
|
|
3
3
|
import { useToast } from "../../toasts/toast-provider.js";
|
|
4
|
-
import { recordId, recordKey } from "../../live/model-helpers.js";
|
|
4
|
+
import { recordId, recordKey, recordTitle } from "../../live/model-helpers.js";
|
|
5
5
|
import { refusalReason } from "../displays/board-columns.js";
|
|
6
6
|
import { displayRow } from "../displays/list-displays.js";
|
|
7
7
|
import { useAdminMeta } from "../meta-gate.js";
|
|
8
8
|
import { useRefreshResource } from "../pages/use-layout-data.js";
|
|
9
9
|
import { listLabels, withLabels } from "../../live/relation-labels.js";
|
|
10
|
-
import {
|
|
10
|
+
import { text } from "./messages.js";
|
|
11
11
|
import { undoOps } from "./undo-ops.js";
|
|
12
|
+
import { rankBounds } from "../rank-after/rank-bounds.js";
|
|
13
|
+
import { reorderSpan } from "./reorder-span.js";
|
|
14
|
+
import { dependencyRule } from "../rank-after/dependency-rule.js";
|
|
12
15
|
import { allowedDrop } from "./drop-rule.js";
|
|
13
|
-
import { text } from "./messages.js";
|
|
14
16
|
import { useRef, useState } from "react";
|
|
15
17
|
import { encodeKey } from "@protobase/schema/internal";
|
|
16
18
|
import { useQuery } from "@tanstack/react-query";
|
|
@@ -22,9 +24,10 @@ import { arrayMove } from "@dnd-kit/sortable";
|
|
|
22
24
|
* `reorder` with their ETags; moves made while one saves are saved after it, with the values and ETags it gave back.
|
|
23
25
|
* Each save says so with **Undo**, which puts back the values it changed. A refusal puts them back as the server has
|
|
24
26
|
* them and says why; either way every list of the resource reloads. A drop `rule` refuses moves to the first place below
|
|
25
|
-
* that it allows, with a warning giving its reason
|
|
27
|
+
* that it allows, with a warning giving its reason; with the position's `after`, so do the records the moved one
|
|
28
|
+
* depends on, read first (see `rankBounds`).
|
|
26
29
|
*/
|
|
27
|
-
const useHandOrder = ({ model, field, filter, fields, rule }) => {
|
|
30
|
+
const useHandOrder = ({ model, view, field, filter, fields, rule }) => {
|
|
28
31
|
const client = useClient();
|
|
29
32
|
const toast = useToast();
|
|
30
33
|
const refresh = useRefreshResource();
|
|
@@ -122,6 +125,17 @@ const useHandOrder = ({ model, field, filter, fields, rule }) => {
|
|
|
122
125
|
const move = (from, dropped) => {
|
|
123
126
|
const rows = latest.current ?? loaded.data;
|
|
124
127
|
if (!rows || from === dropped) return;
|
|
128
|
+
if (!model.position?.after) return place(rows, from, dropped, rule);
|
|
129
|
+
const record = displayRow(model, rows[from]).record;
|
|
130
|
+
const id = rows[from].id;
|
|
131
|
+
rankBounds(client, model, view, record).then((bounds) => {
|
|
132
|
+
const now = latest.current ?? loaded.data;
|
|
133
|
+
const dependencies = dependencyRule(model, bounds, recordTitle(model, view, record, id));
|
|
134
|
+
place(now, now.findIndex((row) => row.id === id), dropped, (drop) => dependencies(drop) ?? rule?.(drop));
|
|
135
|
+
}, refused(text("couldNotChange")));
|
|
136
|
+
};
|
|
137
|
+
const place = (rows, from, dropped, rule) => {
|
|
138
|
+
if (from === -1 || from === dropped) return;
|
|
125
139
|
const to = rule ? placeAllowed(rows, from, dropped, rule) : dropped;
|
|
126
140
|
if (from === to) return;
|
|
127
141
|
latest.current = arrayMove(rows, from, to);
|
package/dist/app/named-icon.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Bath, Bed, Bell, Bookmark, Calendar, DoorOpen, ExternalLink, FileText, Heart, House, KeyRound, LandPlot, Leaf, RulerDimensionLine, Send, Tag, Wallet } from "lucide-react";
|
|
1
|
+
import { Ban, Bath, Bed, Bell, Bookmark, Calendar, DoorOpen, ExternalLink, FileText, Heart, House, KeyRound, LandPlot, Leaf, RulerDimensionLine, Send, Tag, Wallet } from "lucide-react";
|
|
2
2
|
//#region src/app/named-icon.ts
|
|
3
3
|
/** The icons an action's, a field's or a sign-in prompt's `icon` may name. */
|
|
4
4
|
const byName = {
|
|
@@ -18,7 +18,8 @@ const byName = {
|
|
|
18
18
|
heart: Heart,
|
|
19
19
|
bell: Bell,
|
|
20
20
|
bookmark: Bookmark,
|
|
21
|
-
"external-link": ExternalLink
|
|
21
|
+
"external-link": ExternalLink,
|
|
22
|
+
ban: Ban
|
|
22
23
|
};
|
|
23
24
|
/** The icon a name stands for, or nothing for a name the app does not know. */
|
|
24
25
|
const namedIcon = (name) => name !== void 0 && Object.hasOwn(byName, name) ? byName[name] : void 0;
|
|
@@ -6,9 +6,11 @@ import { propsOf } from "./block-props.js";
|
|
|
6
6
|
import { useState } from "react";
|
|
7
7
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
8
8
|
//#region src/app/pages/blocks/action.tsx
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
/**
|
|
10
|
+
* A button for a named action of `resource`'s view, by default of the record around it, on that record, asking first
|
|
11
|
+
* when the action has `confirm` or `inputs`; nothing when the view has no such action for the user.
|
|
12
|
+
*/
|
|
13
|
+
const ActionButton = ({ name, resource, label, variant }) => {
|
|
12
14
|
const scope = useLayoutRecord();
|
|
13
15
|
const action = useRunAction(resource ?? scope?.model.name ?? "", name, resource === void 0 || resource === scope?.model.name ? scope : void 0);
|
|
14
16
|
const [asking, setAsking] = useState(false);
|
|
@@ -38,5 +40,7 @@ const ActionBlock = ({ node }) => {
|
|
|
38
40
|
}
|
|
39
41
|
})] });
|
|
40
42
|
};
|
|
43
|
+
/** An Action block: its button. */
|
|
44
|
+
const ActionBlock = ({ node }) => /* @__PURE__ */ jsx(ActionButton, { ...propsOf(node) });
|
|
41
45
|
//#endregion
|
|
42
|
-
export { ActionBlock };
|
|
46
|
+
export { ActionBlock, ActionButton };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { recordKey } from "../../live/model-helpers.js";
|
|
2
|
+
import { text } from "./messages.js";
|
|
3
|
+
import { encodeKey } from "@protobase/schema/internal";
|
|
4
|
+
//#region src/app/rank-after/dependency-rule.ts
|
|
5
|
+
/**
|
|
6
|
+
* The drop rule of a list that holds every record in rank order: the record titled `title` lands below each record it
|
|
7
|
+
* depends on and above each that depends on it, by `bounds`; records the list does not hold do not count.
|
|
8
|
+
*/
|
|
9
|
+
const dependencyRule = (model, bounds, title) => ({ order, index }) => {
|
|
10
|
+
const at = (key) => order.findIndex((entry) => encodeKey(recordKey(model, entry)) === key);
|
|
11
|
+
const above = bounds.after.find((bound) => at(bound.key) > index);
|
|
12
|
+
if (above) return text("dependsOn", title, above.title);
|
|
13
|
+
const below = bounds.before.find((bound) => at(bound.key) !== -1 && at(bound.key) < index);
|
|
14
|
+
return below ? text("dependsOn", below.title, title) : void 0;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { dependencyRule };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defineMessages } from "../../locale/messages.js";
|
|
2
|
+
//#region src/app/rank-after/messages.ts
|
|
3
|
+
const text = defineMessages({
|
|
4
|
+
en: { dependsOn: (record, other) => `${record} depends on ${other}.` },
|
|
5
|
+
nl: { dependsOn: (record, other) => `${record} hangt af van ${other}.` }
|
|
6
|
+
});
|
|
7
|
+
//#endregion
|
|
8
|
+
export { text };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { recordKey, recordTitle } from "../../live/model-helpers.js";
|
|
2
|
+
import { encodeKey } from "@protobase/schema/internal";
|
|
3
|
+
import { printFilter, where } from "@protobase/schema";
|
|
4
|
+
//#region src/app/rank-after/rank-bounds.ts
|
|
5
|
+
const everyRecord = async (client, resource, filter) => {
|
|
6
|
+
const records = [];
|
|
7
|
+
let pageToken = "";
|
|
8
|
+
do {
|
|
9
|
+
const page = await client.search(resource, {
|
|
10
|
+
filter,
|
|
11
|
+
pageSize: 100,
|
|
12
|
+
...pageToken && { pageToken }
|
|
13
|
+
});
|
|
14
|
+
records.push(...page.items);
|
|
15
|
+
pageToken = page.nextPageToken;
|
|
16
|
+
} while (pageToken);
|
|
17
|
+
return records;
|
|
18
|
+
};
|
|
19
|
+
const narrowed = (filter, extra) => extra === void 0 ? filter : `(${filter}) AND (${extra})`;
|
|
20
|
+
/**
|
|
21
|
+
* The records `record` must rank between, through the list API: the join records that hold its key on either side,
|
|
22
|
+
* then the records they name, those it depends on narrowed to the pending ones. Without `after` on the resource's
|
|
23
|
+
* position there are none.
|
|
24
|
+
*/
|
|
25
|
+
const rankBounds = async (client, model, view, record) => {
|
|
26
|
+
const after = model.position?.after;
|
|
27
|
+
if (!after) return {
|
|
28
|
+
after: [],
|
|
29
|
+
before: []
|
|
30
|
+
};
|
|
31
|
+
const id = model.primaryKey[0];
|
|
32
|
+
const self = recordKey(model, record);
|
|
33
|
+
const encoded = (value) => encodeKey([value]);
|
|
34
|
+
const links = await everyRecord(client, after.resource, narrowed(printFilter(where.or(where.eq(after.field, self), where.eq(after.key, self))), after.filter));
|
|
35
|
+
const other = (from, to) => links.flatMap((link) => encoded(link[from]) === encoded(self) && encoded(link[to]) !== encoded(self) ? [link[to]] : []);
|
|
36
|
+
const dependsOn = other(after.field, after.key);
|
|
37
|
+
const dependents = other(after.key, after.field);
|
|
38
|
+
const [pending, waiting] = await Promise.all([everyRecord(client, model.name, narrowed(printFilter(where.in(id, [self, ...dependsOn])), after.while)), dependents.length === 0 ? [] : everyRecord(client, model.name, printFilter(where.in(id, dependents)))]);
|
|
39
|
+
const bound = (found) => {
|
|
40
|
+
const key = encodeKey(recordKey(model, found));
|
|
41
|
+
return {
|
|
42
|
+
key,
|
|
43
|
+
rank: found[model.position.field],
|
|
44
|
+
title: recordTitle(model, view, found, key)
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const selfPending = pending.some((found) => encoded(found[id]) === encoded(self));
|
|
48
|
+
return {
|
|
49
|
+
after: pending.filter((found) => encoded(found[id]) !== encoded(self)).map(bound),
|
|
50
|
+
before: selfPending ? waiting.map(bound) : []
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { rankBounds };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { recordKey } from "../../live/model-helpers.js";
|
|
2
|
+
import { reorderSpan } from "../hand-order/reorder-span.js";
|
|
3
|
+
import { placing } from "../displays/board-place.js";
|
|
4
|
+
import { text } from "./messages.js";
|
|
5
|
+
import { encodeKey } from "@protobase/schema/internal";
|
|
6
|
+
//#region src/app/rank-after/ranked-place.ts
|
|
7
|
+
const isRank = (value) => value !== null && value !== void 0 && value !== "";
|
|
8
|
+
/**
|
|
9
|
+
* Where a card of a ranked board dropped at `index` of a column, its cards `rows`, may land by `bounds`: at the first
|
|
10
|
+
* place from there down where the ranks its `reorder` gives keep it below each record it depends on and above each that
|
|
11
|
+
* depends on it, wherever those are. In the last place it may take a rank just above what it depends on, for a record
|
|
12
|
+
* of another column that ranks above the whole column. With the reason it refused `index`; with no place, none.
|
|
13
|
+
*/
|
|
14
|
+
const rankedPlace = ({ model, rank, rows, row, index, bounds, title }) => {
|
|
15
|
+
const end = rows.some((entry) => entry.key === row.key) ? rows.length - 1 : rows.length;
|
|
16
|
+
let reason;
|
|
17
|
+
for (let at = index; at <= end; at++) {
|
|
18
|
+
const { order, first, last } = placing(rows, row, at);
|
|
19
|
+
const ranks = order.map((entry) => entry.record[rank]);
|
|
20
|
+
const span = reorderSpan(ranks, first, last);
|
|
21
|
+
const ranked = new Map(order.map((entry, i) => [encodeKey(recordKey(model, entry.record)), i >= span.first && i <= span.last ? span.start + i - span.first : ranks[i]]));
|
|
22
|
+
const rankOf = (bound) => ranked.has(bound.key) ? ranked.get(bound.key) : bound.rank;
|
|
23
|
+
const mine = Number(ranked.get(encodeKey(recordKey(model, row.record))));
|
|
24
|
+
const above = bounds.after.filter((bound) => isRank(rankOf(bound)) && Number(rankOf(bound)) >= mine);
|
|
25
|
+
const below = bounds.before.find((bound) => isRank(rankOf(bound)) && Number(rankOf(bound)) <= mine);
|
|
26
|
+
const refusal = below ? text("dependsOn", below.title, title) : above[0] && text("dependsOn", title, above[0].title);
|
|
27
|
+
if (!refusal) return {
|
|
28
|
+
place: { index: at },
|
|
29
|
+
...reason !== void 0 && { reason }
|
|
30
|
+
};
|
|
31
|
+
reason ??= refusal;
|
|
32
|
+
if (at !== end || below) continue;
|
|
33
|
+
const bump = Math.max(...above.map((bound) => Number(rankOf(bound)))) + 1;
|
|
34
|
+
if (bounds.before.every((bound) => !isRank(rankOf(bound)) || Number(rankOf(bound)) > bump)) return {
|
|
35
|
+
place: {
|
|
36
|
+
index: at,
|
|
37
|
+
bump
|
|
38
|
+
},
|
|
39
|
+
reason
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { reason };
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { rankedPlace };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn.js";
|
|
2
|
+
import { Button } from "../../primitives/button.js";
|
|
3
|
+
import { text } from "./messages.js";
|
|
4
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region src/app/related-cards/clamped.tsx
|
|
7
|
+
/**
|
|
8
|
+
* Its content cut off at a few lines' height with "Show more" under it when it is taller, which shows it all; the cut
|
|
9
|
+
* hides the rest rather than scrolling it, so the page keeps its one scrollbar.
|
|
10
|
+
*/
|
|
11
|
+
const Clamped = ({ children }) => {
|
|
12
|
+
const content = useRef(null);
|
|
13
|
+
const [open, setOpen] = useState(false);
|
|
14
|
+
const [taller, setTaller] = useState(false);
|
|
15
|
+
useLayoutEffect(() => {
|
|
16
|
+
const box = content.current;
|
|
17
|
+
if (!box) return;
|
|
18
|
+
const measure = () => setTaller(box.scrollHeight > box.parentElement.clientHeight + 1);
|
|
19
|
+
measure();
|
|
20
|
+
const observer = new ResizeObserver(measure);
|
|
21
|
+
observer.observe(box);
|
|
22
|
+
return () => observer.disconnect();
|
|
23
|
+
}, [open]);
|
|
24
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
25
|
+
className: "flex flex-col items-start gap-1",
|
|
26
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
27
|
+
className: cn("w-full min-w-0", !open && "max-h-40 overflow-hidden"),
|
|
28
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
29
|
+
ref: content,
|
|
30
|
+
children
|
|
31
|
+
})
|
|
32
|
+
}), (taller || open) && /* @__PURE__ */ jsx(Button, {
|
|
33
|
+
size: "sm",
|
|
34
|
+
variant: "ghost",
|
|
35
|
+
className: "-ml-2",
|
|
36
|
+
"aria-expanded": open,
|
|
37
|
+
onClick: () => setOpen(!open),
|
|
38
|
+
children: open ? text("showLess") : text("showMore")
|
|
39
|
+
})]
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { Clamped };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineMessages } from "../../locale/messages.js";
|
|
2
|
+
//#region src/app/related-cards/messages.ts
|
|
3
|
+
const text = defineMessages({
|
|
4
|
+
en: {
|
|
5
|
+
showMore: "Show more",
|
|
6
|
+
showLess: "Show less",
|
|
7
|
+
couldNotLoad: "Could not load",
|
|
8
|
+
nothingYet: "Nothing here yet."
|
|
9
|
+
},
|
|
10
|
+
nl: {
|
|
11
|
+
showMore: "Meer tonen",
|
|
12
|
+
showLess: "Minder tonen",
|
|
13
|
+
couldNotLoad: "Kon niet laden",
|
|
14
|
+
nothingYet: "Hier staat nog niets."
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
//#endregion
|
|
18
|
+
export { text };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { recordKey, recordTitle } from "../../live/model-helpers.js";
|
|
3
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
4
|
+
import { AppLink } from "../use-app-address.js";
|
|
5
|
+
import { RecordScope } from "../pages/record-scope.js";
|
|
6
|
+
import { ActionButton } from "../pages/blocks/action.js";
|
|
7
|
+
import { FieldValue } from "../pages/field-value.js";
|
|
8
|
+
import { usePathValue } from "../pages/use-path-value.js";
|
|
9
|
+
import { fieldLabel } from "../pages/blocks/field.js";
|
|
10
|
+
import { Clamped } from "./clamped.js";
|
|
11
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
12
|
+
//#region src/app/related-cards/related-card.tsx
|
|
13
|
+
const CardField = ({ scope, path }) => {
|
|
14
|
+
const { views } = useAdminMeta();
|
|
15
|
+
const value = usePathValue(scope, path);
|
|
16
|
+
if (value.state === "missing") return null;
|
|
17
|
+
const label = fieldLabel(path, value.state === "ready" ? views[value.model.name]?.fields[value.field.name]?.label : void 0);
|
|
18
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
19
|
+
className: "min-w-0",
|
|
20
|
+
children: [/* @__PURE__ */ jsx("dt", {
|
|
21
|
+
className: "text-body text-muted-foreground",
|
|
22
|
+
children: label
|
|
23
|
+
}), /* @__PURE__ */ jsx("dd", {
|
|
24
|
+
className: "mt-0.5",
|
|
25
|
+
children: value.state === "loading" ? /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-24" }) : /* @__PURE__ */ jsx(Clamped, { children: /* @__PURE__ */ jsx(FieldValue, {
|
|
26
|
+
model: value.model,
|
|
27
|
+
field: value.field,
|
|
28
|
+
value: value.value
|
|
29
|
+
}) })
|
|
30
|
+
})]
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* A related record as a card: its title linking to its page, its `fields` under their labels and the buttons of its
|
|
35
|
+
* view's `actions`, run on it as everywhere else.
|
|
36
|
+
*/
|
|
37
|
+
const RelatedCard = ({ scope, view, fields, actions }) => /* @__PURE__ */ jsxs("li", {
|
|
38
|
+
className: "flex min-w-0 flex-col gap-3 rounded-lg border border-border bg-surface p-4",
|
|
39
|
+
children: [
|
|
40
|
+
/* @__PURE__ */ jsx(AppLink, {
|
|
41
|
+
to: {
|
|
42
|
+
kind: "record",
|
|
43
|
+
resource: scope.model.name,
|
|
44
|
+
key: recordKey(scope.model, scope.record)
|
|
45
|
+
},
|
|
46
|
+
className: "self-start font-medium text-primary-text hover:underline",
|
|
47
|
+
children: recordTitle(scope.model, view, scope.record, scope.key)
|
|
48
|
+
}),
|
|
49
|
+
fields.length > 0 && /* @__PURE__ */ jsx("dl", {
|
|
50
|
+
className: "flex flex-col gap-3",
|
|
51
|
+
children: fields.map((path) => /* @__PURE__ */ jsx(CardField, {
|
|
52
|
+
scope,
|
|
53
|
+
path
|
|
54
|
+
}, path))
|
|
55
|
+
}),
|
|
56
|
+
actions.length > 0 && /* @__PURE__ */ jsx(RecordScope, {
|
|
57
|
+
value: scope,
|
|
58
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
59
|
+
className: "flex flex-wrap items-center gap-2",
|
|
60
|
+
children: actions.map((name) => /* @__PURE__ */ jsx(ActionButton, { name }, name))
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
]
|
|
64
|
+
});
|
|
65
|
+
//#endregion
|
|
66
|
+
export { RelatedCard };
|