@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.
@@ -0,0 +1,205 @@
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, spanClass } 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-flow-row-dense auto-rows-[116px] grid-cols-2 gap-4 lg:grid-cols-4">
168
+ {group.widgets.map((spec) => {
169
+ return (
170
+ <div key={spec.key} className={cn(spanClass(spec), 'min-h-0')}>
171
+ {loading ? (
172
+ <WidgetSkeleton
173
+ spec={{
174
+ ...spec,
175
+ title: tr(spec.title, spec.title),
176
+ subtitle: tr(spec.subtitle, spec.subtitle),
177
+ }}
178
+ />
179
+ ) : (
180
+ <WidgetRenderer
181
+ spec={{
182
+ ...spec,
183
+ title: tr(spec.title, spec.title),
184
+ subtitle: tr(spec.subtitle, spec.subtitle),
185
+ }}
186
+ data={data?.[spec.key]}
187
+ locale={locale}
188
+ currency={currency}
189
+ emptyText={
190
+ spec.empty
191
+ ? tr(spec.empty, spec.empty)
192
+ : s.widgetEmpty
193
+ }
194
+ errorText={s.widgetError}
195
+ />
196
+ )}
197
+ </div>
198
+ )
199
+ })}
200
+ </div>
201
+ </section>
202
+ ))}
203
+ </div>
204
+ )
205
+ }
@@ -0,0 +1,178 @@
1
+ // Dashboard widget types — the SDK-facing surface of the modular dashboard
2
+ // contract (CONTRACT-dashboard-widgets.md §1, §3, §4). The host (ops/kernel)
3
+ // computes the data and ships the specs as raw i18n keys; the SDK renders.
4
+ //
5
+ // These mirror the v3 `contributions.dashboard[]` shape so a host can forward
6
+ // the backend response straight into <DashboardGrid> without remapping.
7
+
8
+ /** Widget renderer kinds. `custom` defers to a federated slot component. */
9
+ export type WidgetKind =
10
+ | 'stat'
11
+ | 'bar'
12
+ | 'line'
13
+ | 'area'
14
+ | 'pie'
15
+ | 'donut'
16
+ | 'list'
17
+ | 'progress'
18
+ | 'custom'
19
+
20
+ /** Grid footprint in a 4-column grid: sm=1, md=2, lg=3, full=4. */
21
+ export type WidgetSize = 'sm' | 'md' | 'lg' | 'full'
22
+
23
+ /** Value format applied to the scalar value and to series values. */
24
+ export type WidgetFormat = 'number' | 'currency' | 'percent' | 'compact'
25
+
26
+ /** Accent color token (theme CSS vars). */
27
+ export type WidgetAccent =
28
+ | 'emerald'
29
+ | 'sky'
30
+ | 'violet'
31
+ | 'amber'
32
+ | 'rose'
33
+ | 'slate'
34
+
35
+ /** Aggregation kinds for declarative queries. */
36
+ export type WidgetAggregate = 'count' | 'sum' | 'avg' | 'min' | 'max'
37
+
38
+ /** Where-clause operators (mirror the list builder). */
39
+ export interface WidgetWhereOp {
40
+ eq?: unknown
41
+ neq?: unknown
42
+ gt?: unknown
43
+ gte?: unknown
44
+ lt?: unknown
45
+ lte?: unknown
46
+ contains?: unknown
47
+ }
48
+
49
+ /**
50
+ * Declarative aggregation query (kinds other than `custom`). The host resolves
51
+ * the logical table from `model` and computes the aggregate org-scoped.
52
+ */
53
+ export interface DashboardWidgetQuery {
54
+ model: string
55
+ aggregate: WidgetAggregate
56
+ field?: string
57
+ where?: Record<string, unknown | WidgetWhereOp>
58
+ group_by?: string
59
+ label_field?: string
60
+ date_field?: string
61
+ interval?: 'day' | 'week' | 'month'
62
+ range?:
63
+ | 'this_day'
64
+ | 'last_7_days'
65
+ | 'last_30_days'
66
+ | 'last_12_months'
67
+ | 'this_month'
68
+ | 'this_year'
69
+ | 'all'
70
+ order?: 'asc' | 'desc'
71
+ limit?: number
72
+ }
73
+
74
+ /** Optional delta comparison against the previous window → `+14.2%` chip. */
75
+ export interface DashboardWidgetCompare {
76
+ to: 'previous_period'
77
+ }
78
+
79
+ /**
80
+ * A single dashboard widget spec (v3 contract §1). The host ships these as raw
81
+ * i18n keys (`title`, `subtitle`, `group`, `empty`); the grid translates them.
82
+ */
83
+ export interface DashboardWidgetSpec {
84
+ /** Unique within the addon. Capability = `<addon>.dashboard.<key>`. */
85
+ key: string
86
+ /** i18n key for the title. */
87
+ title: string
88
+ /** i18n key for the subtitle. */
89
+ subtitle?: string
90
+ /** lucide icon slug. */
91
+ icon?: string
92
+ kind: WidgetKind
93
+ size?: WidgetSize
94
+ /** i18n key for the group heading. */
95
+ group?: string
96
+ /** Order within the group. */
97
+ order?: number
98
+ accent?: WidgetAccent
99
+ format?: WidgetFormat
100
+ /** Capability gating the widget (useCan). */
101
+ permission?: string
102
+ /** i18n key for the per-widget empty state. */
103
+ empty?: string
104
+
105
+ // declarative kinds
106
+ query?: DashboardWidgetQuery
107
+ compare?: DashboardWidgetCompare
108
+
109
+ // custom (federated) kind
110
+ slot?: string
111
+ expose?: string
112
+ }
113
+
114
+ /** A bucket of an aggregated series. */
115
+ export interface WidgetSeriesPoint {
116
+ key: string
117
+ label: string
118
+ value: number
119
+ }
120
+
121
+ /**
122
+ * The computed data for one widget (CONTRACT §3). `value` for scalars,
123
+ * `series` for bucketed/temporal aggregates, `delta` for the compare chip
124
+ * (fraction, e.g. `0.142` → `+14.2%`).
125
+ */
126
+ export interface WidgetData {
127
+ value?: number
128
+ delta?: number
129
+ series?: WidgetSeriesPoint[]
130
+ }
131
+
132
+ /** A titled group of widgets (CONTRACT §3 — backend grouping/order). */
133
+ export interface DashboardWidgetGroup {
134
+ /** i18n key for the group heading. */
135
+ title: string
136
+ widgets: DashboardWidgetSpec[]
137
+ }
138
+
139
+ /**
140
+ * Loads the data for a batch of widget keys. The host runs the aggregation and
141
+ * returns a `{ [key]: WidgetData }` map. Keys missing from the result render
142
+ * their empty state.
143
+ */
144
+ export type LoadWidgetData = (
145
+ keys: string[],
146
+ ) => Promise<Record<string, WidgetData>>
147
+
148
+ /** Props for <DashboardGrid>. */
149
+ export interface DashboardGridProps {
150
+ /** Pre-grouped widgets (backend grouping/order). */
151
+ groups?: DashboardWidgetGroup[]
152
+ /** Flat widget list — grouped client-side by `group`/`order`. */
153
+ widgets?: DashboardWidgetSpec[]
154
+ /** Batch loader for widget data (org-scoped, host-side aggregation). */
155
+ loadData: LoadWidgetData
156
+ /** When true, bypass permission gating (admin/owner sees everything). */
157
+ isAdmin?: boolean
158
+ /** BCP-47 locale for number/currency/date formatting. */
159
+ locale?: string
160
+ /** ISO-4217 currency for `format: 'currency'` widgets. */
161
+ currency?: string
162
+ /** Extra class on the grid root. */
163
+ className?: string
164
+ /** Optional override for empty/loading/error copy. */
165
+ strings?: Partial<DashboardGridStrings>
166
+ }
167
+
168
+ /** Translatable copy used by the grid chrome. Defaults are English. */
169
+ export interface DashboardGridStrings {
170
+ /** Global empty state title (no widgets at all). */
171
+ emptyTitle: string
172
+ /** Global empty state description. */
173
+ emptyDescription: string
174
+ /** Per-widget error message. */
175
+ widgetError: string
176
+ /** Per-widget empty (no data) fallback when the spec has no `empty` key. */
177
+ widgetEmpty: string
178
+ }
package/src/index.ts CHANGED
@@ -181,3 +181,59 @@ export {
181
181
  ActivityTimeline,
182
182
  type ActivityTimelineProps,
183
183
  } from './activity-timeline'
