@protobase/ui 0.19.16 → 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 (30) 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/list-group-chart.js +37 -0
  7. package/dist/app/list-page.js +10 -3
  8. package/dist/app/pages/blocks/chart.js +26 -0
  9. package/dist/app/pages/render-node.js +2 -0
  10. package/dist/app/relation-options.js +44 -0
  11. package/dist/app/relation-picker.js +114 -35
  12. package/dist/data/api-provider.d.ts +2 -0
  13. package/dist/data/query-keys.js +5 -0
  14. package/dist/data/use-aggregates.js +14 -1
  15. package/dist/index.js +2 -2
  16. package/dist/package.js +1 -1
  17. package/dist/shell/assets/app-B5h_q_JL.js +69 -0
  18. package/dist/shell/assets/{index-CmmYBR7Y.js → index-B0veahji.js} +1 -1
  19. package/dist/shell/assets/index-BZmb28OT.css +2 -0
  20. package/dist/shell/assets/{maplibre-Siom2Zwj.js → maplibre-CbjT6RKG.js} +1 -1
  21. package/dist/shell/assets/{markdown-content-ChyGEubR.js → markdown-content-BjRGtSOA.js} +1 -1
  22. package/dist/shell/assets/{record-map-B_QnHF99.js → record-map-rmkTnAs8.js} +1 -1
  23. package/dist/shell/assets/{records-map-CcHhEDBC.js → records-map-D1vLGY06.js} +1 -1
  24. package/dist/shell/assets/{shared-protobase-ui-zRmreKZS.js → shared-protobase-ui-PnkJqBqB.js} +1 -1
  25. package/dist/shell/index.html +4 -4
  26. package/dist/shell/protobase.shell.json +1 -1
  27. package/dist/tokens.css +17 -0
  28. package/package.json +4 -4
  29. package/dist/shell/assets/app-xQmLJM07.js +0 -69
  30. 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 };
@@ -0,0 +1,37 @@
1
+ import { humanize } from "../live/naming.js";
2
+ import { ChartView } from "./charts/chart-view.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app/list-group-chart.tsx
5
+ const measureTitle = (chart, view, plural) => {
6
+ const name = chart.sum ?? chart.average;
7
+ if (name === void 0) return plural;
8
+ const label = view?.fields[name]?.label ?? humanize(name);
9
+ return chart.sum !== void 0 ? `${label}` : `Average ${label.toLowerCase()}`;
10
+ };
11
+ /** The view's chart per value of a field, above the list and following its filter. */
12
+ const ListGroupChart = ({ model, view, chart, plural, filter }) => {
13
+ const by = view?.fields[chart.groupBy]?.label ?? humanize(chart.groupBy);
14
+ return /* @__PURE__ */ jsxs("section", {
15
+ className: "rounded-lg border bg-background px-4 pb-3 pt-3",
16
+ "aria-label": `${measureTitle(chart, view, plural)} by ${by.toLowerCase()}`,
17
+ children: [/* @__PURE__ */ jsxs("h2", {
18
+ className: "mb-2 font-semibold",
19
+ children: [
20
+ measureTitle(chart, view, plural),
21
+ " by ",
22
+ by.toLowerCase()
23
+ ]
24
+ }), /* @__PURE__ */ jsx(ChartView, {
25
+ model,
26
+ view,
27
+ groupBy: chart.groupBy,
28
+ type: chart.type,
29
+ sum: chart.sum,
30
+ average: chart.average,
31
+ limit: chart.limit,
32
+ filter
33
+ })]
34
+ });
35
+ };
36
+ //#endregion
37
+ export { ListGroupChart };
@@ -45,6 +45,7 @@ import { useExportFlow } from "./export/use-export-flow.js";
45
45
  import { ListCards } from "./displays/list-cards.js";
46
46
  import { useListScroll } from "./list-scroll.js";
47
47
  import { ListChart } from "./list-chart.js";
48
+ import { ListGroupChart } from "./list-group-chart.js";
48
49
  import { ListSearch } from "./list-search.js";
49
50
  import { withRelatedDotsColumns } from "./related-dots/related-dots-columns.js";
50
51
  import { SavedList } from "./saved/saved-list.js";
@@ -55,7 +56,7 @@ import { useMemo, useState } from "react";
55
56
  import { Download, Plus, SlidersHorizontal } from "lucide-react";
56
57
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
57
58
  import { createWhere } from "@protobase/schema";
58
- import { tableDisplay } from "@protobase/schema/internal";
59
+ import { isGroupChart, tableDisplay } from "@protobase/schema/internal";
59
60
  //#region src/app/list-page.tsx
60
61
  const where$1 = createWhere();
61
62
  const searchText = (extras) => extras.find((clause) => clause.kind === "search");
@@ -422,13 +423,19 @@ const LoadedList = ({ model, address, scope, heading }) => {
422
423
  title: "Could not load rows",
423
424
  error: rows.error
424
425
  }),
