@protobase/ui 0.21.1 → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/app/current-address.js +2 -2
  2. package/dist/app/displays/display-controls.js +3 -4
  3. package/dist/app/displays/list-displays.js +58 -4
  4. package/dist/app/displays/record-card.js +2 -2
  5. package/dist/app/embedded-list/embedded-columns.js +1 -1
  6. package/dist/app/embedded-list/embedded-display.js +6 -2
  7. package/dist/app/embedded-list/embedded-list.js +1 -1
  8. package/dist/app/field-editor.js +1 -1
  9. package/dist/app/hand-order/hand-order.js +21 -0
  10. package/dist/app/hand-order/hand-sorted-list.js +98 -0
  11. package/dist/app/hand-order/sort-button.js +20 -0
  12. package/dist/app/hand-order/use-hand-order.js +105 -0
  13. package/dist/app/list-page.js +39 -12
  14. package/dist/app/pages/field-value.js +1 -1
  15. package/dist/app/record-page.js +2 -2
  16. package/dist/app/related-dots/related-dots-columns.js +2 -1
  17. package/dist/app/related-dots/related-dots.js +1 -1
  18. package/dist/app/related-section.js +37 -8
  19. package/dist/data-grid/cell-text.js +30 -0
  20. package/dist/data-grid/cells.js +27 -35
  21. package/dist/data-grid/content-widths.js +81 -0
  22. package/dist/data-grid/data-grid.js +58 -40
  23. package/dist/data-grid/default-pins.js +13 -0
  24. package/dist/data-grid/grid-row.js +16 -4
  25. package/dist/data-grid/pinned-storage.js +2 -1
  26. package/dist/data-grid/row-reorder.js +68 -0
  27. package/dist/data-grid/truncated-text.js +16 -0
  28. package/dist/data-grid/use-fitted-columns.js +28 -0
  29. package/dist/data-grid/use-pinned-columns.js +6 -5
  30. package/dist/live/columns-from-model.js +6 -6
  31. package/dist/package.js +1 -1
  32. package/dist/shell/assets/app-wZRscg1o.js +69 -0
  33. package/dist/shell/assets/index-C1fzTv_X.css +2 -0
  34. package/dist/shell/assets/{index-CW037BBI.js → index-RHKnIOOP.js} +1 -1
  35. package/dist/shell/assets/{maplibre-Ck9abcyu.js → maplibre-DN_v_fcJ.js} +1 -1
  36. package/dist/shell/assets/{markdown-content-BDjSKrC7.js → markdown-content-Be4vIiWm.js} +1 -1
  37. package/dist/shell/assets/{record-map-CKPZ2KJs.js → record-map-CVeshts3.js} +1 -1
  38. package/dist/shell/assets/{records-map-DadV3GNO.js → records-map-ClnWq-RH.js} +1 -1
  39. package/dist/shell/assets/{shared-protobase-ui-tJCYxj7E.js → shared-protobase-ui-DWCBNg3c.js} +1 -1
  40. package/dist/shell/index.html +4 -4
  41. package/dist/shell/protobase.shell.json +1 -1
  42. package/package.json +4 -4
  43. package/dist/shell/assets/app-DGVgRtZo.js +0 -69
  44. package/dist/shell/assets/index-Dc6xgHsz.css +0 -2
@@ -1,4 +1,4 @@
1
- import { defaultDisplay } from "./displays/list-displays.js";
1
+ import { defaultDisplay, listDefaultDisplay } from "./displays/list-displays.js";
2
2
  import { defaultOrderBy } from "./list-order.js";
3
3
  import { encodeKey } from "@protobase/schema/internal";
4
4
  //#region src/app/current-address.ts
@@ -22,7 +22,7 @@ const listedResource = (resources, views, resource) => {
22
22
  };
23
23
  /** What a list shows when its address names nothing: its default display for the screen, the list's sort, the filters in a bar. */
24
24
  const listDefaults = (model, view, xs = false) => ({
25
- display: defaultDisplay(view, xs),
25
+ display: model ? listDefaultDisplay(model, view, xs) : defaultDisplay(view, xs),
26
26
  orderBy: model ? defaultOrderBy(model, view) : "",
27
27
  filterLayout: "bar"
28
28
  });
@@ -18,11 +18,10 @@ const icons = {
18
18
  map: Map
19
19
  };
20
20
  /**
21
- * The list page's switch between the table and the view's displays, as buttons or, on a phone, as a menu; nothing for a
22
- * list with only its table.
21
+ * The list page's switch between the table and the view's displays, or `choices`, as buttons or, on a phone, as a menu;
22
+ * nothing for a list with only its table.
23
23
  */
