@asteby/metacore-runtime-react 18.16.1 → 18.17.1

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.
@@ -0,0 +1,138 @@
1
+ // Shared widget formatting + accent theming. Reuses the same Intl-based
2
+ // approach as the table cells (dynamic-columns) so dashboard numbers read
3
+ // identically to the grids: org currency + locale, compact notation, percent.
4
+ /**
5
+ * Formats a scalar/series value with the widget's declared format. `currency`
6
+ * uses the org currency + locale (same fallback chain as table cells); `percent`
7
+ * treats the value as a fraction (0.142 → "14.2%"); `compact` uses Intl compact
8
+ * notation (1_200 → "1.2K").
9
+ */
10
+ export function formatWidgetValue(value, { format = 'number', locale, currency }) {
11
+ if (value === null || value === undefined || Number.isNaN(value))
12
+ return '—';
13
+ const loc = locale || undefined;
14
+ switch (format) {
15
+ case 'currency':
16
+ return new Intl.NumberFormat(loc, {
17
+ style: 'currency',
18
+ currency: currency || 'USD',
19
+ maximumFractionDigits: 2,
20
+ }).format(value);
21
+ case 'percent':
22
+ return new Intl.NumberFormat(loc, {
23
+ style: 'percent',
24
+ minimumFractionDigits: 0,
25
+ maximumFractionDigits: 1,
26
+ }).format(value);
27
+ case 'compact':
28
+ return new Intl.NumberFormat(loc, {
29
+ notation: 'compact',
30
+ maximumFractionDigits: 1,
31
+ }).format(value);
32
+ case 'number':
33
+ default:
34
+ return new Intl.NumberFormat(loc, {
35
+ maximumFractionDigits: 2,
36
+ }).format(value);
37
+ }
38
+ }
39
+ /** Axis-tick formatter — always compact so charts stay legible. */
40
+ export function formatAxisTick(value, { format, locale, currency }) {
41
+ const loc = locale || undefined;
42
+ if (format === 'currency') {
43
+ return new Intl.NumberFormat(loc, {
44
+ style: 'currency',
45
+ currency: currency || 'USD',
46
+ notation: 'compact',
47
+ maximumFractionDigits: 1,
48
+ }).format(value);
49
+ }
50
+ if (format === 'percent') {
51
+ return new Intl.NumberFormat(loc, {
52
+ style: 'percent',
53
+ maximumFractionDigits: 0,
54
+ }).format(value);
55
+ }
56
+ return new Intl.NumberFormat(loc, {
57
+ notation: 'compact',
58
+ maximumFractionDigits: 1,
59
+ }).format(value);
60
+ }
61
+ /** Formats the compare delta fraction (0.142 → "+14.2%"). */
62
+ export function formatDelta(delta, locale) {
63
+ const sign = delta > 0 ? '+' : '';
64
+ return (sign +
65
+ new Intl.NumberFormat(locale || undefined, {
66
+ style: 'percent',
67
+ minimumFractionDigits: 0,
68
+ maximumFractionDigits: 1,
69
+ }).format(delta));
70
+ }
71
+ const ACCENTS = {
72
+ emerald: {
73
+ chip: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
74
+ text: 'text-emerald-600 dark:text-emerald-400',
75
+ bar: 'bg-emerald-500',
76
+ track: 'bg-emerald-500/15',
77
+ chartVar: 'var(--color-widget-emerald, #10b981)',
78
+ },
79
+ sky: {
80
+ chip: 'bg-sky-500/10 text-sky-600 dark:text-sky-400',
81
+ text: 'text-sky-600 dark:text-sky-400',
82
+ bar: 'bg-sky-500',
83
+ track: 'bg-sky-500/15',
84
+ chartVar: 'var(--color-widget-sky, #0ea5e9)',
85
+ },
86
+ violet: {
87
+ chip: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
88
+ text: 'text-violet-600 dark:text-violet-400',
89
+ bar: 'bg-violet-500',
90
+ track: 'bg-violet-500/15',
91
+ chartVar: 'var(--color-widget-violet, #8b5cf6)',
92
+ },
93
+ amber: {
94
+ chip: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
95
+ text: 'text-amber-600 dark:text-amber-400',
96
+ bar: 'bg-amber-500',
97
+ track: 'bg-amber-500/15',
98
+ chartVar: 'var(--color-widget-amber, #f59e0b)',
99
+ },
100
+ rose: {
101
+ chip: 'bg-rose-500/10 text-rose-600 dark:text-rose-400',
102
+ text: 'text-rose-600 dark:text-rose-400',
103
+ bar: 'bg-rose-500',
104
+ track: 'bg-rose-500/15',
105
+ chartVar: 'var(--color-widget-rose, #f43f5e)',
106
+ },
107
+ slate: {
108
+ chip: 'bg-slate-500/10 text-slate-600 dark:text-slate-300',
109
+ text: 'text-slate-600 dark:text-slate-300',
110
+ bar: 'bg-slate-500',
111
+ track: 'bg-slate-500/15',
112
+ chartVar: 'var(--color-widget-slate, #64748b)',
113
+ },
114
+ };
115
+ const DEFAULT_ACCENT = 'sky';
116
+ /** Resolves the accent class bundle, defaulting to `sky`. */
117
+ export function accentClasses(accent) {
118
+ return ACCENTS[accent ?? DEFAULT_ACCENT] ?? ACCENTS[DEFAULT_ACCENT];
119
+ }
120
+ /**
121
+ * A categorical palette for multi-series charts (pie/donut/bar by bucket).
122
+ * Cycles the accent chart vars so slices stay theme-aware + dark-mode safe.
123
+ */
124
+ export const CHART_PALETTE = [
125
+ ACCENTS.sky.chartVar,
126
+ ACCENTS.emerald.chartVar,
127
+ ACCENTS.violet.chartVar,
128
+ ACCENTS.amber.chartVar,
129
+ ACCENTS.rose.chartVar,
130
+ ACCENTS.slate.chartVar,
131
+ ];
132
+ /** Picks a palette color by index, wrapping around. */
133
+ export function paletteColor(index) {
134
+ return CHART_PALETTE[index % CHART_PALETTE.length];
135
+ }
136
+ /** Grid/axis muted color from the theme (works in light + dark). */
137
+ export const CHART_MUTED = 'var(--muted-foreground, #94a3b8)';
138
+ export const CHART_GRID = 'var(--border, #e2e8f0)';
@@ -0,0 +1,34 @@
1
+ import * as React from 'react';
2
+ import type { DashboardWidgetSpec, WidgetData, WidgetSize } from '../dashboard-types';
3
+ /** Maps a widget size to its column span in the 4-col grid. */
4
+ export declare const SIZE_SPAN: Record<WidgetSize, number>;
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. */
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
+ export interface WidgetRendererProps {
15
+ spec: DashboardWidgetSpec;
16
+ data?: WidgetData;
17
+ locale?: string;
18
+ currency?: string;
19
+ /** Translated empty fallback for this widget. */
20
+ emptyText: string;
21
+ /** Translated error message for this widget. */
22
+ errorText: string;
23
+ }
24
+ /**
25
+ * Renders one widget. `kind:"custom"` defers to the federated slot
26
+ * (`spec.slot ?? 'dashboard.widgets'`) inside the same card chrome so it
27
+ * combines with the declarative widgets.
28
+ */
29
+ export declare function WidgetRenderer({ spec, data, locale, currency, emptyText, errorText, }: WidgetRendererProps): React.JSX.Element;
30
+ /** Skeleton placeholder shown per widget while data loads. */
31
+ export declare function WidgetSkeleton({ spec }: {
32
+ spec: DashboardWidgetSpec;
33
+ }): React.JSX.Element;
34
+ //# sourceMappingURL=widget-renderer.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,83 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Per-widget dispatcher: maps a spec.kind to its built-in renderer (or a
3
+ // federated <Slot> for kind:"custom"), wrapped in an error boundary so a single
4
+ // broken widget renders its own error card instead of tumbling the grid.
5
+ import * as React from 'react';
6
+ import { Slot } from '../slot';
7
+ import { WidgetCard, WidgetError } from './widget-card';
8
+ import { StatWidget, BarWidget, LineWidget, AreaWidget, PieWidget, DonutWidget, ListWidget, ProgressWidget, } from './renderers';
9
+ /** Maps a widget size to its column span in the 4-col grid. */
10
+ export const SIZE_SPAN = {
11
+ sm: 1,
12
+ md: 2,
13
+ lg: 3,
14
+ full: 4,
15
+ };
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. */
18
+ export const SIZE_CLASS = {
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',
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
+ const RENDERERS = {
41
+ stat: StatWidget,
42
+ bar: BarWidget,
43
+ line: LineWidget,
44
+ area: AreaWidget,
45
+ pie: PieWidget,
46
+ donut: DonutWidget,
47
+ list: ListWidget,
48
+ progress: ProgressWidget,
49
+ };
50
+ /** Isolated boundary: a throwing widget renders its own error card. */
51
+ class WidgetErrorBoundary extends React.Component {
52
+ state = { error: false };
53
+ static getDerivedStateFromError() {
54
+ return { error: true };
55
+ }
56
+ render() {
57
+ if (this.state.error) {
58
+ return (_jsx(WidgetCard, { "data-testid": `widget-${this.props.spec.key}`, title: this.props.spec.title, subtitle: this.props.spec.subtitle, icon: this.props.spec.icon, accent: this.props.spec.accent, children: _jsx(WidgetError, { message: this.props.message }) }));
59
+ }
60
+ return this.props.children;
61
+ }
62
+ }
63
+ /**
64
+ * Renders one widget. `kind:"custom"` defers to the federated slot
65
+ * (`spec.slot ?? 'dashboard.widgets'`) inside the same card chrome so it
66
+ * combines with the declarative widgets.
67
+ */
68
+ export function WidgetRenderer({ spec, data, locale, currency, emptyText, errorText, }) {
69
+ let body;
70
+ if (spec.kind === 'custom') {
71
+ body = (_jsx(WidgetCard, { "data-testid": `widget-${spec.key}`, title: spec.title, subtitle: spec.subtitle, icon: spec.icon, accent: spec.accent, children: _jsx(Slot, { name: spec.slot ?? 'dashboard.widgets', props: { spec, data, locale, currency }, fallback: _jsx("div", { className: "flex flex-1 items-center justify-center py-6 text-xs text-muted-foreground", children: emptyText }) }) }));
72
+ }
73
+ else {
74
+ const Renderer = RENDERERS[spec.kind];
75
+ body = Renderer ? (_jsx(Renderer, { spec: spec, data: data, locale: locale, currency: currency, emptyText: emptyText })) : (_jsx(WidgetCard, { "data-testid": `widget-${spec.key}`, title: spec.title, subtitle: spec.subtitle, icon: spec.icon, accent: spec.accent, children: _jsx(WidgetError, { message: errorText }) }));
76
+ }
77
+ return (_jsx(WidgetErrorBoundary, { spec: spec, message: errorText, children: body }));
78
+ }
79
+ /** Skeleton placeholder shown per widget while data loads. */
80
+ export function WidgetSkeleton({ spec }) {
81
+ const isChart = spec.kind !== 'stat' && spec.kind !== 'progress' && spec.kind !== 'custom';
82
+ return (_jsx(WidgetCard, { "data-testid": `widget-skeleton-${spec.key}`, title: spec.title, subtitle: spec.subtitle, icon: spec.icon, accent: spec.accent, children: isChart ? (_jsx("div", { className: "h-[132px] w-full animate-pulse rounded-md bg-muted" })) : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("div", { className: "h-8 w-2/3 animate-pulse rounded bg-muted" }), _jsx("div", { className: "h-2 w-full animate-pulse rounded bg-muted/60" })] })) }));
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "18.16.1",
3
+ "version": "18.17.1",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@module-federation/runtime": "^2.5.0",
21
+ "recharts": "^2.15.0",
21
22
  "zod": "^4.3.0"
