@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.
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/create-values.js +2 -1
  4. package/dist/app/create/form.d.ts +3 -2
  5. package/dist/app/create/form.js +3 -1
  6. package/dist/app/field-editor.js +34 -12
  7. package/dist/app/field-errors.js +70 -0
  8. package/dist/app/history/describe-change.js +44 -0
  9. package/dist/app/history/history-section.js +92 -0
  10. package/dist/app/history/use-record-history.js +34 -0
  11. package/dist/app/pages/blocks/modal-form.js +13 -4
  12. package/dist/app/record-page.js +52 -9
  13. package/dist/app/use-focus-first-invalid.js +16 -0
  14. package/dist/app/use-live-save.js +4 -2
  15. package/dist/app/use-record-editor.js +11 -3
  16. package/dist/data/api-provider.d.ts +8 -0
  17. package/dist/data/query-keys.js +8 -0
  18. package/dist/data-grid/grid-header.js +2 -1
  19. package/dist/format/date.js +15 -2
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.js +2 -1
  22. package/dist/live/field-values.js +1 -0
  23. package/dist/package.js +1 -1
  24. package/dist/primitives/checkbox.js +2 -1
  25. package/dist/primitives/field-control.d.ts +19 -0
  26. package/dist/primitives/field-control.js +21 -0
  27. package/dist/primitives/input.js +17 -11
  28. package/dist/primitives/switch.js +15 -9
  29. package/dist/primitives/textarea.js +4 -0
  30. package/dist/record-view/field.d.ts +7 -1
  31. package/dist/record-view/field.js +83 -56
  32. package/dist/shell/assets/app-DGVgRtZo.js +69 -0
  33. package/dist/shell/assets/{index-B0veahji.js → index-CW037BBI.js} +1 -1
  34. package/dist/shell/assets/index-Dc6xgHsz.css +2 -0
  35. package/dist/shell/assets/{maplibre-CbjT6RKG.js → maplibre-Ck9abcyu.js} +2 -2
  36. package/dist/shell/assets/{markdown-content-BjRGtSOA.js → markdown-content-BDjSKrC7.js} +1 -1
  37. package/dist/shell/assets/{record-map-rmkTnAs8.js → record-map-CKPZ2KJs.js} +1 -1
  38. package/dist/shell/assets/{records-map-D1vLGY06.js → records-map-DadV3GNO.js} +1 -1
  39. package/dist/shell/assets/shared-protobase-ui-tJCYxj7E.js +1 -0
  40. package/dist/shell/index.html +4 -4
  41. package/dist/shell/protobase.shell.json +1 -1
  42. package/package.json +4 -4
  43. package/dist/shell/assets/app-B5h_q_JL.js +0 -69
  44. package/dist/shell/assets/index-BZmb28OT.css +0 -2
  45. 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
- 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,
@@ -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
@@ -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,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) return /* @__PURE__ */ jsx(RelationPicker, {
46
- target: relationPicker.target,
47
- view: relationPicker.view,
48
- value: String(value),
49
- label: relationLabel,
50
- invalid,
51
- onChange: (next, label) => onChange({
52
- key: next,
53
- label
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
- 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],
@@ -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. The read view, which is all a
61
- * user who may only read the resource gets, shows every field: the record rather than what can be changed.
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
- label: hint(name)?.label ?? humanize(name),
201
+ name,
202
+ label,
172
203
  required: shown === "form" && access !== "read" && isRequiredOnEdit(field),
173
204
  help: fieldHelp(hint(name), view, editing),
174
- error: editor.errors[name],
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: related.labels[name],
193
- relationTo: related.addresses[name],
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) => editor.change(name, 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";