@asteby/metacore-runtime-react 18.16.0 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/dashboard-grid.d.ts +6 -0
  3. package/dist/dashboard-grid.d.ts.map +1 -0
  4. package/dist/dashboard-grid.js +127 -0
  5. package/dist/dashboard-types.d.ts +130 -0
  6. package/dist/dashboard-types.d.ts.map +1 -0
  7. package/dist/dashboard-types.js +7 -0
  8. package/dist/index.d.ts +6 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +5 -0
  11. package/dist/permissions-manager.d.ts.map +1 -1
  12. package/dist/permissions-manager.js +16 -7
  13. package/dist/widgets/renderers.d.ts +19 -0
  14. package/dist/widgets/renderers.d.ts.map +1 -0
  15. package/dist/widgets/renderers.js +78 -0
  16. package/dist/widgets/widget-card.d.ts +34 -0
  17. package/dist/widgets/widget-card.d.ts.map +1 -0
  18. package/dist/widgets/widget-card.js +30 -0
  19. package/dist/widgets/widget-format.d.ts +42 -0
  20. package/dist/widgets/widget-format.d.ts.map +1 -0
  21. package/dist/widgets/widget-format.js +138 -0
  22. package/dist/widgets/widget-renderer.d.ts +27 -0
  23. package/dist/widgets/widget-renderer.d.ts.map +1 -0
  24. package/dist/widgets/widget-renderer.js +66 -0
  25. package/package.json +4 -3
  26. package/src/__tests__/dashboard-grid.test.tsx +222 -0
  27. package/src/__tests__/permissions-manager.test.tsx +55 -42
  28. package/src/dashboard-grid.tsx +206 -0
  29. package/src/dashboard-types.ts +178 -0
  30. package/src/index.ts +56 -0
  31. package/src/permissions-manager.tsx +90 -65
  32. package/src/widgets/renderers.tsx +336 -0
  33. package/src/widgets/widget-card.tsx +125 -0
  34. package/src/widgets/widget-format.ts +181 -0
  35. package/src/widgets/widget-renderer.tsx +181 -0
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Card, CardContent, CardHeader } from '@asteby/metacore-ui';
3
+ import { cn } from '@asteby/metacore-ui/lib';
4
+ import { DynamicIcon, isLucideIconName } from '../dynamic-icon';
5
+ import { accentClasses } from './widget-format';
6
+ /**
7
+ * The card frame shared by all widgets. Keep the chrome here so a single style
8
+ * change propagates to every kind (and to federated custom widgets).
9
+ */
10
+ export function WidgetCard({ title, subtitle, icon, accent, headerExtra, children, className, ...rest }) {
11
+ const a = accentClasses(accent);
12
+ const showIcon = icon && isLucideIconName(icon);
13
+ return (_jsxs(Card, { ...rest, className: cn(
14
+ // base: subtle border, ring on hover, gentle lift, mount fade-in
15
+ 'group/widget relative flex h-full flex-col overflow-hidden', 'border-border/60 transition-all duration-200', 'hover:border-border hover:ring-1 hover:ring-ring/30 hover:shadow-sm', 'motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-500', className), children: [_jsxs(CardHeader, { className: "flex flex-row items-start justify-between gap-3 space-y-0 pb-2", children: [_jsxs("div", { className: "flex min-w-0 items-start gap-3", children: [showIcon && (_jsx("span", { className: cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.chip), children: _jsx(DynamicIcon, { name: icon, className: "size-[18px]" }) })), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "truncate text-sm font-medium leading-tight text-foreground", children: title }), subtitle && (_jsx("div", { className: "mt-0.5 truncate text-xs text-muted-foreground", children: subtitle }))] })] }), headerExtra && _jsx("div", { className: "shrink-0", children: headerExtra })] }), _jsx(CardContent, { className: "flex flex-1 flex-col justify-end pt-1", children: children })] }));
16
+ }
17
+ /** Delta chip: green up / red down, neutral on zero. `text` is preformatted. */
18
+ export function DeltaChip({ delta, text }) {
19
+ const up = delta > 0;
20
+ const down = delta < 0;
21
+ return (_jsxs("span", { className: cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium tabular-nums', up && 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', down && 'bg-rose-500/10 text-rose-600 dark:text-rose-400', !up && !down && 'bg-muted text-muted-foreground'), children: [up && '▲', down && '▼', text] }));
22
+ }
23
+ /** Centered empty state inside a widget body (no data / missing). */
24
+ export function WidgetEmpty({ message }) {
25
+ return (_jsx("div", { className: "flex flex-1 items-center justify-center py-6 text-center text-xs text-muted-foreground", children: message }));
26
+ }
27
+ /** Per-widget error state — isolated so a broken widget never tumbles the grid. */
28
+ export function WidgetError({ message }) {
29
+ return (_jsxs("div", { className: "flex flex-1 items-center justify-center gap-2 py-6 text-center text-xs text-destructive", children: [_jsx(DynamicIcon, { name: "TriangleAlert", className: "size-4" }), _jsx("span", { children: message })] }));
30
+ }
@@ -0,0 +1,42 @@
1
+ import type { WidgetAccent, WidgetFormat } from '../dashboard-types';
2
+ export interface WidgetFormatCtx {
3
+ format?: WidgetFormat;
4
+ locale?: string;
5
+ currency?: string;
6
+ }
7
+ /**
8
+ * Formats a scalar/series value with the widget's declared format. `currency`
9
+ * uses the org currency + locale (same fallback chain as table cells); `percent`
10
+ * treats the value as a fraction (0.142 → "14.2%"); `compact` uses Intl compact
11
+ * notation (1_200 → "1.2K").
12
+ */
13
+ export declare function formatWidgetValue(value: number, { format, locale, currency }: WidgetFormatCtx): string;
14
+ /** Axis-tick formatter — always compact so charts stay legible. */
15
+ export declare function formatAxisTick(value: number, { format, locale, currency }: WidgetFormatCtx): string;
16
+ /** Formats the compare delta fraction (0.142 → "+14.2%"). */
17
+ export declare function formatDelta(delta: number, locale?: string): string;
18
+ export interface AccentClasses {
19
+ /** Icon chip background + foreground. */
20
+ chip: string;
21
+ /** Text color for accented labels. */
22
+ text: string;
23
+ /** Solid bar/fill background (progress, list proportion). */
24
+ bar: string;
25
+ /** Soft track background behind a bar. */
26
+ track: string;
27
+ /** Hex-ish CSS color used for recharts stroke/fill (var-based). */
28
+ chartVar: string;
29
+ }
30
+ /** Resolves the accent class bundle, defaulting to `sky`. */
31
+ export declare function accentClasses(accent?: WidgetAccent): AccentClasses;
32
+ /**
33
+ * A categorical palette for multi-series charts (pie/donut/bar by bucket).
34
+ * Cycles the accent chart vars so slices stay theme-aware + dark-mode safe.
35
+ */
36
+ export declare const CHART_PALETTE: string[];
37
+ /** Picks a palette color by index, wrapping around. */
38
+ export declare function paletteColor(index: number): string;
39
+ /** Grid/axis muted color from the theme (works in light + dark). */
40
+ export declare const CHART_MUTED = "var(--muted-foreground, #94a3b8)";
41
+ export declare const CHART_GRID = "var(--border, #e2e8f0)";
42
+ //# sourceMappingURL=widget-format.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"widget-format.d.ts","sourceRoot":"","sources":["../../src/widgets/widget-format.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAEpE,MAAM,WAAW,eAAe;IAC5B,MAAM,CAAC,EAAE,YAAY,CAAA;IACrB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,MAAM,EACb,EAAE,MAAiB,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,eAAe,GACzD,MAAM,CA2BR;AAED,mEAAmE;AACnE,wBAAgB,cAAc,CAC1B,KAAK,EAAE,MAAM,EACb,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,eAAe,GAC9C,MAAM,CAoBR;AAED,6DAA6D;AAC7D,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAUlE;AAQD,MAAM,WAAW,aAAa;IAC1B,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAA;IACZ,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAA;IACZ,6DAA6D;IAC7D,GAAG,EAAE,MAAM,CAAA;IACX,0CAA0C;IAC1C,KAAK,EAAE,MAAM,CAAA;IACb,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAA;CACnB;AAiDD,6DAA6D;AAC7D,wBAAgB,aAAa,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG,aAAa,CAElE;AAED;;;GAGG;AACH,eAAO,MAAM,aAAa,EAAE,MAAM,EAOjC,CAAA;AAED,uDAAuD;AACvD,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,oEAAoE;AACpE,eAAO,MAAM,WAAW,qCAAqC,CAAA;AAC7D,eAAO,MAAM,UAAU,2BAA2B,CAAA"}
@@ -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.0",
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": {
@@ -34,7 +35,7 @@
34
35
  "sonner": ">=1.7",
35
36
  "zustand": ">=5",
36
37
  "@asteby/metacore-sdk": "^3.2.0",
37
- "@asteby/metacore-ui": "^2.5.1"
38
+ "@asteby/metacore-ui": "^2.5.2"
38
39
  },
39
40
  "peerDependenciesMeta": {
40
41
  "@tanstack/react-router": {
@@ -64,7 +65,7 @@
64
65
  "vitest": "^4.0.0",
65
66
  "zustand": "^5.0.0",
66
67
  "@asteby/metacore-sdk": "3.2.0",
67
- "@asteby/metacore-ui": "2.5.1"
68
+ "@asteby/metacore-ui": "2.5.2"
68
69
  },
69
70
  "scripts": {
70
71
  "build": "tsc -p tsconfig.json",
@@ -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
+ })