@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.
@@ -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
- order,
130
- first: from === -1 ? place.index : Math.min(from, place.index),
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 etags = Object.fromEntries(sent.map((entry) => [encodeKey(recordKey(model, entry.record)), moving && entry.key === row.key ? "*" : etagOf(entry)]));
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: sent.map((entry) => recordKey(model, entry.record)),
35
- start: span.start,
36
- etags
37
- };
38
- if (!moving) return [reorder];
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
- }, reorder];
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 { boardWrites, placeOps } from "./board-place.js";
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 saved(boardWrites(model, place.order, await client.batchWrite(placeOps(model, by, rank, row, to, place.order, place.first, place.last))), to);
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
- const moved = {
77
- ...row,
78
- record: {
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 { reorderSpan } from "./reorder-span.js";
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);
@@ -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
- /** A button for a named action of the resource's view, asking first when the action has `confirm` or `inputs`. */
10
- const ActionBlock = ({ node }) => {
11
- const { name, resource, label, variant } = propsOf(node);
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 };