@protobase/ui 0.19.15 → 0.20.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 (80) hide show
  1. package/dist/app/app.js +2 -2
  2. package/dist/app/charts/chart-groups.js +52 -0
  3. package/dist/app/charts/chart-view.js +75 -0
  4. package/dist/app/charts/group-bars.js +49 -0
  5. package/dist/app/charts/group-donut.js +84 -0
  6. package/dist/app/charts/period-chart.js +174 -0
  7. package/dist/app/displays/board-column.js +114 -0
  8. package/dist/app/displays/board-columns.js +45 -0
  9. package/dist/app/displays/board-display.js +96 -0
  10. package/dist/app/displays/board-dnd.js +32 -0
  11. package/dist/app/displays/list-displays.js +13 -1
  12. package/dist/app/displays/record-card.js +16 -5
  13. package/dist/app/displays/use-board-column.js +23 -0
  14. package/dist/app/displays/use-board-move.js +60 -0
  15. package/dist/app/displays/virtual-cards.js +1 -0
  16. package/dist/app/embedded-list/embedded-list.js +9 -0
  17. package/dist/app/export/export-options.js +2 -2
  18. package/dist/app/gallery/use-gallery-photos.js +2 -2
  19. package/dist/app/import/lookups.js +2 -2
  20. package/dist/app/list-edit/cell-field-editor.js +61 -0
  21. package/dist/app/list-edit/use-list-cell-editor.js +30 -0
  22. package/dist/app/list-edit/use-list-edits.js +75 -0
  23. package/dist/app/list-group-chart.js +37 -0
  24. package/dist/app/list-page.js +38 -13
  25. package/dist/app/map/map-area.js +6 -6
  26. package/dist/app/pages/action-href.js +4 -2
  27. package/dist/app/pages/blocks/chart.js +26 -0
  28. package/dist/app/pages/render-node.js +2 -0
  29. package/dist/app/pages/use-run-action.js +4 -3
  30. package/dist/app/record-page.js +30 -16
  31. package/dist/app/record-preview.js +25 -0
  32. package/dist/app/record-row/record-row-body.js +2 -2
  33. package/dist/app/record-row/record-row.js +1 -0
  34. package/dist/app/related-dots/card-dots-lines.js +16 -0
  35. package/dist/app/related-dots/card-dots.js +34 -0
  36. package/dist/app/related-dots/dot-card.js +3 -3
  37. package/dist/app/related-dots/related-dots-columns.js +3 -1
  38. package/dist/app/related-dots/related-dots.js +22 -9
  39. package/dist/app/related-section.js +4 -4
  40. package/dist/app/relation-options.js +44 -0
  41. package/dist/app/relation-picker.js +114 -35
  42. package/dist/app/saved/saved-records.js +2 -2
  43. package/dist/app/search/search-model.js +2 -2
  44. package/dist/app/searches/saved-search.js +3 -3
  45. package/dist/app/searches/search-diff.js +8 -8
  46. package/dist/app/use-nav-recent.js +1 -2
  47. package/dist/data/api-provider.d.ts +4 -1
  48. package/dist/data/api-provider.js +12 -5
  49. package/dist/data/focus-refresh.js +44 -0
  50. package/dist/data/query-keys.js +18 -0
  51. package/dist/data/use-aggregates.js +14 -1
  52. package/dist/data/use-record.js +17 -2
  53. package/dist/data-grid/cell-moves.js +26 -0
  54. package/dist/data-grid/data-grid.js +32 -7
  55. package/dist/data-grid/grid-header.js +1 -1
  56. package/dist/data-grid/grid-row.js +17 -13
  57. package/dist/data-grid/page-cache.js +3 -0
  58. package/dist/data-grid/use-cell-editing.js +113 -0
  59. package/dist/data-grid/use-paged-rows.js +25 -4
  60. package/dist/index.js +2 -2
  61. package/dist/live/field-values.js +3 -1
  62. package/dist/live/related-column-rows.js +3 -3
  63. package/dist/live/relation-labels.js +2 -2
  64. package/dist/live/use-live-rows.js +17 -4
  65. package/dist/live/use-reference-search.js +2 -2
  66. package/dist/package.js +1 -1
  67. package/dist/shell/assets/app-B5h_q_JL.js +69 -0
  68. package/dist/shell/assets/{index-CzV9U_NJ.js → index-B0veahji.js} +1 -1
  69. package/dist/shell/assets/index-BZmb28OT.css +2 -0
  70. package/dist/shell/assets/{maplibre-G35v6TYU.js → maplibre-CbjT6RKG.js} +1 -1
  71. package/dist/shell/assets/{markdown-content-DGFnggSX.js → markdown-content-BjRGtSOA.js} +1 -1
  72. package/dist/shell/assets/{record-map-ZAC_U52Y.js → record-map-rmkTnAs8.js} +1 -1
  73. package/dist/shell/assets/{records-map-DLXOQVpM.js → records-map-D1vLGY06.js} +1 -1
  74. package/dist/shell/assets/{shared-protobase-ui-xByMDSPg.js → shared-protobase-ui-PnkJqBqB.js} +1 -1
  75. package/dist/shell/index.html +4 -4
  76. package/dist/shell/protobase.shell.json +1 -1
  77. package/dist/tokens.css +17 -0
  78. package/package.json +4 -4
  79. package/dist/shell/assets/app-WromtADN.js +0 -69
  80. package/dist/shell/assets/index-DEEIUcYb.css +0 -2
