@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 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,qBA2IpB"}
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"}
@@ -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, spanClass } from './widgets/widget-renderer';
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('flex flex-col gap-8', className), children: visibleGroups.map((group) => (_jsxs("section", { className: "flex flex-col gap-3", children: [group.title && (_jsx("h2", { className: "text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: tr(group.title, group.title) })), _jsx("div", { className: "grid grid-flow-row-dense auto-rows-[116px] grid-cols-2 gap-4 lg:grid-cols-4", children: group.widgets.map((spec) => {
114
- return (_jsx("div", { className: cn(spanClass(spec), 'min-h-0'), children: loading ? (_jsx(WidgetSkeleton, { spec: {
115
- ...spec,
116
- title: tr(spec.title, spec.title),
117
- subtitle: tr(spec.subtitle, spec.subtitle),
118
- } })) : (_jsx(WidgetRenderer, { spec: {
119
- ...spec,
120
- title: tr(spec.title, spec.title),
121
- subtitle: tr(spec.subtitle, spec.subtitle),
122
- }, data: data?.[spec.key], locale: locale, currency: currency, emptyText: spec.empty
123
- ? tr(spec.empty, spec.empty)
124
- : s.widgetEmpty, errorText: s.widgetError })) }, spec.key));
125
- }) })] }, group.title || '__default'))) }));
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;AA4CD,wBAAgB,UAAU,CAAC,CAAC,EAAE,iBAAiB,qBA6B9C;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"}
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
- // Chart body that fills the card's flexible height. The dashboard grid gives
9
- // each chart cell a definite height (fixed auto-rows × row-span), so height:100%
10
- // resolves cleanly and charts scale with the card instead of a fixed stub.
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: "min-h-0 flex-1", children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: children }) }));
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-0 flex-1 flex-col justify-center", children: _jsx("div", { className: "text-[2rem] font-semibold leading-none tabular-nums tracking-tight text-foreground", children: formatWidgetValue(value, ctx) }) })) : (_jsx(WidgetEmpty, { message: p.emptyText })) }));
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: "flex min-h-0 flex-1 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 })) }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "18.17.1",
3
+ "version": "18.17.3",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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', () => {
@@ -15,7 +15,7 @@ import type {
15
15
  DashboardWidgetGroup,
16
16
  DashboardWidgetSpec,
17
17
  } from './dashboard-types'
18
- import { WidgetRenderer, WidgetSkeleton, spanClass } from './widgets/widget-renderer'
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 data-testid="dashboard-grid" className={cn('flex flex-col gap-8', className)}>
160
- {visibleGroups.map((group) => (
161
- <section key={group.title || '__default'} className="flex flex-col gap-3">
162
- {group.title && (
163
- <h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
164
- {tr(group.title, group.title)}
165
- </h2>
166
- )}
167
- <div className="grid grid-flow-row-dense auto-rows-[116px] grid-cols-2 gap-4 lg:grid-cols-4">
168
- {group.widgets.map((spec) => {
169
- return (
170
- <div key={spec.key} className={cn(spanClass(spec), 'min-h-0')}>
171
- {loading ? (
172
- <WidgetSkeleton
173
- spec={{
174
- ...spec,
175
- title: tr(spec.title, spec.title),
176
- subtitle: tr(spec.subtitle, spec.subtitle),
177
- }}
178
- />
179
- ) : (
180
- <WidgetRenderer
181
- spec={{
182
- ...spec,
183
- title: tr(spec.title, spec.title),
184
- subtitle: tr(spec.subtitle, spec.subtitle),
185
- }}
186
- data={data?.[spec.key]}
187
- locale={locale}
188
- currency={currency}
189
- emptyText={
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
- </section>
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
- // Chart body that fills the card's flexible height. The dashboard grid gives
57
- // each chart cell a definite height (fixed auto-rows × row-span), so height:100%
58
- // resolves cleanly and charts scale with the card instead of a fixed stub.
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="min-h-0 flex-1">
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-0 flex-1 flex-col justify-center">
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
- <WidgetEmpty message={p.emptyText} />
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="flex min-h-0 flex-1 items-center gap-3">
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