@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,181 @@
|
|
|
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
|
+
import type { WidgetAccent, WidgetFormat } from '../dashboard-types'
|
|
6
|
+
|
|
7
|
+
export interface WidgetFormatCtx {
|
|
8
|
+
format?: WidgetFormat
|
|
9
|
+
locale?: string
|
|
10
|
+
currency?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Formats a scalar/series value with the widget's declared format. `currency`
|
|
15
|
+
* uses the org currency + locale (same fallback chain as table cells); `percent`
|
|
16
|
+
* treats the value as a fraction (0.142 → "14.2%"); `compact` uses Intl compact
|
|
17
|
+
* notation (1_200 → "1.2K").
|
|
18
|
+
*/
|
|
19
|
+
export function formatWidgetValue(
|
|
20
|
+
value: number,
|
|
21
|
+
{ format = 'number', locale, currency }: WidgetFormatCtx,
|
|
22
|
+
): string {
|
|
23
|
+
if (value === null || value === undefined || Number.isNaN(value)) return '—'
|
|
24
|
+
const loc = locale || undefined
|
|
25
|
+
switch (format) {
|
|
26
|
+
case 'currency':
|
|
27
|
+
return new Intl.NumberFormat(loc, {
|
|
28
|
+
style: 'currency',
|
|
29
|
+
currency: currency || 'USD',
|
|
30
|
+
maximumFractionDigits: 2,
|
|
31
|
+
}).format(value)
|
|
32
|
+
case 'percent':
|
|
33
|
+
return new Intl.NumberFormat(loc, {
|
|
34
|
+
style: 'percent',
|
|
35
|
+
minimumFractionDigits: 0,
|
|
36
|
+
maximumFractionDigits: 1,
|
|
37
|
+
}).format(value)
|
|
38
|
+
case 'compact':
|
|
39
|
+
return new Intl.NumberFormat(loc, {
|
|
40
|
+
notation: 'compact',
|
|
41
|
+
maximumFractionDigits: 1,
|
|
42
|
+
}).format(value)
|
|
43
|
+
case 'number':
|
|
44
|
+
default:
|
|
45
|
+
return new Intl.NumberFormat(loc, {
|
|
46
|
+
maximumFractionDigits: 2,
|
|
47
|
+
}).format(value)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Axis-tick formatter — always compact so charts stay legible. */
|
|
52
|
+
export function formatAxisTick(
|
|
53
|
+
value: number,
|
|
54
|
+
{ format, locale, currency }: WidgetFormatCtx,
|
|
55
|
+
): string {
|
|
56
|
+
const loc = locale || undefined
|
|
57
|
+
if (format === 'currency') {
|
|
58
|
+
return new Intl.NumberFormat(loc, {
|
|
59
|
+
style: 'currency',
|
|
60
|
+
currency: currency || 'USD',
|
|
61
|
+
notation: 'compact',
|
|
62
|
+
maximumFractionDigits: 1,
|
|
63
|
+
}).format(value)
|
|
64
|
+
}
|
|
65
|
+
if (format === 'percent') {
|
|
66
|
+
return new Intl.NumberFormat(loc, {
|
|
67
|
+
style: 'percent',
|
|
68
|
+
maximumFractionDigits: 0,
|
|
69
|
+
}).format(value)
|
|
70
|
+
}
|
|
71
|
+
return new Intl.NumberFormat(loc, {
|
|
72
|
+
notation: 'compact',
|
|
73
|
+
maximumFractionDigits: 1,
|
|
74
|
+
}).format(value)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Formats the compare delta fraction (0.142 → "+14.2%"). */
|
|
78
|
+
export function formatDelta(delta: number, locale?: string): string {
|
|
79
|
+
const sign = delta > 0 ? '+' : ''
|
|
80
|
+
return (
|
|
81
|
+
sign +
|
|
82
|
+
new Intl.NumberFormat(locale || undefined, {
|
|
83
|
+
style: 'percent',
|
|
84
|
+
minimumFractionDigits: 0,
|
|
85
|
+
maximumFractionDigits: 1,
|
|
86
|
+
}).format(delta)
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Accent theming -------------------------------------------------------
|
|
91
|
+
// Tailwind tokens scanned in THIS package's build (the renderers live in the
|
|
92
|
+
// SDK, not the federated host), so static utility classes are safe. We avoid
|
|
93
|
+
// host-dependent arbitrary values. Each accent maps to a small bundle of
|
|
94
|
+
// classes for the icon chip, the chart stroke/fill and the progress/list bar.
|
|
95
|
+
|
|
96
|
+
export interface AccentClasses {
|
|
97
|
+
/** Icon chip background + foreground. */
|
|
98
|
+
chip: string
|
|
99
|
+
/** Text color for accented labels. */
|
|
100
|
+
text: string
|
|
101
|
+
/** Solid bar/fill background (progress, list proportion). */
|
|
102
|
+
bar: string
|
|
103
|
+
/** Soft track background behind a bar. */
|
|
104
|
+
track: string
|
|
105
|
+
/** Hex-ish CSS color used for recharts stroke/fill (var-based). */
|
|
106
|
+
chartVar: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ACCENTS: Record<WidgetAccent, AccentClasses> = {
|
|
110
|
+
emerald: {
|
|
111
|
+
chip: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
|
112
|
+
text: 'text-emerald-600 dark:text-emerald-400',
|
|
113
|
+
bar: 'bg-emerald-500',
|
|
114
|
+
track: 'bg-emerald-500/15',
|
|
115
|
+
chartVar: 'var(--color-widget-emerald, #10b981)',
|
|
116
|
+
},
|
|
117
|
+
sky: {
|
|
118
|
+
chip: 'bg-sky-500/10 text-sky-600 dark:text-sky-400',
|
|
119
|
+
text: 'text-sky-600 dark:text-sky-400',
|
|
120
|
+
bar: 'bg-sky-500',
|
|
121
|
+
track: 'bg-sky-500/15',
|
|
122
|
+
chartVar: 'var(--color-widget-sky, #0ea5e9)',
|
|
123
|
+
},
|
|
124
|
+
violet: {
|
|
125
|
+
chip: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
|
126
|
+
text: 'text-violet-600 dark:text-violet-400',
|
|
127
|
+
bar: 'bg-violet-500',
|
|
128
|
+
track: 'bg-violet-500/15',
|
|
129
|
+
chartVar: 'var(--color-widget-violet, #8b5cf6)',
|
|
130
|
+
},
|
|
131
|
+
amber: {
|
|
132
|
+
chip: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
|
133
|
+
text: 'text-amber-600 dark:text-amber-400',
|
|
134
|
+
bar: 'bg-amber-500',
|
|
135
|
+
track: 'bg-amber-500/15',
|
|
136
|
+
chartVar: 'var(--color-widget-amber, #f59e0b)',
|
|
137
|
+
},
|
|
138
|
+
rose: {
|
|
139
|
+
chip: 'bg-rose-500/10 text-rose-600 dark:text-rose-400',
|
|
140
|
+
text: 'text-rose-600 dark:text-rose-400',
|
|
141
|
+
bar: 'bg-rose-500',
|
|
142
|
+
track: 'bg-rose-500/15',
|
|
143
|
+
chartVar: 'var(--color-widget-rose, #f43f5e)',
|
|
144
|
+
},
|
|
145
|
+
slate: {
|
|
146
|
+
chip: 'bg-slate-500/10 text-slate-600 dark:text-slate-300',
|
|
147
|
+
text: 'text-slate-600 dark:text-slate-300',
|
|
148
|
+
bar: 'bg-slate-500',
|
|
149
|
+
track: 'bg-slate-500/15',
|
|
150
|
+
chartVar: 'var(--color-widget-slate, #64748b)',
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const DEFAULT_ACCENT: WidgetAccent = 'sky'
|
|
155
|
+
|
|
156
|
+
/** Resolves the accent class bundle, defaulting to `sky`. */
|
|
157
|
+
export function accentClasses(accent?: WidgetAccent): AccentClasses {
|
|
158
|
+
return ACCENTS[accent ?? DEFAULT_ACCENT] ?? ACCENTS[DEFAULT_ACCENT]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A categorical palette for multi-series charts (pie/donut/bar by bucket).
|
|
163
|
+
* Cycles the accent chart vars so slices stay theme-aware + dark-mode safe.
|
|
164
|
+
*/
|
|
165
|
+
export const CHART_PALETTE: string[] = [
|
|
166
|
+
ACCENTS.sky.chartVar,
|
|
167
|
+
ACCENTS.emerald.chartVar,
|
|
168
|
+
ACCENTS.violet.chartVar,
|
|
169
|
+
ACCENTS.amber.chartVar,
|
|
170
|
+
ACCENTS.rose.chartVar,
|
|
171
|
+
ACCENTS.slate.chartVar,
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
/** Picks a palette color by index, wrapping around. */
|
|
175
|
+
export function paletteColor(index: number): string {
|
|
176
|
+
return CHART_PALETTE[index % CHART_PALETTE.length]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Grid/axis muted color from the theme (works in light + dark). */
|
|
180
|
+
export const CHART_MUTED = 'var(--muted-foreground, #94a3b8)'
|
|
181
|
+
export const CHART_GRID = 'var(--border, #e2e8f0)'
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Per-widget dispatcher: maps a spec.kind to its built-in renderer (or a
|
|
2
|
+
// federated <Slot> for kind:"custom"), wrapped in an error boundary so a single
|
|
3
|
+
// broken widget renders its own error card instead of tumbling the grid.
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { Slot } from '../slot'
|
|
7
|
+
import type { DashboardWidgetSpec, WidgetData, WidgetSize } from '../dashboard-types'
|
|
8
|
+
import { WidgetCard, WidgetError } from './widget-card'
|
|
9
|
+
import {
|
|
10
|
+
StatWidget,
|
|
11
|
+
BarWidget,
|
|
12
|
+
LineWidget,
|
|
13
|
+
AreaWidget,
|
|
14
|
+
PieWidget,
|
|
15
|
+
DonutWidget,
|
|
16
|
+
ListWidget,
|
|
17
|
+
ProgressWidget,
|
|
18
|
+
type WidgetRenderProps,
|
|
19
|
+
} from './renderers'
|
|
20
|
+
|
|
21
|
+
/** Maps a widget size to its column span in the 4-col grid. */
|
|
22
|
+
export const SIZE_SPAN: Record<WidgetSize, number> = {
|
|
23
|
+
sm: 1,
|
|
24
|
+
md: 2,
|
|
25
|
+
lg: 3,
|
|
26
|
+
full: 4,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Tailwind col-span class per size (static → scanned in this package build). */
|
|
30
|
+
export const SIZE_CLASS: Record<WidgetSize, string> = {
|
|
31
|
+
sm: 'sm:col-span-2 lg:col-span-1',
|
|
32
|
+
md: 'sm:col-span-2 lg:col-span-2',
|
|
33
|
+
lg: 'sm:col-span-2 lg:col-span-3',
|
|
34
|
+
full: 'sm:col-span-2 lg:col-span-4',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const RENDERERS: Record<
|
|
38
|
+
string,
|
|
39
|
+
(p: WidgetRenderProps) => React.ReactElement
|
|
40
|
+
> = {
|
|
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
|
+
|
|
51
|
+
interface BoundaryProps {
|
|
52
|
+
spec: DashboardWidgetSpec
|
|
53
|
+
message: string
|
|
54
|
+
children: React.ReactNode
|
|
55
|
+
}
|
|
56
|
+
interface BoundaryState {
|
|
57
|
+
error: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Isolated boundary: a throwing widget renders its own error card. */
|
|
61
|
+
class WidgetErrorBoundary extends React.Component<BoundaryProps, BoundaryState> {
|
|
62
|
+
state: BoundaryState = { error: false }
|
|
63
|
+
static getDerivedStateFromError(): BoundaryState {
|
|
64
|
+
return { error: true }
|
|
65
|
+
}
|
|
66
|
+
render() {
|
|
67
|
+
if (this.state.error) {
|
|
68
|
+
return (
|
|
69
|
+
<WidgetCard
|
|
70
|
+
data-testid={`widget-${this.props.spec.key}`}
|
|
71
|
+
title={this.props.spec.title}
|
|
72
|
+
subtitle={this.props.spec.subtitle}
|
|
73
|
+
icon={this.props.spec.icon}
|
|
74
|
+
accent={this.props.spec.accent}
|
|
75
|
+
>
|
|
76
|
+
<WidgetError message={this.props.message} />
|
|
77
|
+
</WidgetCard>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
return this.props.children
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface WidgetRendererProps {
|
|
85
|
+
spec: DashboardWidgetSpec
|
|
86
|
+
data?: WidgetData
|
|
87
|
+
locale?: string
|
|
88
|
+
currency?: string
|
|
89
|
+
/** Translated empty fallback for this widget. */
|
|
90
|
+
emptyText: string
|
|
91
|
+
/** Translated error message for this widget. */
|
|
92
|
+
errorText: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Renders one widget. `kind:"custom"` defers to the federated slot
|
|
97
|
+
* (`spec.slot ?? 'dashboard.widgets'`) inside the same card chrome so it
|
|
98
|
+
* combines with the declarative widgets.
|
|
99
|
+
*/
|
|
100
|
+
export function WidgetRenderer({
|
|
101
|
+
spec,
|
|
102
|
+
data,
|
|
103
|
+
locale,
|
|
104
|
+
currency,
|
|
105
|
+
emptyText,
|
|
106
|
+
errorText,
|
|
107
|
+
}: WidgetRendererProps) {
|
|
108
|
+
let body: React.ReactNode
|
|
109
|
+
if (spec.kind === 'custom') {
|
|
110
|
+
body = (
|
|
111
|
+
<WidgetCard
|
|
112
|
+
data-testid={`widget-${spec.key}`}
|
|
113
|
+
title={spec.title}
|
|
114
|
+
subtitle={spec.subtitle}
|
|
115
|
+
icon={spec.icon}
|
|
116
|
+
accent={spec.accent}
|
|
117
|
+
>
|
|
118
|
+
<Slot
|
|
119
|
+
name={spec.slot ?? 'dashboard.widgets'}
|
|
120
|
+
props={{ spec, data, locale, currency }}
|
|
121
|
+
fallback={
|
|
122
|
+
<div className="flex flex-1 items-center justify-center py-6 text-xs text-muted-foreground">
|
|
123
|
+
{emptyText}
|
|
124
|
+
</div>
|
|
125
|
+
}
|
|
126
|
+
/>
|
|
127
|
+
</WidgetCard>
|
|
128
|
+
)
|
|
129
|
+
} else {
|
|
130
|
+
const Renderer = RENDERERS[spec.kind]
|
|
131
|
+
body = Renderer ? (
|
|
132
|
+
<Renderer
|
|
133
|
+
spec={spec}
|
|
134
|
+
data={data}
|
|
135
|
+
locale={locale}
|
|
136
|
+
currency={currency}
|
|
137
|
+
emptyText={emptyText}
|
|
138
|
+
/>
|
|
139
|
+
) : (
|
|
140
|
+
<WidgetCard
|
|
141
|
+
data-testid={`widget-${spec.key}`}
|
|
142
|
+
title={spec.title}
|
|
143
|
+
subtitle={spec.subtitle}
|
|
144
|
+
icon={spec.icon}
|
|
145
|
+
accent={spec.accent}
|
|
146
|
+
>
|
|
147
|
+
<WidgetError message={errorText} />
|
|
148
|
+
</WidgetCard>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<WidgetErrorBoundary spec={spec} message={errorText}>
|
|
154
|
+
{body}
|
|
155
|
+
</WidgetErrorBoundary>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Skeleton placeholder shown per widget while data loads. */
|
|
160
|
+
export function WidgetSkeleton({ spec }: { spec: DashboardWidgetSpec }) {
|
|
161
|
+
const isChart =
|
|
162
|
+
spec.kind !== 'stat' && spec.kind !== 'progress' && spec.kind !== 'custom'
|
|
163
|
+
return (
|
|
164
|
+
<WidgetCard
|
|
165
|
+
data-testid={`widget-skeleton-${spec.key}`}
|
|
166
|
+
title={spec.title}
|
|
167
|
+
subtitle={spec.subtitle}
|
|
168
|
+
icon={spec.icon}
|
|
169
|
+
accent={spec.accent}
|
|
170
|
+
>
|
|
171
|
+
{isChart ? (
|
|
172
|
+
<div className="h-[132px] w-full animate-pulse rounded-md bg-muted" />
|
|
173
|
+
) : (
|
|
174
|
+
<div className="flex flex-col gap-2">
|
|
175
|
+
<div className="h-8 w-2/3 animate-pulse rounded bg-muted" />
|
|
176
|
+
<div className="h-2 w-full animate-pulse rounded bg-muted/60" />
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</WidgetCard>
|
|
180
|
+
)
|
|
181
|
+
}
|