@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.
- package/CHANGELOG.md +30 -0
- package/dist/dashboard-grid.d.ts +6 -0
- package/dist/dashboard-grid.d.ts.map +1 -0
- package/dist/dashboard-grid.js +126 -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 +83 -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 +34 -0
- package/dist/widgets/widget-renderer.d.ts.map +1 -0
- package/dist/widgets/widget-renderer.js +83 -0
- package/package.json +2 -1
- package/src/__tests__/dashboard-grid.test.tsx +222 -0
- package/src/dashboard-grid.tsx +205 -0
- package/src/dashboard-types.ts +178 -0
- package/src/index.ts +56 -0
- package/src/widgets/renderers.tsx +351 -0
- package/src/widgets/widget-card.tsx +125 -0
- package/src/widgets/widget-format.ts +181 -0
- package/src/widgets/widget-renderer.tsx +200 -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,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.
|
|
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
|
+
})
|