24
- const DisplaySwitch = ({ view, value, onChange, menu = false }) => {
25
- const choices = displayChoices(view);
24
+ const DisplaySwitch = ({ view, value, onChange, menu = false, choices = displayChoices(view) }) => {
26
25
  if (choices.length < 2) return null;
27
26
  const options = choices.map((choice) => ({
28
27
  value: choice.name,
@@ -1,5 +1,6 @@
1
1
  import { recordKey, recordTitleField } from "../../live/model-helpers.js";
2
2
  import { humanize } from "../../live/naming.js";
3
+ import { listColumnNames } from "../../live/columns-from-model.js";
3
4
  import { displayValue } from "../../live/display-value.js";
4
5
  import { photosKey } from "../../live/row-photos.js";
5
6
  import { filledFields } from "../filled-fields.js";
@@ -18,6 +19,40 @@ const defaultDisplay = (view, xs, preferred) => {
18
19
  const cards = displaysOf(view).filter((display) => display.kind === "cards");
19
20
  return cards.find((display) => display.name === fallback)?.name ?? cards[0]?.name ?? fallback;
20
21
  };
22
+ /** The name of the list a phone shows for a list without a cards display. */
23
+ const rowsDisplay = "rows";
24
+ /**
25
+ * The list a phone shows for a list without a cards display, as a cards display of a row per record: the title, the
26
+ * first enum column as a badge on the right, and the next two columns, booleans left out, on a muted line below.
27
+ * `undefined` for a view with a cards display, or a display of its own named `rows`.
28
+ */
29
+ const phoneRows = (model, view) => {
30
+ if (displaysOf(view).some((display) => display.kind === "cards" || display.name === "rows")) return void 0;
31
+ const title = recordTitleField(model, view);
32
+ const candidates = listColumnNames(model, view).filter((name) => name !== title && model.fields[name] !== void 0);
33
+ const badge = candidates.find((name) => model.fields[name].type === "enum");
34
+ const footer = candidates.filter((name) => name !== badge && model.fields[name].type !== "boolean").slice(0, 2);
35
+ return {
36
+ kind: "cards",
37
+ name: rowsDisplay,
38
+ label: "List",
39
+ icon: "list",
40
+ title,
41
+ ...badge !== void 0 && { badge },
42
+ footer
43
+ };
44
+ };
45
+ const isPhoneRows = (view, display) => display.name === "rows" && !displaysOf(view).some((other) => other.name === "rows");
46
+ /**
47
+ * The display a list page opens in when its address names none: `defaultDisplay`, but below the `sm` breakpoint the
48
+ * phone's rows (`phoneRows`) in place of the table.
49
+ */
50
+ const listDefaultDisplay = (model, view, xs) => {
51
+ const display = defaultDisplay(view, xs);
52
+ return xs && display === tableDisplay && phoneRows(model, view) ? rowsDisplay : display;
53
+ };
54
+ /** The display a list page shows for the address's `name`: `shownDisplay`, or the phone's rows when it names them, on any screen. */
55
+ const shownListDisplay = (model, view, name) => (name === "rows" ? phoneRows(model, view) : void 0) ?? shownDisplay(view, name);
21
56
  /** The display a list shows for the address's `name`: `undefined` for the table, and the view's default for a name it does not have. */
22
57
  const shownDisplay = (view, name) => {
23
58
  if (name === tableDisplay) return void 0;
@@ -38,6 +73,15 @@ const displayChoices = (view) => [{
38
73
  label: display.label ?? humanize(display.name),
39
74
  icon: display.icon ?? defaultIcons[display.kind]
40
75
  }))];
76
+ /** The list page's display switch: `displayChoices`, and below the `sm` breakpoint the phone's rows when it has them. */
77
+ const listDisplayChoices = (model, view, xs) => {
78
+ const choices = displayChoices(view);
79
+ return xs && phoneRows(model, view) ? [...choices, {
80
+ name: rowsDisplay,
81
+ label: "List",
82
+ icon: "list"
83
+ }] : choices;
84
+ };
41
85
  /** The fields a cards display shows, its title the view's title field when it names none. */
42
86
  const cardFields = (model, view, display) => ({
43
87
  image: display.image,
@@ -101,9 +145,19 @@ const shownFields = (model, view, display) => {
101
145
  ].filter((name) => name !== void 0);
102
146
  };
103
147
  /** The fields to label the list's rows for: the table's columns and every display's, so one load serves them all. */
104
- const loadedFields = (model, view, columns) => [.../* @__PURE__ */ new Set([...columns, ...displaysOf(view).flatMap((display) => shownFields(model, view, display))])];
105
- /** The sortable fields a display shows, for the sort menu that stands in for column headers. */
106
- const sortFields = (model, view, display) => [...new Set(shownFields(model, view, display))].filter((name) => model.fields[name]?.sortable);
148
+ const loadedFields = (model, view, columns) => {
149
+ const rows = phoneRows(model, view);
150
+ const displays = rows ? [...displaysOf(view), rows] : displaysOf(view);
151
+ return [.../* @__PURE__ */ new Set([...columns, ...displays.flatMap((display) => shownFields(model, view, display))])];
152
+ };
153
+ /**
154
+ * The sortable fields a display shows, for the sort menu that stands in for column headers; for the phone's rows, those
155
+ * of the table's columns and the title, as the table's headers sort by.
156
+ */
157
+ const sortFields = (model, view, display) => {
158
+ const shown = isPhoneRows(view, display) ? [recordTitleField(model, view), ...listColumnNames(model, view)] : shownFields(model, view, display);
159
+ return [...new Set(shown)].filter((name) => model.fields[name]?.sortable);
160
+ };
107
161
  const isLabelled = (value) => typeof value === "object" && value !== null && "id" in value && "name" in value;
108
162
  /**
109
163
  * A loaded row, its relations as `{ id, name }`, split into the record as the API gives it, the relations' names and
@@ -128,4 +182,4 @@ const displayRow = (model, row) => {
128
182
  };
129
183
  };
130
184
  //#endregion
131
- export { amountParts, cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, filledAmount, filledMeta, loadedFields, mapCards, shownDisplay, shownFields, sortFields };
185
+ export { amountParts, cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, filledAmount, filledMeta, listDefaultDisplay, listDisplayChoices, loadedFields, mapCards, phoneRows, rowsDisplay, shownDisplay, shownFields, shownListDisplay, sortFields };
@@ -3,14 +3,14 @@ import { CustomBoundary } from "../pages/custom-boundary.js";
3
3
  import { recordKey } from "../../live/model-helpers.js";
4
4
  import { humanize } from "../../live/naming.js";
5
5
  import { useProjectUi } from "../pages/project-ui.js";
6
+ import { markdownExcerpt } from "../../markdown/markdown-excerpt.js";
7
+ import { isMarkdown } from "../../markdown/markdown-hint.js";
6
8
  import { displayValue } from "../../live/display-value.js";
7
9
  import { amountParts, cardFields, filledAmount, filledMeta } from "./list-displays.js";
8
10
  import { AppLink } from "../use-app-address.js";
9
11
  import { shownWidget } from "../field-types/shown-widget.js";
10
12
  import { WidgetPart } from "../field-types/widget-part.js";
11
13
  import { Markdown } from "../../markdown/markdown.js";
12
- import { isMarkdown } from "../../markdown/markdown-hint.js";
13
- import { markdownExcerpt } from "../../markdown/markdown-excerpt.js";
14
14
  import { ValueBadge } from "../value-badge.js";
15
15
  import { useSeedRecord } from "../record-preview.js";
16
16
  import { namedIcon } from "../named-icon.js";
@@ -1,7 +1,7 @@
1
1
  import { humanize } from "../../live/naming.js";
2
+ import { columnsFromModel } from "../../live/columns-from-model.js";
2
3
  import { displayRow } from "../displays/list-displays.js";
3
4
  import { scopeOf } from "../pages/use-layout-data.js";
4
- import { columnsFromModel } from "../../live/columns-from-model.js";
5
5
  import { FieldValue } from "../pages/field-value.js";
6
6
  import { usePathValue } from "../pages/use-path-value.js";
7
7
  import { withFieldTypeCells } from "../field-types/field-type-cells.js";
@@ -18,13 +18,17 @@ const embeddedDisplay = (view, xs, { picked, preferred }) => {
18
18
  return defaultDisplay(view, xs, preferred !== void 0 && names.includes(preferred) ? preferred : void 0);
19
19
  };
20
20
  /**
21
- * The resource's list with the embedded list's filter, order and display, leaving out what the list does by default, and
21
+ * The resource's list with the embedded list's filter, order and display, leaving out what the list does by default (an
22
+ * embedded list's own default display among it, so a phone opens the list in its rows rather than the table), and
22
23
  * the saved search whose results it shows, if any. `resource` names another list of the same records, such as the
23
24
  * favorites that list a user's saved listings.
24
25
  */
25
26
  const viewAllAddress = (model, view, xs, { resource = model.name, ...list }) => changedList({
26
27
  kind: "list",
27
28
  resource
28
- }, list, listDefaults(model, view, xs));
29
+ }, list, {
30
+ ...listDefaults(model, view, xs),
31
+ display: defaultDisplay(view, xs)
32
+ });
29
33
  //#endregion
30
34
  export { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, viewAllAddress, writePickedDisplay };
@@ -5,10 +5,10 @@ import { humanize } from "../../live/naming.js";
5
5
  import { useProjectUi } from "../pages/project-ui.js";
6
6
  import { useAdminMeta } from "../meta-gate.js";
7
7
  import { ErrorBanner } from "../error-banner.js";
8
+ import { listColumnNames } from "../../live/columns-from-model.js";
8
9
  import { displayRow, loadedFields, shownDisplay } from "../displays/list-displays.js";
9
10
  import { defaultOrderBy, parseSort, sortToOrderBy } from "../list-order.js";
10
11
  import { AppLink, useAppAddress } from "../use-app-address.js";
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
14
  import { BoardDisplay } from "../displays/board-display.js";
@@ -6,6 +6,7 @@ import { humanize } from "../live/naming.js";
6
6
  import { fromDraft, isEditable, toFieldDraft } from "../live/field-values.js";
7
7
  import { Switch } from "../primitives/switch.js";
8
8
  import { Textarea } from "../primitives/textarea.js";
9
+ import { isMarkdown } from "../markdown/markdown-hint.js";
9
10
  import { displayValue } from "../live/display-value.js";
10
11
  import { AppLink } from "./use-app-address.js";
11
12
  import { RelationPicker } from "./relation-picker.js";
@@ -15,7 +16,6 @@ import { useShownWidget } from "./field-types/shown-widget.js";
15
16
  import { WidgetPart } from "./field-types/widget-part.js";
16
17
  import { Markdown } from "../markdown/markdown.js";
17
18
  import { MarkdownEditor } from "../markdown/markdown-editor.js";
18
- import { isMarkdown } from "../markdown/markdown-hint.js";
19
19
  import { ArrowUpRight } from "lucide-react";
20
20
  import { jsx, jsxs } from "react/jsx-runtime";
21
21
  import { enumLabel } from "@protobase/schema";
@@ -0,0 +1,21 @@
1
+ import { can } from "../permissions.js";
2
+ //#region src/app/hand-order/hand-order.ts
3
+ /** The field a user may sort `model`'s records by hand in: its `position`, when they may update it. */
4
+ const writablePosition = (model, permissions) => {
5
+ const position = model.position;
6
+ if (!position || !can(permissions, "update")) return void 0;
7
+ const field = model.fields[position.field];
8
+ return field && !field.readOnly ? position.field : void 0;
9
+ };
10
+ /**
11
+ * The field the list page sorts all of `model`'s records by hand in. A resource whose records are ordered within a
12
+ * parent is sorted in the parent's related section instead, since its list mixes the parents.
13
+ */
14
+ const listHandOrder = (model, permissions) => model.position?.within === void 0 ? writablePosition(model, permissions) : void 0;
15
+ /**
16
+ * The field a related section sorts its records by hand in: those of one parent, when the section lists every record
17
+ * whose `within` relation points at the page's record, no filter or join resource leaving some out.
18
+ */
19
+ const relatedHandOrder = (item, model, permissions) => model.position?.within !== void 0 && item.field === model.position.within && !item.through && !item.filter ? writablePosition(model, permissions) : void 0;
20
+ //#endregion
21
+ export { listHandOrder, relatedHandOrder };
@@ -0,0 +1,98 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { recordKey, recordTitle } from "../../live/model-helpers.js";
3
+ import { humanize } from "../../live/naming.js";
4
+ import { useProjectUi } from "../pages/project-ui.js";
5
+ import { useAdminMeta } from "../meta-gate.js";
6
+ import { ErrorBanner } from "../error-banner.js";
7
+ import { loadedFields } from "../displays/list-displays.js";
8
+ import { useAppAddress } from "../use-app-address.js";
9
+ import { DataGrid } from "../../data-grid/data-grid.js";
10
+ import { embeddedColumns } from "../embedded-list/embedded-columns.js";
11
+ import { useHandOrder } from "./use-hand-order.js";
12
+ import { useMemo } from "react";
13
+ import { jsx, jsxs } from "react/jsx-runtime";
14
+ //#region src/app/hand-order/hand-sorted-list.tsx
15
+ /**
16
+ * The records in their order by hand, in the list's table with a drag handle in each row's checkbox place: dragging a
17
+ * row, or moving it with the keyboard from its handle, renumbers them all. The page scrolls them, and a row still opens
18
+ * its record.
19
+ */
20
+ const HandSortedList = ({ model, view, field, filter, columns }) => {
21
+ const { resources, views } = useAdminMeta();
22
+ const { fields: fieldTypes } = useProjectUi();
23
+ const { go } = useAppAddress();
24
+ const specs = useMemo(() => embeddedColumns(model, view, columns, {
25
+ resources,
26
+ views,
27
+ fieldTypes
28
+ }).map((spec) => ({
29
+ ...spec,
30
+ sortable: false
31
+ })), [
32
+ model,
33
+ view,
34
+ columns.join(","),
35
+ resources,
36
+ views,
37
+ fieldTypes
38
+ ]);
39
+ const fields = useMemo(() => loadedFields(model, view, specs.map((spec) => spec.id)), [
40
+ model,
41
+ view,
42
+ specs
43
+ ]);
44
+ const order = useHandOrder({
45
+ model,
46
+ field,
47
+ filter,
48
+ fields
49
+ });
50
+ const rows = order.rows;
51
+ const source = useMemo(() => rows && {
52
+ total: rows.length,
53
+ rowAt: (index) => rows[index]
54
+ }, [rows]);
55
+ const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
56
+ if (order.loading) return /* @__PURE__ */ jsx("div", {
57
+ className: "flex flex-col gap-2",
58
+ children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
59
+ });
60
+ if (order.error) return /* @__PURE__ */ jsx(ErrorBanner, {
61
+ title: "Could not load",
62
+ error: order.error
63
+ });
64
+ if (order.more) return /* @__PURE__ */ jsxs("p", {
65
+ className: "text-muted-foreground",
66
+ children: [
67
+ "There are more than ",
68
+ 500 .toLocaleString("en-IE"),
69
+ " ",
70
+ plural,
71
+ ", too many to sort by hand."
72
+ ]
73
+ });
74
+ if (!source || source.total === 0) return /* @__PURE__ */ jsx("p", {
75
+ className: "text-muted-foreground",
76
+ children: "Nothing here yet."
77
+ });
78
+ const openRecord = (row) => go({
79
+ kind: "record",
80
+ resource: model.name,
81
+ key: recordKey(model, row)
82
+ });
83
+ return /* @__PURE__ */ jsx(DataGrid, {
84
+ fit: true,
85
+ scroll: "parent",
86
+ columns: specs,
87
+ source,
88
+ latencyMs: 0,
89
+ onOpenRow: openRecord,
90
+ rowNumbers: false,
91
+ reorder: {
92
+ label: (row) => recordTitle(model, view, row, row.id),
93
+ onMove: order.move
94
+ }
95
+ });
96
+ };
97
+ //#endregion
98
+ export { HandSortedList };
@@ -0,0 +1,20 @@
1
+ import { Button } from "../../primitives/button.js";
2
+ import { ArrowUpDown, Check } from "lucide-react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app/hand-order/sort-button.tsx
5
+ /** The button that turns sorting by hand on, as **Sort**, and off again, as **Done**; with `iconOnly` its icon alone. */
6
+ const SortButton = ({ sorting, onChange, size, iconOnly = false }) => {
7
+ const label = sorting ? "Done" : "Sort";
8
+ return /* @__PURE__ */ jsxs(Button, {
9
+ size,
10
+ variant: sorting ? "primary" : "secondary",
11
+ onClick: () => onChange(!sorting),
12
+ ...iconOnly && {
13
+ "aria-label": label,
14
+ title: label
15
+ },
16
+ children: [sorting ? /* @__PURE__ */ jsx(Check, { className: "size-3.5" }) : /* @__PURE__ */ jsx(ArrowUpDown, { className: "size-3.5" }), !iconOnly && label]
17
+ });
18
+ };
19
+ //#endregion
20
+ export { SortButton };
@@ -0,0 +1,105 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { recordId, recordKey } from "../../live/model-helpers.js";
4
+ import { useToast } from "../../toasts/toast-provider.js";
5
+ import { useAdminMeta } from "../meta-gate.js";
6
+ import { refusalReason } from "../displays/board-columns.js";
7
+ import { useRefreshResource } from "../pages/use-layout-data.js";
8
+ import { listLabels, withLabels } from "../../live/relation-labels.js";
9
+ import { useRef, useState } from "react";
10
+ import { useQuery } from "@tanstack/react-query";
11
+ import { encodeKey } from "@protobase/schema/internal";
12
+ import { arrayMove } from "@dnd-kit/sortable";
13
+ //#region src/app/hand-order/use-hand-order.ts
14
+ /**
15
+ * The records of `filter` in the order of `field`, all of them up to `handOrderLimit`, with `more` when there are
16
+ * others, and `move` to put one elsewhere. A move shows at once and renumbers them all 1, 2, 3, ... in one batch
17
+ * `reorder` with their ETags; moves made while one saves are saved after it, with the ETags it gave back. A refusal
18
+ * puts them back as the server has them and says why; either way every list of the resource reloads.
19
+ */
20
+ const useHandOrder = ({ model, field, filter, fields }) => {
21
+ const client = useClient();
22
+ const toast = useToast();
23
+ const refresh = useRefreshResource();
24
+ const { resources, views } = useAdminMeta();
25
+ const orderBy = `${field} asc`;
26
+ const loaded = useQuery({
27
+ queryKey: [...keys.list(model.name, filter, orderBy, 500, "hand-order"), fields.join(",")],
28
+ queryFn: async () => {
29
+ const labels = listLabels(resources, views, model, fields);
30
+ const page = await client.search(model.name, {
31
+ filter: filter || void 0,
32
+ orderBy,
33
+ pageSize: 500,
34
+ labels
35
+ });
36
+ return {
37
+ rows: withLabels(resources, model, fields, page.items, page.labels).map((row, index) => ({
38
+ ...row,
39
+ id: recordId(model, page.items[index])
40
+ })),
41
+ more: Boolean(page.nextPageToken)
42
+ };
43
+ }
44
+ });
45
+ const [moved, setMoved] = useState();
46
+ const latest = useRef(void 0);
47
+ const saving = useRef(Promise.resolve());
48
+ const waiting = useRef(0);
49
+ const save = async () => {
50
+ const rows = latest.current;
51
+ if (!rows) return;
52
+ const etags = Object.fromEntries(rows.flatMap((row) => typeof row.etag === "string" ? [[encodeKey(recordKey(model, row)), row.etag]] : []));
53
+ const written = (await client.batchWrite([{
54
+ op: "reorder",
55
+ resource: model.name,
56
+ field,
57
+ keys: rows.map((row) => recordKey(model, row)),
58
+ etags
59
+ }])).results[0];
60
+ if (written?.op !== "reorder" || !latest.current) return;
61
+ const stored = new Map(written.records.map((entry) => [recordId(model, entry.record), entry]));
62
+ latest.current = latest.current.map((row) => {
63
+ const entry = stored.get(row.id);
64
+ return entry ? {
65
+ ...row,
66
+ [field]: entry.record[field],
67
+ etag: entry.etag
68
+ } : row;
69
+ });
70
+ };
71
+ const refused = (error) => {
72
+ latest.current = void 0;
73
+ setMoved(void 0);
74
+ toast.show({
75
+ state: "error",
76
+ title: "Could not change the order",
77
+ description: refusalReason(error)
78
+ });
79
+ };
80
+ const settle = async () => {
81
+ waiting.current -= 1;
82
+ if (waiting.current > 0) return;
83
+ await refresh(model.name);
84
+ if (waiting.current > 0) return;
85
+ latest.current = void 0;
86
+ setMoved(void 0);
87
+ };
88
+ const move = (from, to) => {
89
+ const rows = latest.current ?? loaded.data?.rows;
90
+ if (!rows || from === to) return;
91
+ latest.current = arrayMove(rows, from, to);
92
+ setMoved(latest.current);
93
+ waiting.current += 1;
94
+ saving.current = saving.current.then(save).catch(refused).then(settle);
95
+ };
96
+ return {
97
+ rows: moved ?? loaded.data?.rows,
98
+ more: loaded.data?.more ?? false,
99
+ loading: loaded.isPending,
100
+ error: loaded.error,
101
+ move
102
+ };
103
+ };
104
+ //#endregion
105
+ export { useHandOrder };
@@ -1,4 +1,4 @@
1
- import { useIsDesktop } from "../lib/use-media-query.js";
1
+ import { useIsDesktop, useIsXs } from "../lib/use-media-query.js";
2
2
  import { usePageScroll } from "../app-shell/page-scroll.js";
3
3
  import { PageHeader } from "../app-shell/page-header.js";
4
4
  import { useAuth } from "../auth/auth-provider.js";
@@ -9,12 +9,12 @@ import { useProjectUi } from "./pages/project-ui.js";
9
9
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
10
10
  import { Sheet } from "../primitives/sheet.js";
11
11
  import { ErrorBanner } from "./error-banner.js";
12
- import { displayRow, loadedFields, shownDisplay } from "./displays/list-displays.js";
12
+ import { columnsFromModel, listColumnNames } from "../live/columns-from-model.js";
13
+ import { displayRow, listDisplayChoices, loadedFields, shownListDisplay } from "./displays/list-displays.js";
13
14
  import { parseSort, sortToOrderBy } from "./list-order.js";
14
15
  import { listedResource } from "./current-address.js";
15
16
  import { AppLink, useAppAddress } from "./use-app-address.js";
16
17
  import { can, rowAllows } from "./permissions.js";
17
- import { columnsFromModel } from "../live/columns-from-model.js";
18
18
  import { pinnedStorageKey } from "../data-grid/pinned-storage.js";
19
19
  import { DataGrid } from "../data-grid/data-grid.js";
20
20
  import { useLiveRows } from "../live/use-live-rows.js";
@@ -52,6 +52,9 @@ import { SavedList } from "./saved/saved-list.js";
52
52
  import { SaveSearchButton } from "./searches/save-search-button.js";
53
53
  import { SavedSearches } from "./searches/saved-searches.js";
54
54
  import { useListCellEditor } from "./list-edit/use-list-cell-editor.js";
55
+ import { listHandOrder } from "./hand-order/hand-order.js";
56
+ import { HandSortedList } from "./hand-order/hand-sorted-list.js";
57
+ import { SortButton } from "./hand-order/sort-button.js";
55
58
  import { useMemo, useState } from "react";
56
59
  import { Download, Plus, SlidersHorizontal } from "lucide-react";
57
60
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -128,6 +131,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
128
131
  const extras = parsed.ok ? parsed.extras : [];
129
132
  const { orderBy, filterLayout: layout } = address;
130
133
  const desktop = useIsDesktop();
134
+ const xs = useIsXs();
131
135
  const [sheetOpen, setSheetOpen] = useState(false);
132
136
  const scoped = scope ? [...extras, scope] : extras;
133
137
  const config = useFilterConfig({
@@ -159,8 +163,12 @@ const LoadedList = ({ model, address, scope, heading }) => {
159
163
  orderBy,
160
164
  related
161
165
  });
162
- const display = shownDisplay(view, address.display);
163
- const pageScroll = display?.kind !== "map";
166
+ const display = shownListDisplay(model, view, address.display);
167
+ const permissions = usePermissions(resource);
168
+ const handOrder = scope ? void 0 : listHandOrder(model, permissions);
169
+ const [sorting, setSorting] = useState(false);
170
+ const handSorting = sorting && handOrder !== void 0;
171
+ const pageScroll = handSorting || display?.kind !== "map";
164
172
  usePageScroll(pageScroll);
165
173
  const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
166
174
  const scroll = useListScroll();
@@ -172,7 +180,6 @@ const LoadedList = ({ model, address, scope, heading }) => {
172
180
  snapshot: row
173
181
  });
174
182
  const singular = view?.names?.singular ?? humanize(resource);
175
- const permissions = usePermissions(resource);
176
183
  const cellEditing = useListCellEditor(model, view, permissions);
177
184
  const { state: auth } = useAuth();
178
185
  const restore = useRestore(model);
@@ -308,8 +315,14 @@ const LoadedList = ({ model, address, scope, heading }) => {
308
315
  },
309
316
  children: [/* @__PURE__ */ jsx(Download, { className: "size-3.5" }), desktop && "Export"]
310
317
  });
311
- const displaySwitch = (menu) => /* @__PURE__ */ jsx(DisplaySwitch, {
318
+ const sortButton = handOrder && /* @__PURE__ */ jsx(SortButton, {
319
+ sorting: handSorting,
320
+ onChange: setSorting,
321
+ iconOnly: !desktop
322
+ });
323
+ const displaySwitch = (menu) => !handSorting && /* @__PURE__ */ jsx(DisplaySwitch, {
312
324
  view,
325
+ choices: listDisplayChoices(model, view, xs),
313
326
  value: display?.name ?? tableDisplay,
314
327
  onChange: (name) => set({ display: name }),
315
328
  menu
@@ -392,7 +405,19 @@ const LoadedList = ({ model, address, scope, heading }) => {
392
405
  list: displayRows
393
406
  });
394
407
  };
395
- const shown = drawDisplay();
408
+ const shown = handSorting ? /* @__PURE__ */ jsxs("div", {
409
+ className: "flex flex-col gap-3",
410
+ children: [/* @__PURE__ */ jsx("p", {
411
+ className: "text-muted-foreground",
412
+ children: "Drag a row by its handle to move it. Every move is saved as you drop it."
413
+ }), /* @__PURE__ */ jsx(HandSortedList, {
414
+ model,
415
+ view,
416
+ field: handOrder,
417
+ filter: "",
418
+ columns: listColumnNames(model, view)
419
+ })]
420
+ }) : drawDisplay();
396
421
  return /* @__PURE__ */ jsxs("div", {
397
422
  className: "flex min-h-0 flex-1 flex-col gap-3 px-5 py-4",
398
423
  children: [
@@ -404,11 +429,13 @@ const LoadedList = ({ model, address, scope, heading }) => {
404
429
  aside: !desktop && displaySwitch(true),
405
430
  actions: desktop ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
406
431
  displaySwitch(false),
432
+ sortButton,
407
433
  saveSearch,
408
434
  importButton,
409
435
  exportButton,
410
436
  createButton
411
437
  ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
438
+ sortButton,
412
439
  saveSearch,
413
440
  importButton,
414
441
  exportButton,
@@ -419,11 +446,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
419
446
  title: "The filter in the address could not be read",
420
447
  error: new Error(parsed.errors.map((error) => error.message).join("; "))
421
448
  }),
422
- rows.error && !display && /* @__PURE__ */ jsx(ErrorBanner, {
449
+ rows.error && !display && !handSorting && /* @__PURE__ */ jsx(ErrorBanner, {
423
450
  title: "Could not load rows",
424
451
  error: rows.error
425
452
  }),
426
- view?.chart && (isGroupChart(view.chart) ? /* @__PURE__ */ jsx(ListGroupChart, {
453
+ view?.chart && !handSorting && (isGroupChart(view.chart) ? /* @__PURE__ */ jsx(ListGroupChart, {
427
454
  model,
428
455
  view,
429
456
  chart: view.chart,
@@ -436,8 +463,8 @@ const LoadedList = ({ model, address, scope, heading }) => {
436
463
  noun: plural.toLowerCase(),
437
464
  filter: loadFilter
438
465
  })),
439
- desktop ? desktopToolbar : phoneFilters,
440
- desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
466
+ !handSorting && (desktop ? desktopToolbar : phoneFilters),
467
+ desktop && layout === "panel" && !handSorting ? /* @__PURE__ */ jsxs("div", {
441
468
  className: "flex min-h-0 flex-1 gap-4",
442
469
  children: [/* @__PURE__ */ jsx("div", {
443
470
  className: pageScroll ? "sticky top-[calc(var(--pb-sticky-top,0px)+1rem)] flex max-h-[calc(100dvh-var(--pb-sticky-top,0px)-var(--pb-sticky-bottom,0px)-2rem)] shrink-0 self-start" : "flex shrink-0",
@@ -3,13 +3,13 @@ import { keys } from "../../data/query-keys.js";
3
3
  import { useClient } from "../../data/api-provider.js";
4
4
  import { useAdminMeta } from "../meta-gate.js";
5
5
  import { fileShown, shownImage } from "../../live/file-values.js";
6
+ import { isMarkdown } from "../../markdown/markdown-hint.js";
6
7
  import { displayValue } from "../../live/display-value.js";
7
8
  import { AppLink } from "../use-app-address.js";
8
9
  import { FileImage } from "../../live/file-image.js";
9
10
  import { useShownWidget } from "../field-types/shown-widget.js";
10
11
  import { WidgetPart } from "../field-types/widget-part.js";
11
12
  import { Markdown } from "../../markdown/markdown.js";
12
- import { isMarkdown } from "../../markdown/markdown-hint.js";
13
13
  import { fetchLabels } from "../../live/relation-labels.js";
14
14
  import { ValueBadge } from "../value-badge.js";
15
15
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -22,16 +22,16 @@ import { fromDraft, isRequiredOnEdit } from "../live/field-values.js";
22
22
  import { fieldHelp, helpShown, recordShown, whenShown } from "./record-mode/record-mode.js";
23
23
  import { isChosen, keyValue } from "./create/create-values.js";
24
24
  import { ErrorBanner } from "./error-banner.js";
25
+ import { isMarkdown } from "../markdown/markdown-hint.js";
26
+ import { toneFor } from "../live/columns-from-model.js";
25
27
  import { displayValue } from "../live/display-value.js";
26
28
  import { filledFields } from "./filled-fields.js";
27
29
  import { AppLink, useAppAddress } from "./use-app-address.js";
28
30
  import { shownWidget } from "./field-types/shown-widget.js";
29
31
  import { WidgetPart } from "./field-types/widget-part.js";
30
32
  import { Markdown } from "../markdown/markdown.js";
31
- import { isMarkdown } from "../markdown/markdown-hint.js";
32
33
  import { FieldEditor } from "./field-editor.js";
33
34
  import { can, fieldAccess } from "./permissions.js";
34
- import { toneFor } from "../live/columns-from-model.js";
35
35
  import { ValueBadge } from "./value-badge.js";
36
36
  import { FieldValue } from "./pages/field-value.js";
37
37
  import { fieldMessage } from "./field-errors.js";
@@ -1,5 +1,6 @@
1
1
  import { humanize } from "../../live/naming.js";
2
- import { headerWidth, listColumnNames } from "../../live/columns-from-model.js";
2
+ import { headerWidth } from "../../data-grid/content-widths.js";
3
+ import { listColumnNames } from "../../live/columns-from-model.js";
3
4
  import { RelatedDots } from "./related-dots.js";
4
5
  import { RecordCard } from "../displays/record-card.js";
5
6
  import { jsx } from "react/jsx-runtime";
@@ -2,10 +2,10 @@ import { cn } from "../../lib/cn.js";
2
2
  import { toneDots } from "../../primitives/badge.js";
3
3
  import { recordTitleField } from "../../live/model-helpers.js";
4
4
  import { humanize } from "../../live/naming.js";
5
+ import { toneFor } from "../../live/columns-from-model.js";
5
6
  import { displayValue } from "../../live/display-value.js";
6
7
  import { displayRow } from "../displays/list-displays.js";
7
8
  import { AppLink } from "../use-app-address.js";
8
- import { toneFor } from "../../live/columns-from-model.js";
9
9
  import { valueColor } from "../../live/value-color.js";
10
10
  import { placeCard } from "./card-place.js";
11
11
  import { dotCard } from "./dot-card.js";