@asteby/metacore-runtime-react 18.17.1 → 18.17.3
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 +26 -14
- package/dist/widgets/renderers.d.ts.map +1 -1
- package/dist/widgets/renderers.js +8 -6
- package/dist/widgets/widget-renderer.d.ts +3 -0
- package/dist/widgets/widget-renderer.d.ts.map +1 -1
- package/dist/widgets/widget-renderer.js +5 -0
- package/package.json +1 -1
- package/src/__tests__/dashboard-grid.test.tsx +18 -0
- package/src/dashboard-grid.tsx +50 -44
- package/src/widgets/renderers.tsx +11 -7
- package/src/widgets/widget-renderer.tsx +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 18.17.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d2c92e1: Fix React #310: move flatten/order useMemo before the empty-state early return (conditional hook crashed on empty→populated)
|
|
8
|
+
|
|
9
|
+
## 18.17.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 714cc34: Dashboard: masonry (balanced CSS columns) layout — no blank cells; charts fixed-height, compact stat cards
|
|
14
|
+
|
|
3
15
|
## 18.17.1
|
|
4
16
|
|
|
5
17
|
### Patch 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,qBAiJpB"}
|
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.',
|
|
@@ -106,21 +106,33 @@ export function DashboardGrid({ groups, widgets, loadData, isAdmin, locale, curr
|
|
|
106
106
|
};
|
|
107
107
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
108
108
|
}, [keySig, loadData]);
|
|
109
|
+
// Flatten every group into ONE ordered list (compact KPIs before charts) for
|
|
110
|
+
// the masonry grid. MUST run before any early return — it is a hook, and a
|
|
111
|
+
// conditional hook (placed after the empty-state return) trips React #310
|
|
112
|
+
// when the dashboard transitions empty → populated.
|
|
113
|
+
const ordered = React.useMemo(() => {
|
|
114
|
+
const flat = visibleGroups.flatMap((g) => g.widgets);
|
|
115
|
+
return flat
|
|
116
|
+
.map((w, i) => ({ w, i }))
|
|
117
|
+
.sort((a, b) => (isTallWidget(a.w) ? 1 : 0) - (isTallWidget(b.w) ? 1 : 0) ||
|
|
118
|
+
a.i - b.i)
|
|
119
|
+
.map((x) => x.w);
|
|
120
|
+
}, [visibleGroups]);
|
|
109
121
|
// Global empty state (no widgets at all / none visible after gating).
|
|
110
122
|
if (visibleGroups.length === 0) {
|
|
111
123
|
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
124
|
}
|
|
113
|
-
return (_jsx("div", { "data-testid": "dashboard-grid", className: cn(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
125
|
+
return (_jsx("div", { "data-testid": "dashboard-grid", className: cn(
|
|
126
|
+
// Masonry: balanced CSS columns. Cards take their natural height
|
|
127
|
+
// (compact stats, taller charts) and flow to equalize column
|
|
128
|
+
// height, so there are NO blank cells — the gaps a fixed grid
|
|
129
|
+
// leaves around mixed 1×1 / 2×2 tiles simply don't exist here.
|
|
130
|
+
'columns-1 gap-4 md:columns-2 xl:columns-3', className), children: ordered.map((spec) => {
|
|
131
|
+
const rspec = {
|
|
132
|
+
...spec,
|
|
133
|
+
title: tr(spec.title, spec.title),
|
|
134
|
+
subtitle: tr(spec.subtitle, spec.subtitle),
|
|
135
|
+
};
|
|
136
|
+
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));
|
|
137
|
+
}) }));
|
|
126
138
|
}
|
|
@@ -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,11 +5,13 @@ 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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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]';
|
|
11
13
|
function ChartArea({ children }) {
|
|
12
|
-
return (_jsx("div", { className:
|
|
14
|
+
return (_jsx("div", { className: cn(CHART_H, 'w-full'), children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: children }) }));
|
|
13
15
|
}
|
|
14
16
|
// Compact recharts tooltip styled with theme tokens.
|
|
15
17
|
function ChartTooltip({ ctx }) {
|
|
@@ -28,7 +30,7 @@ export function StatWidget(p) {
|
|
|
28
30
|
const value = p.data?.value;
|
|
29
31
|
const delta = p.data?.delta;
|
|
30
32
|
const hasValue = typeof value === 'number' && !Number.isNaN(value);
|
|
31
|
-
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-
|
|
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 })) }));
|
|
32
34
|
}
|
|
33
35
|
// --- bar ------------------------------------------------------------------
|
|
34
36
|
export function BarWidget(p) {
|
|
@@ -52,7 +54,7 @@ export function AreaWidget(p) {
|
|
|
52
54
|
// --- pie / donut (shared) -------------------------------------------------
|
|
53
55
|
function CircularWidget(p) {
|
|
54
56
|
const ctx = fmtCtx(p.spec, p.locale, p.currency);
|
|
55
|
-
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 })) }));
|
|
56
58
|
}
|
|
57
59
|
export function PieWidget(p) {
|
|
58
60
|
return _jsx(CircularWidget, { ...p, variant: "pie" });
|
|
@@ -11,6 +11,9 @@ export declare function defaultSize(spec: DashboardWidgetSpec): WidgetSize;
|
|
|
11
11
|
/** Combined col + row span for a widget's grid cell. Row-span drives the
|
|
12
12
|
* height contrast (chart=2, stat=1) that makes the layout feel designed. */
|
|
13
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;
|
|
14
17
|
export interface WidgetRendererProps {
|
|
15
18
|
spec: DashboardWidgetSpec;
|
|
16
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;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;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"}
|
|
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"}
|
|
@@ -37,6 +37,11 @@ export function spanClass(spec) {
|
|
|
37
37
|
const col = SIZE_CLASS[defaultSize(spec)];
|
|
38
38
|
return `${col} ${TALL_KINDS.has(spec.kind) ? 'row-span-2' : 'row-span-1'}`;
|
|
39
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
|
+
}
|
|
40
45
|
const RENDERERS = {
|
|
41
46
|
stat: StatWidget,
|
|
42
47
|
bar: BarWidget,
|
package/package.json
CHANGED
|
@@ -155,6 +155,24 @@ describe('DashboardGrid render', () => {
|
|
|
155
155
|
render(<DashboardGrid widgets={[]} loadData={loaderOf({})} />)
|
|
156
156
|
expect(screen.getByTestId('dashboard-empty')).toBeTruthy()
|
|
157
157
|
})
|
|
158
|
+
|
|
159
|
+
it('survives an empty → populated transition (React #310 regression)', async () => {
|
|
160
|
+
// The flatten/order useMemo must run BEFORE the empty-state early return.
|
|
161
|
+
// When it sat after the return, an empty render called one fewer hook
|
|
162
|
+
// than the populated render → "Rendered more hooks" (React #310) crash.
|
|
163
|
+
const { rerender } = render(
|
|
164
|
+
<DashboardGrid widgets={[]} loadData={loaderOf({})} />,
|
|
165
|
+
)
|
|
166
|
+
expect(screen.getByTestId('dashboard-empty')).toBeTruthy()
|
|
167
|
+
rerender(
|
|
168
|
+
<DashboardGrid
|
|
169
|
+
widgets={[spec({ key: 'rev', kind: 'stat' })]}
|
|
170
|
+
loadData={loaderOf({ rev: { value: 5 } })}
|
|
171
|
+
/>,
|
|
172
|
+
)
|
|
173
|
+
await waitFor(() => expect(screen.getByTestId('widget-rev')).toBeTruthy())
|
|
174
|
+
expect(screen.getByText('5')).toBeTruthy()
|
|
175
|
+
})
|
|
158
176
|
})
|
|
159
177
|
|
|
160
178
|
describe('DashboardGrid permission gating', () => {
|
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',
|
|
@@ -132,6 +132,22 @@ export function DashboardGrid({
|
|
|
132
132
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
133
133
|
}, [keySig, loadData])
|
|
134
134
|
|
|
135
|
+
// Flatten every group into ONE ordered list (compact KPIs before charts) for
|
|
136
|
+
// the masonry grid. MUST run before any early return — it is a hook, and a
|
|
137
|
+
// conditional hook (placed after the empty-state return) trips React #310
|
|
138
|
+
// when the dashboard transitions empty → populated.
|
|
139
|
+
const ordered = React.useMemo(() => {
|
|
140
|
+
const flat = visibleGroups.flatMap((g) => g.widgets)
|
|
141
|
+
return flat
|
|
142
|
+
.map((w, i) => ({ w, i }))
|
|
143
|
+
.sort(
|
|
144
|
+
(a, b) =>
|
|
145
|
+
(isTallWidget(a.w) ? 1 : 0) - (isTallWidget(b.w) ? 1 : 0) ||
|
|
146
|
+
a.i - b.i,
|
|
147
|
+
)
|
|
148
|
+
.map((x) => x.w)
|
|
149
|
+
}, [visibleGroups])
|
|
150
|
+
|
|
135
151
|
// Global empty state (no widgets at all / none visible after gating).
|
|
136
152
|
if (visibleGroups.length === 0) {
|
|
137
153
|
return (
|
|
@@ -156,50 +172,40 @@ export function DashboardGrid({
|
|
|
156
172
|
}
|
|
157
173
|
|
|
158
174
|
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
|
-
spec.empty
|
|
191
|
-
? tr(spec.empty, spec.empty)
|
|
192
|
-
: s.widgetEmpty
|
|
193
|
-
}
|
|
194
|
-
errorText={s.widgetError}
|
|
195
|
-
/>
|
|
196
|
-
)}
|
|
197
|
-
</div>
|
|
198
|
-
)
|
|
199
|
-
})}
|
|
175
|
+
<div
|
|
176
|
+
data-testid="dashboard-grid"
|
|
177
|
+
className={cn(
|
|
178
|
+
// Masonry: balanced CSS columns. Cards take their natural height
|
|
179
|
+
// (compact stats, taller charts) and flow to equalize column
|
|
180
|
+
// height, so there are NO blank cells — the gaps a fixed grid
|
|
181
|
+
// leaves around mixed 1×1 / 2×2 tiles simply don't exist here.
|
|
182
|
+
'columns-1 gap-4 md:columns-2 xl:columns-3',
|
|
183
|
+
className,
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
{ordered.map((spec) => {
|
|
187
|
+
const rspec = {
|
|
188
|
+
...spec,
|
|
189
|
+
title: tr(spec.title, spec.title),
|
|
190
|
+
subtitle: tr(spec.subtitle, spec.subtitle),
|
|
191
|
+
}
|
|
192
|
+
return (
|
|
193
|
+
<div key={spec.key} className="mb-4 break-inside-avoid">
|
|
194
|
+
{loading ? (
|
|
195
|
+
<WidgetSkeleton spec={rspec} />
|
|
196
|
+
) : (
|
|
197
|
+
<WidgetRenderer
|
|
198
|
+
spec={rspec}
|
|
199
|
+
data={data?.[spec.key]}
|
|
200
|
+
locale={locale}
|
|
201
|
+
currency={currency}
|
|
202
|
+
emptyText={spec.empty ? tr(spec.empty, spec.empty) : s.widgetEmpty}
|
|
203
|
+
errorText={s.widgetError}
|
|
204
|
+
/>
|
|
205
|
+
)}
|
|
200
206
|
</div>
|
|
201
|
-
|
|
202
|
-
)
|
|
207
|
+
)
|
|
208
|
+
})}
|
|
203
209
|
</div>
|
|
204
210
|
)
|
|
205
211
|
}
|
|
@@ -53,12 +53,14 @@ 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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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]'
|
|
59
61
|
function ChartArea({ children }: { children: React.ReactElement }) {
|
|
60
62
|
return (
|
|
61
|
-
<div className=
|
|
63
|
+
<div className={cn(CHART_H, 'w-full')}>
|
|
62
64
|
<ResponsiveContainer width="100%" height="100%">
|
|
63
65
|
{children}
|
|
64
66
|
</ResponsiveContainer>
|
|
@@ -105,13 +107,15 @@ export function StatWidget(p: WidgetRenderProps) {
|
|
|
105
107
|
}
|
|
106
108
|
>
|
|
107
109
|
{hasValue ? (
|
|
108
|
-
<div className="flex min-h-
|
|
110
|
+
<div className="flex min-h-[2.5rem] items-end">
|
|
109
111
|
<div className="text-[2rem] font-semibold leading-none tabular-nums tracking-tight text-foreground">
|
|
110
112
|
{formatWidgetValue(value!, ctx)}
|
|
111
113
|
</div>
|
|
112
114
|
</div>
|
|
113
115
|
) : (
|
|
114
|
-
<
|
|
116
|
+
<div className="flex min-h-[2.5rem] items-center text-sm text-muted-foreground">
|
|
117
|
+
{p.emptyText}
|
|
118
|
+
</div>
|
|
115
119
|
)}
|
|
116
120
|
</WidgetCard>
|
|
117
121
|
)
|
|
@@ -223,7 +227,7 @@ function CircularWidget(p: WidgetRenderProps & { variant: 'pie' | 'donut' }) {
|
|
|
223
227
|
accent={p.spec.accent}
|
|
224
228
|
>
|
|
225
229
|
{hasSeries(p.data) ? (
|
|
226
|
-
<div className=
|
|
230
|
+
<div className={cn(CHART_H, 'flex items-center gap-3')}>
|
|
227
231
|
<div className="h-full min-h-0 w-[46%] shrink-0">
|
|
228
232
|
<ResponsiveContainer width="100%" height="100%">
|
|
229
233
|
<PieChart>
|
|
@@ -53,6 +53,12 @@ export function spanClass(spec: DashboardWidgetSpec): string {
|
|
|
53
53
|
return `${col} ${TALL_KINDS.has(spec.kind) ? 'row-span-2' : 'row-span-1'}`
|
|
54
54
|
}
|
|
55
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)
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
const RENDERERS: Record<
|
|
57
63
|
string,
|
|
58
64
|
(p: WidgetRenderProps) => React.ReactElement
|