@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.
- package/dist/app/charts/chart-groups.js +52 -0
- package/dist/app/charts/chart-view.js +75 -0
- package/dist/app/charts/group-bars.js +49 -0
- package/dist/app/charts/group-donut.js +84 -0
- package/dist/app/charts/period-chart.js +174 -0
- package/dist/app/create/create-values.js +2 -1
- package/dist/app/field-editor.js +29 -11
- 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/list-group-chart.js +37 -0
- package/dist/app/list-page.js +10 -3
- package/dist/app/pages/blocks/chart.js +26 -0
- package/dist/app/pages/render-node.js +2 -0
- package/dist/app/record-page.js +43 -7
- package/dist/app/relation-options.js +44 -0
- package/dist/app/relation-picker.js +114 -35
- package/dist/data/api-provider.d.ts +10 -0
- package/dist/data/query-keys.js +13 -0
- package/dist/data/use-aggregates.js +14 -1
- package/dist/format/date.js +15 -2
- package/dist/index.js +2 -2
- package/dist/live/field-values.js +1 -0
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-BBx2Bkop.js +69 -0
- package/dist/shell/assets/index-Dc6xgHsz.css +2 -0
- package/dist/shell/assets/{index-CmmYBR7Y.js → index-iXIDNHEm.js} +1 -1
- package/dist/shell/assets/{maplibre-Siom2Zwj.js → maplibre-DxWag0Wp.js} +1 -1
- package/dist/shell/assets/{markdown-content-ChyGEubR.js → markdown-content-Bco0XDqw.js} +1 -1
- package/dist/shell/assets/{record-map-B_QnHF99.js → record-map-Ca1fz02Q.js} +1 -1
- package/dist/shell/assets/{records-map-CcHhEDBC.js → records-map-JooppeKw.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-zRmreKZS.js → shared-protobase-ui-BZs06oKf.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/tokens.css +17 -0
- package/package.json +4 -4
- package/dist/shell/assets/app-xQmLJM07.js +0 -69
- 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 };
|
package/dist/app/field-editor.js
CHANGED
|
@@ -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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 };
|