@protobase/ui 0.21.0 → 0.21.2

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 (45) hide show
  1. package/dist/app/create/create-page.js +12 -4
  2. package/dist/app/create/create-record-form.js +3 -1
  3. package/dist/app/create/form.d.ts +3 -2
  4. package/dist/app/create/form.js +3 -1
  5. package/dist/app/field-editor.js +5 -1
  6. package/dist/app/field-errors.js +70 -0
  7. package/dist/app/hand-order/hand-order.js +21 -0
  8. package/dist/app/hand-order/hand-sorted-list.js +98 -0
  9. package/dist/app/hand-order/sort-button.js +20 -0
  10. package/dist/app/hand-order/use-hand-order.js +105 -0
  11. package/dist/app/list-page.js +34 -9
  12. package/dist/app/pages/blocks/modal-form.js +13 -4
  13. package/dist/app/record-page.js +9 -2
  14. package/dist/app/related-section.js +37 -8
  15. package/dist/app/use-focus-first-invalid.js +16 -0
  16. package/dist/app/use-live-save.js +4 -2
  17. package/dist/app/use-record-editor.js +11 -3
  18. package/dist/data-grid/data-grid.js +49 -36
  19. package/dist/data-grid/grid-header.js +2 -1
  20. package/dist/data-grid/grid-row.js +16 -4
  21. package/dist/data-grid/row-reorder.js +68 -0
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.js +2 -1
  24. package/dist/package.js +1 -1
  25. package/dist/primitives/checkbox.js +2 -1
  26. package/dist/primitives/field-control.d.ts +19 -0
  27. package/dist/primitives/field-control.js +21 -0
  28. package/dist/primitives/input.js +17 -11
  29. package/dist/primitives/switch.js +15 -9
  30. package/dist/primitives/textarea.js +4 -0
  31. package/dist/record-view/field.d.ts +7 -1
  32. package/dist/record-view/field.js +83 -56
  33. package/dist/shell/assets/app-BnuJWZWd.js +69 -0
  34. package/dist/shell/assets/{index-iXIDNHEm.js → index-7lQIOMrK.js} +1 -1
  35. package/dist/shell/assets/{index-Dc6xgHsz.css → index-B48ty05p.css} +1 -1
  36. package/dist/shell/assets/{maplibre-DxWag0Wp.js → maplibre-DEejTZ7I.js} +2 -2
  37. package/dist/shell/assets/{markdown-content-Bco0XDqw.js → markdown-content-ZzP9i4q4.js} +1 -1
  38. package/dist/shell/assets/{record-map-Ca1fz02Q.js → record-map-B-zsmXL2.js} +1 -1
  39. package/dist/shell/assets/{records-map-JooppeKw.js → records-map-Lj1MQgsW.js} +1 -1
  40. package/dist/shell/assets/shared-protobase-ui-Bnd7T1j4.js +1 -0
  41. package/dist/shell/index.html +4 -4
  42. package/dist/shell/protobase.shell.json +1 -1
  43. package/package.json +4 -4
  44. package/dist/shell/assets/app-BBx2Bkop.js +0 -69
  45. package/dist/shell/assets/shared-protobase-ui-BZs06oKf.js +0 -1
@@ -7,10 +7,11 @@ import { useAppAddress } from "../use-app-address.js";
7
7
  import { FieldEditor } from "../field-editor.js";
8
8
  import { can } from "../permissions.js";
9
9
  import { fetchLabels } from "../../live/relation-labels.js";
10
+ import { fieldErrors, fieldMessage } from "../field-errors.js";
11
+ import { useFocusFirstInvalid } from "../use-focus-first-invalid.js";
10
12
  import { useLiveSave } from "../use-live-save.js";
11
13
  import { CreateRecordForm } from "./create-record-form.js";
12
14
  import { useEffect, useState } from "react";
13
- import { isApiError } from "@protobase/client";
14
15
  import { jsx, jsxs } from "react/jsx-runtime";
15
16
  import { useQueryClient } from "@tanstack/react-query";
