@protobase/ui 0.19.16 → 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.
Files changed (38) hide show
  1. package/dist/app/charts/chart-groups.js +52 -0
  2. package/dist/app/charts/chart-view.js +75 -0
  3. package/dist/app/charts/group-bars.js +49 -0
  4. package/dist/app/charts/group-donut.js +84 -0
  5. package/dist/app/charts/period-chart.js +174 -0
  6. package/dist/app/create/create-values.js +2 -1
  7. package/dist/app/field-editor.js +29 -11
  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/list-group-chart.js +37 -0
  12. package/dist/app/list-page.js +10 -3
  13. package/dist/app/pages/blocks/chart.js +26 -0
  14. package/dist/app/pages/render-node.js +2 -0
  15. package/dist/app/record-page.js +43 -7
  16. package/dist/app/relation-options.js +44 -0
  17. package/dist/app/relation-picker.js +114 -35
  18. package/dist/data/api-provider.d.ts +10 -0
  19. package/dist/data/query-keys.js +13 -0
  20. package/dist/data/use-aggregates.js +14 -1
  21. package/dist/format/date.js +15 -2
  22. package/dist/index.js +2 -2
  23. package/dist/live/field-values.js +1 -0
  24. package/dist/package.js +1 -1
  25. package/dist/shell/assets/app-BBx2Bkop.js +69 -0
  26. package/dist/shell/assets/index-Dc6xgHsz.css +2 -0
  27. package/dist/shell/assets/{index-CmmYBR7Y.js → index-iXIDNHEm.js} +1 -1
  28. package/dist/shell/assets/{maplibre-Siom2Zwj.js → maplibre-DxWag0Wp.js} +1 -1
  29. package/dist/shell/assets/{markdown-content-ChyGEubR.js → markdown-content-Bco0XDqw.js} +1 -1
  30. package/dist/shell/assets/{record-map-B_QnHF99.js → record-map-Ca1fz02Q.js} +1 -1
  31. package/dist/shell/assets/{records-map-CcHhEDBC.js → records-map-JooppeKw.js} +1 -1
  32. package/dist/shell/assets/{shared-protobase-ui-zRmreKZS.js → shared-protobase-ui-BZs06oKf.js} +1 -1
  33. package/dist/shell/index.html +4 -4
  34. package/dist/shell/protobase.shell.json +1 -1
  35. package/dist/tokens.css +17 -0
  36. package/package.json +4 -4
  37. package/dist/shell/assets/app-xQmLJM07.js +0 -69
  38. package/dist/shell/assets/index-CmJl5dIO.css +0 -2