184
+ export {
185
+ DashboardGrid,
186
+ normalizeGroups,
187
+ } from './dashboard-grid'
188
+ export type {
189
+ WidgetKind,
190
+ WidgetSize,
191
+ WidgetFormat,
192
+ WidgetAccent,
193
+ WidgetAggregate,
194
+ WidgetWhereOp,
195
+ DashboardWidgetQuery,
196
+ DashboardWidgetCompare,
197
+ DashboardWidgetSpec,
198
+ WidgetSeriesPoint,
199
+ WidgetData,
200
+ DashboardWidgetGroup,
201
+ LoadWidgetData,
202
+ DashboardGridProps,
203
+ DashboardGridStrings,
204
+ } from './dashboard-types'
205
+ export {
206
+ StatWidget,
207
+ BarWidget,
208
+ LineWidget,
209
+ AreaWidget,
210
+ PieWidget,
211
+ DonutWidget,
212
+ ListWidget,
213
+ ProgressWidget,
214
+ type WidgetRenderProps,
215
+ } from './widgets/renderers'
216
+ export {
217
+ WidgetRenderer,
218
+ WidgetSkeleton,
219
+ SIZE_SPAN,
220
+ SIZE_CLASS,
221
+ type WidgetRendererProps,
222
+ } from './widgets/widget-renderer'
223
+ export {
224
+ WidgetCard,
225
+ DeltaChip,
226
+ WidgetEmpty,
227
+ WidgetError,
228
+ type WidgetCardProps,
229
+ } from './widgets/widget-card'
230
+ export {
231
+ formatWidgetValue,
232
+ formatAxisTick,
233
+ formatDelta,
234
+ accentClasses,
235
+ paletteColor,
236
+ CHART_PALETTE,
237
+ type AccentClasses,
238
+ type WidgetFormatCtx,
239
+ } from './widgets/widget-format'