@asteby/metacore-runtime-react 18.16.1 → 18.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,27 @@
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
+ export declare const SIZE_CLASS: Record<WidgetSize, string>;
7
+ export interface WidgetRendererProps {
8
+ spec: DashboardWidgetSpec;
9
+ data?: WidgetData;
10
+ locale?: string;
11
+ currency?: string;
12
+ /** Translated empty fallback for this widget. */
13
+ emptyText: string;
14
+ /** Translated error message for this widget. */
15
+ errorText: string;
16
+ }
17
+ /**
18
+ * Renders one widget. `kind:"custom"` defers to the federated slot
19
+ * (`spec.slot ?? 'dashboard.widgets'`) inside the same card chrome so it
20
+ * combines with the declarative widgets.
21
+ */
22
+ export declare function WidgetRenderer({ spec, data, locale, currency, emptyText, errorText, }: WidgetRendererProps): React.JSX.Element;
23
+ /** Skeleton placeholder shown per widget while data loads. */
24
+ export declare function WidgetSkeleton({ spec }: {
25
+ spec: DashboardWidgetSpec;
26
+ }): React.JSX.Element;
27
+ //# 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,iFAAiF;AACjF,eAAO,MAAM,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAKjD,CAAA;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,66 @@
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
+ export const SIZE_CLASS = {
18
+ sm: 'sm:col-span-2 lg:col-span-1',
19
+ md: 'sm:col-span-2 lg:col-span-2',
20
+ lg: 'sm:col-span-2 lg:col-span-3',
21
+ full: 'sm:col-span-2 lg:col-span-4',
22
+ };
23
+ const RENDERERS = {
24
+ stat: StatWidget,
25
+ bar: BarWidget,
26
+ line: LineWidget,
27
+ area: AreaWidget,
28
+ pie: PieWidget,
29
+ donut: DonutWidget,
30
+ list: ListWidget,
31
+ progress: ProgressWidget,
32
+ };
33
+ /** Isolated boundary: a throwing widget renders its own error card. */
34
+ class WidgetErrorBoundary extends React.Component {
35
+ state = { error: false };
36
+ static getDerivedStateFromError() {
37
+ return { error: true };
38
+ }
39
+ render() {
40
+ if (this.state.error) {
41
+ 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 }) }));
42
+ }
43
+ return this.props.children;
44
+ }
45
+ }
46
+ /**
47
+ * Renders one widget. `kind:"custom"` defers to the federated slot
48
+ * (`spec.slot ?? 'dashboard.widgets'`) inside the same card chrome so it
49
+ * combines with the declarative widgets.
50
+ */
51
+ export function WidgetRenderer({ spec, data, locale, currency, emptyText, errorText, }) {
52
+ let body;
53
+ if (spec.kind === 'custom') {
54
+ 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 }) }) }));
55
+ }
56
+ else {
57
+ const Renderer = RENDERERS[spec.kind];
58
+ 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 }) }));
59
+ }
60
+ return (_jsx(WidgetErrorBoundary, { spec: spec, message: errorText, children: body }));
61
+ }
62
+ /** Skeleton placeholder shown per widget while data loads. */
63
+ export function WidgetSkeleton({ spec }) {
64
+ const isChart = spec.kind !== 'stat' && spec.kind !== 'progress' && spec.kind !== 'custom';
65
+ 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" })] })) }));
66
+ }
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.0",
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
+ })
@@ -0,0 +1,206 @@
1
+ // DashboardGrid — the modular dashboard surface. Renders the union of
2
+ // declarative + federated widgets in a responsive 4-column grid, grouped with
3
+ // headings, honouring per-widget size, permission gating (useCan), batch data
4
+ // loading with per-widget skeletons, isolated per-widget errors, and a pro
5
+ // global empty state. See CONTRACT-dashboard-widgets.md §4.
6
+
7
+ import * as React from 'react'
8
+ import { useTranslation } from 'react-i18next'
9
+ import { cn } from '@asteby/metacore-ui/lib'
10
+ import { useCan, usePermissionsActive } from './permissions-context'
11
+ import { DynamicIcon } from './dynamic-icon'
12
+ import type {
13
+ DashboardGridProps,
14
+ DashboardGridStrings,
15
+ DashboardWidgetGroup,
16
+ DashboardWidgetSpec,
17
+ } from './dashboard-types'
18
+ import { WidgetRenderer, WidgetSkeleton, SIZE_CLASS } from './widgets/widget-renderer'
19
+
20
+ const DEFAULT_STRINGS: DashboardGridStrings = {
21
+ emptyTitle: 'Your dashboard is empty',
22
+ emptyDescription:
23
+ 'Install an addon with dashboard widgets to start seeing metrics here.',
24
+ widgetError: 'Could not load this widget.',
25
+ widgetEmpty: 'No data yet.',
26
+ }
27
+
28
+ /** Normalizes flat `widgets` + `groups` into an ordered group list. */
29
+ export function normalizeGroups(
30
+ groups?: DashboardWidgetGroup[],
31
+ widgets?: DashboardWidgetSpec[],
32
+ ): DashboardWidgetGroup[] {
33
+ if (groups && groups.length > 0) return groups
34
+ if (!widgets || widgets.length === 0) return []
35
+ // Group flat widgets by `group` (preserve first-seen order), sort each
36
+ // group by `order` (default 100), keep insertion order across groups.
37
+ const map = new Map<string, DashboardWidgetSpec[]>()
38
+ for (const w of widgets) {
39
+ const key = w.group ?? ''
40
+ const arr = map.get(key) ?? []
41
+ arr.push(w)
42
+ map.set(key, arr)
43
+ }
44
+ return Array.from(map.entries()).map(([title, ws]) => ({
45
+ title,
46
+ widgets: [...ws].sort((a, b) => (a.order ?? 100) - (b.order ?? 100)),
47
+ }))
48
+ }
49
+
50
+ /** Collects every widget key across groups (for the batch loader). */
51
+ function allKeys(groups: DashboardWidgetGroup[]): string[] {
52
+ const keys: string[] = []
53
+ for (const g of groups) for (const w of g.widgets) keys.push(w.key)
54
+ return keys
55
+ }
56
+
57
+ export function DashboardGrid({
58
+ groups,
59
+ widgets,
60
+ loadData,
61
+ isAdmin,
62
+ locale,
63
+ currency,
64
+ className,
65
+ strings,
66
+ }: DashboardGridProps) {
67
+ const { t } = useTranslation()
68
+ const can = useCan()
69
+ const permissionsActive = usePermissionsActive()
70
+ const s = { ...DEFAULT_STRINGS, ...strings }
71
+
72
+ // i18n helper: translate a key, falling back to the raw key when missing
73
+ // (specs ship raw i18n keys; the host bundle may or may not have them).
74
+ const tr = React.useCallback(
75
+ (key?: string, fallback?: string): string => {
76
+ if (!key) return fallback ?? ''
77
+ const out = t(key)
78
+ return out === key ? fallback ?? key : out
79
+ },
80
+ [t],
81
+ )
82
+
83
+ // Permission gating: admin bypass; without a PermissionsProvider everything
84
+ // is visible (retrocompat). With one, honour each widget's `permission`.
85
+ const visibleGroups = React.useMemo(() => {
86
+ const norm = normalizeGroups(groups, widgets)
87
+ const gate = (w: DashboardWidgetSpec): boolean => {
88
+ if (isAdmin) return true
89
+ if (!permissionsActive) return true
90
+ if (!w.permission) return true
91
+ return can(w.permission)
92
+ }
93
+ return norm
94
+ .map((g) => ({ ...g, widgets: g.widgets.filter(gate) }))
95
+ .filter((g) => g.widgets.length > 0)
96
+ }, [groups, widgets, isAdmin, permissionsActive, can])
97
+
98
+ const keys = React.useMemo(() => allKeys(visibleGroups), [visibleGroups])
99
+ const keySig = keys.join(',')
100
+
101
+ const [data, setData] = React.useState<
102
+ Record<string, import('./dashboard-types').WidgetData> | null
103
+ >(null)
104
+ const [loading, setLoading] = React.useState(true)
105
+
106
+ React.useEffect(() => {
107
+ let cancelled = false
108
+ if (keys.length === 0) {
109
+ setLoading(false)
110
+ setData({})
111
+ return
112
+ }
113
+ setLoading(true)
114
+ loadData(keys)
115
+ .then((res) => {
116
+ if (!cancelled) {
117
+ setData(res ?? {})
118
+ setLoading(false)
119
+ }
120
+ })
121
+ .catch(() => {
122
+ // Batch failure: still render the grid; every widget falls to
123
+ // its empty/error state rather than blanking the page.
124
+ if (!cancelled) {
125
+ setData({})
126
+ setLoading(false)
127
+ }
128
+ })
129
+ return () => {
130
+ cancelled = true
131
+ }
132
+ // eslint-disable-next-line react-hooks/exhaustive-deps
133
+ }, [keySig, loadData])
134
+
135
+ // Global empty state (no widgets at all / none visible after gating).
136
+ if (visibleGroups.length === 0) {
137
+ return (
138
+ <div
139
+ data-testid="dashboard-empty"
140
+ className={cn(
141
+ 'flex min-h-[40vh] flex-col items-center justify-center rounded-xl border border-dashed border-border/60 p-10 text-center',
142
+ className,
143
+ )}
144
+ >
145
+ <div className="mb-4 flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
146
+ <DynamicIcon name="LayoutDashboard" className="size-7" />
147
+ </div>
148
+ <h3 className="text-base font-semibold text-foreground">
149
+ {tr(undefined, s.emptyTitle) || s.emptyTitle}
150
+ </h3>
151
+ <p className="mt-1 max-w-sm text-sm text-muted-foreground">
152
+ {s.emptyDescription}
153
+ </p>
154
+ </div>
155
+ )
156
+ }
157
+
158
+ 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-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
168
+ {group.widgets.map((spec) => {
169
+ const size = spec.size ?? 'sm'
170
+ return (
171
+ <div key={spec.key} className={SIZE_CLASS[size]}>
172
+ {loading ? (
173
+ <WidgetSkeleton
174
+ spec={{
175
+ ...spec,
176
+ title: tr(spec.title, spec.title),
177
+ subtitle: tr(spec.subtitle, spec.subtitle),
178
+ }}
179
+ />
180
+ ) : (
181
+ <WidgetRenderer
182
+ spec={{
183
+ ...spec,
184
+ title: tr(spec.title, spec.title),
185
+ subtitle: tr(spec.subtitle, spec.subtitle),
186
+ }}
187
+ data={data?.[spec.key]}
188
+ locale={locale}
189
+ currency={currency}
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
+ })}
201
+ </div>
202
+ </section>
203
+ ))}
204
+ </div>
205
+ )
206
+ }