@cosmicdrift/kumiko-renderer 0.384.0 → 0.385.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/action-form-body.d.ts +50 -0
- package/dist/app/action-form-body.js +275 -0
- package/dist/app/client-actions.js +2 -2
- package/dist/app/config-edit-body.d.ts +9 -0
- package/dist/app/config-edit-body.js +340 -0
- package/dist/app/entity-edit-prefill.d.ts +27 -0
- package/dist/app/entity-edit-prefill.js +377 -0
- package/dist/app/entity-edit-screen.d.ts +11 -0
- package/dist/app/entity-edit-screen.js +395 -0
- package/dist/app/entity-list-screen.d.ts +10 -0
- package/dist/app/entity-list-screen.js +547 -0
- package/dist/app/kumiko-screen.d.ts +1 -36
- package/dist/app/kumiko-screen.js +16 -2855
- package/dist/app/projection-detail-body.d.ts +12 -0
- package/dist/app/projection-detail-body.js +464 -0
- package/dist/app/projection-detail-shim.d.ts +1 -1
- package/dist/app/projection-detail-shim.js +5 -1
- package/dist/app/projection-list-body.d.ts +10 -0
- package/dist/app/projection-list-body.js +245 -0
- package/dist/app/projection-list-shim.d.ts +2 -1
- package/dist/app/projection-list-shim.js +4 -0
- package/dist/app/reference-facet-bridge.js +2 -2
- package/dist/app/row-actions.js +3 -4
- package/dist/app/screen-shared.d.ts +72 -0
- package/dist/app/screen-shared.js +325 -0
- package/dist/app/secret-mint-body.js +3 -3
- package/dist/components/edit-mounts.d.ts +33 -0
- package/dist/components/edit-mounts.js +53 -0
- package/dist/components/error-boundary.js +2 -2
- package/dist/components/reference-create-dialog.js +1 -1
- package/dist/components/related-list-section.js +2 -2
- package/dist/components/render-edit-helpers.d.ts +29 -0
- package/dist/components/render-edit-helpers.js +121 -0
- package/dist/components/render-edit.d.ts +1 -4
- package/dist/components/render-edit.js +41 -443
- package/dist/components/render-field.js +3 -4
- package/dist/components/render-list.js +2 -2
- package/dist/components/use-wizard-form-draft.d.ts +51 -0
- package/dist/components/use-wizard-form-draft.js +291 -0
- package/dist/dev-console.d.ts +3 -0
- package/dist/dev-console.js +15 -0
- package/dist/headless/contracts/primitives.js +14 -17
- package/dist/headless/format/index.js +3 -3
- package/dist/headless/view-model/edit.d.ts +1 -1
- package/dist/headless/view-model/edit.js +9 -16
- package/dist/headless/view-model/list.d.ts +1 -1
- package/dist/headless/view-model/list.js +3 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -5
- package/dist/live/error-mapping.js +3 -4
- package/dist/web/app/create-app.js +5 -8
- package/dist/web/app/dashboard-body.js +4 -599
- package/dist/web/app/dashboard-panels.d.ts +53 -0
- package/dist/web/app/dashboard-panels.js +373 -0
- package/dist/web/app/dashboard-shared.d.ts +90 -0
- package/dist/web/app/dashboard-shared.js +171 -0
- package/dist/web/app/dashboard-text.d.ts +6 -0
- package/dist/web/app/dashboard-text.js +65 -0
- package/dist/web/layout/nav-tree.js +3 -4
- package/dist/web/layout/target-resolver-stub.js +2 -2
- package/dist/web/primitives/banner.d.ts +4 -0
- package/dist/web/primitives/banner.js +28 -0
- package/dist/web/primitives/button.d.ts +7 -0
- package/dist/web/primitives/button.js +48 -0
- package/dist/web/primitives/card-styles.d.ts +8 -0
- package/dist/web/primitives/card-styles.js +18 -0
- package/dist/web/primitives/card.d.ts +4 -0
- package/dist/web/primitives/card.js +41 -0
- package/dist/web/primitives/contexts.d.ts +22 -0
- package/dist/web/primitives/contexts.js +50 -0
- package/dist/web/primitives/data-table-cell.d.ts +76 -0
- package/dist/web/primitives/data-table-cell.js +403 -0
- package/dist/web/primitives/data-table.d.ts +4 -0
- package/dist/web/primitives/data-table.js +279 -0
- package/dist/web/primitives/default-form.d.ts +4 -0
- package/dist/web/primitives/default-form.js +79 -0
- package/dist/web/primitives/facet-filter.d.ts +14 -0
- package/dist/web/primitives/facet-filter.js +39 -0
- package/dist/web/primitives/field.d.ts +63 -0
- package/dist/web/primitives/field.js +181 -0
- package/dist/web/primitives/form.d.ts +53 -0
- package/dist/web/primitives/form.js +252 -0
- package/dist/web/primitives/index.d.ts +7 -30
- package/dist/web/primitives/index.js +27 -2006
- package/dist/web/primitives/input.d.ts +4 -0
- package/dist/web/primitives/input.js +166 -0
- package/dist/web/primitives/layout-elements.d.ts +14 -0
- package/dist/web/primitives/layout-elements.js +194 -0
- package/dist/web/primitives/modal.js +1 -1
- package/dist/web/primitives/pager.d.ts +22 -0
- package/dist/web/primitives/pager.js +89 -0
- package/dist/web/primitives/row-actions.d.ts +13 -0
- package/dist/web/primitives/row-actions.js +178 -0
- package/dist/web/primitives/secret-reveal.d.ts +4 -0
- package/dist/web/primitives/secret-reveal.js +67 -0
- package/package.json +5 -5
- package/src/changes.json +6 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardListPanel, DashboardProgressListPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardTextPanel } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
import { type Translate } from "../../headless/index.ts";
|
|
4
|
+
import { type ScreenParams } from "./dashboard-shared.tsx";
|
|
5
|
+
export declare function StatPanelBody({ panel, label, screenId, screenParams, translate, variant, }: {
|
|
6
|
+
readonly panel: DashboardStatPanel;
|
|
7
|
+
readonly label: string;
|
|
8
|
+
readonly screenId: string;
|
|
9
|
+
readonly screenParams: ScreenParams;
|
|
10
|
+
readonly translate: Translate;
|
|
11
|
+
readonly variant: "card" | "strip";
|
|
12
|
+
}): ReactNode;
|
|
13
|
+
export declare function StatGroupPanelBody({ panel, label, screenId, screenParams, translate, }: {
|
|
14
|
+
readonly panel: DashboardStatGroupPanel;
|
|
15
|
+
readonly label: string | undefined;
|
|
16
|
+
readonly screenId: string;
|
|
17
|
+
readonly screenParams: ScreenParams;
|
|
18
|
+
readonly translate: Translate;
|
|
19
|
+
}): ReactNode;
|
|
20
|
+
export declare function ChartPanelBody({ panel, label, screenParams, translate, }: {
|
|
21
|
+
readonly panel: DashboardChartPanel;
|
|
22
|
+
readonly label: string;
|
|
23
|
+
readonly screenParams: ScreenParams;
|
|
24
|
+
readonly translate: Translate;
|
|
25
|
+
}): ReactNode;
|
|
26
|
+
export declare function ListPanelBody({ panel, label, screenParams, translate, }: {
|
|
27
|
+
readonly panel: DashboardListPanel;
|
|
28
|
+
readonly label: string;
|
|
29
|
+
readonly screenParams: ScreenParams;
|
|
30
|
+
readonly translate: Translate;
|
|
31
|
+
}): ReactNode;
|
|
32
|
+
export declare function FeedPanelBody({ panel, label, screenParams, translate, }: {
|
|
33
|
+
readonly panel: DashboardFeedPanel;
|
|
34
|
+
readonly label: string;
|
|
35
|
+
readonly screenParams: ScreenParams;
|
|
36
|
+
readonly translate: Translate;
|
|
37
|
+
}): ReactNode;
|
|
38
|
+
export declare function ProgressListPanelBody({ panel, label, screenParams, translate, }: {
|
|
39
|
+
readonly panel: DashboardProgressListPanel;
|
|
40
|
+
readonly label: string;
|
|
41
|
+
readonly screenParams: ScreenParams;
|
|
42
|
+
readonly translate: Translate;
|
|
43
|
+
}): ReactNode;
|
|
44
|
+
export declare function TextPanelBody({ panel, label, translate, }: {
|
|
45
|
+
readonly panel: DashboardTextPanel;
|
|
46
|
+
readonly label: string | undefined;
|
|
47
|
+
readonly translate: Translate;
|
|
48
|
+
}): ReactNode;
|
|
49
|
+
export declare function CustomPanelBody({ panel, screenId, filterParams, }: {
|
|
50
|
+
readonly panel: DashboardCustomPanel;
|
|
51
|
+
readonly screenId: string;
|
|
52
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
53
|
+
}): ReactNode;
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import { DASHBOARD_STAT_TONES } from "@cosmicdrift/kumiko-types/screen";
|
|
4
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { rendererWarn } from "../../dev-console.js";
|
|
7
|
+
import { EMPTY_VALUE_PLACEHOLDER } from "../../headless/index.js";
|
|
8
|
+
import { extensionSectionName, lastSegment, useExtensionSectionComponent, useNav, usePrimitives, useTranslation, useUserRoles, } from "../../index.js";
|
|
9
|
+
import { initialWindowSelection, } from "../widgets/chart-window.js";
|
|
10
|
+
import { chartToneColor, SegmentBarChart, StackedAreaChart, StackedBarChart, TimeseriesChart, } from "../widgets/charts.js";
|
|
11
|
+
import { DashboardListTable } from "../widgets/dashboard-list.js";
|
|
12
|
+
import { FeedList } from "../widgets/feed-list.js";
|
|
13
|
+
import { ModeSwitch } from "../widgets/mode-switch.js";
|
|
14
|
+
import { ProgressList } from "../widgets/progress-list.js";
|
|
15
|
+
import { SectionCard } from "../widgets/section-card.js";
|
|
16
|
+
import { StatCard, StatStripCell } from "../widgets/stat.js";
|
|
17
|
+
import { EmptyState, LoadingState } from "../widgets/states.js";
|
|
18
|
+
import { DAY_MS, HOUR_MS, PanelError, PanelShell, panelPayload, QueryPanel, useDashboardFormats, useDashboardQuery, useReportPanelLoaded, } from "./dashboard-shared.js";
|
|
19
|
+
import { isRecord, resolveDashboardText } from "./dashboard-text.js";
|
|
20
|
+
function isSparkPoint(value) {
|
|
21
|
+
return (typeof value === "object" &&
|
|
22
|
+
value !== null &&
|
|
23
|
+
"value" in value &&
|
|
24
|
+
(typeof value.value === "number" || value.value === null));
|
|
25
|
+
}
|
|
26
|
+
function readSparkline(panel, record) {
|
|
27
|
+
if (panel.sparklineField === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
const raw = record[panel.sparklineField];
|
|
30
|
+
if (!Array.isArray(raw))
|
|
31
|
+
return undefined;
|
|
32
|
+
return raw.filter(isSparkPoint).flatMap((p) => (p.value === null ? [] : [p.value]));
|
|
33
|
+
}
|
|
34
|
+
function readTone(raw) {
|
|
35
|
+
return isOneOf(DASHBOARD_STAT_TONES, raw) ? raw : undefined;
|
|
36
|
+
}
|
|
37
|
+
function fallbackText(raw, placeholder) {
|
|
38
|
+
if (raw === undefined || raw === null || isRecord(raw))
|
|
39
|
+
return placeholder;
|
|
40
|
+
return String(raw);
|
|
41
|
+
}
|
|
42
|
+
export function StatPanelBody({ panel, label, screenId, screenParams, translate, variant, }) {
|
|
43
|
+
const formats = useDashboardFormats();
|
|
44
|
+
// Resolved HERE (not in a separate always-rendered child) so `icon` on
|
|
45
|
+
// <StatCard> is `undefined` — not a React element that renders empty —
|
|
46
|
+
// when the icon name isn't registered. StatCard gates its accent chip on
|
|
47
|
+
// `icon !== undefined`, so a resolved-but-hidden element used to leave a
|
|
48
|
+
// stray accent-colored chip next to the label.
|
|
49
|
+
const iconName = panel.icon !== undefined ? extensionSectionName(panel.icon) : undefined;
|
|
50
|
+
const Icon = useExtensionSectionComponent(iconName);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (panel.icon !== undefined && iconName !== undefined && Icon === undefined) {
|
|
53
|
+
rendererWarn(`[kumiko] Dashboard stat-panel "${panel.id}" on screen "${screenId}" references icon ` +
|
|
54
|
+
`"${iconName}", which is not registered in clientFeatures.extensionSectionComponents.`);
|
|
55
|
+
}
|
|
56
|
+
}, [panel.icon, panel.id, iconName, Icon, screenId]);
|
|
57
|
+
const { data, error, loading, refetch } = useDashboardQuery(panel.query, panelPayload(panel, screenParams));
|
|
58
|
+
useReportPanelLoaded(data);
|
|
59
|
+
if (loading && data === null)
|
|
60
|
+
return _jsx(LoadingState, { rows: 2 });
|
|
61
|
+
if (error !== null) {
|
|
62
|
+
return _jsx(PanelError, { label: label, error: error, onRetry: () => void refetch() });
|
|
63
|
+
}
|
|
64
|
+
const record = data ?? {};
|
|
65
|
+
const rawValue = record[panel.valueField];
|
|
66
|
+
const tone = readTone(panel.toneField !== undefined ? record[panel.toneField] : undefined) ??
|
|
67
|
+
// A fixed tone must not paint an all-clear "0" as an alarm.
|
|
68
|
+
(rawValue === 0 ? undefined : panel.tone);
|
|
69
|
+
const sub = panel.subField !== undefined ? record[panel.subField] : undefined;
|
|
70
|
+
const delta = readDelta(panel, record);
|
|
71
|
+
const valueFormat = panel.valueFormat;
|
|
72
|
+
let value;
|
|
73
|
+
if (typeof rawValue !== "number") {
|
|
74
|
+
value =
|
|
75
|
+
resolveDashboardText(rawValue, translate, formats) ??
|
|
76
|
+
fallbackText(rawValue, EMPTY_VALUE_PLACEHOLDER);
|
|
77
|
+
}
|
|
78
|
+
else if (valueFormat?.kind === "relativeTime") {
|
|
79
|
+
value = formats.formatRelative(rawValue);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
value = formats.formatPanelValue(valueFormat)(rawValue);
|
|
83
|
+
}
|
|
84
|
+
const spark = readSparkline(panel, record);
|
|
85
|
+
const testId = `dashboard-panel-${panel.id}`;
|
|
86
|
+
const subText = resolveDashboardText(sub, translate, formats) ?? fallbackText(sub, undefined);
|
|
87
|
+
const icon = Icon !== undefined ? (_jsx(Icon, { entityName: screenId, entityId: null, screenId: screenId, filterParams: screenParams.filterParams })) : undefined;
|
|
88
|
+
if (variant === "strip") {
|
|
89
|
+
return (_jsx(StatStripCell, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
|
|
90
|
+
}
|
|
91
|
+
return (_jsx(StatCard, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
|
|
92
|
+
}
|
|
93
|
+
function readDelta(panel, record) {
|
|
94
|
+
if (panel.deltaField === undefined || panel.deltaDirectionField === undefined)
|
|
95
|
+
return undefined;
|
|
96
|
+
const value = record[panel.deltaField];
|
|
97
|
+
const direction = record[panel.deltaDirectionField];
|
|
98
|
+
if (value === undefined || value === null)
|
|
99
|
+
return undefined;
|
|
100
|
+
if (direction !== "up" && direction !== "down")
|
|
101
|
+
return undefined;
|
|
102
|
+
const tone = readTone(panel.deltaToneField !== undefined ? record[panel.deltaToneField] : undefined);
|
|
103
|
+
return { value: String(value), direction, ...(tone !== undefined && { tone }) };
|
|
104
|
+
}
|
|
105
|
+
export function StatGroupPanelBody({ panel, label, screenId, screenParams, translate, }) {
|
|
106
|
+
const testId = `dashboard-panel-${panel.id}`;
|
|
107
|
+
if (label === undefined) {
|
|
108
|
+
return (_jsx("div", { "data-testid": testId, className: "grid grid-cols-2 gap-y-2 rounded-lg border border-border bg-card py-1 lg:auto-cols-fr lg:grid-flow-col lg:divide-x lg:divide-border-row", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "strip" }, stat.id))) }));
|
|
109
|
+
}
|
|
110
|
+
return (_jsx(SectionCard, { title: label, ...(panel.subtitle !== undefined && { subtitle: translate(panel.subtitle) }), testId: testId, children: _jsx("section", { className: "grid grid-cols-[repeat(auto-fit,minmax(min(12rem,100%),1fr))] gap-3", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }, stat.id))) }) }));
|
|
111
|
+
}
|
|
112
|
+
function markerKindColor(kind) {
|
|
113
|
+
if (kind === undefined)
|
|
114
|
+
return undefined;
|
|
115
|
+
if (kind.color !== undefined)
|
|
116
|
+
return kind.color;
|
|
117
|
+
return kind.tone !== undefined ? chartToneColor(kind.tone) : undefined;
|
|
118
|
+
}
|
|
119
|
+
function resolveMarkers(markers, translate, formats, markerKinds) {
|
|
120
|
+
return markers?.map((marker) => {
|
|
121
|
+
const color = typeof marker.kind === "string" ? markerKindColor(markerKinds?.[marker.kind]) : undefined;
|
|
122
|
+
const legendKey = typeof marker.kind === "string" ? markerKinds?.[marker.kind]?.label : undefined;
|
|
123
|
+
const legendLabel = legendKey !== undefined ? translate(legendKey) : undefined;
|
|
124
|
+
return {
|
|
125
|
+
atMs: marker.atMs,
|
|
126
|
+
label: resolveDashboardText(marker.label, translate, formats) ?? "",
|
|
127
|
+
...(color !== undefined && { color }),
|
|
128
|
+
...(legendLabel !== undefined && { legendLabel }),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// A plain bar chart ("changes per day") ships `points` without `series`.
|
|
133
|
+
const SINGLE_SERIES_KEY = "value";
|
|
134
|
+
function chartSeries(data, panelLabel, translate) {
|
|
135
|
+
if (data.series !== undefined && data.series.length > 0) {
|
|
136
|
+
return data.series.map((s) => ({ ...s, label: translate(s.label) }));
|
|
137
|
+
}
|
|
138
|
+
if (data.points !== undefined && data.points.length > 0) {
|
|
139
|
+
return [{ key: SINGLE_SERIES_KEY, label: panelLabel, points: data.points }];
|
|
140
|
+
}
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
function distinctBucketCount(series) {
|
|
144
|
+
return new Set(series.flatMap((s) => s.points.map((p) => p.atMs))).size;
|
|
145
|
+
}
|
|
146
|
+
function isChartEmpty(chart, data, series) {
|
|
147
|
+
if (chart === "segment-bars")
|
|
148
|
+
return (data.rows ?? []).length === 0;
|
|
149
|
+
if (chart === "stacked-bars")
|
|
150
|
+
return distinctBucketCount(series) === 0;
|
|
151
|
+
if (chart === "stacked-area")
|
|
152
|
+
return distinctBucketCount(series) < 2;
|
|
153
|
+
const measured = (data.points ?? []).filter((p) => p.value !== null);
|
|
154
|
+
return measured.length < 2;
|
|
155
|
+
}
|
|
156
|
+
function windowOf(data, series) {
|
|
157
|
+
const times = series.flatMap((s) => s.points.map((p) => p.atMs));
|
|
158
|
+
const startMs = data.windowStartMs ?? (times.length > 0 ? Math.min(...times) : 0);
|
|
159
|
+
const endMs = data.windowEndMs ?? (times.length > 0 ? Math.max(...times) : 1);
|
|
160
|
+
return { startMs, endMs };
|
|
161
|
+
}
|
|
162
|
+
export function ChartPanelBody({ panel, label, screenParams, translate, }) {
|
|
163
|
+
const t = useTranslation();
|
|
164
|
+
const todayLabel = t("kumiko.dashboard.today");
|
|
165
|
+
const tones = panel.seriesTones;
|
|
166
|
+
const colors = panel.seriesColors;
|
|
167
|
+
const testId = `dashboard-chart-${panel.id}`;
|
|
168
|
+
const ranges = panel.chart === "stacked-area" && panel.ranges !== undefined
|
|
169
|
+
? {
|
|
170
|
+
default: panel.ranges.default,
|
|
171
|
+
options: panel.ranges.options.map((option) => ({
|
|
172
|
+
...option,
|
|
173
|
+
label: translate(option.label),
|
|
174
|
+
})),
|
|
175
|
+
}
|
|
176
|
+
: undefined;
|
|
177
|
+
const [windowSelection, setWindowSelection] = useState(() => initialWindowSelection(ranges, panel.chart === "stacked-area" ? panel.initialWindow : undefined));
|
|
178
|
+
const rangeSwitch = ranges !== undefined ? (_jsx(ModeSwitch, { value: windowSelection?.kind === "range" ? windowSelection.value : null, options: ranges.options.map((option) => ({
|
|
179
|
+
value: option.value,
|
|
180
|
+
label: option.label,
|
|
181
|
+
})), onChange: (value) => setWindowSelection({ kind: "range", value }), ariaLabel: label, variant: "pill", testId: `dashboard-chart-range-${panel.id}` })) : undefined;
|
|
182
|
+
return (_jsx(QueryPanel, { panel: panel, label: label, subtitle: panel.subtitle !== undefined ? translate(panel.subtitle) : undefined, action: rangeSwitch, screenParams: screenParams, translate: translate, skeleton: panel.chart === "segment-bars" ? "segments" : "bars", isEmpty: (data) => isChartEmpty(panel.chart, data, chartSeries(data, label, translate)), children: (data, formats) => {
|
|
183
|
+
const series = chartSeries(data, label, translate);
|
|
184
|
+
const { startMs, endMs } = windowOf(data, series);
|
|
185
|
+
const markers = resolveMarkers(data.markers, translate, formats, panel.markerKinds);
|
|
186
|
+
const formatValue = formats.formatPanelValue(panel.valueFormat);
|
|
187
|
+
if (panel.chart === "segment-bars") {
|
|
188
|
+
return (_jsx(SegmentBarChart, { rows: (data.rows ?? []).map((row) => ({
|
|
189
|
+
...row,
|
|
190
|
+
label: translate(row.label),
|
|
191
|
+
segments: row.segments.map((seg) => ({ ...seg, label: translate(seg.label) })),
|
|
192
|
+
})), tones: tones, colors: colors, ariaLabel: label, formatValue: formatValue, testId: testId }));
|
|
193
|
+
}
|
|
194
|
+
if (panel.chart === "stacked-bars") {
|
|
195
|
+
const times = series.flatMap((s) => s.points.map((p) => p.atMs));
|
|
196
|
+
const spacing = Math.min(...[...new Set(times)]
|
|
197
|
+
.sort((a, b) => a - b)
|
|
198
|
+
.flatMap((atMs, i, all) => {
|
|
199
|
+
const prev = all[i - 1];
|
|
200
|
+
return prev === undefined ? [] : [atMs - prev];
|
|
201
|
+
}), DAY_MS);
|
|
202
|
+
return (_jsx(StackedBarChart, { series: series, windowEndMs: endMs, tones: tones, colors: colors, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: spacing < DAY_MS - HOUR_MS ? formats.formatHour : formats.formatDay, formatValue: formatValue, formatAxisValue: formats.formatPanelAxisValue(panel.valueFormat), testId: testId }));
|
|
203
|
+
}
|
|
204
|
+
if (panel.chart === "stacked-area") {
|
|
205
|
+
const lines = (data.lines ?? []).map((line) => ({
|
|
206
|
+
...line,
|
|
207
|
+
label: translate(line.label),
|
|
208
|
+
}));
|
|
209
|
+
const todayMs = data.todayMs ?? undefined;
|
|
210
|
+
const formatDateByFormat = (atMs, format) => format === "month" ? formats.formatMonth(atMs) : formats.formatDay(atMs);
|
|
211
|
+
return (_jsx(StackedAreaChart, { series: series, lines: lines, windowStartMs: startMs, windowEndMs: endMs, ...(todayMs !== undefined && { todayMs }), tones: tones, colors: colors, markers: markers, ...(ranges !== undefined && { ranges }), brush: panel.brush === true, windowSelection: windowSelection, onWindowSelectionChange: setWindowSelection, brushLabels: {
|
|
212
|
+
start: t("kumiko.dashboard.brush-start"),
|
|
213
|
+
end: t("kumiko.dashboard.brush-end"),
|
|
214
|
+
}, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: formatDateByFormat, formatValue: formatValue, formatAxisValue: formats.formatPanelAxisValue(panel.valueFormat), markerLabels: panel.markerLabels === true, formatMarkerTime: formatDateByFormat, timeZone: formats.timeZone, ...(panel.dateFormat !== undefined && { dateFormat: panel.dateFormat }), ...(panel.markerLegend !== undefined && { markerLegend: panel.markerLegend }), scrollable: panel.scrollable === true, showLegendTotals: panel.legendTotals !== false, emptyContent: _jsx(EmptyState, { title: translate(panel.emptyLabel ?? "kumiko.list.no-entries") }), testId: testId }));
|
|
215
|
+
}
|
|
216
|
+
return (_jsx(TimeseriesChart, { points: data.points ?? [], windowStartMs: startMs, windowEndMs: endMs, markers: markers, formatMarkerTime: formats.formatDay, ...((panel.valueFormat !== undefined || panel.unit !== undefined) && {
|
|
217
|
+
yAxis: {
|
|
218
|
+
ticks: 3,
|
|
219
|
+
format: formats.formatPanelAxisValue(panel.valueFormat),
|
|
220
|
+
...(panel.unit !== undefined && { unit: translate(panel.unit) }),
|
|
221
|
+
},
|
|
222
|
+
}), ariaLabel: label, testId: testId }));
|
|
223
|
+
} }));
|
|
224
|
+
}
|
|
225
|
+
export function ListPanelBody({ panel, label, screenParams, translate, }) {
|
|
226
|
+
const { formatPercent, formatNumber, formatDateTime } = useDashboardFormats();
|
|
227
|
+
const columns = panel.columns.map((c) => {
|
|
228
|
+
const normalized = normalizeListColumn(c);
|
|
229
|
+
return {
|
|
230
|
+
field: normalized.field,
|
|
231
|
+
label: translate(normalized.label ?? normalized.field),
|
|
232
|
+
...(normalized.display !== undefined && { display: normalized.display }),
|
|
233
|
+
...(normalized.badgeToneField !== undefined && {
|
|
234
|
+
badgeToneField: normalized.badgeToneField,
|
|
235
|
+
}),
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => (_jsx(DashboardListTable, { columns: columns, rows: data.rows ?? [], formatPercent: formatPercent, formatNumber: formatNumber, formatDateTime: formatDateTime, translate: translate })) }));
|
|
239
|
+
}
|
|
240
|
+
export function FeedPanelBody({ panel, label, screenParams, translate, }) {
|
|
241
|
+
return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data, formats) => {
|
|
242
|
+
const rows = (data.rows ?? []).map((row, i) => {
|
|
243
|
+
const trailing = resolveDashboardText(row.trailing, translate, formats);
|
|
244
|
+
return {
|
|
245
|
+
id: String(i),
|
|
246
|
+
primary: resolveDashboardText(row.primary, translate, formats) ?? EMPTY_VALUE_PLACEHOLDER,
|
|
247
|
+
...(trailing !== undefined && { trailing }),
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
return _jsx(FeedList, { rows: rows });
|
|
251
|
+
} }));
|
|
252
|
+
}
|
|
253
|
+
function isFiniteNumber(value) {
|
|
254
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
255
|
+
}
|
|
256
|
+
function isProgressTone(value) {
|
|
257
|
+
return value === "default" || value === "danger";
|
|
258
|
+
}
|
|
259
|
+
function progressUsageOf(row) {
|
|
260
|
+
const { used, limit } = row;
|
|
261
|
+
if (!isFiniteNumber(used))
|
|
262
|
+
return undefined;
|
|
263
|
+
if (limit === null)
|
|
264
|
+
return { used, limit: null };
|
|
265
|
+
return isFiniteNumber(limit) ? { used, limit } : undefined;
|
|
266
|
+
}
|
|
267
|
+
function toProgressListRow(row, index, translate, formats) {
|
|
268
|
+
const sub = resolveDashboardText(row["sub"], translate, formats);
|
|
269
|
+
const usage = progressUsageOf(row);
|
|
270
|
+
const explicitTone = isProgressTone(row["tone"]) ? row["tone"] : undefined;
|
|
271
|
+
const common = {
|
|
272
|
+
id: String(index),
|
|
273
|
+
label: resolveDashboardText(row["label"], translate, formats) ?? EMPTY_VALUE_PLACEHOLDER,
|
|
274
|
+
...(sub !== undefined && { sub }),
|
|
275
|
+
};
|
|
276
|
+
if (usage === undefined) {
|
|
277
|
+
return {
|
|
278
|
+
...common,
|
|
279
|
+
value: resolveDashboardText(row["value"], translate, formats) ?? EMPTY_VALUE_PLACEHOLDER,
|
|
280
|
+
fraction: isFiniteNumber(row["fraction"]) ? row["fraction"] : 0,
|
|
281
|
+
...(explicitTone !== undefined && { tone: explicitTone }),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const { used, limit } = usage;
|
|
285
|
+
const usedText = formats.formatNumber(used);
|
|
286
|
+
const limitText = limit === null ? translate("kumiko.dashboard.progress.unlimited") : formats.formatNumber(limit);
|
|
287
|
+
const exhausted = limit !== null && used >= limit;
|
|
288
|
+
const tone = explicitTone ?? (exhausted ? "danger" : undefined);
|
|
289
|
+
let fraction = null;
|
|
290
|
+
if (limit !== null)
|
|
291
|
+
fraction = limit === 0 ? 1 : used / limit;
|
|
292
|
+
return {
|
|
293
|
+
...common,
|
|
294
|
+
value: `${usedText} / ${limitText}`,
|
|
295
|
+
fraction,
|
|
296
|
+
...(tone !== undefined && { tone }),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
export function ProgressListPanelBody({ panel, label, screenParams, translate, }) {
|
|
300
|
+
return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data, formats) => {
|
|
301
|
+
const rows = (data.rows ?? []).map((row, i) => toProgressListRow(row, i, translate, formats));
|
|
302
|
+
return _jsx(ProgressList, { rows: rows });
|
|
303
|
+
} }));
|
|
304
|
+
}
|
|
305
|
+
function TextBlockView({ block, translate, }) {
|
|
306
|
+
switch (block.kind) {
|
|
307
|
+
case "heading":
|
|
308
|
+
return (_jsx("h3", { "data-block": "heading", className: "text-sm font-semibold text-foreground", children: translate(block.text) }));
|
|
309
|
+
case "paragraph":
|
|
310
|
+
return (_jsx("p", { "data-block": "paragraph", className: block.emphasis === "strong"
|
|
311
|
+
? "text-sm font-medium text-foreground"
|
|
312
|
+
: "text-sm text-muted-foreground", children: translate(block.text) }));
|
|
313
|
+
case "list":
|
|
314
|
+
return (_jsx("ul", { "data-block": "list", className: "list-disc space-y-1 pl-5 text-sm text-muted-foreground", children: block.items.map((item) => (_jsx("li", { children: translate(item) }, item))) }));
|
|
315
|
+
case "code":
|
|
316
|
+
return (_jsx("pre", { "data-block": "code", className: "overflow-x-auto rounded-md bg-muted p-3 text-xs", children: _jsx("code", { children: block.code }) }));
|
|
317
|
+
case "hint":
|
|
318
|
+
return (_jsx("p", { "data-block": "hint", className: "text-xs text-muted-foreground", children: translate(block.text) }));
|
|
319
|
+
case "link":
|
|
320
|
+
return _jsx(TextBlockLink, { block: block, translate: translate });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function TextBlockLink({ block, translate, }) {
|
|
324
|
+
const { Link } = usePrimitives();
|
|
325
|
+
const nav = useNav();
|
|
326
|
+
const userRoles = useUserRoles();
|
|
327
|
+
if (block.roles !== undefined && !block.roles.some((role) => userRoles?.includes(role))) {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
const target = { screenId: lastSegment(block.screen) };
|
|
331
|
+
return (_jsx(Link, { href: nav.hrefFor(target), onPress: () => nav.navigate(target), testId: `dashboard-text-link-${target.screenId}`, children: translate(block.text) }));
|
|
332
|
+
}
|
|
333
|
+
function textBlockKey(block) {
|
|
334
|
+
switch (block.kind) {
|
|
335
|
+
case "list":
|
|
336
|
+
return `list:${block.items.join("|")}`;
|
|
337
|
+
case "code":
|
|
338
|
+
return `code:${block.code}`;
|
|
339
|
+
default:
|
|
340
|
+
return `${block.kind}:${block.text}`;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
export function TextPanelBody({ panel, label, translate, }) {
|
|
344
|
+
const blocks = (_jsx("div", { className: "flex flex-col gap-3", children: panel.blocks.map((block) => (_jsx(TextBlockView, { block: block, translate: translate }, textBlockKey(block)))) }));
|
|
345
|
+
const testId = `dashboard-panel-${panel.id}`;
|
|
346
|
+
if (label !== undefined) {
|
|
347
|
+
return (_jsx(PanelShell, { testId: testId, title: label, children: blocks }));
|
|
348
|
+
}
|
|
349
|
+
return (_jsx("section", { "data-testid": testId, className: "h-full rounded-lg border border-border bg-card p-4 text-card-foreground", children: blocks }));
|
|
350
|
+
}
|
|
351
|
+
// Resolves panel.component through the same extensionSectionComponents
|
|
352
|
+
// registry as entityEdit extension sections and list header slots; it
|
|
353
|
+
// stays at its array position instead of moving into a separate slot (see
|
|
354
|
+
// the file header comment on the registry). Renders nothing + a dev warning
|
|
355
|
+
// for an unregistered name, analogous to ListHeaderSlotMount in
|
|
356
|
+
// render-list.tsx.
|
|
357
|
+
export function CustomPanelBody({ panel, screenId, filterParams, }) {
|
|
358
|
+
const name = extensionSectionName(panel.component);
|
|
359
|
+
const Component = useExtensionSectionComponent(name);
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (name !== undefined && Component === undefined) {
|
|
362
|
+
rendererWarn(`[kumiko] Dashboard custom-panel "${panel.id}" on screen "${screenId}" references component ` +
|
|
363
|
+
`"${name}", which is not registered in clientFeatures.extensionSectionComponents — the panel renders nothing.`);
|
|
364
|
+
}
|
|
365
|
+
}, [name, Component, panel.id, screenId]);
|
|
366
|
+
if (Component === undefined)
|
|
367
|
+
return null;
|
|
368
|
+
// Dashboard panels have no entity; entityName carries the screen.id so
|
|
369
|
+
// ExtensionSectionProps need not be loosened for this one mount site (that
|
|
370
|
+
// would break every existing registered section that expects entityName to
|
|
371
|
+
// be a guaranteed string).
|
|
372
|
+
return (_jsx(Component, { entityName: screenId, entityId: null, screenId: screenId, filterParams: filterParams }));
|
|
373
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { DashboardCurrencyFormat, DashboardPanelEmptyState, DashboardPanelQueryOptions } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
import { type Translate } from "../../headless/index.ts";
|
|
4
|
+
import { dispatcherErrorText, type UseQueryResult } from "../../index.ts";
|
|
5
|
+
export declare const WIDE_PANEL = "sm:col-span-2 lg:col-span-4";
|
|
6
|
+
export declare const HALF_PANEL = "sm:col-span-2 lg:col-span-2";
|
|
7
|
+
export declare const HOUR_MS = 3600000;
|
|
8
|
+
export declare const DAY_MS: number;
|
|
9
|
+
export type ScreenParams = {
|
|
10
|
+
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
11
|
+
readonly rangeParams: Readonly<Record<string, unknown>>;
|
|
12
|
+
/** `{ [payloadKey]: recordId }` of a record-bound dashboard, else empty. */
|
|
13
|
+
readonly recordParams: Readonly<Record<string, unknown>>;
|
|
14
|
+
};
|
|
15
|
+
export declare function panelPayload(panel: DashboardPanelQueryOptions, screenParams: ScreenParams): Readonly<Record<string, unknown>>;
|
|
16
|
+
type RefetchQuery = () => Promise<void>;
|
|
17
|
+
type DashboardQueryRuntime = {
|
|
18
|
+
/** Polling interval for every panel and gate query; undefined = off. */
|
|
19
|
+
readonly refreshIntervalMs: number | undefined;
|
|
20
|
+
/** Adds a query's refetch to the set `refetchAll` walks; returns its removal. */
|
|
21
|
+
readonly register: (refetch: RefetchQuery) => () => void;
|
|
22
|
+
};
|
|
23
|
+
export declare const DashboardQueryContext: import("react").Context<DashboardQueryRuntime>;
|
|
24
|
+
export declare function useRefetchRegistry(): {
|
|
25
|
+
readonly register: DashboardQueryRuntime["register"];
|
|
26
|
+
readonly refetchRegistered: () => Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
/** Live query of a panel or gate that takes part in the dashboard's refresh:
|
|
29
|
+
* polls at the screen's interval and is refetched by `refetchAll` after an
|
|
30
|
+
* action. A disabled query registers nothing. */
|
|
31
|
+
export declare function useDashboardQuery<TData>(type: string, payload: Readonly<Record<string, unknown>>, options?: {
|
|
32
|
+
readonly enabled?: boolean;
|
|
33
|
+
}): UseQueryResult<TData>;
|
|
34
|
+
export type DashboardFormats = {
|
|
35
|
+
readonly timeZone: string;
|
|
36
|
+
readonly formatNumber: (value: number) => string;
|
|
37
|
+
readonly formatPercent: (fraction: number) => string;
|
|
38
|
+
readonly formatDay: (atMs: number) => string;
|
|
39
|
+
readonly formatMonth: (atMs: number) => string;
|
|
40
|
+
readonly formatHour: (atMs: number) => string;
|
|
41
|
+
readonly formatDateTime: (atMs: number) => string;
|
|
42
|
+
readonly formatMedium: (atMs: number) => string;
|
|
43
|
+
readonly formatMinor: (amountMinor: number, currency: string, fractionDigits?: number) => string;
|
|
44
|
+
/** "5 minutes ago"; the component that called `useDashboardFormats` redraws on a
|
|
45
|
+
* timer while at least one of its renders used this. */
|
|
46
|
+
readonly formatRelative: (atMs: number) => string;
|
|
47
|
+
/** Number formatter for a panel; currency when `valueFormat` is set. */
|
|
48
|
+
readonly formatPanelValue: (valueFormat: DashboardCurrencyFormat | undefined) => (value: number) => string;
|
|
49
|
+
/** Compact variant of `formatPanelValue` for chart axes ("1,5 Mio. €"). */
|
|
50
|
+
readonly formatPanelAxisValue: (valueFormat: DashboardCurrencyFormat | undefined) => (value: number) => string;
|
|
51
|
+
};
|
|
52
|
+
export declare function useDashboardFormats(): DashboardFormats;
|
|
53
|
+
export declare function PanelShell({ testId, title, subtitle, action, children, }: {
|
|
54
|
+
readonly testId: string;
|
|
55
|
+
readonly title: string;
|
|
56
|
+
readonly subtitle?: string;
|
|
57
|
+
readonly action?: ReactNode;
|
|
58
|
+
readonly children: ReactNode;
|
|
59
|
+
}): ReactNode;
|
|
60
|
+
export declare function PanelError({ label, error, onRetry, testId, }: {
|
|
61
|
+
readonly label: string;
|
|
62
|
+
readonly error: Parameters<typeof dispatcherErrorText>[0];
|
|
63
|
+
readonly onRetry: () => void;
|
|
64
|
+
readonly testId?: string;
|
|
65
|
+
}): ReactNode;
|
|
66
|
+
type SkeletonShape = "bars" | "segments" | "rows";
|
|
67
|
+
type QueryPanelProps<TData> = {
|
|
68
|
+
readonly panel: DashboardPanelQueryOptions & DashboardPanelEmptyState & {
|
|
69
|
+
readonly id: string;
|
|
70
|
+
readonly query: string;
|
|
71
|
+
};
|
|
72
|
+
readonly label: string;
|
|
73
|
+
readonly subtitle?: string;
|
|
74
|
+
readonly action?: ReactNode;
|
|
75
|
+
readonly screenParams: ScreenParams;
|
|
76
|
+
readonly translate: Translate;
|
|
77
|
+
readonly skeleton: SkeletonShape;
|
|
78
|
+
readonly isEmpty: (data: TData) => boolean;
|
|
79
|
+
/** Receives the formats of the panel itself, so a relative time inside the body
|
|
80
|
+
* redraws this panel and nothing else. */
|
|
81
|
+
readonly children: (data: TData, formats: DashboardFormats) => ReactNode;
|
|
82
|
+
};
|
|
83
|
+
export declare function QueryPanel<TData>({ panel, label, subtitle, action, screenParams, translate, skeleton, isEmpty, children, }: QueryPanelProps<TData>): ReactNode;
|
|
84
|
+
export declare function useLoadedAt(screenParams: ScreenParams): {
|
|
85
|
+
readonly loadedAtMs: number;
|
|
86
|
+
readonly reportLoaded: (atMs: number) => void;
|
|
87
|
+
};
|
|
88
|
+
export declare const PanelLoadedContext: import("react").Context<(atMs: number) => void>;
|
|
89
|
+
export declare function useReportPanelLoaded(data: unknown): void;
|
|
90
|
+
export {};
|