425
- view?.chart && /* @__PURE__ */ jsx(ListChart, {
426
+ view?.chart && (isGroupChart(view.chart) ? /* @__PURE__ */ jsx(ListGroupChart, {
427
+ model,
428
+ view,
429
+ chart: view.chart,
430
+ plural,
431
+ filter: loadFilter
432
+ }) : /* @__PURE__ */ jsx(ListChart, {
426
433
  resource,
427
434
  chart: view.chart,
428
435
  title: `${plural} created`,
429
436
  noun: plural.toLowerCase(),
430
437
  filter: loadFilter
431
- }),
438
+ })),
432
439
  desktop ? desktopToolbar : phoneFilters,
433
440
  desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
434
441
  className: "flex min-h-0 flex-1 gap-4",
@@ -0,0 +1,26 @@
1
+ import { useAdminMeta } from "../../meta-gate.js";
2
+ import { propsOf } from "./block-props.js";
3
+ import { CardFrame } from "./card-frame.js";
4
+ import { ChartView } from "../../charts/chart-view.js";
5
+ import { jsx } from "react/jsx-runtime";
6
+ //#region src/app/pages/blocks/chart.tsx
7
+ /** Counts, sums or averages of the records that match, per value or date bucket of a field, in a card. */
8
+ const ChartBlock = ({ node, slots }) => {
9
+ const { resource, title, description, actions: _actions, span: _span, ...chart } = propsOf(node);
10
+ const { resources, views } = useAdminMeta();
11
+ const model = resources[resource];
12
+ if (!model) return null;
13
+ return /* @__PURE__ */ jsx(CardFrame, {
14
+ title,
15
+ description,
16
+ actions: slots.actions,
17
+ className: "h-full",
18
+ children: /* @__PURE__ */ jsx(ChartView, {
19
+ model,
20
+ view: views[resource],
21
+ ...chart
22
+ })
23
+ });
24
+ };
25
+ //#endregion
26
+ export { ChartBlock };
@@ -4,6 +4,7 @@ import { useProjectUi } from "./project-ui.js";
4
4
  import { ActionBlock } from "./blocks/action.js";
5
5
  import { CardBlock } from "./blocks/card.js";
6
6
  import { CardRowBlock } from "./blocks/card-row.js";
7
+ import { ChartBlock } from "./blocks/chart.js";
7
8
  import { FieldBlock } from "./blocks/field.js";
8
9
  import { GridBlock } from "./blocks/grid.js";
9
10
  import { LinkBlock } from "./blocks/link.js";
@@ -30,6 +31,7 @@ const blocks = {
30
31
  Table: TableBlock,
31
32
  CardRow: CardRowBlock,
32
33
  RecordRow: RecordRowBlock,
34
+ Chart: ChartBlock,
33
35
  Progress: ProgressBlock,
34
36
  ModalForm: ModalFormBlock,
35
37
  Action: ActionBlock,
@@ -0,0 +1,44 @@
1
+ import { createWhere, printFilter } from "@protobase/schema";
2
+ //#region src/app/relation-options.ts
3
+ const where$1 = createWhere();
4
+ /** The words of typed text, as the list search splits it. */
5
+ const wordsOf = (text) => text.split(/\s+/).filter(Boolean);
6
+ /**
7
+ * How a relation picker finds the records that match typed text. A target with search fields uses the list's search;
8
+ * else, when its title field is a filterable text field, every word must appear in it, ignoring case, as the search
9
+ * matches one field. Otherwise, or once the server `refused` such a filter (the scan guard finds no index for it), the
10
+ * picker loads the records page by page and keeps the rows whose title matches (`local`).
11
+ */
12
+ const relationOptionsQuery = (target, titleField, text, refused = false) => {
13
+ const words = wordsOf(text);
14
+ if (words.length === 0) return {
15
+ filter: void 0,
16
+ local: false
17
+ };
18
+ if (refused) return {
19
+ filter: void 0,
20
+ local: true
21
+ };
22
+ if (target.search?.length) return {
23
+ filter: printFilter(where$1.search(text)),
24
+ local: false
25
+ };
26
+ const field = target.fields[titleField];
27
+ if (field?.type !== "text" || !field.filterable) return {
28
+ filter: void 0,
29
+ local: true
30
+ };
31
+ const contains = words.map((word) => where$1.eq(titleField, `*${word.replaceAll("*", "")}*`));
32
+ return {
33
+ filter: printFilter(where$1.and(...contains)),
34
+ local: false
35
+ };
36
+ };
37
+ /** Whether a title holds every word of the text, ignoring case: the picker's match on rows it filters itself. */
38
+ const titleMatches = (title, text) => {
39
+ if (title === null || title === void 0) return false;
40
+ const value = String(title).toLowerCase();
41
+ return wordsOf(text).every((word) => value.includes(word.toLowerCase()));
42
+ };
43
+ //#endregion
44
+ export { relationOptionsQuery, titleMatches };