package/dist/app/app.js CHANGED
@@ -12,7 +12,7 @@ import { CustomBoundary } from "./pages/custom-boundary.js";
12
12
  import { PluginSourceProvider } from "../plugins/plugin-source.js";
13
13
  import { AuthGate } from "../auth/auth-gate.js";
14
14
  import { isAdmin, userToShell } from "../auth/user-to-shell.js";
15
- import { useRecord } from "../data/use-record.js";
15
+ import { useOpenedRecord } from "../data/use-record.js";
16
16
  import { recordTitle } from "../live/model-helpers.js";
17
17
  import { humanize } from "../live/naming.js";
18
18
  import { ThemeProvider, useTheme } from "../theme/theme-provider.js";
@@ -92,7 +92,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
92
92
  const view = resource ? meta.views[resource] : void 0;
93
93
  const group = groups.find((entry) => entry.items.some((item) => item.id === place));
94
94
  const model = resource ? meta.resources[resource] : void 0;
95
- const record = useRecord(shown?.resource ?? "", shown?.key ?? "", Boolean(model && shown));
95
+ const record = useOpenedRecord(shown?.resource ?? "", shown?.key ?? "", Boolean(model && shown));
96
96
  const title = model && shown && record.data ? recordTitle(model, view, record.data.record, shown.key) : void 0;
97
97
  const titleLoading = Boolean(shown) && (record.isLoading || drawnFrom?.state === "loading");
98
98
  const { shell } = useProjectUi();