16
17
  //#region src/app/create/create-page.tsx
@@ -53,6 +54,8 @@ const LoadedCreate = ({ resource, values }) => {
53
54
  const sections = createSections(model, view);
54
55
  const [draft, setDraft] = useState(() => initialDraft(model, sections, values));
55
56
  const [errors, setErrors] = useState({});
57
+ const [rejections, setRejections] = useState(0);
58
+ const formRef = useFocusFirstInvalid(rejections);
56
59
  useEffect(() => {
57
60
  for (const [name, key] of Object.entries(values ?? {})) {
58
61
  const target = meta.resources[model.fields[name]?.relation?.resource ?? ""];
@@ -87,7 +90,9 @@ const LoadedCreate = ({ resource, values }) => {
87
90
  try {
88
91
  created = await client.create(resource, createBody(model, meta.resources, draft));
89
92
  } catch (error) {
90
- if (isApiError(error)) setErrors(Object.fromEntries(error.errors.flatMap((entry) => entry.field ? [[entry.field, entry.message]] : [])));
93
+ const found = fieldErrors(error);
94
+ setErrors(found);
95
+ if (Object.keys(found).length > 0) setRejections((count) => count + 1);
91
96
  throw error;
92
97
  }
93
98
  for (const prefix of [
@@ -111,12 +116,14 @@ const LoadedCreate = ({ resource, values }) => {
111
116
  const field = model.fields[name];
112
117
  const value = draft[name];
113
118
  const target = field.relation ? meta.resources[field.relation.resource] : void 0;
119
+ const label = view?.fields[name]?.label ?? humanize(name);
120
+ const problem = errors[name];
114
121
  return {
115
122
  name,
116
- label: view?.fields[name]?.label ?? humanize(name),
123
+ label,
117
124
  help: view?.fields[name]?.help,
118
125
  required: isRequired(field),
119
- error: errors[name],
126
+ error: problem && fieldMessage(field, label, problem),
120
127
  wide: Boolean(view?.fields[name]?.multiline || view?.fields[name]?.markdown),
121
128
  editor: /* @__PURE__ */ jsx(FieldEditor, {
122
129
  field,
@@ -137,6 +144,7 @@ const LoadedCreate = ({ resource, values }) => {
137
144
  })
138
145
  }));
139
146
  return /* @__PURE__ */ jsx(CreateRecordForm, {
147
+ ref: formRef,
140
148
  noun,
141
149
  collection: view?.names?.plural ?? humanize(resource),
142
150
  sections: formSections,
@@ -4,7 +4,8 @@ import { Form } from "./form.js";
4
4
  import { jsx } from "react/jsx-runtime";
5
5
  //#region src/app/create/create-record-form.tsx
6
6
  /** The presentation of "New <thing>": the view's sections, each field with its editor, and Create or Cancel. */
7
- const CreateRecordForm = ({ noun, collection, sections, saving, message, onSubmit, onCancel }) => /* @__PURE__ */ jsx(Form, {
7
+ const CreateRecordForm = ({ noun, collection, sections, saving, message, onSubmit, onCancel, ref }) => /* @__PURE__ */ jsx(Form, {
8
+ ref,
8
9
  title: `New ${noun.toLowerCase()}`,
9
10
  eyebrow: collection,
10
11
  submit: `Create ${noun.toLowerCase()}`,
@@ -19,6 +20,7 @@ const CreateRecordForm = ({ noun, collection, sections, saving, message, onSubmi
19
20
  children: /* @__PURE__ */ jsx("div", {
20
21
  className: "grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2",
21
22
  children: section.fields.map((field) => /* @__PURE__ */ jsx(Field, {
23
+ name: field.name,
22
24
  label: field.label,
23
25
  required: field.required,
24
26
  help: field.help,
@@ -1,4 +1,4 @@
1
- import { ReactNode } from "react";
1
+ import { ReactNode, Ref } from "react";
2
2
  //#region src/app/create/form.d.ts
3
3
  export type FormProps = {
4
4
  /**
@@ -24,7 +24,8 @@ export type FormProps = {
24
24
  onCancel?: () => void;
25
25
  /** The form's sections (`Section`), drawn one under the other with a line between them. */
26
26
  children: ReactNode;
27
+ ref?: Ref<HTMLFormElement>;
27
28
  };
28
29
  /** The frame of the app's forms: Cancel and the submit button, a failure that belongs to no field, and the sections. */
29
- export declare const Form: ({ title, eyebrow, submit, saving, disabled, error, errorTitle, onSubmit, onCancel, children }: FormProps) => import("react").JSX.Element;
30
+ export declare const Form: ({ title, eyebrow, submit, saving, disabled, error, errorTitle, onSubmit, onCancel, children, ref }: FormProps) => import("react").JSX.Element;
30
31
  //#endregion
@@ -3,7 +3,7 @@ import { ErrorBanner } from "../error-banner.js";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/app/create/form.tsx
5
5
  /** The frame of the app's forms: Cancel and the submit button, a failure that belongs to no field, and the sections. */
6
- const Form = ({ title, eyebrow, submit, saving, disabled, error, errorTitle = "Not saved", onSubmit, onCancel, children }) => {
6
+ const Form = ({ title, eyebrow, submit, saving, disabled, error, errorTitle = "Not saved", onSubmit, onCancel, children, ref }) => {
7
7
  const buttons = /* @__PURE__ */ jsxs("div", {
8
8
  className: "flex items-center gap-2",
9
9
  children: [onCancel && /* @__PURE__ */ jsx(Button, {
@@ -35,6 +35,7 @@ const Form = ({ title, eyebrow, submit, saving, disabled, error, errorTitle = "N
35
35
  onSubmit();
36
36
  };
37
37
  if (title === void 0) return /* @__PURE__ */ jsxs("form", {
38
+ ref,
38
39
  className: "max-w-[760px]",
39
40
  onSubmit: send,
40
41
  children: [
@@ -47,6 +48,7 @@ const Form = ({ title, eyebrow, submit, saving, disabled, error, errorTitle = "N
47
48
  ]
48
49
  });
49
50
  return /* @__PURE__ */ jsx("form", {
51
+ ref,
50
52
  className: "flex-1",
51
53
  onSubmit: send,
52
54
  children: /* @__PURE__ */ jsxs("div", {
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { useFieldControl } from "../primitives/field-control.js";
2
3
  import { Input } from "../primitives/input.js";
3
4
  import { buttonClass } from "../primitives/button.js";
4
5
  import { humanize } from "../live/naming.js";
@@ -25,6 +26,7 @@ const numeric = /* @__PURE__ */ new Set([
25
26
  "decimal"
26
27
  ]);
27
28
  const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo, relationPicker, invalid, locked, placeholder, reveal, resource, onChange }) => {
29
+ const control = useFieldControl();
28
30
  if (field.type === "file") return /* @__PURE__ */ jsx(FileField, {
29
31
  field,
30
32
  hints,
@@ -78,7 +80,7 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
78
80
  if (field.type === "boolean") return /* @__PURE__ */ jsx(Switch, {
79
81
  checked: Boolean(value),
80
82
  onChange,
81
- label: field.name
83
+ label: hints?.label ?? humanize(field.name)
82
84
  });
83
85
  if (field.type === "relation") {
84
86
  const label = relationLabel ?? String(stored ?? "—");
@@ -102,6 +104,8 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
102
104
  children: displayValue(field, stored, hints)
103
105
  });
104
106
  if (field.type === "enum") return /* @__PURE__ */ jsxs("select", {
107
+ ...control,
108
+ ...invalid && { "aria-invalid": true },
105
109
  value: String(value),
106
110
  onChange: (event) => onChange(event.target.value),
107
111
  className: "h-8 w-full rounded-md border border-border-strong bg-background px-2 shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20",
@@ -0,0 +1,70 @@
1
+ import { formatInt } from "../format/number.js";
2
+ import { isApiError } from "@protobase/client";
3
+ //#region src/app/field-errors.ts
4
+ const numberIn = (params, key) => typeof params?.[key] === "number" ? params[key] : void 0;
5
+ const characters = (count) => `${formatInt(count)} character${count === 1 ? "" : "s"}`;
6
+ const textMessage = (label, params) => {
7
+ const min = numberIn(params, "min");
8
+ const max = numberIn(params, "max");
9
+ if (min !== void 0) return `${label} needs at least ${characters(min)}`;
10
+ if (max !== void 0) return `${label} can have at most ${characters(max)}`;
11
+ };
12
+ const wholeNumber = (params) => {
13
+ const min = numberIn(params, "min");
14
+ const max = numberIn(params, "max");
15
+ if (min !== void 0) return `Enter a whole number of at least ${formatInt(min)}`;
16
+ if (max !== void 0) return `Enter a whole number of at most ${formatInt(max)}`;
17
+ return "Enter a whole number";
18
+ };
19
+ const decimalMessage = (params) => {
20
+ const precision = numberIn(params, "precision");
21
+ const scale = numberIn(params, "scale");
22
+ if (precision === void 0 || scale === void 0) return "Enter a number";
23
+ const below = formatInt(10 ** (precision - scale));
24
+ return scale === 0 ? `Enter a whole number below ${below}` : `Enter a number with at most ${scale} decimal${scale === 1 ? "" : "s"}, below ${below}`;
25
+ };
26
+ /** What to say about a value of each field type the server turned down; without an entry, the server's message shows. */
27
+ const invalidValue = {
28
+ text: textMessage,
29
+ integer: (_, params) => wholeNumber(params),
30
+ bigint: () => "Enter a whole number",
31
+ decimal: (_, params) => decimalMessage(params),
32
+ date: () => "Enter a date",
33
+ timestamp: () => "Enter a date and time",
34
+ country: () => "Enter a two-letter country code, such as NL",
35
+ currency: () => "Enter a three-letter currency code, such as EUR",
36
+ uuid: () => "Enter a valid ID",
37
+ enum: () => "Choose one of the options",
38
+ relation: () => "Choose a record",
39
+ json: () => "Enter valid JSON"
40
+ };
41
+ /**
42
+ * The message a form shows under a field the server turned down, in the words of the field's label: "Country is
43
+ * required", "Enter a number with at most 2 decimals". A `.validate()` rule's message and anything unknown show as the
44
+ * server words them.
45
+ */
46
+ const fieldMessage = (field, label, error) => {
47
+ if (error.code === "required") return `${label} is required`;
48
+ if (error.code !== "invalid-value" || !field) return error.message;
49
+ return invalidValue[field.type]?.(label, error.params) ?? error.message;
50
+ };
51
+ /** The first problem with each field in an API error, by field name; none for any other error, nor for a rule about the whole record. */
52
+ const fieldErrors = (error) => {
53
+ if (!isApiError(error)) return {};
54
+ const found = {};
55
+ for (const entry of error.errors) if (entry.field && entry.field !== "_record" && !Object.hasOwn(found, entry.field)) found[entry.field] = entry;
56
+ return found;
57
+ };
58
+ /**
59
+ * What a save's toast says about an API error: how many fields to check when the fields show what is wrong, besides
60
+ * the messages of rules about the whole record; the error's own message otherwise.
61
+ */
62
+ const saveFailureDescription = (error) => {
63
+ if (error.operation !== void 0) return error.message;
64
+ const fields = new Set(error.errors.flatMap((entry) => entry.field && entry.field !== "_record" ? [entry.field] : []));
65
+ const record = error.errors.flatMap((entry) => entry.field === "_record" ? [entry.message] : []);
66
+ if (fields.size === 0 && record.length === 0) return error.message;
67
+ return [...fields.size === 0 ? [] : [`Check ${fields.size === 1 ? "the marked field" : `${fields.size} fields`}.`], ...record].join(" ");
68
+ };
69
+ //#endregion
70
+ export { fieldErrors, fieldMessage, saveFailureDescription };
@@ -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 };
@@ -14,7 +14,7 @@ import { parseSort, sortToOrderBy } from "./list-order.js";
14
14
  import { listedResource } from "./current-address.js";
15
15
  import { AppLink, useAppAddress } from "./use-app-address.js";
16
16
  import { can, rowAllows } from "./permissions.js";
17
- import { columnsFromModel } from "../live/columns-from-model.js";
17
+ import { columnsFromModel, listColumnNames } 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";
@@ -160,7 +163,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
160
163
  related
161
164
  });
162
165
  const display = shownDisplay(view, address.display);
163
- const pageScroll = display?.kind !== "map";
166
+ const permissions = usePermissions(resource);
167
+ const handOrder = scope ? void 0 : listHandOrder(model, permissions);
168
+ const [sorting, setSorting] = useState(false);
169
+ const handSorting = sorting && handOrder !== void 0;
170
+ const pageScroll = handSorting || display?.kind !== "map";
164
171
  usePageScroll(pageScroll);
165
172
  const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
166
173
  const scroll = useListScroll();
@@ -172,7 +179,6 @@ const LoadedList = ({ model, address, scope, heading }) => {
172
179
  snapshot: row
173
180
  });
174
181
  const singular = view?.names?.singular ?? humanize(resource);
175
- const permissions = usePermissions(resource);
176
182
  const cellEditing = useListCellEditor(model, view, permissions);
177
183
  const { state: auth } = useAuth();
178
184
  const restore = useRestore(model);
@@ -308,7 +314,12 @@ const LoadedList = ({ model, address, scope, heading }) => {
308
314
  },
309
315
  children: [/* @__PURE__ */ jsx(Download, { className: "size-3.5" }), desktop && "Export"]
310
316
  });
311
- const displaySwitch = (menu) => /* @__PURE__ */ jsx(DisplaySwitch, {
317
+ const sortButton = handOrder && /* @__PURE__ */ jsx(SortButton, {
318
+ sorting: handSorting,
319
+ onChange: setSorting,
320
+ iconOnly: !desktop
321
+ });
322
+ const displaySwitch = (menu) => !handSorting && /* @__PURE__ */ jsx(DisplaySwitch, {
312
323
  view,
313
324
  value: display?.name ?? tableDisplay,
314
325
  onChange: (name) => set({ display: name }),
@@ -392,7 +403,19 @@ const LoadedList = ({ model, address, scope, heading }) => {
392
403
  list: displayRows
393
404
  });
394
405
  };
395
- const shown = drawDisplay();
406
+ const shown = handSorting ? /* @__PURE__ */ jsxs("div", {
407
+ className: "flex flex-col gap-3",
408
+ children: [/* @__PURE__ */ jsx("p", {
409
+ className: "text-muted-foreground",
410
+ children: "Drag a row by its handle to move it. Every move is saved as you drop it."
411
+ }), /* @__PURE__ */ jsx(HandSortedList, {
412
+ model,
413
+ view,
414
+ field: handOrder,
415
+ filter: "",
416
+ columns: listColumnNames(model, view)
417
+ })]
418
+ }) : drawDisplay();
396
419
  return /* @__PURE__ */ jsxs("div", {
397
420
  className: "flex min-h-0 flex-1 flex-col gap-3 px-5 py-4",
398
421
  children: [
@@ -404,11 +427,13 @@ const LoadedList = ({ model, address, scope, heading }) => {
404
427
  aside: !desktop && displaySwitch(true),
405
428
  actions: desktop ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
406
429
  displaySwitch(false),
430
+ sortButton,
407
431
  saveSearch,
408
432
  importButton,
409
433
  exportButton,
410
434
  createButton
411
435
  ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
436
+ sortButton,
412
437
  saveSearch,
413
438
  importButton,
414
439
  exportButton,
@@ -419,11 +444,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
419
444
  title: "The filter in the address could not be read",
420
445
  error: new Error(parsed.errors.map((error) => error.message).join("; "))
421
446
  }),
422
- rows.error && !display && /* @__PURE__ */ jsx(ErrorBanner, {
447
+ rows.error && !display && !handSorting && /* @__PURE__ */ jsx(ErrorBanner, {
423
448
  title: "Could not load rows",
424
449
  error: rows.error
425
450
  }),
426
- view?.chart && (isGroupChart(view.chart) ? /* @__PURE__ */ jsx(ListGroupChart, {
451
+ view?.chart && !handSorting && (isGroupChart(view.chart) ? /* @__PURE__ */ jsx(ListGroupChart, {
427
452
  model,
428
453
  view,
429
454
  chart: view.chart,
@@ -436,8 +461,8 @@ const LoadedList = ({ model, address, scope, heading }) => {
436
461
  noun: plural.toLowerCase(),
437
462
  filter: loadFilter
438
463
  })),
439
- desktop ? desktopToolbar : phoneFilters,
440
- desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
464
+ !handSorting && (desktop ? desktopToolbar : phoneFilters),
465
+ desktop && layout === "panel" && !handSorting ? /* @__PURE__ */ jsxs("div", {
441
466
  className: "flex min-h-0 flex-1 gap-4",
442
467
  children: [/* @__PURE__ */ jsx("div", {
443
468
  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",
@@ -13,8 +13,9 @@ import { useLayoutRecord } from "../record-scope.js";
13
13
  import { can } from "../../permissions.js";
14
14
  import { useFirstRecord, useRefreshResource } from "../use-layout-data.js";
15
15
  import { propsOf } from "./block-props.js";
16
+ import { fieldErrors, fieldMessage } from "../../field-errors.js";
17
+ import { useFocusFirstInvalid } from "../../use-focus-first-invalid.js";
16
18
  import { useState } from "react";
17
- import { isApiError } from "@protobase/client";
18
19
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
19
20
  //#region src/app/pages/blocks/modal-form.tsx
20
21
  /** The literal defaults of new records, or the record's values when editing; relations as `{ key, label }` once chosen. */
@@ -39,6 +40,8 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
39
40
  const view = views[model.name];
40
41
  const [draft, setDraft] = useState(() => startingDraft(model, fields, record));
41
42
  const [errors, setErrors] = useState({});
43
+ const [rejections, setRejections] = useState(0);
44
+ const formRef = useFocusFirstInvalid(rejections);
42
45
  const [failure, setFailure] = useState();
43
46
  const [saving, setSaving] = useState(false);
44
47
  const submit = async () => {
@@ -55,7 +58,9 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
55
58
  });
56
59
  onClose();
57
60
  }, (error) => {
58
- if (isApiError(error)) setErrors(Object.fromEntries(error.errors.flatMap((entry) => entry.field ? [[entry.field, entry.message]] : [])));
61
+ const found = fieldErrors(error);
62
+ setErrors(found);
63
+ if (Object.keys(found).length > 0) setRejections((count) => count + 1);
59
64
  setFailure(error);
60
65
  });
61
66
  setSaving(false);
@@ -73,6 +78,7 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
73
78
  children: mode === "create" ? "Create" : "Save"
74
79
  })] }),
75
80
  children: /* @__PURE__ */ jsxs("form", {
81
+ ref: formRef,
76
82
  className: "flex flex-col gap-4",
77
83
  onSubmit: (event) => {
78
84
  event.preventDefault();
@@ -85,10 +91,13 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
85
91
  const field = model.fields[name];
86
92
  const value = draft[name];
87
93
  const target = field.relation ? resources[field.relation.resource] : void 0;
94
+ const label = view?.fields[name]?.label ?? humanize(name);
95
+ const problem = errors[name];
88
96
  return /* @__PURE__ */ jsx(Field, {
89
- label: view?.fields[name]?.label ?? humanize(name),
97
+ name,
98
+ label,
90
99
  help: view?.fields[name]?.help,
91
- error: errors[name],
100
+ error: problem && fieldMessage(field, label, problem),
92
101
  children: /* @__PURE__ */ jsx(FieldEditor, {
93
102
  field,
94
103
  hints: view?.fields[name],