22
23
  },
23
24
  "peerDependencies": {
@@ -0,0 +1,222 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // DashboardGrid contract coverage:
4
+ // - normalizeGroups (pure): flat widgets → ordered groups by group/order.
5
+ // - render per kind: stat/list/progress paint their value; chart kinds paint
6
+ // the card chrome (title) — recharts itself isn't exercised in jsdom.
7
+ // - skeletons while loading, then real content after the batch resolves.
8
+ // - global empty state when there are no widgets.
9
+ // - permission gating via <PermissionsProvider> (and isAdmin bypass).
10
+ // - isolated per-widget error: a throwing renderer shows its error card and
11
+ // the sibling widgets still render.
12
+ import { afterEach, describe, expect, it, vi } from 'vitest'
13
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
14
+
15
+ // react-i18next: identity translator (returns the key) so specs' raw i18n keys
16
+ // surface verbatim and we can assert on them.
17
+ vi.mock('react-i18next', () => ({
18
+ useTranslation: () => ({ t: (k: string) => k }),
19
+ }))
20
+
21
+ // recharts' ResponsiveContainer relies on ResizeObserver; stub it for happy-dom.
22
+ class ResizeObserverStub {
23
+ observe() {}
24
+ unobserve() {}
25
+ disconnect() {}
26
+ }
27
+ ;(globalThis as any).ResizeObserver = (globalThis as any).ResizeObserver ?? ResizeObserverStub
28
+
29
+ afterEach(cleanup)
30
+
31
+ import { DashboardGrid, normalizeGroups } from '../dashboard-grid'
32
+ import { PermissionsProvider } from '../permissions-context'
33
+ import type {
34
+ DashboardWidgetSpec,
35
+ WidgetData,
36
+ } from '../dashboard-types'
37
+
38
+ const spec = (over: Partial<DashboardWidgetSpec>): DashboardWidgetSpec => ({
39
+ key: 'k',
40
+ title: 'Title',
41
+ kind: 'stat',
42
+ ...over,
43
+ })
44
+
45
+ const loaderOf =
46
+ (map: Record<string, WidgetData>) => async (keys: string[]) => {
47
+ const out: Record<string, WidgetData> = {}
48
+ for (const k of keys) if (map[k]) out[k] = map[k]
49
+ return out
50
+ }
51
+
52
+ describe('normalizeGroups', () => {
53
+ it('returns explicit groups untouched', () => {
54
+ const groups = [{ title: 'g1', widgets: [spec({ key: 'a' })] }]
55
+ expect(normalizeGroups(groups, undefined)).toBe(groups)
56
+ })
57
+
58
+ it('groups flat widgets by `group` and sorts each by `order`', () => {
59
+ const widgets = [
60
+ spec({ key: 'b', group: 'sales', order: 20 }),
61
+ spec({ key: 'a', group: 'sales', order: 10 }),
62
+ spec({ key: 'c', group: 'stock' }),
63
+ ]
64
+ const out = normalizeGroups(undefined, widgets)
65
+ expect(out.map((g) => g.title)).toEqual(['sales', 'stock'])
66
+ expect(out[0].widgets.map((w) => w.key)).toEqual(['a', 'b'])
67
+ })
68
+
69
+ it('returns [] when there is nothing', () => {
70
+ expect(normalizeGroups(undefined, [])).toEqual([])
71
+ })
72
+ })
73
+
74
+ describe('DashboardGrid render', () => {
75
+ it('shows a skeleton while loading then the stat value', async () => {
76
+ let resolve!: (v: Record<string, WidgetData>) => void
77
+ const loadData = vi.fn(
78
+ () => new Promise<Record<string, WidgetData>>((r) => (resolve = r)),
79
+ )
80
+ render(
81
+ <DashboardGrid
82
+ widgets={[spec({ key: 'rev', kind: 'stat' })]}
83
+ loadData={loadData}
84
+ />,
85
+ )
86
+ // skeleton up first
87
+ expect(screen.getByTestId('widget-skeleton-rev')).toBeTruthy()
88
+ resolve({ rev: { value: 42 } })
89
+ await waitFor(() =>
90
+ expect(screen.getByTestId('widget-rev')).toBeTruthy(),
91
+ )
92
+ expect(screen.getByText('42')).toBeTruthy()
93
+ })
94
+
95
+ it('renders a currency stat with the org currency + delta chip', async () => {
96
+ render(
97
+ <DashboardGrid
98
+ widgets={[
99
+ spec({ key: 'rev', kind: 'stat', format: 'currency' }),
100
+ ]}
101
+ currency="MXN"
102
+ locale="en-US"
103
+ loadData={loaderOf({ rev: { value: 1000, delta: 0.142 } })}
104
+ />,
105
+ )
106
+ await waitFor(() => expect(screen.getByTestId('widget-rev')).toBeTruthy())
107
+ // MX$1,000.00 (en-US + MXN) — assert the currency symbol + amount present
108
+ expect(screen.getByText(/1,000/)).toBeTruthy()
109
+ expect(screen.getByText(/\+14\.2%/)).toBeTruthy()
110
+ })
111
+
112
+ it('renders a list widget with each bucket label + value', async () => {
113
+ render(
114
+ <DashboardGrid
115
+ widgets={[spec({ key: 'top', kind: 'list' })]}
116
+ loadData={loaderOf({
117
+ top: {
118
+ series: [
119
+ { key: 'a', label: 'Alpha', value: 10 },
120
+ { key: 'b', label: 'Beta', value: 5 },
121
+ ],
122
+ },
123
+ })}
124
+ />,
125
+ )
126
+ await waitFor(() => expect(screen.getByText('Alpha')).toBeTruthy())
127
+ expect(screen.getByText('Beta')).toBeTruthy()
128
+ })
129
+
130
+ it('paints the card chrome (title) for chart kinds', async () => {
131
+ render(
132
+ <DashboardGrid
133
+ widgets={[spec({ key: 'chart', title: 'My Chart', kind: 'bar' })]}
134
+ loadData={loaderOf({
135
+ chart: { series: [{ key: 'a', label: 'A', value: 3 }] },
136
+ })}
137
+ />,
138
+ )
139
+ await waitFor(() => expect(screen.getByTestId('widget-chart')).toBeTruthy())
140
+ expect(screen.getByText('My Chart')).toBeTruthy()
141
+ })
142
+
143
+ it('shows the per-widget empty state when data is missing', async () => {
144
+ render(
145
+ <DashboardGrid
146
+ widgets={[spec({ key: 'empty', kind: 'stat', empty: 'No stock' })]}
147
+ loadData={loaderOf({})}
148
+ />,
149
+ )
150
+ await waitFor(() => expect(screen.getByTestId('widget-empty')).toBeTruthy())
151
+ expect(screen.getByText('No stock')).toBeTruthy()
152
+ })
153
+
154
+ it('shows the global empty state when there are no widgets', () => {
155
+ render(<DashboardGrid widgets={[]} loadData={loaderOf({})} />)
156
+ expect(screen.getByTestId('dashboard-empty')).toBeTruthy()
157
+ })
158
+ })
159
+
160
+ describe('DashboardGrid permission gating', () => {
161
+ it('without a PermissionsProvider every widget is visible', async () => {
162
+ render(
163
+ <DashboardGrid
164
+ widgets={[spec({ key: 'p', kind: 'stat', permission: 'x.read' })]}
165
+ loadData={loaderOf({ p: { value: 1 } })}
166
+ />,
167
+ )
168
+ await waitFor(() => expect(screen.getByTestId('widget-p')).toBeTruthy())
169
+ })
170
+
171
+ it('hides widgets whose permission is not granted', () => {
172
+ render(
173
+ <PermissionsProvider permissions={['other.read']} isAdmin={false}>
174
+ <DashboardGrid
175
+ widgets={[
176
+ spec({ key: 'p', kind: 'stat', permission: 'secret.read' }),
177
+ ]}
178
+ loadData={loaderOf({ p: { value: 1 } })}
179
+ />
180
+ </PermissionsProvider>,
181
+ )
182
+ // gated out → whole grid is empty → global empty state
183
+ expect(screen.queryByTestId('widget-skeleton-p')).toBeNull()
184
+ expect(screen.getByTestId('dashboard-empty')).toBeTruthy()
185
+ })
186
+
187
+ it('shows granted widgets and respects isAdmin bypass', async () => {
188
+ render(
189
+ <PermissionsProvider permissions={[]} isAdmin={false}>
190
+ <DashboardGrid
191
+ isAdmin
192
+ widgets={[
193
+ spec({ key: 'p', kind: 'stat', permission: 'secret.read' }),
194
+ ]}
195
+ loadData={loaderOf({ p: { value: 7 } })}
196
+ />
197
+ </PermissionsProvider>,
198
+ )
199
+ await waitFor(() => expect(screen.getByTestId('widget-p')).toBeTruthy())
200
+ })
201
+ })
202
+
203
+ describe('DashboardGrid error isolation', () => {
204
+ it('a throwing widget shows its error card; siblings still render', async () => {
205
+ // An unknown kind without a renderer falls to the error card path.
206
+ render(
207
+ <DashboardGrid
208
+ widgets={[
209
+ spec({ key: 'ok', kind: 'stat' }),
210
+ spec({ key: 'bad', kind: 'nonsense' as any }),
211
+ ]}
212
+ loadData={loaderOf({ ok: { value: 9 }, bad: { value: 1 } })}
213
+ />,
214
+ )
215
+ await waitFor(() => expect(screen.getByTestId('widget-ok')).toBeTruthy())
216
+ // sibling renders its value
217
+ expect(screen.getByText('9')).toBeTruthy()
218
+ // broken one shows the error card (not crashing the grid)
219
+ expect(screen.getByTestId('widget-bad')).toBeTruthy()
220
+ expect(screen.getByText('Could not load this widget.')).toBeTruthy()
221
+ })
222
+ })