@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.
- package/dist/app/app.js +2 -2
- 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/displays/board-column.js +114 -0
- package/dist/app/displays/board-columns.js +45 -0
- package/dist/app/displays/board-display.js +96 -0
- package/dist/app/displays/board-dnd.js +32 -0
- package/dist/app/displays/list-displays.js +13 -1
- package/dist/app/displays/record-card.js +16 -5
- package/dist/app/displays/use-board-column.js +23 -0
- package/dist/app/displays/use-board-move.js +60 -0
- package/dist/app/displays/virtual-cards.js +1 -0
- package/dist/app/embedded-list/embedded-list.js +9 -0
- package/dist/app/export/export-options.js +2 -2
- package/dist/app/gallery/use-gallery-photos.js +2 -2
- package/dist/app/import/lookups.js +2 -2
- package/dist/app/list-edit/cell-field-editor.js +61 -0
- package/dist/app/list-edit/use-list-cell-editor.js +30 -0
- package/dist/app/list-edit/use-list-edits.js +75 -0
- package/dist/app/list-group-chart.js +37 -0
- package/dist/app/list-page.js +38 -13
- package/dist/app/map/map-area.js +6 -6
- package/dist/app/pages/action-href.js +4 -2
- package/dist/app/pages/blocks/chart.js +26 -0
- package/dist/app/pages/render-node.js +2 -0
- package/dist/app/pages/use-run-action.js +4 -3
- package/dist/app/record-page.js +30 -16
- package/dist/app/record-preview.js +25 -0
- package/dist/app/record-row/record-row-body.js +2 -2
- package/dist/app/record-row/record-row.js +1 -0
- package/dist/app/related-dots/card-dots-lines.js +16 -0
- package/dist/app/related-dots/card-dots.js +34 -0
- package/dist/app/related-dots/dot-card.js +3 -3
- package/dist/app/related-dots/related-dots-columns.js +3 -1
- package/dist/app/related-dots/related-dots.js +22 -9
- package/dist/app/related-section.js +4 -4
- package/dist/app/relation-options.js +44 -0
- package/dist/app/relation-picker.js +114 -35
- package/dist/app/saved/saved-records.js +2 -2
- package/dist/app/search/search-model.js +2 -2
- package/dist/app/searches/saved-search.js +3 -3
- package/dist/app/searches/search-diff.js +8 -8
- package/dist/app/use-nav-recent.js +1 -2
- package/dist/data/api-provider.d.ts +4 -1
- package/dist/data/api-provider.js +12 -5
- package/dist/data/focus-refresh.js +44 -0
- package/dist/data/query-keys.js +18 -0
- package/dist/data/use-aggregates.js +14 -1
- package/dist/data/use-record.js +17 -2
- package/dist/data-grid/cell-moves.js +26 -0
- package/dist/data-grid/data-grid.js +32 -7
- package/dist/data-grid/grid-header.js +1 -1
- package/dist/data-grid/grid-row.js +17 -13
- package/dist/data-grid/page-cache.js +3 -0
- package/dist/data-grid/use-cell-editing.js +113 -0
- package/dist/data-grid/use-paged-rows.js +25 -4
- package/dist/index.js +2 -2
- package/dist/live/field-values.js +3 -1
- package/dist/live/related-column-rows.js +3 -3
- package/dist/live/relation-labels.js +2 -2
- package/dist/live/use-live-rows.js +17 -4
- package/dist/live/use-reference-search.js +2 -2
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-B5h_q_JL.js +69 -0
- package/dist/shell/assets/{index-CzV9U_NJ.js → index-B0veahji.js} +1 -1
- package/dist/shell/assets/index-BZmb28OT.css +2 -0
- package/dist/shell/assets/{maplibre-G35v6TYU.js → maplibre-CbjT6RKG.js} +1 -1
- package/dist/shell/assets/{markdown-content-DGFnggSX.js → markdown-content-BjRGtSOA.js} +1 -1
- package/dist/shell/assets/{record-map-ZAC_U52Y.js → record-map-rmkTnAs8.js} +1 -1
- package/dist/shell/assets/{records-map-DLXOQVpM.js → records-map-D1vLGY06.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-xByMDSPg.js → shared-protobase-ui-PnkJqBqB.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-WromtADN.js +0 -69
- 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 {
|
|
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 =
|
|
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 };
|