@protobase/ui 0.20.0 → 0.21.1
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/create-values.js +2 -1
- package/dist/app/create/form.d.ts +3 -2
- package/dist/app/create/form.js +3 -1
- package/dist/app/field-editor.js +34 -12
- package/dist/app/field-errors.js +70 -0
- package/dist/app/history/describe-change.js +44 -0
- package/dist/app/history/history-section.js +92 -0
- package/dist/app/history/use-record-history.js +34 -0
- package/dist/app/pages/blocks/modal-form.js +13 -4
- package/dist/app/record-page.js +52 -9
- 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/api-provider.d.ts +8 -0
- package/dist/data/query-keys.js +8 -0
- package/dist/data-grid/grid-header.js +2 -1
- package/dist/format/date.js +15 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/live/field-values.js +1 -0
- 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-DGVgRtZo.js +69 -0
- package/dist/shell/assets/{index-B0veahji.js → index-CW037BBI.js} +1 -1
- package/dist/shell/assets/index-Dc6xgHsz.css +2 -0
- package/dist/shell/assets/{maplibre-CbjT6RKG.js → maplibre-Ck9abcyu.js} +2 -2
- package/dist/shell/assets/{markdown-content-BjRGtSOA.js → markdown-content-BDjSKrC7.js} +1 -1
- package/dist/shell/assets/{record-map-rmkTnAs8.js → record-map-CKPZ2KJs.js} +1 -1
- package/dist/shell/assets/{records-map-D1vLGY06.js → records-map-DadV3GNO.js} +1 -1
- package/dist/shell/assets/shared-protobase-ui-tJCYxj7E.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-B5h_q_JL.js +0 -69
- package/dist/shell/assets/index-BZmb28OT.css +0 -2
- package/dist/shell/assets/shared-protobase-ui-PnkJqBqB.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,
|
|
@@ -29,6 +29,7 @@ const createSections = (model, view) => {
|
|
|
29
29
|
fields: rest
|
|
30
30
|
}];
|
|
31
31
|
};
|
|
32
|
+
/** A chosen record's key as the API takes it: a number when the target's key is an integer. */
|
|
32
33
|
const keyValue = (target, key) => {
|
|
33
34
|
return (target?.primaryKey.length === 1 ? target.fields[target.primaryKey[0]]?.type : void 0) === "integer" ? Number(key) : key;
|
|
34
35
|
};
|
|
@@ -68,4 +69,4 @@ const initialDraft = (model, sections, given = {}) => Object.fromEntries(section
|
|
|
68
69
|
})));
|
|
69
70
|
const hasDatabaseDefault = (field) => Boolean(field.default && "db" in field.default);
|
|
70
71
|
//#endregion
|
|
71
|
-
export { createBody, createSections, hasDatabaseDefault, initialDraft, isChosen, isCreatable, isRequired };
|
|
72
|
+
export { createBody, createSections, hasDatabaseDefault, initialDraft, isChosen, isCreatable, isRequired, keyValue };
|
|
@@ -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,5 +1,7 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { useFieldControl } from "../primitives/field-control.js";
|
|
2
3
|
import { Input } from "../primitives/input.js";
|
|
4
|
+
import { buttonClass } from "../primitives/button.js";
|
|
3
5
|
import { humanize } from "../live/naming.js";
|
|
4
6
|
import { fromDraft, isEditable, toFieldDraft } from "../live/field-values.js";
|
|
5
7
|
import { Switch } from "../primitives/switch.js";
|
|
@@ -14,6 +16,7 @@ import { WidgetPart } from "./field-types/widget-part.js";
|
|
|
14
16
|
import { Markdown } from "../markdown/markdown.js";
|
|
15
17
|
import { MarkdownEditor } from "../markdown/markdown-editor.js";
|
|
16
18
|
import { isMarkdown } from "../markdown/markdown-hint.js";
|
|
19
|
+
import { ArrowUpRight } from "lucide-react";
|
|
17
20
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
18
21
|
import { enumLabel } from "@protobase/schema";
|
|
19
22
|
//#region src/app/field-editor.tsx
|
|
@@ -23,6 +26,7 @@ const numeric = /* @__PURE__ */ new Set([
|
|
|
23
26
|
"decimal"
|
|
24
27
|
]);
|
|
25
28
|
const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo, relationPicker, invalid, locked, placeholder, reveal, resource, onChange }) => {
|
|
29
|
+
const control = useFieldControl();
|
|
26
30
|
if (field.type === "file") return /* @__PURE__ */ jsx(FileField, {
|
|
27
31
|
field,
|
|
28
32
|
hints,
|
|
@@ -42,17 +46,33 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
|
|
|
42
46
|
placeholder,
|
|
43
47
|
onChange
|
|
44
48
|
});
|
|
45
|
-
if (field.type === "relation" && relationPicker && !field.readOnly
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
if (field.type === "relation" && relationPicker && !field.readOnly && !locked) {
|
|
50
|
+
const picker = /* @__PURE__ */ jsx(RelationPicker, {
|
|
51
|
+
target: relationPicker.target,
|
|
52
|
+
view: relationPicker.view,
|
|
53
|
+
value: String(value),
|
|
54
|
+
label: relationLabel,
|
|
55
|
+
invalid,
|
|
56
|
+
onChange: (next, label) => onChange({
|
|
57
|
+
key: next,
|
|
58
|
+
label
|
|
59
|
+
})
|
|
60
|
+
});
|
|
61
|
+
if (!relationTo) return picker;
|
|
62
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
63
|
+
className: "flex items-center gap-1",
|
|
64
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
65
|
+
className: "min-w-0 flex-1",
|
|
66
|
+
children: picker
|
|
67
|
+
}), /* @__PURE__ */ jsx(AppLink, {
|
|
68
|
+
to: relationTo,
|
|
69
|
+
"aria-label": `Open ${relationLabel ?? String(value)}`,
|
|
70
|
+
title: "Open",
|
|
71
|
+
className: cn(buttonClass({ variant: "ghost" }), "w-8 px-0"),
|
|
72
|
+
children: /* @__PURE__ */ jsx(ArrowUpRight, { className: "size-4" })
|
|
73
|
+
})]
|
|
74
|
+
});
|
|
75
|
+
}
|
|
56
76
|
if (field.type === "boolean" && locked) return /* @__PURE__ */ jsx("div", {
|
|
57
77
|
className: "py-1.5 text-muted-foreground",
|
|
58
78
|
children: displayValue(field, stored, hints)
|
|
@@ -60,7 +80,7 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
|
|
|
60
80
|
if (field.type === "boolean") return /* @__PURE__ */ jsx(Switch, {
|
|
61
81
|
checked: Boolean(value),
|
|
62
82
|
onChange,
|
|
63
|
-
label: field.name
|
|
83
|
+
label: hints?.label ?? humanize(field.name)
|
|
64
84
|
});
|
|
65
85
|
if (field.type === "relation") {
|
|
66
86
|
const label = relationLabel ?? String(stored ?? "—");
|
|
@@ -84,6 +104,8 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
|
|
|
84
104
|
children: displayValue(field, stored, hints)
|
|
85
105
|
});
|
|
86
106
|
if (field.type === "enum") return /* @__PURE__ */ jsxs("select", {
|
|
107
|
+
...control,
|
|
108
|
+
...invalid && { "aria-invalid": true },
|
|
87
109
|
value: String(value),
|
|
88
110
|
onChange: (event) => onChange(event.target.value),
|
|
89
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,44 @@
|
|
|
1
|
+
import { humanize } from "../../live/naming.js";
|
|
2
|
+
import { displayValue } from "../../live/display-value.js";
|
|
3
|
+
//#region src/app/history/describe-change.ts
|
|
4
|
+
const verbs = {
|
|
5
|
+
create: "created this",
|
|
6
|
+
update: "changed",
|
|
7
|
+
delete: "deleted this",
|
|
8
|
+
undelete: "restored this"
|
|
9
|
+
};
|
|
10
|
+
/** Who made a change: their name, else their id; a write from outside the app names the database role. */
|
|
11
|
+
const changedBy = (change) => {
|
|
12
|
+
if (change.actor) return change.actor.name ?? change.actor.id;
|
|
13
|
+
return change.databaseRole ? `Database (${change.databaseRole})` : "Database";
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A change as the record page tells it: who, what they did, and for an update each changed field the model still has,
|
|
17
|
+
* before and after, as the view labels and formats it.
|
|
18
|
+
*/
|
|
19
|
+
const describeChange = (change, model, view) => {
|
|
20
|
+
const fields = (change.operation === "create" ? [] : change.changed).flatMap((name) => {
|
|
21
|
+
const field = model.fields[name];
|
|
22
|
+
if (!field || name === model.softDelete) return [];
|
|
23
|
+
const hints = view?.fields[name];
|
|
24
|
+
const label = hints?.label ?? humanize(name);
|
|
25
|
+
if (field.sensitive || !change.before || !change.after) return [{
|
|
26
|
+
name,
|
|
27
|
+
label
|
|
28
|
+
}];
|
|
29
|
+
return [{
|
|
30
|
+
name,
|
|
31
|
+
label,
|
|
32
|
+
before: displayValue(field, change.before[name], hints),
|
|
33
|
+
after: displayValue(field, change.after[name], hints)
|
|
34
|
+
}];
|
|
35
|
+
});
|
|
36
|
+
const verb = change.operation === "update" && fields.length === 0 ? "changed this" : verbs[change.operation];
|
|
37
|
+
return {
|
|
38
|
+
who: changedBy(change),
|
|
39
|
+
verb,
|
|
40
|
+
fields
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { changedBy, describeChange };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { formatDateTime, formatRelativeTime } from "../../format/date.js";
|
|
3
|
+
import { Button } from "../../primitives/button.js";
|
|
4
|
+
import { Section } from "../../record-view/section.js";
|
|
5
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
6
|
+
import { describeChange } from "./describe-change.js";
|
|
7
|
+
import { useRecordHistory } from "./use-record-history.js";
|
|
8
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
+
//#region src/app/history/history-section.tsx
|
|
10
|
+
const Loading = () => /* @__PURE__ */ jsx("div", {
|
|
11
|
+
className: "flex flex-col gap-2",
|
|
12
|
+
children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
|
|
13
|
+
});
|
|
14
|
+
const Change = ({ change, model, view }) => {
|
|
15
|
+
const { who, verb, fields } = describeChange(change, model, view);
|
|
16
|
+
const at = Date.parse(change.at);
|
|
17
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
18
|
+
className: "flex flex-col gap-1.5 py-3 first:pt-0 last:pb-0",
|
|
19
|
+
children: [/* @__PURE__ */ jsxs("p", { children: [
|
|
20
|
+
/* @__PURE__ */ jsx("span", {
|
|
21
|
+
className: "font-medium text-foreground",
|
|
22
|
+
children: who
|
|
23
|
+
}),
|
|
24
|
+
" ",
|
|
25
|
+
/* @__PURE__ */ jsx("span", {
|
|
26
|
+
className: "text-muted-foreground",
|
|
27
|
+
children: verb
|
|
28
|
+
}),
|
|
29
|
+
" ",
|
|
30
|
+
/* @__PURE__ */ jsxs("time", {
|
|
31
|
+
dateTime: change.at,
|
|
32
|
+
title: formatDateTime(at),
|
|
33
|
+
className: "text-muted-foreground",
|
|
34
|
+
children: ["· ", formatRelativeTime(Math.max(0, Math.round((Date.now() - at) / 1e3)))]
|
|
35
|
+
})
|
|
36
|
+
] }), fields.length > 0 && /* @__PURE__ */ jsx("dl", {
|
|
37
|
+
className: "grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-4 gap-y-1",
|
|
38
|
+
children: fields.map((field) => /* @__PURE__ */ jsxs("div", {
|
|
39
|
+
className: "contents",
|
|
40
|
+
children: [/* @__PURE__ */ jsx("dt", {
|
|
41
|
+
className: "text-muted-foreground",
|
|
42
|
+
children: field.label
|
|
43
|
+
}), /* @__PURE__ */ jsx("dd", {
|
|
44
|
+
className: "min-w-0 break-words",
|
|
45
|
+
children: field.before === void 0 ? /* @__PURE__ */ jsx("span", {
|
|
46
|
+
className: "text-muted-foreground",
|
|
47
|
+
children: "changed"
|
|
48
|
+
}) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
49
|
+
/* @__PURE__ */ jsx("del", {
|
|
50
|
+
className: "text-muted-foreground",
|
|
51
|
+
children: field.before
|
|
52
|
+
}),
|
|
53
|
+
" → ",
|
|
54
|
+
/* @__PURE__ */ jsx("ins", {
|
|
55
|
+
className: "no-underline",
|
|
56
|
+
children: field.after
|
|
57
|
+
})
|
|
58
|
+
] })
|
|
59
|
+
})]
|
|
60
|
+
}, field.name))
|
|
61
|
+
})]
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
/** The record's changes, newest first, with who made each and the fields before and after; Show more pages further back. */
|
|
65
|
+
const HistorySection = ({ item, model, view, recordKey, etag }) => {
|
|
66
|
+
const history = useRecordHistory(model.name, recordKey, item.limit ?? 10, etag);
|
|
67
|
+
return /* @__PURE__ */ jsx(Section, {
|
|
68
|
+
title: item.title,
|
|
69
|
+
children: history.isPending ? /* @__PURE__ */ jsx(Loading, {}) : history.error ? /* @__PURE__ */ jsx(ErrorBanner, {
|
|
70
|
+
title: "Could not load the history",
|
|
71
|
+
error: history.error
|
|
72
|
+
}) : history.items.length === 0 ? /* @__PURE__ */ jsx("p", {
|
|
73
|
+
className: "text-muted-foreground",
|
|
74
|
+
children: "No changes kept yet."
|
|
75
|
+
}) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("ol", {
|
|
76
|
+
className: "divide-y divide-border",
|
|
77
|
+
children: history.items.map((change) => /* @__PURE__ */ jsx(Change, {
|
|
78
|
+
change,
|
|
79
|
+
model,
|
|
80
|
+
view
|
|
81
|
+
}, change.id))
|
|
82
|
+
}), history.hasNextPage && /* @__PURE__ */ jsx(Button, {
|
|
83
|
+
size: "sm",
|
|
84
|
+
className: "mt-4",
|
|
85
|
+
disabled: history.isFetchingNextPage,
|
|
86
|
+
onClick: () => void history.fetchNextPage(),
|
|
87
|
+
children: "Show more"
|
|
88
|
+
})] })
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
//#endregion
|
|
92
|
+
export { HistorySection };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../../data/api-provider.js";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
5
|
+
//#region src/app/history/use-record-history.ts
|
|
6
|
+
/** How long after a save the history looks again: the server keeps a change just after its write commits. */
|
|
7
|
+
const afterSave = 1500;
|
|
8
|
+
/**
|
|
9
|
+
* A record's changes, newest first, `pageSize` at a time. Its key starts with the record's, so refreshing the record
|
|
10
|
+
* refreshes it too; it looks again shortly after `etag` changes, once the server has kept the save.
|
|
11
|
+
*/
|
|
12
|
+
const useRecordHistory = (resource, recordKey, pageSize, etag) => {
|
|
13
|
+
const client = useClient();
|
|
14
|
+
const query = useInfiniteQuery({
|
|
15
|
+
queryKey: keys.history(resource, recordKey, pageSize),
|
|
16
|
+
initialPageParam: "",
|
|
17
|
+
queryFn: ({ pageParam }) => client.history(resource, recordKey, {
|
|
18
|
+
pageSize,
|
|
19
|
+
pageToken: pageParam
|
|
20
|
+
}),
|
|
21
|
+
getNextPageParam: (last) => last.nextPageToken || void 0
|
|
22
|
+
});
|
|
23
|
+
const { refetch } = query;
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const timer = setTimeout(() => void refetch(), afterSave);
|
|
26
|
+
return () => clearTimeout(timer);
|
|
27
|
+
}, [etag, refetch]);
|
|
28
|
+
return {
|
|
29
|
+
...query,
|
|
30
|
+
items: query.data?.pages.flatMap((page) => page.items) ?? []
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
//#endregion
|
|
34
|
+
export { useRecordHistory };
|
|
@@ -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],
|
package/dist/app/record-page.js
CHANGED
|
@@ -18,8 +18,9 @@ import { MetadataCard, RelatedRecords, SidebarCard } from "../record-view/record
|
|
|
18
18
|
import { RecordHeader } from "../record-view/record-header.js";
|
|
19
19
|
import { SpecTable } from "../record-view/spec-table.js";
|
|
20
20
|
import { KeyFacts } from "../record-view/key-facts.js";
|
|
21
|
-
import { isRequiredOnEdit } from "../live/field-values.js";
|
|
21
|
+
import { fromDraft, isRequiredOnEdit } from "../live/field-values.js";
|
|
22
22
|
import { fieldHelp, helpShown, recordShown, whenShown } from "./record-mode/record-mode.js";
|
|
23
|
+
import { isChosen, keyValue } from "./create/create-values.js";
|
|
23
24
|
import { ErrorBanner } from "./error-banner.js";
|
|
24
25
|
import { displayValue } from "../live/display-value.js";
|
|
25
26
|
import { filledFields } from "./filled-fields.js";
|
|
@@ -33,6 +34,8 @@ import { can, fieldAccess } from "./permissions.js";
|
|
|
33
34
|
import { toneFor } from "../live/columns-from-model.js";
|
|
34
35
|
import { ValueBadge } from "./value-badge.js";
|
|
35
36
|
import { FieldValue } from "./pages/field-value.js";
|
|
37
|
+
import { fieldMessage } from "./field-errors.js";
|
|
38
|
+
import { useFocusFirstInvalid } from "./use-focus-first-invalid.js";
|
|
36
39
|
import { namedIcon } from "./named-icon.js";
|
|
37
40
|
import { useDeleteFlow } from "./delete/use-delete-flow.js";
|
|
38
41
|
import { useRestore } from "./delete/use-restore.js";
|
|
@@ -45,6 +48,7 @@ import { draftStorageKey } from "./draft-storage.js";
|
|
|
45
48
|
import { useLines } from "./use-lines.js";
|
|
46
49
|
import { useLinesDraft } from "./use-lines-draft.js";
|
|
47
50
|
import { GallerySection } from "./gallery/gallery-section.js";
|
|
51
|
+
import { HistorySection } from "./history/history-section.js";
|
|
48
52
|
import { useRecordEditor } from "./use-record-editor.js";
|
|
49
53
|
import { useRelatedRecords } from "./use-related-records.js";
|
|
50
54
|
import { RelatedSection } from "./related-section.js";
|
|
@@ -57,8 +61,9 @@ import { enumLabel } from "@protobase/schema";
|
|
|
57
61
|
//#region src/app/record-page.tsx
|
|
58
62
|
const hidden = (model, name) => name === model.tenant || model.primaryKey.includes(name);
|
|
59
63
|
/**
|
|
60
|
-
* The view's layout, or one section with the editable fields when the view has none
|
|
61
|
-
* user who may only read the resource gets, shows every field: the record rather
|
|
64
|
+
* The view's layout, or one section with the editable fields when the view has none, then History when the resource
|
|
65
|
+
* keeps it. The read view, which is all a user who may only read the resource gets, shows every field: the record rather
|
|
66
|
+
* than what can be changed.
|
|
62
67
|
*/
|
|
63
68
|
const layoutFor = (model, view, read) => {
|
|
64
69
|
if (view && view.layout.length > 0) return view.layout;
|
|
@@ -68,7 +73,10 @@ const layoutFor = (model, view, read) => {
|
|
|
68
73
|
kind: "section",
|
|
69
74
|
title: "Details",
|
|
70
75
|
fields: (editable.length > 0 && !read ? editable : shown).map((field) => field.name)
|
|
71
|
-
}
|
|
76
|
+
}, ...model.history ? [{
|
|
77
|
+
kind: "history",
|
|
78
|
+
title: "History"
|
|
79
|
+
}] : []];
|
|
72
80
|
};
|
|
73
81
|
const RecordPage = ({ resource, recordKey }) => {
|
|
74
82
|
const { resources } = useAdminMeta();
|
|
@@ -124,6 +132,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
124
132
|
});
|
|
125
133
|
const queryClient = useQueryClient();
|
|
126
134
|
const client = useClient();
|
|
135
|
+
const fieldsRef = useFocusFirstInvalid(editor.rejections);
|
|
127
136
|
const permissions = usePermissions(model.name);
|
|
128
137
|
const restore = useRestore(model);
|
|
129
138
|
const canUpdate = can(permissions, "update") && stored.permissions?.update !== false;
|
|
@@ -152,6 +161,24 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
152
161
|
onReview: () => void queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) })
|
|
153
162
|
});
|
|
154
163
|
const related = useRelatedRecords(model, view, meta.resources, meta.views, stored.record, basePath);
|
|
164
|
+
const pickTarget = (name) => {
|
|
165
|
+
const field = model.fields[name];
|
|
166
|
+
const target = field?.relation ? meta.resources[field.relation.resource] : void 0;
|
|
167
|
+
if (!target || target.primaryKey.length !== 1 || name === model.tenant || model.primaryKey.includes(name)) return void 0;
|
|
168
|
+
return target;
|
|
169
|
+
};
|
|
170
|
+
const picked = Object.values(model.fields).filter((field) => pickTarget(field.name));
|
|
171
|
+
const drafted = {
|
|
172
|
+
...stored.record,
|
|
173
|
+
...Object.fromEntries(picked.map((field) => [field.name, fromDraft(field, editor.value(field.name))]))
|
|
174
|
+
};
|
|
175
|
+
const chosen = useRelatedRecords(model, view, meta.resources, meta.views, drafted, basePath);
|
|
176
|
+
const pick = (name, next) => {
|
|
177
|
+
const target = pickTarget(name);
|
|
178
|
+
if (!target || !isChosen(next)) return editor.change(name, next);
|
|
179
|
+
queryClient.setQueryData(keys.relatedLabel(target.name, next.key), next.label);
|
|
180
|
+
editor.change(name, keyValue(target, next.key));
|
|
181
|
+
};
|
|
155
182
|
const { fields: fieldTypes } = useProjectUi();
|
|
156
183
|
const layout = layoutFor(model, view, shown === "read").filter((item) => whenShown(item.when, editing));
|
|
157
184
|
const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
|
|
@@ -167,11 +194,15 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
167
194
|
const access = fieldAccess(stored.permissions, name);
|
|
168
195
|
if (!field || access === "hidden") return null;
|
|
169
196
|
const conflict = editor.conflicts[name];
|
|
197
|
+
const target = pickTarget(name);
|
|
198
|
+
const label = hint(name)?.label ?? humanize(name);
|
|
199
|
+
const problem = editor.errors[name];
|
|
170
200
|
return /* @__PURE__ */ jsx(Field, {
|
|
171
|
-
|
|
201
|
+
name,
|
|
202
|
+
label,
|
|
172
203
|
required: shown === "form" && access !== "read" && isRequiredOnEdit(field),
|
|
173
204
|
help: fieldHelp(hint(name), view, editing),
|
|
174
|
-
error:
|
|
205
|
+
error: problem && fieldMessage(field, label, problem),
|
|
175
206
|
wide: Boolean(hint(name)?.multiline || hint(name)?.markdown),
|
|
176
207
|
conflict: conflict === void 0 ? void 0 : {
|
|
177
208
|
user: {
|
|
@@ -189,13 +220,17 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
189
220
|
hints: hint(name),
|
|
190
221
|
value: editor.value(name),
|
|
191
222
|
stored: stored.record[name],
|
|
192
|
-
relationLabel:
|
|
193
|
-
relationTo:
|
|
223
|
+
relationLabel: chosen.labels[name],
|
|
224
|
+
relationTo: chosen.addresses[name],
|
|
225
|
+
relationPicker: target && {
|
|
226
|
+
target,
|
|
227
|
+
view: meta.views[target.name]
|
|
228
|
+
},
|
|
194
229
|
locked: shown !== "form" || access === "read",
|
|
195
230
|
invalid: Boolean(editor.errors[name]),
|
|
196
231
|
reveal: field.sensitive ? () => client.reveal(model.name, recordKey, name) : void 0,
|
|
197
232
|
resource: model.name,
|
|
198
|
-
onChange: (next) =>
|
|
233
|
+
onChange: (next) => pick(name, next)
|
|
199
234
|
}) : /* @__PURE__ */ jsx(Skeleton, { className: "h-9" })
|
|
200
235
|
}, field.sensitive ? `${name}:${stored.etag}` : name);
|
|
201
236
|
};
|
|
@@ -294,6 +329,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
294
329
|
})
|
|
295
330
|
] });
|
|
296
331
|
return /* @__PURE__ */ jsxs("div", {
|
|
332
|
+
ref: fieldsRef,
|
|
297
333
|
className: "flex flex-1 flex-col",
|
|
298
334
|
inert: preview,
|
|
299
335
|
children: [
|
|
@@ -381,6 +417,13 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
381
417
|
record: stored.record,
|
|
382
418
|
recordKey
|
|
383
419
|
}, `gallery:${index}`)];
|
|
420
|
+
if (item.kind === "history") return [/* @__PURE__ */ jsx(HistorySection, {
|
|
421
|
+
item,
|
|
422
|
+
model,
|
|
423
|
+
view,
|
|
424
|
+
recordKey,
|
|
425
|
+
etag: stored.etag
|
|
426
|
+
}, `history:${index}`)];
|
|
384
427
|
if (item.kind !== "section") return [];
|
|
385
428
|
const visibleFields = item.fields.filter((name) => model.fields[name] && visible(name));
|
|
386
429
|
const facts = shown === "read" && item.presentation === "facts";
|