@@ -0,0 +1,52 @@
1
+ import { createWhere, enumLabel, printFilter } from "@protobase/schema";
2
+ //#region src/app/charts/chart-groups.ts
3
+ const where$1 = createWhere();
4
+ const slot = (index) => index >= 0 && index < 8 ? `var(--chart-${index + 1})` : "var(--faint-foreground)";
5
+ const colorOf = (field, hints, value, index) => {
6
+ const key = String(value);
7
+ if (value === null) return "var(--faint-foreground)";
8
+ if (hints?.valueColors && Object.hasOwn(hints.valueColors, key)) return hints.valueColors[key];
9
+ if (field.type === "enum") return slot(field.enumValues?.indexOf(key) ?? -1);
10
+ if (field.type === "boolean") return slot(value ? 0 : 1);
11
+ return slot(index);
12
+ };
13
+ const labelOf = (field, hints, group) => {
14
+ if (group.value === null) return "Not set";
15
+ if (field.type === "enum") return enumLabel(String(group.value), hints?.valueLabels);
16
+ if (field.type === "boolean") return group.value ? "Yes" : "No";
17
+ return group.label ?? String(group.value);
18
+ };
19
+ /** The records of one group: the chart's own filter and the group's value. */
20
+ const groupFilter = (field, value, filter = "") => {
21
+ const own = printFilter(value === null ? where$1.isNull(field.name) : where$1.eq(field.name, value));
22
+ return filter.trim() ? `(${filter}) AND ${own}` : own;
23
+ };
24
+ const measured = (group, measure) => measure.kind === "count" ? group.count : group[measure.kind] ?? null;
25
+ /** The groups of a field of values as slices, in the order the API ranks them. */
26
+ const valueSlices = (groups, field, hints, measure, filter = "") => groups.map((group, index) => ({
27
+ key: String(group.value),
28
+ label: labelOf(field, hints, group),
29
+ value: measured(group, measure),
30
+ count: group.count,
31
+ color: colorOf(field, hints, group.value, index),
32
+ filter: groupFilter(field, group.value, filter)
33
+ }));
34
+ /** A date field's buckets as points in time, the bucket's start read as UTC as the list's chart reads it. */
35
+ const periodPoints = (groups, measure) => groups.map((group) => ({
36
+ at: Date.parse(`${String(group.value)}Z`),
37
+ value: measured(group, measure),
38
+ count: group.count
39
+ }));
40
+ /** A measured number as text: a count whole, a sum or average with the field's decimals, prefix and suffix. */
41
+ const measureText = (value, measure) => {
42
+ if (value === null) return "—";
43
+ if (measure.kind === "count") return value.toLocaleString("en-IE");
44
+ const decimals = measure.hints?.decimals ?? (Number.isInteger(value) ? 0 : 2);
45
+ const text = value.toLocaleString("en-IE", {
46
+ minimumFractionDigits: decimals,
47
+ maximumFractionDigits: decimals
48
+ });
49
+ return `${measure.hints?.prefix ?? ""}${text}${measure.hints?.suffix ?? ""}`;
50
+ };
51
+ //#endregion
52
+ export { groupFilter, measureText, periodPoints, valueSlices };
@@ -0,0 +1,75 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { labelFieldOf } from "../../live/model-helpers.js";
3
+ import { useAdminMeta } from "../meta-gate.js";
4
+ import { useAggregate } from "../../data/use-aggregates.js";
5
+ import { periodPoints, valueSlices } from "./chart-groups.js";
6
+ import { GroupBars } from "./group-bars.js";
7
+ import { GroupDonut } from "./group-donut.js";
8
+ import { PeriodChart } from "./period-chart.js";
9
+ import { jsx } from "react/jsx-runtime";
10
+ //#region src/app/charts/chart-view.tsx
11
+ const measureOf = (model, view, sum, average) => {
12
+ const name = sum ?? average;
13
+ const field = name === void 0 ? void 0 : model.fields[name];
14
+ if (!field || name === void 0) return { kind: "count" };
15
+ return {
16
+ kind: sum !== void 0 ? "sum" : "average",
17
+ field,
18
+ hints: view?.fields[name]
19
+ };
20
+ };
21
+ /**
22
+ * Loads a chart's groups through `:aggregate` and draws them: a date field's buckets as bars or a line, a field's
23
+ * values as ranked bars or a donut, each value linking to its records. The server checked the chart at startup.
24
+ */
25
+ const ChartView = ({ model, view, groupBy, type = "bar", sum, average, filter = "", granularity = "day", range = "30d", limit = 10, empty = "Nothing to chart yet." }) => {
26
+ const { resources, views } = useAdminMeta();
27
+ const field = model.fields[groupBy];
28
+ const dated = field?.type === "date" || field?.type === "timestamp";
29
+ const target = field?.type === "relation" && field.relation ? resources[field.relation.resource] : void 0;
30
+ const label = target && target.primaryKey.length === 1 ? labelFieldOf(target, views[target.name]) : void 0;
31
+ const params = dated ? {
32
+ granularity,
33
+ range
34
+ } : {
35
+ limit,
36
+ ...label && { label }
37
+ };
38
+ const result = useAggregate(model.name, {
39
+ groupBy,
40
+ filter,
41
+ ...sum && { sum },
42
+ ...average && { average },
43
+ ...params
44
+ }, Boolean(field));
45
+ const measure = measureOf(model, view, sum, average);
46
+ if (!field) return null;
47
+ if (result.isPending) return /* @__PURE__ */ jsx(Skeleton, { className: "h-36 w-full" });
48
+ if (result.error) return /* @__PURE__ */ jsx("p", {
49
+ className: "text-danger-text",
50
+ children: "Could not load the chart."
51
+ });
52
+ const { groups } = result.data;
53
+ if (groups.every((group) => group.count === 0)) return /* @__PURE__ */ jsx("p", {
54
+ className: "text-muted-foreground",
55
+ children: empty
56
+ });
57
+ if (dated) return /* @__PURE__ */ jsx(PeriodChart, {
58
+ points: periodPoints(groups, measure),
59
+ measure,
60
+ bucket: granularity,
61
+ type: type === "line" ? "line" : "bar"
62
+ });
63
+ const slices = valueSlices(groups, field, view?.fields[groupBy], measure, filter);
64
+ return type === "donut" ? /* @__PURE__ */ jsx(GroupDonut, {
65
+ resource: model.name,
66
+ slices,
67
+ measure
68
+ }) : /* @__PURE__ */ jsx(GroupBars, {
69
+ resource: model.name,
70
+ slices,
71
+ measure
72
+ });
73
+ };
74
+ //#endregion
75
+ export { ChartView };
@@ -0,0 +1,49 @@
1
+ import { AppLink } from "../use-app-address.js";
2
+ import { measureText } from "./chart-groups.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app/charts/group-bars.tsx
5
+ /**
6
+ * A ranked list of a field's values, each a bar as long as its number and a link to its records. Bars in rows read on
7
+ * a phone as well as on a desk, and carry their label and value as text, so no colour has to say what a bar is.
8
+ */
9
+ const GroupBars = ({ resource, slices, measure }) => {
10
+ const largest = Math.max(0, ...slices.map((slice) => slice.value ?? 0));
11
+ return /* @__PURE__ */ jsx("ul", {
12
+ className: "flex flex-col gap-1",
13
+ children: slices.map((slice) => {
14
+ const share = largest > 0 && slice.value !== null ? Math.max(0, slice.value) / largest : 0;
15
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(AppLink, {
16
+ to: {
17
+ kind: "list",
18
+ resource,
19
+ filter: slice.filter
20
+ },
21
+ className: "group grid grid-cols-[minmax(6rem,35%)_1fr_auto] items-center gap-3 rounded-md px-1 py-1 hover:bg-muted",
22
+ title: measure.kind === "count" ? void 0 : `${measureText(slice.count, { kind: "count" })} records`,
23
+ children: [
24
+ /* @__PURE__ */ jsx("span", {
25
+ className: "truncate group-hover:text-primary-text",
26
+ children: slice.label
27
+ }),
28
+ /* @__PURE__ */ jsx("span", {
29
+ className: "h-3 rounded-r bg-muted",
30
+ "aria-hidden": true,
31
+ children: /* @__PURE__ */ jsx("span", {
32
+ className: "block h-full rounded-r",
33
+ style: {
34
+ width: `${share * 100}%`,
35
+ background: "var(--chart-1)"
36
+ }
37
+ })
38
+ }),
39
+ /* @__PURE__ */ jsx("span", {
40
+ className: "text-right font-medium tabular-nums",
41
+ children: measureText(slice.value, measure)
42
+ })
43
+ ]
44
+ }) }, slice.key);
45
+ })
46
+ });
47
+ };
48
+ //#endregion
49
+ export { GroupBars };
@@ -0,0 +1,84 @@
1
+ import { AppLink } from "../use-app-address.js";
2
+ import { measureText } from "./chart-groups.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
5
+ //#region src/app/charts/group-donut.tsx
6
+ const percent = (part, whole) => whole > 0 ? `${Math.round(part / whole * 100)}%` : "";
7
+ const SliceTooltip = ({ active, payload, measure }) => {
8
+ const slice = payload?.[0]?.payload;
9
+ if (!active || !slice) return null;
10
+ return /* @__PURE__ */ jsxs("div", {
11
+ className: "rounded-md border bg-background px-2.5 py-1.5 text-xs shadow-pop",
12
+ children: [/* @__PURE__ */ jsx("div", {
13
+ className: "text-muted-foreground",
14
+ children: slice.label
15
+ }), /* @__PURE__ */ jsx("div", {
16
+ className: "font-semibold tabular-nums",
17
+ children: measureText(slice.value, measure)
18
+ })]
19
+ });
20
+ };
21
+ /**
22
+ * A donut of how a total shares out over a field's values, with a legend that names each, gives its number and share,
23
+ * and links to its records: the legend, not the colour, says which slice is which.
24
+ */
25
+ const GroupDonut = ({ resource, slices, measure }) => {
26
+ const drawn = slices.filter((slice) => slice.value !== null && slice.value > 0);
27
+ const whole = drawn.reduce((sum, slice) => sum + slice.value, 0);
28
+ return /* @__PURE__ */ jsxs("div", {
29
+ className: "flex flex-col items-center gap-4 sm:flex-row sm:items-center",
30
+ children: [/* @__PURE__ */ jsx("div", {
31
+ className: "size-36 shrink-0",
32
+ children: /* @__PURE__ */ jsx(ResponsiveContainer, {
33
+ width: "100%",
34
+ height: "100%",
35
+ children: /* @__PURE__ */ jsxs(PieChart, { children: [/* @__PURE__ */ jsx(Pie, {
36
+ data: drawn,
37
+ dataKey: "value",
38
+ nameKey: "label",
39
+ innerRadius: "62%",
40
+ outerRadius: "100%",
41
+ paddingAngle: drawn.length > 1 ? 1 : 0,
42
+ stroke: "var(--background)",
43
+ strokeWidth: 2,
44
+ isAnimationActive: false,
45
+ children: drawn.map((slice) => /* @__PURE__ */ jsx(Cell, { fill: slice.color }, slice.key))
46
+ }), /* @__PURE__ */ jsx(Tooltip, {
47
+ content: /* @__PURE__ */ jsx(SliceTooltip, { measure }),
48
+ isAnimationActive: false
49
+ })] })
50
+ })
51
+ }), /* @__PURE__ */ jsx("ul", {
52
+ className: "flex w-full min-w-0 flex-col gap-0.5",
53
+ children: slices.map((slice) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(AppLink, {
54
+ to: {
55
+ kind: "list",
56
+ resource,
57
+ filter: slice.filter
58
+ },
59
+ className: "group flex items-center gap-2 rounded-md px-1 py-0.5 hover:bg-muted",
60
+ children: [
61
+ /* @__PURE__ */ jsx("span", {
62
+ className: "size-2.5 shrink-0 rounded-full",
63
+ style: { background: slice.color },
64
+ "aria-hidden": true
65
+ }),
66
+ /* @__PURE__ */ jsx("span", {
67
+ className: "min-w-0 flex-1 truncate group-hover:text-primary-text",
68
+ children: slice.label
69
+ }),
70
+ /* @__PURE__ */ jsx("span", {
71
+ className: "font-medium tabular-nums",
72
+ children: measureText(slice.value, measure)
73
+ }),
74
+ /* @__PURE__ */ jsx("span", {
75
+ className: "w-9 text-right text-xs text-muted-foreground tabular-nums",
76
+ children: percent(slice.value ?? 0, whole)
77
+ })
78
+ ]
79
+ }) }, slice.key))
80
+ })]
81
+ });
82
+ };
83
+ //#endregion
84
+ export { GroupDonut };
@@ -0,0 +1,174 @@
1
+ import { useMediaQuery } from "../../lib/use-media-query.js";
2
+ import { measureText } from "./chart-groups.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
5
+ //#region src/app/charts/period-chart.tsx
6
+ const formats = {
7
+ hour: {
8
+ hour: "2-digit",
9
+ minute: "2-digit",
10
+ hour12: false
11
+ },
12
+ day: {
13
+ day: "numeric",
14
+ month: "short"
15
+ },
16
+ week: {
17
+ day: "numeric",
18
+ month: "short"
19
+ },
20
+ month: {
21
+ month: "short",
22
+ year: "2-digit"
23
+ },
24
+ year: { year: "numeric" }
25
+ };
26
+ const tooltipFormats = {
27
+ hour: {
28
+ day: "numeric",
29
+ month: "short",
30
+ hour: "2-digit",
31
+ minute: "2-digit",
32
+ hour12: false
33
+ },
34
+ day: {
35
+ day: "numeric",
36
+ month: "short",
37
+ year: "numeric"
38
+ },
39
+ week: {
40
+ day: "numeric",
41
+ month: "short",
42
+ year: "numeric"
43
+ },
44
+ month: {
45
+ month: "long",
46
+ year: "numeric"
47
+ },
48
+ year: { year: "numeric" }
49
+ };
50
+ const formatterOf = (options) => {
51
+ const format = new Intl.DateTimeFormat("en-GB", {
52
+ ...options,
53
+ timeZone: "UTC"
54
+ });
55
+ return (ms) => format.format(ms);
56
+ };
57
+ const axisTick = {
58
+ fontSize: 11,
59
+ fill: "var(--muted-foreground)"
60
+ };
61
+ const PointTooltip = ({ active, payload, measure, bucket }) => {
62
+ const point = payload?.[0]?.payload;
63
+ if (!active || !point) return null;
64
+ return /* @__PURE__ */ jsxs("div", {
65
+ className: "rounded-md border bg-background px-2.5 py-1.5 text-xs shadow-pop",
66
+ children: [/* @__PURE__ */ jsx("div", {
67
+ className: "text-muted-foreground",
68
+ children: (bucket === "week" ? "Week of " : "") + formatterOf(tooltipFormats[bucket])(point.at)
69
+ }), /* @__PURE__ */ jsx("div", {
70
+ className: "font-semibold tabular-nums",
71
+ children: measureText(point.value, measure)
72
+ })]
73
+ });
74
+ };
75
+ /** A date field's buckets over time, as bars or a line; a bucket without a value to add up is a gap. */
76
+ const PeriodChart = ({ points, measure, bucket, type }) => {
77
+ const wide = useMediaQuery("(min-width: 640px)");
78
+ const tick = formatterOf(formats[bucket]);
79
+ const grid = /* @__PURE__ */ jsx(CartesianGrid, {
80
+ vertical: false,
81
+ stroke: "var(--border)"
82
+ });
83
+ const x = /* @__PURE__ */ jsx(XAxis, {
84
+ dataKey: "at",
85
+ tickFormatter: tick,
86
+ tick: axisTick,
87
+ axisLine: false,
88
+ tickLine: false,
89
+ minTickGap: wide ? 24 : 40,
90
+ height: 18
91
+ });
92
+ const y = /* @__PURE__ */ jsx(YAxis, {
93
+ width: wide ? 48 : 36,
94
+ tick: axisTick,
95
+ axisLine: false,
96
+ tickLine: false,
97
+ tickCount: 3,
98
+ tickFormatter: (value) => value.toLocaleString("en-IE", { notation: "compact" })
99
+ });
100
+ const tooltip = /* @__PURE__ */ jsx(Tooltip, {
101
+ cursor: type === "bar" ? { fill: "var(--muted)" } : { stroke: "var(--border-strong)" },
102
+ content: /* @__PURE__ */ jsx(PointTooltip, {
103
+ measure,
104
+ bucket
105
+ }),
106
+ isAnimationActive: false
107
+ });
108
+ return /* @__PURE__ */ jsx("div", {
109
+ className: "h-40",
110
+ children: /* @__PURE__ */ jsx(ResponsiveContainer, {
111
+ width: "100%",
112
+ height: "100%",
113
+ children: type === "line" ? /* @__PURE__ */ jsxs(LineChart, {
114
+ data: points,
115
+ margin: {
116
+ top: 6,
117
+ right: 6,
118
+ bottom: 0,
119
+ left: 0
120
+ },
121
+ children: [
122
+ grid,
123
+ x,
124
+ y,
125
+ tooltip,
126
+ /* @__PURE__ */ jsx(Line, {
127
+ dataKey: "value",
128
+ stroke: "var(--chart-1)",
129
+ strokeWidth: 2,
130
+ dot: points.length <= 31 ? {
131
+ r: 3,
132
+ fill: "var(--chart-1)",
133
+ strokeWidth: 0
134
+ } : false,
135
+ activeDot: {
136
+ r: 4,
137
+ stroke: "var(--background)",
138
+ strokeWidth: 2
139
+ },
140
+ isAnimationActive: false
141
+ })
142
+ ]
143
+ }) : /* @__PURE__ */ jsxs(BarChart, {
144
+ data: points,
145
+ margin: {
146
+ top: 6,
147
+ right: 0,
148
+ bottom: 0,
149
+ left: 0
150
+ },
151
+ barCategoryGap: "22%",
152
+ children: [
153
+ grid,
154
+ x,
155
+ y,
156
+ tooltip,
157
+ /* @__PURE__ */ jsx(Bar, {
158
+ dataKey: "value",
159
+ fill: "var(--chart-1)",
160
+ radius: [
161
+ 4,
162
+ 4,
163
+ 0,
164
+ 0
165
+ ],
166
+ isAnimationActive: false
167
+ })
168
+ ]
169
+ })
170
+ })
171
+ });
172
+ };
173
+ //#endregion
174
+ export { PeriodChart };
@@ -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 };