@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.
- package/dist/app/create/create-page.js +12 -4
- package/dist/app/create/create-record-form.js +3 -1
- package/dist/app/create/form.d.ts +3 -2
- package/dist/app/create/form.js +3 -1
- package/dist/app/field-editor.js +5 -1
- package/dist/app/field-errors.js +70 -0
- package/dist/app/hand-order/hand-order.js +21 -0
- package/dist/app/hand-order/hand-sorted-list.js +98 -0
- package/dist/app/hand-order/sort-button.js +20 -0
- package/dist/app/hand-order/use-hand-order.js +105 -0
- package/dist/app/list-page.js +34 -9
- package/dist/app/pages/blocks/modal-form.js +13 -4
- package/dist/app/record-page.js +9 -2
- package/dist/app/related-section.js +37 -8
- package/dist/app/use-focus-first-invalid.js +16 -0
- package/dist/app/use-live-save.js +4 -2
- package/dist/app/use-record-editor.js +11 -3
- package/dist/data-grid/data-grid.js +49 -36
- package/dist/data-grid/grid-header.js +2 -1
- package/dist/data-grid/grid-row.js +16 -4
- package/dist/data-grid/row-reorder.js +68 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/package.js +1 -1
- package/dist/primitives/checkbox.js +2 -1
- package/dist/primitives/field-control.d.ts +19 -0
- package/dist/primitives/field-control.js +21 -0
- package/dist/primitives/input.js +17 -11
- package/dist/primitives/switch.js +15 -9
- package/dist/primitives/textarea.js +4 -0
- package/dist/record-view/field.d.ts +7 -1
- package/dist/record-view/field.js +83 -56
- package/dist/shell/assets/app-BnuJWZWd.js +69 -0
- package/dist/shell/assets/{index-iXIDNHEm.js → index-7lQIOMrK.js} +1 -1
- package/dist/shell/assets/{index-Dc6xgHsz.css → index-B48ty05p.css} +1 -1
- package/dist/shell/assets/{maplibre-DxWag0Wp.js → maplibre-DEejTZ7I.js} +2 -2
- package/dist/shell/assets/{markdown-content-Bco0XDqw.js → markdown-content-ZzP9i4q4.js} +1 -1
- package/dist/shell/assets/{record-map-Ca1fz02Q.js → record-map-B-zsmXL2.js} +1 -1
- package/dist/shell/assets/{records-map-JooppeKw.js → records-map-Lj1MQgsW.js} +1 -1
- package/dist/shell/assets/shared-protobase-ui-Bnd7T1j4.js +1 -0
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-BBx2Bkop.js +0 -69
- 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
|
-
|
|
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
|
|
123
|
+
label,
|
|
117
124
|
help: view?.fields[name]?.help,
|
|
118
125
|
required: isRequired(field),
|
|
119
|
-
error:
|
|
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
|
package/dist/app/create/form.js
CHANGED
|
@@ -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", {
|
package/dist/app/field-editor.js
CHANGED
|
@@ -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 };
|
package/dist/app/list-page.js
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
97
|
+
name,
|
|
98
|
+
label,
|
|
90
99
|
help: view?.fields[name]?.help,
|
|
91
|
-
error:
|
|
100
|
+
error: problem && fieldMessage(field, label, problem),
|
|
92
101
|
children: /* @__PURE__ */ jsx(FieldEditor, {
|
|
93
102
|
field,
|
|
94
103
|
hints: view?.fields[name],
|