@@ -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 };
@@ -0,0 +1,114 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { Skeleton } from "../../primitives/skeleton.js";
3
+ import { Button } from "../../primitives/button.js";
4
+ import { humanize } from "../../live/naming.js";
5
+ import { ErrorBanner } from "../error-banner.js";
6
+ import { boardCard } from "./board-columns.js";
7
+ import { rowAllows } from "../permissions.js";
8
+ import { ValueBadge } from "../value-badge.js";
9
+ import { RecordCard } from "./record-card.js";
10
+ import { columnId } from "./board-dnd.js";
11
+ import { useBoardColumn } from "./use-board-column.js";
12
+ import { jsx, jsxs } from "react/jsx-runtime";
13
+ import { useDraggable, useDroppable } from "@dnd-kit/core";
14
+ //#region src/app/displays/board-column.tsx
15
+ /** One column of a board: its loaded records, those on their way out shown in their new column, and those on their way in at its top. */
16
+ const BoardColumnView = ({ model, view, display, column, fields, filter, orderBy, limit, moves, movable, dragged }) => {
17
+ const list = useBoardColumn({
18
+ model,
19
+ by: display.by,
20
+ value: column.value,
21
+ fields,
22
+ filter,
23
+ orderBy
24
+ });
25
+ const { setNodeRef, isOver } = useDroppable({ id: columnId(column.value) });
26
+ const card = boardCard(model, view, display);
27
+ const loaded = new Set(list.rows.map((row) => row.key));
28
+ const moving = new Map(moves.map((entry) => [entry.row.key, entry]));
29
+ const leaving = list.rows.filter((row) => moving.has(row.key) && moving.get(row.key).to !== column.value);
30
+ const arriving = moves.filter((entry) => entry.to === column.value && !loaded.has(entry.row.key)).map((entry) => entry.row);
31
+ const staying = list.rows.filter((row) => !leaving.includes(row)).map((row) => moving.has(row.key) ? moving.get(row.key).row : row);
32
+ const rows = [...arriving, ...staying].slice(0, limit);
33
+ const count = list.total - leaving.length + arriving.length;
34
+ if (column.value === null && !list.loading && count === 0 && !isOver) return null;
35
+ const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
36
+ const hints = view?.fields[display.by];
37
+ return /* @__PURE__ */ jsxs("section", {
38
+ ref: setNodeRef,
39
+ "aria-label": `${column.label}, ${count}`,
40
+ className: cn("flex w-72 shrink-0 snap-start flex-col gap-2 rounded-lg bg-surface p-2", isOver && "ring-2 ring-primary/30"),
41
+ children: [
42
+ /* @__PURE__ */ jsxs("header", {
43
+ className: "flex items-center justify-between gap-2 px-1 pt-0.5",
44
+ children: [/* @__PURE__ */ jsx("h2", {
45
+ className: "min-w-0 truncate text-xs font-medium",
46
+ children: column.value === null ? /* @__PURE__ */ jsx("span", {
47
+ className: "text-muted-foreground",
48
+ children: column.label
49
+ }) : /* @__PURE__ */ jsx(ValueBadge, {
50
+ value: column.value,
51
+ hints,
52
+ children: column.label
53
+ })
54
+ }), !list.loading && /* @__PURE__ */ jsx("span", {
55
+ className: "text-xs tabular-nums text-muted-foreground",
56
+ children: count.toLocaleString("en-IE")
57
+ })]
58
+ }),
59
+ list.loading ? Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-20" }, index)) : list.error ? /* @__PURE__ */ jsx(ErrorBanner, {
60
+ title: "Could not load rows",
61
+ error: list.error
62
+ }) : rows.length === 0 ? /* @__PURE__ */ jsxs("p", {
63
+ className: "px-1 py-4 text-center text-xs text-muted-foreground",
64
+ children: ["No ", plural]
65
+ }) : /* @__PURE__ */ jsx("ul", {
66
+ className: "flex flex-col gap-2",
67
+ children: rows.map((row) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(BoardCard, {
68
+ model,
69
+ view,
70
+ card,
71
+ row,
72
+ movable: movable && rowAllows(row.record, "update"),
73
+ dragged
74
+ }) }, row.key))
75
+ }),
76
+ limit === void 0 && list.more && /* @__PURE__ */ jsx(Button, {
77
+ size: "sm",
78
+ variant: "ghost",
79
+ onClick: list.loadMore,
80
+ children: "Show more"
81
+ })
82
+ ]
83
+ });
84
+ };
85
+ const BoardCard = ({ model, view, card, row, movable, dragged }) => {
86
+ const { setNodeRef, setActivatorNodeRef, listeners, isDragging } = useDraggable({
87
+ id: row.key,
88
+ data: { row },
89
+ disabled: !movable
90
+ });
91
+ return /* @__PURE__ */ jsx("div", {
92
+ ref: (element) => {
93
+ setNodeRef(element);
94
+ setActivatorNodeRef(element?.querySelector("a") ?? element);
95
+ },
96
+ ...listeners,
97
+ onDragStart: (event) => event.preventDefault(),
98
+ onClickCapture: (event) => {
99
+ if (!dragged()) return;
100
+ event.preventDefault();
101
+ event.stopPropagation();
102
+ },
103
+ className: cn(movable && "cursor-grab touch-manipulation", isDragging && "opacity-40"),
104
+ children: /* @__PURE__ */ jsx(RecordCard, {
105
+ model,
106
+ view,
107
+ display: card,
108
+ row,
109
+ swipe: false
110
+ })
111
+ });
112
+ };
113
+ //#endregion
114
+ export { BoardColumnView };
@@ -0,0 +1,45 @@
1
+ import { recordTitleField } from "../../live/model-helpers.js";
2
+ import { humanize } from "../../live/naming.js";
3
+ import { isApiError } from "@protobase/client";
4
+ import { enumLabel, printFilter, where } from "@protobase/schema";
5
+ //#region src/app/displays/board-columns.ts
6
+ /**
7
+ * A board's columns: one per value of its enum field, in the order the field lists them, headed by the view's value
8
+ * label; then, when the field may be empty, one for the records without a value.
9
+ */
10
+ const boardColumns = (model, view, display) => {
11
+ const field = model.fields[display.by];
12
+ if (!field) return [];
13
+ const hints = view?.fields[display.by];
14
+ const values = (field.enumValues ?? []).map((value) => ({
15
+ value,
16
+ label: enumLabel(value, hints?.valueLabels)
17
+ }));
18
+ if (!field.nullable) return values;
19
+ return [...values, {
20
+ value: null,
21
+ label: `No ${(hints?.label ?? humanize(display.by)).toLowerCase()}`
22
+ }];
23
+ };
24
+ /** The list's filter, its search included, narrowed to the records of one column. */
25
+ const columnFilter = (filter, by, value) => {
26
+ const column = printFilter(value === null ? where.isNull(by) : where.eq(by, value));
27
+ return filter === "" ? column : `(${filter}) AND ${column}`;
28
+ };
29
+ /** The built-in card a board draws for each record: that of a cards display of the board's fields. */
30
+ const boardCard = (model, view, display) => ({
31
+ kind: "cards",
32
+ name: display.name,
33
+ title: display.title ?? recordTitleField(model, view),
34
+ ...display.subtitle !== void 0 && { subtitle: display.subtitle },
35
+ ...display.badge !== void 0 && { badge: display.badge },
36
+ ...display.meta && { meta: display.meta }
37
+ });
38
+ /** Why the server refused a move: the reasons it gives per field, such as a `validate` message, else its detail. */
39
+ const refusalReason = (error) => {
40
+ if (!isApiError(error)) return error instanceof Error ? error.message : "Something went wrong";
41
+ const reasons = error.errors.map((entry) => entry.message);
42
+ return reasons.length > 0 ? reasons.join("; ") : error.message;
43
+ };
44
+ //#endregion
45
+ export { boardCard, boardColumns, columnFilter, refusalReason };