@protobase/ui 0.20.0 → 0.21.0

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.
@@ -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,5 +1,6 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { Input } from "../primitives/input.js";
3
+ import { buttonClass } from "../primitives/button.js";
3
4
  import { humanize } from "../live/naming.js";
4
5
  import { fromDraft, isEditable, toFieldDraft } from "../live/field-values.js";
5
6
  import { Switch } from "../primitives/switch.js";
@@ -14,6 +15,7 @@ import { WidgetPart } from "./field-types/widget-part.js";
14
15
  import { Markdown } from "../markdown/markdown.js";
15
16
  import { MarkdownEditor } from "../markdown/markdown-editor.js";
16
17
  import { isMarkdown } from "../markdown/markdown-hint.js";
18
+ import { ArrowUpRight } from "lucide-react";
17
19
  import { jsx, jsxs } from "react/jsx-runtime";
18
20
  import { enumLabel } from "@protobase/schema";
19
21
  //#region src/app/field-editor.tsx
@@ -42,17 +44,33 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
42
44
  placeholder,
43
45
  onChange
44
46
  });
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
- });
47
+ if (field.type === "relation" && relationPicker && !field.readOnly && !locked) {
48
+ const picker = /* @__PURE__ */ jsx(RelationPicker, {
49
+ target: relationPicker.target,
50
+ view: relationPicker.view,
51
+ value: String(value),
52
+ label: relationLabel,
53
+ invalid,
54
+ onChange: (next, label) => onChange({
55
+ key: next,
56
+ label
57
+ })
58
+ });
59
+ if (!relationTo) return picker;
60
+ return /* @__PURE__ */ jsxs("div", {
61
+ className: "flex items-center gap-1",
62
+ children: [/* @__PURE__ */ jsx("div", {
63
+ className: "min-w-0 flex-1",
64
+ children: picker
65
+ }), /* @__PURE__ */ jsx(AppLink, {
66
+ to: relationTo,
67
+ "aria-label": `Open ${relationLabel ?? String(value)}`,
68
+ title: "Open",
69
+ className: cn(buttonClass({ variant: "ghost" }), "w-8 px-0"),
70
+ children: /* @__PURE__ */ jsx(ArrowUpRight, { className: "size-4" })
71
+ })]
72
+ });
73
+ }
56
74
  if (field.type === "boolean" && locked) return /* @__PURE__ */ jsx("div", {
57
75
  className: "py-1.5 text-muted-foreground",
58
76
  children: displayValue(field, stored, hints)
@@ -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 };
@@ -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";
@@ -45,6 +46,7 @@ import { draftStorageKey } from "./draft-storage.js";
45
46
  import { useLines } from "./use-lines.js";
46
47
  import { useLinesDraft } from "./use-lines-draft.js";
47
48
  import { GallerySection } from "./gallery/gallery-section.js";
49
+ import { HistorySection } from "./history/history-section.js";
48
50
  import { useRecordEditor } from "./use-record-editor.js";
49
51
  import { useRelatedRecords } from "./use-related-records.js";
50
52
  import { RelatedSection } from "./related-section.js";
@@ -57,8 +59,9 @@ import { enumLabel } from "@protobase/schema";
57
59
  //#region src/app/record-page.tsx
58
60
  const hidden = (model, name) => name === model.tenant || model.primaryKey.includes(name);
59
61
  /**
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.
62
+ * The view's layout, or one section with the editable fields when the view has none, then History when the resource
63
+ * keeps it. The read view, which is all a user who may only read the resource gets, shows every field: the record rather
64
+ * than what can be changed.
62
65
  */
63
66
  const layoutFor = (model, view, read) => {
64
67
  if (view && view.layout.length > 0) return view.layout;
@@ -68,7 +71,10 @@ const layoutFor = (model, view, read) => {
68
71
  kind: "section",
69
72
  title: "Details",
70
73
  fields: (editable.length > 0 && !read ? editable : shown).map((field) => field.name)
71
- }];
74
+ }, ...model.history ? [{
75
+ kind: "history",
76
+ title: "History"
77
+ }] : []];
72
78
  };
73
79
  const RecordPage = ({ resource, recordKey }) => {
74
80
  const { resources } = useAdminMeta();
@@ -152,6 +158,24 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
152
158
  onReview: () => void queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) })
153
159
  });
154
160
  const related = useRelatedRecords(model, view, meta.resources, meta.views, stored.record, basePath);
161
+ const pickTarget = (name) => {
162
+ const field = model.fields[name];
163
+ const target = field?.relation ? meta.resources[field.relation.resource] : void 0;
164
+ if (!target || target.primaryKey.length !== 1 || name === model.tenant || model.primaryKey.includes(name)) return void 0;
165
+ return target;
166
+ };
167
+ const picked = Object.values(model.fields).filter((field) => pickTarget(field.name));
168
+ const drafted = {
169
+ ...stored.record,
170
+ ...Object.fromEntries(picked.map((field) => [field.name, fromDraft(field, editor.value(field.name))]))
171
+ };
172
+ const chosen = useRelatedRecords(model, view, meta.resources, meta.views, drafted, basePath);
173
+ const pick = (name, next) => {
174
+ const target = pickTarget(name);
175
+ if (!target || !isChosen(next)) return editor.change(name, next);
176
+ queryClient.setQueryData(keys.relatedLabel(target.name, next.key), next.label);
177
+ editor.change(name, keyValue(target, next.key));
178
+ };
155
179
  const { fields: fieldTypes } = useProjectUi();
