@asteby/metacore-runtime-react 18.17.0 → 18.17.2
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/CHANGELOG.md +12 -0
- package/dist/dashboard-grid.d.ts.map +1 -1
- package/dist/dashboard-grid.js +27 -15
- package/dist/widgets/renderers.d.ts.map +1 -1
- package/dist/widgets/renderers.js +12 -5
- package/dist/widgets/widget-card.js +1 -1
- package/dist/widgets/widget-renderer.d.ts +11 -1
- package/dist/widgets/widget-renderer.d.ts.map +1 -1
- package/dist/widgets/widget-renderer.js +27 -5
- package/package.json +3 -3
- package/src/dashboard-grid.tsx +51 -45
- package/src/widgets/renderers.tsx +48 -29
- package/src/widgets/widget-card.tsx +1 -1
- package/src/widgets/widget-renderer.tsx +30 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 18.17.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 714cc34: Dashboard: masonry (balanced CSS columns) layout — no blank cells; charts fixed-height, compact stat cards
|
|
8
|
+
|
|
9
|
+
## 18.17.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 211484b: Dashboard grid: compact KPI stat cards, dense fixed-row packing (charts span 2 rows), charts fill card height
|
|
14
|
+
|
|
3
15
|
## 18.17.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dashboard-grid.d.ts","sourceRoot":"","sources":["../src/dashboard-grid.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAK9B,OAAO,KAAK,EACR,kBAAkB,EAElB,oBAAoB,EACpB,mBAAmB,EACtB,MAAM,mBAAmB,CAAA;AAW1B,uEAAuE;AACvE,wBAAgB,eAAe,CAC3B,MAAM,CAAC,EAAE,oBAAoB,EAAE,EAC/B,OAAO,CAAC,EAAE,mBAAmB,EAAE,GAChC,oBAAoB,EAAE,CAgBxB;AASD,wBAAgB,aAAa,CAAC,EAC1B,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,MAAM,EACN,QAAQ,EACR,SAAS,EACT,OAAO,GACV,EAAE,kBAAkB,
|
|
1
|
+
{"version":3,"file":"dashboard-grid.d.ts","sourceRoot":"","sources":["../src/dashboard-grid.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAK9B,OAAO,KAAK,EACR,kBAAkB,EAElB,oBAAoB,EACpB,mBAAmB,EACtB,MAAM,mBAAmB,CAAA;AAW1B,uEAAuE;AACvE,wBAAgB,eAAe,CAC3B,MAAM,CAAC,EAAE,oBAAoB,EAAE,EAC/B,OAAO,CAAC,EAAE,mBAAmB,EAAE,GAChC,oBAAoB,EAAE,CAgBxB;AASD,wBAAgB,aAAa,CAAC,EAC1B,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,MAAM,EACN,QAAQ,EACR,SAAS,EACT,OAAO,GACV,EAAE,kBAAkB,qBAkJpB"}
|
package/dist/dashboard-grid.js
CHANGED
|
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
9
9
|
import { cn } from '@asteby/metacore-ui/lib';
|
|
10
10
|
import { useCan, usePermissionsActive } from './permissions-context';
|
|
11
11
|
import { DynamicIcon } from './dynamic-icon';
|
|
12
|
-
import { WidgetRenderer, WidgetSkeleton,
|
|
12
|
+
import { WidgetRenderer, WidgetSkeleton, isTallWidget } from './widgets/widget-renderer';
|
|
13
13
|
const DEFAULT_STRINGS = {
|
|
14
14
|
emptyTitle: 'Your dashboard is empty',
|
|
15
15
|
emptyDescription: 'Install an addon with dashboard widgets to start seeing metrics here.',
|
|
@@ -110,18 +110,30 @@ export function DashboardGrid({ groups, widgets, loadData, isAdmin, locale, curr
|
|
|
110
110
|
if (visibleGroups.length === 0) {
|
|
111
111
|
return (_jsxs("div", { "data-testid": "dashboard-empty", className: cn('flex min-h-[40vh] flex-col items-center justify-center rounded-xl border border-dashed border-border/60 p-10 text-center', className), children: [_jsx("div", { className: "mb-4 flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground", children: _jsx(DynamicIcon, { name: "LayoutDashboard", className: "size-7" }) }), _jsx("h3", { className: "text-base font-semibold text-foreground", children: tr(undefined, s.emptyTitle) || s.emptyTitle }), _jsx("p", { className: "mt-1 max-w-sm text-sm text-muted-foreground", children: s.emptyDescription })] }));
|
|
112
112
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
113
|
+
// ONE unified dense grid across every group. Per-group sections used to
|
|
114
|
+
// break the layout into rows, so a lone-widget group (e.g. a single KPI)
|
|
115
|
+
// left the rest of its row blank. Flattening + `grid-flow-row-dense`
|
|
116
|
+
// backfills those holes; ordering compact KPIs before charts makes the top
|
|
117
|
+
// read as a metric band and the charts mosaic below it. No blank space.
|
|
118
|
+
const ordered = React.useMemo(() => {
|
|
119
|
+
const flat = visibleGroups.flatMap((g) => g.widgets);
|
|
120
|
+
return flat
|
|
121
|
+
.map((w, i) => ({ w, i }))
|
|
122
|
+
.sort((a, b) => (isTallWidget(a.w) ? 1 : 0) - (isTallWidget(b.w) ? 1 : 0) ||
|
|
123
|
+
a.i - b.i)
|
|
124
|
+
.map((x) => x.w);
|
|
125
|
+
}, [visibleGroups]);
|
|
126
|
+
return (_jsx("div", { "data-testid": "dashboard-grid", className: cn(
|
|
127
|
+
// Masonry: balanced CSS columns. Cards take their natural height
|
|
128
|
+
// (compact stats, taller charts) and flow to equalize column
|
|
129
|
+
// height, so there are NO blank cells — the gaps a fixed grid
|
|
130
|
+
// leaves around mixed 1×1 / 2×2 tiles simply don't exist here.
|
|
131
|
+
'columns-1 gap-4 md:columns-2 xl:columns-3', className), children: ordered.map((spec) => {
|
|
132
|
+
const rspec = {
|
|
133
|
+
...spec,
|
|
134
|
+
title: tr(spec.title, spec.title),
|
|
135
|
+
subtitle: tr(spec.subtitle, spec.subtitle),
|
|
136
|
+
};
|
|
137
|
+
return (_jsx("div", { className: "mb-4 break-inside-avoid", children: loading ? (_jsx(WidgetSkeleton, { spec: rspec })) : (_jsx(WidgetRenderer, { spec: rspec, data: data?.[spec.key], locale: locale, currency: currency, emptyText: spec.empty ? tr(spec.empty, spec.empty) : s.widgetEmpty, errorText: s.widgetError })) }, spec.key));
|
|
138
|
+
}) }));
|
|
127
139
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"renderers.d.ts","sourceRoot":"","sources":["../../src/widgets/renderers.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAkB9B,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAazE,MAAM,WAAW,iBAAiB;IAC9B,IAAI,EAAE,mBAAmB,CAAA;IACzB,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAA;CACpB;
|
|
1
|
+
{"version":3,"file":"renderers.d.ts","sourceRoot":"","sources":["../../src/widgets/renderers.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAkB9B,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAazE,MAAM,WAAW,iBAAiB;IAC9B,IAAI,EAAE,mBAAmB,CAAA;IACzB,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAA;CACpB;AA8CD,wBAAgB,UAAU,CAAC,CAAC,EAAE,iBAAiB,qBA+B9C;AAGD,wBAAgB,SAAS,CAAC,CAAC,EAAE,iBAAiB,qBAsC7C;AAgDD,wBAAgB,UAAU,CAAC,CAAC,EAAE,iBAAiB,qBAE9C;AACD,wBAAgB,UAAU,CAAC,CAAC,EAAE,iBAAiB,qBAE9C;AAuDD,wBAAgB,SAAS,CAAC,CAAC,EAAE,iBAAiB,qBAE7C;AACD,wBAAgB,WAAW,CAAC,CAAC,EAAE,iBAAiB,qBAE/C;AAGD,wBAAgB,UAAU,CAAC,CAAC,EAAE,iBAAiB,qBAqC9C;AAKD,wBAAgB,cAAc,CAAC,CAAC,EAAE,iBAAiB,qBAkClD"}
|
|
@@ -5,7 +5,14 @@ import { WidgetCard, DeltaChip, WidgetEmpty } from './widget-card';
|
|
|
5
5
|
import { accentClasses, paletteColor, formatWidgetValue, formatAxisTick, formatDelta, CHART_GRID, CHART_MUTED, } from './widget-format';
|
|
6
6
|
const fmtCtx = (spec, locale, currency) => ({ format: spec.format, locale, currency });
|
|
7
7
|
const hasSeries = (d) => Array.isArray(d?.series) && d.series.length > 0;
|
|
8
|
-
|
|
8
|
+
// Fixed-height chart body. In the masonry (CSS columns) layout cards take their
|
|
9
|
+
// natural height, so charts need a definite height for ResponsiveContainer to
|
|
10
|
+
// resolve. This height + the card chrome gives every chart card a consistent,
|
|
11
|
+
// taller footprint that balances cleanly against compact stat cards.
|
|
12
|
+
const CHART_H = 'h-[190px]';
|
|
13
|
+
function ChartArea({ children }) {
|
|
14
|
+
return (_jsx("div", { className: cn(CHART_H, 'w-full'), children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: children }) }));
|
|
15
|
+
}
|
|
9
16
|
// Compact recharts tooltip styled with theme tokens.
|
|
10
17
|
function ChartTooltip({ ctx }) {
|
|
11
18
|
return (_jsx(Tooltip, { cursor: { fill: 'var(--muted, rgba(148,163,184,0.12))', opacity: 0.4 }, contentStyle: {
|
|
@@ -23,20 +30,20 @@ export function StatWidget(p) {
|
|
|
23
30
|
const value = p.data?.value;
|
|
24
31
|
const delta = p.data?.delta;
|
|
25
32
|
const hasValue = typeof value === 'number' && !Number.isNaN(value);
|
|
26
|
-
return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, headerExtra: typeof delta === 'number' ? (_jsx(DeltaChip, { delta: delta, text: formatDelta(delta, p.locale) })) : undefined, children: hasValue ? (_jsx("div", { className: "text-
|
|
33
|
+
return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, headerExtra: typeof delta === 'number' ? (_jsx(DeltaChip, { delta: delta, text: formatDelta(delta, p.locale) })) : undefined, children: hasValue ? (_jsx("div", { className: "flex min-h-[2.5rem] items-end", children: _jsx("div", { className: "text-[2rem] font-semibold leading-none tabular-nums tracking-tight text-foreground", children: formatWidgetValue(value, ctx) }) })) : (_jsx("div", { className: "flex min-h-[2.5rem] items-center text-sm text-muted-foreground", children: p.emptyText })) }));
|
|
27
34
|
}
|
|
28
35
|
// --- bar ------------------------------------------------------------------
|
|
29
36
|
export function BarWidget(p) {
|
|
30
37
|
const ctx = fmtCtx(p.spec, p.locale, p.currency);
|
|
31
38
|
const a = accentClasses(p.spec.accent);
|
|
32
|
-
return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, children: hasSeries(p.data) ? (_jsx(
|
|
39
|
+
return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, children: hasSeries(p.data) ? (_jsx(ChartArea, { children: _jsxs(BarChart, { data: p.data.series, margin: { top: 4, right: 4, left: -16, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false, stroke: CHART_GRID, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: "label", tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, interval: "preserveStartEnd" }), _jsx(YAxis, { tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, width: 44, tickFormatter: (v) => formatAxisTick(v, ctx) }), _jsx(ChartTooltip, { ctx: ctx }), _jsx(Bar, { dataKey: "value", fill: a.chartVar, radius: [4, 4, 0, 0], maxBarSize: 48 })] }) })) : (_jsx(WidgetEmpty, { message: p.emptyText })) }));
|
|
33
40
|
}
|
|
34
41
|
// --- line / area (shared) -------------------------------------------------
|
|
35
42
|
function TimeSeriesWidget(p) {
|
|
36
43
|
const ctx = fmtCtx(p.spec, p.locale, p.currency);
|
|
37
44
|
const a = accentClasses(p.spec.accent);
|
|
38
45
|
const gradId = `wg-grad-${p.spec.key}`;
|
|
39
|
-
return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, children: hasSeries(p.data) ? (_jsx(
|
|
46
|
+
return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, children: hasSeries(p.data) ? (_jsx(ChartArea, { children: p.variant === 'area' ? (_jsxs(AreaChart, { data: p.data.series, margin: { top: 4, right: 6, left: -16, bottom: 0 }, children: [_jsx("defs", { children: _jsxs("linearGradient", { id: gradId, x1: "0", y1: "0", x2: "0", y2: "1", children: [_jsx("stop", { offset: "0%", stopColor: a.chartVar, stopOpacity: 0.35 }), _jsx("stop", { offset: "100%", stopColor: a.chartVar, stopOpacity: 0.02 })] }) }), _jsx(CartesianGrid, { vertical: false, stroke: CHART_GRID, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: "label", tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, interval: "preserveStartEnd" }), _jsx(YAxis, { tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, width: 44, tickFormatter: (v) => formatAxisTick(v, ctx) }), _jsx(ChartTooltip, { ctx: ctx }), _jsx(Area, { type: "monotone", dataKey: "value", stroke: a.chartVar, strokeWidth: 2, fill: `url(#${gradId})` })] })) : (_jsxs(LineChart, { data: p.data.series, margin: { top: 4, right: 6, left: -16, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false, stroke: CHART_GRID, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: "label", tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, interval: "preserveStartEnd" }), _jsx(YAxis, { tick: { fontSize: 10, fill: CHART_MUTED }, tickLine: false, axisLine: false, width: 44, tickFormatter: (v) => formatAxisTick(v, ctx) }), _jsx(ChartTooltip, { ctx: ctx }), _jsx(Line, { type: "monotone", dataKey: "value", stroke: a.chartVar, strokeWidth: 2, dot: false, activeDot: { r: 4 } })] })) })) : (_jsx(WidgetEmpty, { message: p.emptyText })) }));
|
|
40
47
|
}
|
|
41
48
|
export function LineWidget(p) {
|
|
42
49
|
return _jsx(TimeSeriesWidget, { ...p, variant: "line" });
|
|
@@ -47,7 +54,7 @@ export function AreaWidget(p) {
|
|
|
47
54
|
// --- pie / donut (shared) -------------------------------------------------
|
|
48
55
|
function CircularWidget(p) {
|
|
49
56
|
const ctx = fmtCtx(p.spec, p.locale, p.currency);
|
|
50
|
-
return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, children: hasSeries(p.data) ? (_jsxs("div", { className:
|
|
57
|
+
return (_jsx(WidgetCard, { "data-testid": `widget-${p.spec.key}`, title: p.spec.title, subtitle: p.spec.subtitle, icon: p.spec.icon, accent: p.spec.accent, children: hasSeries(p.data) ? (_jsxs("div", { className: cn(CHART_H, 'flex items-center gap-3'), children: [_jsx("div", { className: "h-full min-h-0 w-[46%] shrink-0", children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: _jsxs(PieChart, { children: [_jsx(ChartTooltip, { ctx: ctx }), _jsx(Pie, { data: p.data.series, dataKey: "value", nameKey: "label", innerRadius: p.variant === 'donut' ? '55%' : 0, outerRadius: "92%", paddingAngle: p.variant === 'donut' ? 2 : 0, stroke: "var(--background, #fff)", strokeWidth: 2, children: p.data.series.map((_, i) => (_jsx(Cell, { fill: paletteColor(i) }, i))) })] }) }) }), _jsx("ul", { className: "flex min-w-0 flex-1 flex-col gap-1.5", children: p.data.series.slice(0, 6).map((pt, i) => (_jsxs("li", { className: "flex items-center gap-2 text-xs", children: [_jsx("span", { className: "size-2.5 shrink-0 rounded-sm", style: { background: paletteColor(i) } }), _jsx("span", { className: "truncate text-muted-foreground", children: pt.label }), _jsx("span", { className: "ml-auto shrink-0 font-medium tabular-nums text-foreground", children: formatWidgetValue(pt.value, ctx) })] }, pt.key))) })] })) : (_jsx(WidgetEmpty, { message: p.emptyText })) }));
|
|
51
58
|
}
|
|
52
59
|
export function PieWidget(p) {
|
|
53
60
|
return _jsx(CircularWidget, { ...p, variant: "pie" });
|
|
@@ -12,7 +12,7 @@ export function WidgetCard({ title, subtitle, icon, accent, headerExtra, childre
|
|
|
12
12
|
const showIcon = icon && isLucideIconName(icon);
|
|
13
13
|
return (_jsxs(Card, { ...rest, className: cn(
|
|
14
14
|
// base: subtle border, ring on hover, gentle lift, mount fade-in
|
|
15
|
-
'group/widget relative flex h-full flex-col overflow-hidden', 'border-border/60 transition-all duration-200', 'hover:border-border hover:ring-1 hover:ring-ring/30 hover:shadow-sm', 'motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-500', className), children: [_jsxs(CardHeader, { className: "flex flex-row items-start justify-between gap-3 space-y-0 pb-2", children: [_jsxs("div", { className: "flex min-w-0 items-start gap-3", children: [showIcon && (_jsx("span", { className: cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.chip), children: _jsx(DynamicIcon, { name: icon, className: "size-[18px]" }) })), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "truncate text-sm font-medium leading-tight text-foreground", children: title }), subtitle && (_jsx("div", { className: "mt-0.5 truncate text-xs text-muted-foreground", children: subtitle }))] })] }), headerExtra && _jsx("div", { className: "shrink-0", children: headerExtra })] }), _jsx(CardContent, { className: "flex flex-1 flex-col
|
|
15
|
+
'group/widget relative flex h-full flex-col overflow-hidden', 'border-border/60 transition-all duration-200', 'hover:border-border hover:ring-1 hover:ring-ring/30 hover:shadow-sm', 'motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-500', className), children: [_jsxs(CardHeader, { className: "flex flex-row items-start justify-between gap-3 space-y-0 pb-2", children: [_jsxs("div", { className: "flex min-w-0 items-start gap-3", children: [showIcon && (_jsx("span", { className: cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.chip), children: _jsx(DynamicIcon, { name: icon, className: "size-[18px]" }) })), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "truncate text-sm font-medium leading-tight text-foreground", children: title }), subtitle && (_jsx("div", { className: "mt-0.5 truncate text-xs text-muted-foreground", children: subtitle }))] })] }), headerExtra && _jsx("div", { className: "shrink-0", children: headerExtra })] }), _jsx(CardContent, { className: "flex min-h-0 flex-1 flex-col pt-1", children: children })] }));
|
|
16
16
|
}
|
|
17
17
|
/** Delta chip: green up / red down, neutral on zero. `text` is preformatted. */
|
|
18
18
|
export function DeltaChip({ delta, text }) {
|
|
@@ -2,8 +2,18 @@ import * as React from 'react';
|
|
|
2
2
|
import type { DashboardWidgetSpec, WidgetData, WidgetSize } from '../dashboard-types';
|
|
3
3
|
/** Maps a widget size to its column span in the 4-col grid. */
|
|
4
4
|
export declare const SIZE_SPAN: Record<WidgetSize, number>;
|
|
5
|
-
/** Tailwind col-span class per size (static → scanned in this package build).
|
|
5
|
+
/** Tailwind col-span class per size (static → scanned in this package build).
|
|
6
|
+
* Grid is 2 cols on mobile, 4 on lg: sm=quarter, md=half, lg=¾, full=row. */
|
|
6
7
|
export declare const SIZE_CLASS: Record<WidgetSize, string>;
|
|
8
|
+
/** Default footprint when a spec omits `size`: charts go half-width, stats a
|
|
9
|
+
* quarter — so a tablero of mixed widgets packs densely without manual sizing. */
|
|
10
|
+
export declare function defaultSize(spec: DashboardWidgetSpec): WidgetSize;
|
|
11
|
+
/** Combined col + row span for a widget's grid cell. Row-span drives the
|
|
12
|
+
* height contrast (chart=2, stat=1) that makes the layout feel designed. */
|
|
13
|
+
export declare function spanClass(spec: DashboardWidgetSpec): string;
|
|
14
|
+
/** True for chart/list widgets that occupy 2 rows. Used to order compact KPIs
|
|
15
|
+
* before charts in the unified grid so the top reads as a metric band. */
|
|
16
|
+
export declare function isTallWidget(spec: DashboardWidgetSpec): boolean;
|
|
7
17
|
export interface WidgetRendererProps {
|
|
8
18
|
spec: DashboardWidgetSpec;
|
|
9
19
|
data?: WidgetData;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"widget-renderer.d.ts","sourceRoot":"","sources":["../../src/widgets/widget-renderer.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAcrF,+DAA+D;AAC/D,eAAO,MAAM,SAAS,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAKhD,CAAA;AAED
|
|
1
|
+
{"version":3,"file":"widget-renderer.d.ts","sourceRoot":"","sources":["../../src/widgets/widget-renderer.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAcrF,+DAA+D;AAC/D,eAAO,MAAM,SAAS,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAKhD,CAAA;AAED;6EAC6E;AAC7E,eAAO,MAAM,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAKjD,CAAA;AAMD;kFACkF;AAClF,wBAAgB,WAAW,CAAC,IAAI,EAAE,mBAAmB,GAAG,UAAU,CAGjE;AAED;4EAC4E;AAC5E,wBAAgB,SAAS,CAAC,IAAI,EAAE,mBAAmB,GAAG,MAAM,CAG3D;AAED;0EAC0E;AAC1E,wBAAgB,YAAY,CAAC,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAE/D;AAiDD,MAAM,WAAW,mBAAmB;IAChC,IAAI,EAAE,mBAAmB,CAAA;IACzB,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAA;IACjB,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAA;CACpB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,EAC3B,IAAI,EACJ,IAAI,EACJ,MAAM,EACN,QAAQ,EACR,SAAS,EACT,SAAS,GACZ,EAAE,mBAAmB,qBAkDrB;AAED,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,EAAE,IAAI,EAAE,EAAE;IAAE,IAAI,EAAE,mBAAmB,CAAA;CAAE,qBAqBrE"}
|
|
@@ -13,13 +13,35 @@ export const SIZE_SPAN = {
|
|
|
13
13
|
lg: 3,
|
|
14
14
|
full: 4,
|
|
15
15
|
};
|
|
16
|
-
/** Tailwind col-span class per size (static → scanned in this package build).
|
|
16
|
+
/** Tailwind col-span class per size (static → scanned in this package build).
|
|
17
|
+
* Grid is 2 cols on mobile, 4 on lg: sm=quarter, md=half, lg=¾, full=row. */
|
|
17
18
|
export const SIZE_CLASS = {
|
|
18
|
-
sm: '
|
|
19
|
-
md: '
|
|
20
|
-
lg: '
|
|
21
|
-
full: '
|
|
19
|
+
sm: 'col-span-1 lg:col-span-1',
|
|
20
|
+
md: 'col-span-2 lg:col-span-2',
|
|
21
|
+
lg: 'col-span-2 lg:col-span-3',
|
|
22
|
+
full: 'col-span-2 lg:col-span-4',
|
|
22
23
|
};
|
|
24
|
+
/** Kinds that render a chart/list and want extra vertical room (2 grid rows).
|
|
25
|
+
* Stat/progress stay a single compact row so KPIs read like KPIs, not slabs. */
|
|
26
|
+
const TALL_KINDS = new Set(['bar', 'line', 'area', 'pie', 'donut', 'list', 'custom']);
|
|
27
|
+
/** Default footprint when a spec omits `size`: charts go half-width, stats a
|
|
28
|
+
* quarter — so a tablero of mixed widgets packs densely without manual sizing. */
|
|
29
|
+
export function defaultSize(spec) {
|
|
30
|
+
if (spec.size)
|
|
31
|
+
return spec.size;
|
|
32
|
+
return TALL_KINDS.has(spec.kind) ? 'md' : 'sm';
|
|
33
|
+
}
|
|
34
|
+
/** Combined col + row span for a widget's grid cell. Row-span drives the
|
|
35
|
+
* height contrast (chart=2, stat=1) that makes the layout feel designed. */
|
|
36
|
+
export function spanClass(spec) {
|
|
37
|
+
const col = SIZE_CLASS[defaultSize(spec)];
|
|
38
|
+
return `${col} ${TALL_KINDS.has(spec.kind) ? 'row-span-2' : 'row-span-1'}`;
|
|
39
|
+
}
|
|
40
|
+
/** True for chart/list widgets that occupy 2 rows. Used to order compact KPIs
|
|
41
|
+
* before charts in the unified grid so the top reads as a metric band. */
|
|
42
|
+
export function isTallWidget(spec) {
|
|
43
|
+
return TALL_KINDS.has(spec.kind);
|
|
44
|
+
}
|
|
23
45
|
const RENDERERS = {
|
|
24
46
|
stat: StatWidget,
|
|
25
47
|
bar: BarWidget,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "18.17.
|
|
3
|
+
"version": "18.17.2",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^6.0.0",
|
|
65
65
|
"vitest": "^4.0.0",
|
|
66
66
|
"zustand": "^5.0.0",
|
|
67
|
-
"@asteby/metacore-
|
|
68
|
-
"@asteby/metacore-
|
|
67
|
+
"@asteby/metacore-ui": "2.5.2",
|
|
68
|
+
"@asteby/metacore-sdk": "3.2.0"
|
|
69
69
|
},
|
|
70
70
|
"scripts": {
|
|
71
71
|
"build": "tsc -p tsconfig.json",
|
package/src/dashboard-grid.tsx
CHANGED
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
DashboardWidgetGroup,
|
|
16
16
|
DashboardWidgetSpec,
|
|
17
17
|
} from './dashboard-types'
|
|
18
|
-
import { WidgetRenderer, WidgetSkeleton,
|
|
18
|
+
import { WidgetRenderer, WidgetSkeleton, isTallWidget } from './widgets/widget-renderer'
|
|
19
19
|
|
|
20
20
|
const DEFAULT_STRINGS: DashboardGridStrings = {
|
|
21
21
|
emptyTitle: 'Your dashboard is empty',
|
|
@@ -155,52 +155,58 @@ export function DashboardGrid({
|
|
|
155
155
|
)
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
// ONE unified dense grid across every group. Per-group sections used to
|
|
159
|
+
// break the layout into rows, so a lone-widget group (e.g. a single KPI)
|
|
160
|
+
// left the rest of its row blank. Flattening + `grid-flow-row-dense`
|
|
161
|
+
// backfills those holes; ordering compact KPIs before charts makes the top
|
|
162
|
+
// read as a metric band and the charts mosaic below it. No blank space.
|
|
163
|
+
const ordered = React.useMemo(() => {
|
|
164
|
+
const flat = visibleGroups.flatMap((g) => g.widgets)
|
|
165
|
+
return flat
|
|
166
|
+
.map((w, i) => ({ w, i }))
|
|
167
|
+
.sort(
|
|
168
|
+
(a, b) =>
|
|
169
|
+
(isTallWidget(a.w) ? 1 : 0) - (isTallWidget(b.w) ? 1 : 0) ||
|
|
170
|
+
a.i - b.i,
|
|
171
|
+
)
|
|
172
|
+
.map((x) => x.w)
|
|
173
|
+
}, [visibleGroups])
|
|
174
|
+
|
|
158
175
|
return (
|
|
159
|
-
<div
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
emptyText={
|
|
191
|
-
spec.empty
|
|
192
|
-
? tr(spec.empty, spec.empty)
|
|
193
|
-
: s.widgetEmpty
|
|
194
|
-
}
|
|
195
|
-
errorText={s.widgetError}
|
|
196
|
-
/>
|
|
197
|
-
)}
|
|
198
|
-
</div>
|
|
199
|
-
)
|
|
200
|
-
})}
|
|
176
|
+
<div
|
|
177
|
+
data-testid="dashboard-grid"
|
|
178
|
+
className={cn(
|
|
179
|
+
// Masonry: balanced CSS columns. Cards take their natural height
|
|
180
|
+
// (compact stats, taller charts) and flow to equalize column
|
|
181
|
+
// height, so there are NO blank cells — the gaps a fixed grid
|
|
182
|
+
// leaves around mixed 1×1 / 2×2 tiles simply don't exist here.
|
|
183
|
+
'columns-1 gap-4 md:columns-2 xl:columns-3',
|
|
184
|
+
className,
|
|
185
|
+
)}
|
|
186
|
+
>
|
|
187
|
+
{ordered.map((spec) => {
|
|
188
|
+
const rspec = {
|
|
189
|
+
...spec,
|
|
190
|
+
title: tr(spec.title, spec.title),
|
|
191
|
+
subtitle: tr(spec.subtitle, spec.subtitle),
|
|
192
|
+
}
|
|
193
|
+
return (
|
|
194
|
+
<div key={spec.key} className="mb-4 break-inside-avoid">
|
|
195
|
+
{loading ? (
|
|
196
|
+
<WidgetSkeleton spec={rspec} />
|
|
197
|
+
) : (
|
|
198
|
+
<WidgetRenderer
|
|
199
|
+
spec={rspec}
|
|
200
|
+
data={data?.[spec.key]}
|
|
201
|
+
locale={locale}
|
|
202
|
+
currency={currency}
|
|
203
|
+
emptyText={spec.empty ? tr(spec.empty, spec.empty) : s.widgetEmpty}
|
|
204
|
+
errorText={s.widgetError}
|
|
205
|
+
/>
|
|
206
|
+
)}
|
|
201
207
|
</div>
|
|
202
|
-
|
|
203
|
-
)
|
|
208
|
+
)
|
|
209
|
+
})}
|
|
204
210
|
</div>
|
|
205
211
|
)
|
|
206
212
|
}
|
|
@@ -53,7 +53,20 @@ const fmtCtx = (
|
|
|
53
53
|
const hasSeries = (d?: WidgetData): d is WidgetData & { series: NonNullable<WidgetData['series']> } =>
|
|
54
54
|
Array.isArray(d?.series) && d!.series!.length > 0
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// Fixed-height chart body. In the masonry (CSS columns) layout cards take their
|
|
57
|
+
// natural height, so charts need a definite height for ResponsiveContainer to
|
|
58
|
+
// resolve. This height + the card chrome gives every chart card a consistent,
|
|
59
|
+
// taller footprint that balances cleanly against compact stat cards.
|
|
60
|
+
const CHART_H = 'h-[190px]'
|
|
61
|
+
function ChartArea({ children }: { children: React.ReactElement }) {
|
|
62
|
+
return (
|
|
63
|
+
<div className={cn(CHART_H, 'w-full')}>
|
|
64
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
65
|
+
{children}
|
|
66
|
+
</ResponsiveContainer>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
57
70
|
|
|
58
71
|
// Compact recharts tooltip styled with theme tokens.
|
|
59
72
|
function ChartTooltip({ ctx }: { ctx: WidgetFormatCtx }) {
|
|
@@ -94,11 +107,15 @@ export function StatWidget(p: WidgetRenderProps) {
|
|
|
94
107
|
}
|
|
95
108
|
>
|
|
96
109
|
{hasValue ? (
|
|
97
|
-
<div className="
|
|
98
|
-
|
|
110
|
+
<div className="flex min-h-[2.5rem] items-end">
|
|
111
|
+
<div className="text-[2rem] font-semibold leading-none tabular-nums tracking-tight text-foreground">
|
|
112
|
+
{formatWidgetValue(value!, ctx)}
|
|
113
|
+
</div>
|
|
99
114
|
</div>
|
|
100
115
|
) : (
|
|
101
|
-
<
|
|
116
|
+
<div className="flex min-h-[2.5rem] items-center text-sm text-muted-foreground">
|
|
117
|
+
{p.emptyText}
|
|
118
|
+
</div>
|
|
102
119
|
)}
|
|
103
120
|
</WidgetCard>
|
|
104
121
|
)
|
|
@@ -117,7 +134,7 @@ export function BarWidget(p: WidgetRenderProps) {
|
|
|
117
134
|
accent={p.spec.accent}
|
|
118
135
|
>
|
|
119
136
|
{hasSeries(p.data) ? (
|
|
120
|
-
<
|
|
137
|
+
<ChartArea>
|
|
121
138
|
<BarChart data={p.data.series} margin={{ top: 4, right: 4, left: -16, bottom: 0 }}>
|
|
122
139
|
<CartesianGrid vertical={false} stroke={CHART_GRID} strokeDasharray="3 3" />
|
|
123
140
|
<XAxis
|
|
@@ -135,9 +152,9 @@ export function BarWidget(p: WidgetRenderProps) {
|
|
|
135
152
|
tickFormatter={(v: number) => formatAxisTick(v, ctx)}
|
|
136
153
|
/>
|
|
137
154
|
<ChartTooltip ctx={ctx} />
|
|
138
|
-
<Bar dataKey="value" fill={a.chartVar} radius={[4, 4, 0, 0]} maxBarSize={
|
|
155
|
+
<Bar dataKey="value" fill={a.chartVar} radius={[4, 4, 0, 0]} maxBarSize={48} />
|
|
139
156
|
</BarChart>
|
|
140
|
-
</
|
|
157
|
+
</ChartArea>
|
|
141
158
|
) : (
|
|
142
159
|
<WidgetEmpty message={p.emptyText} />
|
|
143
160
|
)}
|
|
@@ -159,7 +176,7 @@ function TimeSeriesWidget(p: WidgetRenderProps & { variant: 'line' | 'area' }) {
|
|
|
159
176
|
accent={p.spec.accent}
|
|
160
177
|
>
|
|
161
178
|
{hasSeries(p.data) ? (
|
|
162
|
-
<
|
|
179
|
+
<ChartArea>
|
|
163
180
|
{p.variant === 'area' ? (
|
|
164
181
|
<AreaChart data={p.data.series} margin={{ top: 4, right: 6, left: -16, bottom: 0 }}>
|
|
165
182
|
<defs>
|
|
@@ -183,7 +200,7 @@ function TimeSeriesWidget(p: WidgetRenderProps & { variant: 'line' | 'area' }) {
|
|
|
183
200
|
<Line type="monotone" dataKey="value" stroke={a.chartVar} strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
|
|
184
201
|
</LineChart>
|
|
185
202
|
)}
|
|
186
|
-
</
|
|
203
|
+
</ChartArea>
|
|
187
204
|
) : (
|
|
188
205
|
<WidgetEmpty message={p.emptyText} />
|
|
189
206
|
)}
|
|
@@ -210,26 +227,28 @@ function CircularWidget(p: WidgetRenderProps & { variant: 'pie' | 'donut' }) {
|
|
|
210
227
|
accent={p.spec.accent}
|
|
211
228
|
>
|
|
212
229
|
{hasSeries(p.data) ? (
|
|
213
|
-
<div className=
|
|
214
|
-
<
|
|
215
|
-
<
|
|
216
|
-
<
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
<div className={cn(CHART_H, 'flex items-center gap-3')}>
|
|
231
|
+
<div className="h-full min-h-0 w-[46%] shrink-0">
|
|
232
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
233
|
+
<PieChart>
|
|
234
|
+
<ChartTooltip ctx={ctx} />
|
|
235
|
+
<Pie
|
|
236
|
+
data={p.data.series}
|
|
237
|
+
dataKey="value"
|
|
238
|
+
nameKey="label"
|
|
239
|
+
innerRadius={p.variant === 'donut' ? '55%' : 0}
|
|
240
|
+
outerRadius="92%"
|
|
241
|
+
paddingAngle={p.variant === 'donut' ? 2 : 0}
|
|
242
|
+
stroke="var(--background, #fff)"
|
|
243
|
+
strokeWidth={2}
|
|
244
|
+
>
|
|
245
|
+
{p.data.series.map((_, i) => (
|
|
246
|
+
<Cell key={i} fill={paletteColor(i)} />
|
|
247
|
+
))}
|
|
248
|
+
</Pie>
|
|
249
|
+
</PieChart>
|
|
250
|
+
</ResponsiveContainer>
|
|
251
|
+
</div>
|
|
233
252
|
<ul className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
234
253
|
{p.data.series.slice(0, 6).map((pt, i) => (
|
|
235
254
|
<li key={pt.key} className="flex items-center gap-2 text-xs">
|
|
@@ -78,7 +78,7 @@ export function WidgetCard({
|
|
|
78
78
|
</div>
|
|
79
79
|
{headerExtra && <div className="shrink-0">{headerExtra}</div>}
|
|
80
80
|
</CardHeader>
|
|
81
|
-
<CardContent className="flex flex-1 flex-col
|
|
81
|
+
<CardContent className="flex min-h-0 flex-1 flex-col pt-1">
|
|
82
82
|
{children}
|
|
83
83
|
</CardContent>
|
|
84
84
|
</Card>
|
|
@@ -26,12 +26,37 @@ export const SIZE_SPAN: Record<WidgetSize, number> = {
|
|
|
26
26
|
full: 4,
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/** Tailwind col-span class per size (static → scanned in this package build).
|
|
29
|
+
/** Tailwind col-span class per size (static → scanned in this package build).
|
|
30
|
+
* Grid is 2 cols on mobile, 4 on lg: sm=quarter, md=half, lg=¾, full=row. */
|
|
30
31
|
export const SIZE_CLASS: Record<WidgetSize, string> = {
|
|
31
|
-
sm: '
|
|
32
|
-
md: '
|
|
33
|
-
lg: '
|
|
34
|
-
full: '
|
|
32
|
+
sm: 'col-span-1 lg:col-span-1',
|
|
33
|
+
md: 'col-span-2 lg:col-span-2',
|
|
34
|
+
lg: 'col-span-2 lg:col-span-3',
|
|
35
|
+
full: 'col-span-2 lg:col-span-4',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Kinds that render a chart/list and want extra vertical room (2 grid rows).
|
|
39
|
+
* Stat/progress stay a single compact row so KPIs read like KPIs, not slabs. */
|
|
40
|
+
const TALL_KINDS = new Set(['bar', 'line', 'area', 'pie', 'donut', 'list', 'custom'])
|
|
41
|
+
|
|
42
|
+
/** Default footprint when a spec omits `size`: charts go half-width, stats a
|
|
43
|
+
* quarter — so a tablero of mixed widgets packs densely without manual sizing. */
|
|
44
|
+
export function defaultSize(spec: DashboardWidgetSpec): WidgetSize {
|
|
45
|
+
if (spec.size) return spec.size
|
|
46
|
+
return TALL_KINDS.has(spec.kind) ? 'md' : 'sm'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Combined col + row span for a widget's grid cell. Row-span drives the
|
|
50
|
+
* height contrast (chart=2, stat=1) that makes the layout feel designed. */
|
|
51
|
+
export function spanClass(spec: DashboardWidgetSpec): string {
|
|
52
|
+
const col = SIZE_CLASS[defaultSize(spec)]
|
|
53
|
+
return `${col} ${TALL_KINDS.has(spec.kind) ? 'row-span-2' : 'row-span-1'}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True for chart/list widgets that occupy 2 rows. Used to order compact KPIs
|
|
57
|
+
* before charts in the unified grid so the top reads as a metric band. */
|
|
58
|
+
export function isTallWidget(spec: DashboardWidgetSpec): boolean {
|
|
59
|
+
return TALL_KINDS.has(spec.kind)
|
|
35
60
|
}
|
|
36
61
|
|
|
37
62
|
const RENDERERS: Record<
|