@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.
- package/CHANGELOG.md +24 -0
- package/dist/dashboard-grid.d.ts +6 -0
- package/dist/dashboard-grid.d.ts.map +1 -0
- package/dist/dashboard-grid.js +127 -0
- package/dist/dashboard-types.d.ts +130 -0
- package/dist/dashboard-types.d.ts.map +1 -0
- package/dist/dashboard-types.js +7 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/widgets/renderers.d.ts +19 -0
- package/dist/widgets/renderers.d.ts.map +1 -0
- package/dist/widgets/renderers.js +78 -0
- package/dist/widgets/widget-card.d.ts +34 -0
- package/dist/widgets/widget-card.d.ts.map +1 -0
- package/dist/widgets/widget-card.js +30 -0
- package/dist/widgets/widget-format.d.ts +42 -0
- package/dist/widgets/widget-format.d.ts.map +1 -0
- package/dist/widgets/widget-format.js +138 -0
- package/dist/widgets/widget-renderer.d.ts +27 -0
- package/dist/widgets/widget-renderer.d.ts.map +1 -0
- package/dist/widgets/widget-renderer.js +66 -0
- package/package.json +2 -1
- package/src/__tests__/dashboard-grid.test.tsx +222 -0
- package/src/dashboard-grid.tsx +206 -0
- package/src/dashboard-types.ts +178 -0
- package/src/index.ts +56 -0
- package/src/widgets/renderers.tsx +336 -0
- package/src/widgets/widget-card.tsx +125 -0
- package/src/widgets/widget-format.ts +181 -0
- package/src/widgets/widget-renderer.tsx +181 -0
|
@@ -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.
|
|
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
|
+
}
|