156
180
  const layout = layoutFor(model, view, shown === "read").filter((item) => whenShown(item.when, editing));
157
181
  const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
@@ -167,6 +191,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
167
191
  const access = fieldAccess(stored.permissions, name);
168
192
  if (!field || access === "hidden") return null;
169
193
  const conflict = editor.conflicts[name];
194
+ const target = pickTarget(name);
170
195
  return /* @__PURE__ */ jsx(Field, {
171
196
  label: hint(name)?.label ?? humanize(name),
172
197
  required: shown === "form" && access !== "read" && isRequiredOnEdit(field),
@@ -189,13 +214,17 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
189
214
  hints: hint(name),
190
215
  value: editor.value(name),
191
216
  stored: stored.record[name],
192
- relationLabel: related.labels[name],
193
- relationTo: related.addresses[name],
217
+ relationLabel: chosen.labels[name],
218
+ relationTo: chosen.addresses[name],
219
+ relationPicker: target && {
220
+ target,
221
+ view: meta.views[target.name]
222
+ },
194
223
  locked: shown !== "form" || access === "read",
195
224
  invalid: Boolean(editor.errors[name]),
196
225
  reveal: field.sensitive ? () => client.reveal(model.name, recordKey, name) : void 0,
197
226
  resource: model.name,
198
- onChange: (next) => editor.change(name, next)
227
+ onChange: (next) => pick(name, next)
199
228
  }) : /* @__PURE__ */ jsx(Skeleton, { className: "h-9" })
200
229
  }, field.sensitive ? `${name}:${stored.etag}` : name);
201
230
  };
@@ -381,6 +410,13 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
381
410
  record: stored.record,
382
411
  recordKey
383
412
  }, `gallery:${index}`)];
413
+ if (item.kind === "history") return [/* @__PURE__ */ jsx(HistorySection, {
414
+ item,
415
+ model,
416
+ view,
417
+ recordKey,
418
+ etag: stored.etag
419
+ }, `history:${index}`)];
384
420
  if (item.kind !== "section") return [];
385
421
  const visibleFields = item.fields.filter((name) => model.fields[name] && visible(name));
386
422
  const facts = shown === "read" && item.presentation === "facts";
@@ -16,6 +16,10 @@ export declare const useClient: () => {
16
16
  }) => Promise<void>;
17
17
  undelete: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[]) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
18
18
  reveal: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], field: string) => Promise<unknown>;
19
+ history: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
20
+ pageSize?: number;
21
+ pageToken?: string;
22
+ }) => Promise<import("@protobase/client").HistoryPage>;
19
23
  batchWrite: (ops: import("@protobase/client").BatchOp[]) => Promise<import("@protobase/client").BatchResult>;
20
24
  facets: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
21
25
  filter?: import("@protobase/client").FilterInput;
@@ -57,6 +61,10 @@ export declare const useResourceClient: (resource: string) => {
57
61
  }) => Promise<void>;
58
62
  undelete: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[]) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
59
63
  reveal: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], field: string) => Promise<unknown>;
64
+ history: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
65
+ pageSize?: number;
66
+ pageToken?: string;
67
+ }) => Promise<import("@protobase/client").HistoryPage>;
60
68
  batchWrite: (ops: import("@protobase/client").BatchOp[]) => Promise<import("@protobase/client").BatchResult>;
61
69
  facets: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
62
70
  filter?: import("@protobase/client").FilterInput;
@@ -58,6 +58,14 @@ const keys = {
58
58
  resource,
59
59
  key
60
60
  ],
61
+ /** A record's changes; starts with `record`, so refreshing the record refreshes them too. */
62
+ history: (resource, key, pageSize) => [
63
+ "record",
64
+ resource,
65
+ key,
66
+ "history",
67
+ pageSize
68
+ ],
61
69
  facets: (resource, field, filter) => [
62
70
  "facets",
63
71
  resource,
@@ -25,6 +25,19 @@ const formatAgo = (seconds) => {
25
25
  if (seconds < 86400) return `${Math.floor(seconds / 3600)} h ago`;
26
26
  return `${Math.floor(seconds / 86400)} d ago`;
27
27
  };
28
- new Intl.RelativeTimeFormat("en", { numeric: "auto" });
28
+ const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
29
+ const units = [
30
+ ["year", 31536e3],
31
+ ["month", 2592e3],
32
+ ["day", 86400],
33
+ ["hour", 3600],
34
+ ["minute", 60]
35
+ ];
36
+ /** "5 minutes ago", "yesterday", "3 years ago"; `seconds` is how long ago it was. */
37
+ const formatRelativeTime = (seconds) => {
38
+ const [unit, size] = units.find(([, size]) => seconds >= size) ?? ["second", 1];
39
+ if (unit === "second" && seconds < 45) return "just now";
40
+ return relative.format(-Math.floor(seconds / size), unit);
41
+ };
29
42
  //#endregion
30
- export { formatAgo, formatDate, formatDateTime, formatDayShort };
43
+ export { formatAgo, formatDate, formatDateTime, formatDayShort, formatRelativeTime };
@@ -21,6 +21,7 @@ const toPatchValue = (field, draft) => {
21
21
  const text = String(draft).trim();
22
22
  if (text === "") return field.nullable ? null : "";
23
23
  if (field.type === "integer") return Number(text);
24
+ if (field.type === "relation") return typeof draft === "number" ? draft : text;
24
25
  if (textEdited.has(field.type) || field.type === "enum") return text;
25
26
  throw new Error(`Not implemented: editing ${field.type} fields`);
26
27
  };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.20.0";
2
+ var version = "0.21.0";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };