@richardmcquiston01/dashboard-widgets-toolkit 0.4.1 → 0.5.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/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # Dashboard Widgets Toolkit
2
2
 
3
- - Author: Richard McQuiston
3
+ - Author: Richard McQuiston
4
4
  - Website: https://richardmcquiston.com/
5
+ - Demo: https://dashboard-widgets-toolkit-demo.vercel.app/
5
6
 
6
7
  ## Overview
7
8
 
@@ -42,375 +43,7 @@ synced yet.' }`.
42
43
 
43
44
  ## Getting Started
44
45
 
45
- ### Prerequisites
46
-
47
- - Node.js 18+ or Bun 1.3+ (or any modern browser bundler).
48
- - React 18 or later, only if you use `/react`.
49
- - TypeScript is optional; type declarations are included.
50
-
51
- ### Installation
52
-
53
- ```bash
54
- bun add @richardmcquiston01/dashboard-widgets-toolkit
55
- # or
56
- npm install @richardmcquiston01/dashboard-widgets-toolkit
57
- ```
58
-
59
- Both `import` and `require` work, for every entry:
60
-
61
- ```js
62
- const { resolveWidgets } = require('@richardmcquiston01/dashboard-widgets-toolkit/core');
63
- ```
64
-
65
- ### Usage
66
-
67
- **Core: define widgets, register providers, resolve.** Each provider gets
68
- your context and returns a payload (or throws). One failing provider never
69
- breaks the dashboard: it comes back as an `error` widget with the reason.
70
-
71
- ```ts
72
- import {
73
- defineWidget,
74
- emptyWidget,
75
- resolveWidgets,
76
- type WidgetContext,
77
- type WidgetProviders,
78
- } from '@richardmcquiston01/dashboard-widgets-toolkit';
79
-
80
- interface ShopContext extends WidgetContext {
81
- shopId: string;
82
- }
83
-
84
- const widgets = [
85
- defineWidget({ key: 'revenue', title: 'Revenue', kind: 'KPI', sortOrder: 10 }),
86
- defineWidget({ key: 'low-stock', title: 'Low stock', kind: 'ALERT_LIST', sortOrder: 20 }),
87
- defineWidget({
88
- key: 'monthly',
89
- title: 'Revenue by month',
90
- kind: 'GRAPH',
91
- sortOrder: 30,
92
- defaultSize: 'large',
93
- }),
94
- defineWidget({ key: 'audit', title: 'Audit log', kind: 'TABLE', roles: ['admin'] }),
95
- ];
96
-
97
- const providers: WidgetProviders<ShopContext> = {
98
- revenue: async ({ shopId }) => {
99
- const { current, previous } = await loadRevenue(shopId); // your code
100
- return { kind: 'KPI', value: current, previous, format: 'currency', label: 'Revenue' };
101
- },
102
- 'low-stock': async ({ shopId }) => {
103
- const rows = await loadLowStock(shopId);
104
- return {
105
- kind: 'ALERT_LIST',
106
- items: rows.slice(0, 5).map((row) => ({
107
- title: row.title,
108
- href: row.url,
109
- thumbnailUrl: row.thumbnailUrl,
110
- valueLabel: `${row.quantity} left`,
111
- })),
112
- total: rows.length,
113
- emptyText: 'Nothing low on stock.',
114
- };
115
- },
116
- monthly: async ({ shopId }) => {
117
- const months = await loadMonthly(shopId);
118
- if (months.length === 0) return emptyWidget('No orders synced yet.');
119
- return {
120
- kind: 'GRAPH',
121
- chartType: 'bar',
122
- valueFormat: 'currency',
123
- xLabel: 'Month',
124
- series: [{ name: 'Revenue', points: months.map((m) => ({ label: m.label, value: m.revenue })) }],
125
- };
126
- },
127
- };
128
-
129
- const resolved = await resolveWidgets(widgets, providers, {
130
- shopId: 'shop-1',
131
- roles: ['owner'], // "audit" is skipped: it needs "admin"
132
- });
133
- // [{ definition, status: 'ok', data }, { definition, status: 'empty', emptyText }, ...]
134
- ```
135
-
136
- Payloads are validated by default (turn it off with `{ validate: false }`
137
- for trusted, typed providers). In a client, check JSON from your API the same
138
- way:
139
-
140
- ```ts
141
- import { resolvePayload, validateWidgetData } from '@richardmcquiston01/dashboard-widgets-toolkit';
142
-
143
- const result = validateWidgetData(json, { widgetKey: 'revenue', expectedKind: 'KPI' });
144
- if (!result.ok) console.warn(result.error);
145
- // 'Widget "revenue" (KPI): previous must be a finite number, got string.'
146
-
147
- const widget = resolvePayload(definition, json); // ok, empty, or error with that message
148
- ```
149
-
150
- **React: render a dashboard with the viewer's layout.**
151
-
152
- ```tsx
153
- import { useState } from 'react';
154
- import {
155
- parseLayout,
156
- serializeLayout,
157
- type ResolvedWidget,
158
- } from '@richardmcquiston01/dashboard-widgets-toolkit';
159
- import { Dashboard } from '@richardmcquiston01/dashboard-widgets-toolkit/react';
160
- import '@richardmcquiston01/dashboard-widgets-toolkit/styles.css'; // optional
161
-
162
- export function ShopDashboard({ widgets }: { widgets: ResolvedWidget[] }) {
163
- const [layout, setLayout] = useState(() => parseLayout(localStorage.getItem('layout')));
164
- return (
165
- <Dashboard
166
- widgets={widgets}
167
- layout={layout}
168
- onLayoutChange={(next) => {
169
- setLayout(next);
170
- localStorage.setItem('layout', serializeLayout(next));
171
- }}
172
- locale="en-US"
173
- linkTarget="_blank"
174
- onRetry={(key) => refetch(key)}
175
- />
176
- );
177
- }
178
- ```
179
-
180
- `Dashboard` orders widgets by the saved layout (saved order first, new widgets
181
- appended by `sortOrder`, hidden ones removed), and gives each card move
182
- earlier/later, hide and minimize buttons, plus a "Hidden:" bar to bring
183
- widgets back. A minimized widget leaves the grid (so it stops taking space)
184
- and waits in a "Minimized:" bar until the viewer expands it. Without `onLayoutChange` it is read-only. Use `loadingWidgets`
185
- for placeholders while data loads, and `WidgetGrid` for a plain grid with no
186
- layout controls.
187
-
188
- Links open in the same tab unless you set `linkTarget` (for example
189
- `"_blank"`); links that open a new tab get a small external-link icon and
190
- "(opens in a new tab)" for screen readers (label `opensInNewTab`).
191
-
192
- ### Examples
193
-
194
- **Layout functions on their own** (pure; the input is never mutated):
195
-
196
- ```ts
197
- import {
198
- EMPTY_LAYOUT,
199
- hideWidget,
200
- moveWidgetUp,
201
- toggleMinimized,
202
- visibleWidgets,
203
- } from '@richardmcquiston01/dashboard-widgets-toolkit';
204
-
205
- const definitions = [
206
- { key: 'revenue', sortOrder: 10 },
207
- { key: 'low-stock', sortOrder: 20 },
208
- { key: 'monthly', sortOrder: 30 },
209
- ];
210
- let layout = moveWidgetUp(definitions, EMPTY_LAYOUT, 'monthly');
211
- layout = hideWidget(layout, 'low-stock');
212
- layout = toggleMinimized(layout, 'revenue');
213
- visibleWidgets(definitions, layout).map((d) => d.key); // ['revenue', 'monthly']
214
- ```
215
-
216
- **KPI deltas and formatting** with an explicit locale:
217
-
218
- ```ts
219
- import { formatValue, kpiDelta } from '@richardmcquiston01/dashboard-widgets-toolkit';
220
-
221
- formatValue(1234.5, 'currency', { locale: 'en-GB', currency: 'GBP' }); // "£1,234.50"
222
- kpiDelta(120, 100, { locale: 'en-US' });
223
- // { percent: 20, direction: 'up', sentiment: 'good', text: '+20%', description: '+20% vs previous period' }
224
- kpiDelta(120, 100, { higherIsBetter: false })?.sentiment; // 'bad' (e.g. refunds)
225
- ```
226
-
227
- **Styling with Tailwind (or any classes).** Components keep their `dwt-*`
228
- classes and append yours per slot. Skip `styles.css` entirely, or keep it and
229
- override its custom properties:
230
-
231
- ```tsx
232
- <Dashboard
233
- widgets={widgets}
234
- classNames={{ card: 'rounded-lg shadow-sm', cardTitle: 'text-sm font-semibold' }}
235
- />
236
- ```
237
-
238
- ```css
239
- .dwt-dashboard {
240
- --dwt-series-1: #567d62; /* your brand color for single-series charts */
241
- --dwt-radius: 4px;
242
- }
243
- ```
244
-
245
- Dark mode follows the OS setting unless the page sets `data-theme="light"`;
246
- `data-theme="dark"` or a `dark` class on `<html>` forces it.
247
-
248
- **Widget width.** Give a widget a `width` to say how much of the row it
249
- takes, in twelfths (like a 12-column grid): an integer from 2 to 12. A table
250
- with many columns might want half the row:
251
-
252
- ```ts
253
- defineWidget({ key: 'top-products', title: 'Most popular products', kind: 'TABLE', width: 8 });
254
- defineWidget({ key: 'ratings', title: 'Rating breakdown', kind: 'BAR_LIST', width: 4 });
255
- ```
256
-
257
- When any widget in a grid sets `width`, the grid becomes 12 columns
258
- (`dwt-grid--twelve`) and each card spans `--dwt-width` columns. Widgets
259
- without a `width` use one from their `defaultSize` (small 3, medium 4, large
260
- 6, full 12). Narrow grids give widgets more room: under 900px a widget gets
261
- twice its width, under 560px it takes the whole row. `fill: 'width'` still
262
- works, sharing the twelfths left over in a row. The validator rejects
263
- anything but an integer from 2 to 12.
264
-
265
- **Detail view.** Set `detail: true` (or `{ title, pageSize, mode }`) on a
266
- definition and `Dashboard` adds a "View" eye button to the card (the title is
267
- clickable too). It opens a modal dialog with search, per-column filters,
268
- sortable headers and paging. Where the data comes from:
269
-
270
- ```tsx
271
- <Dashboard
272
- widgets={widgets}
273
- loadDetail={(definition, { signal, query }) => fetchAllRows(definition.key, signal)}
274
- />
275
- ```
276
-
277
- - `loadDetail` (close over your own context; the package never fetches)
278
- returns a `DetailData` table: columns with a `key`, rows of cells with
279
- `text` and an optional `value` used for sorting and filtering.
280
- - Without it, a TABLE with no `footer` and a BAR_LIST show their own card data;
281
- other widgets get no View button until you pass `loadDetail`.
282
- - `onOpenDetail={(key) => navigate(...)}` replaces the built-in dialog so you
283
- can open your own page.
284
- - The pieces are exported for your own layout: `WidgetDetail` (controlled by
285
- a `DetailQuery`), `WidgetDetailDialog` and `useDetailData`. In the core,
286
- `queryRows` does the filtering, sorting and paging (case- and
287
- accent-insensitive, numeric-aware), `serializeDetailQuery` /
288
- `parseDetailQuery` keep the query in a URL, and `validateDetailData` checks
289
- what your loader returns.
290
-
291
- Server-side paging and URL deep links are next; see
292
- [docs/design/detail-view.md](./docs/design/detail-view.md).
293
-
294
- **Filling space.** By default each card is only as big as its content, so a
295
- short card beside a tall one leaves a gap. Set `fill` on a widget definition
296
- to have it use the free space in its grid row:
297
-
298
- ```ts
299
- defineWidget({ key: 'countries', title: 'Orders by country', kind: 'BAR_LIST', fill: 'height' });
300
- defineWidget({ key: 'notes', title: 'Notes', kind: 'TEXT', fill: 'width' });
301
- defineWidget({ key: 'trend', title: 'Trend', kind: 'GRAPH', defaultSize: 'large', fill: 'both' });
302
- ```
303
-
304
- | `fill` | Effect |
305
- | -------- | ------------------------------------------------------------------- |
306
- | `height` | Stretches the card to the height of its row (`dwt-card--fill-height`). |
307
- | `width` | Widens the card to take the columns left over in its row (`dwt-card--fill-width`). |
308
- | `both` | Both. |
309
-
310
- `Dashboard` and `WidgetGrid` work out the spans from the rendered grid (so it
311
- follows your CSS and the container width) and set `grid-column: span N` on
312
- width-filling cards; on the server, and before the first measurement, the
313
- grid simply lays out normally. The same helper is exported from the core as
314
- `fillColumnSpans(items, columns)` for custom layouts, and `WidgetCard` takes
315
- `fill` and `columnSpan` props.
316
-
317
- **Loading data after the page renders.** Render the dashboard first and let
318
- each widget's data arrive on its own. `useWidgets` returns placeholders
319
- immediately (so first paint and server rendering never wait), starts every
320
- provider after mount, and replaces each card as its own data arrives:
321
-
322
- ```tsx
323
- import { Dashboard, useWidgets } from '@richardmcquiston01/dashboard-widgets-toolkit/react';
324
-
325
- const { widgets, refresh } = useWidgets(definitions, providers, context, {
326
- timeoutMs: 8000, // a provider that takes longer becomes an error card
327
- refreshMs: 60_000, // optional polling; skips widgets still loading
328
- });
329
-
330
- return <Dashboard widgets={widgets} onRetry={(key) => refresh(key)} />;
331
- ```
332
-
333
- Providers receive an `AbortSignal` as a third argument (`(context, definition,
334
- { signal })`); pass it to `fetch` so a timeout, a refresh or leaving the page
335
- stops the request. Existing two-argument providers keep working.
336
-
337
- - `loadWhen: 'visible'` also waits until a card is near the viewport. Attach
338
- the returned `gridRef` to an element around the dashboard.
339
- - `cache` (your own `{ get, set }` storage: memory, `localStorage`, ...) shows
340
- the last payload instantly as `stale` while a fresh load runs. Cached
341
- payloads are validated again, and a failing cache never fails a widget.
342
- - Keep `definitions`, `providers`, `context` and `cache` referentially stable
343
- (module constants or `useMemo`); a changed value restarts loading.
344
- - Not using React? `createWidgetLoader` (core) is the same logic with
345
- `subscribe`/`getSnapshot`, `load`, `refresh` and `dispose`; `resolveWidget`
346
- and `resolveWidgets` accept `timeoutMs`, `signal` and `cache` too.
347
-
348
- **Charts.** `GRAPH` widgets render as inline SVG with `role="img"`, a
349
- `<title>` and a `<desc>` summarizing the series (latest, high and low), a
350
- legend when there are two or more series, a hover and keyboard (arrow keys)
351
- tooltip, and a collapsible "View as table" with every value. Colors are CSS
352
- custom properties (`--dwt-series-1` … `--dwt-series-8`) with built-in
353
- defaults from a palette checked for color-vision deficiency in light and
354
- dark modes; a graph may have at most eight series.
355
-
356
- ## Maker Toolkit compatibility
357
-
358
- The kind names are upper case, as in Maker Toolkit's `WidgetViewType`, and
359
- the definition mirrors its `widget` table:
360
-
361
- | Maker Toolkit (`widget` table / API) | This package |
362
- | ------------------------------------ | ------------------------------ |
363
- | `widget_key` / `widgetKey` | `key` |
364
- | `title`, `description` | `title`, `description` |
365
- | `view_type` / `viewType` | `kind` |
366
- | `sort_order` / `sortOrder` | `sortOrder` |
367
- | `roles` (empty = everyone) | `roles` (absent/empty = everyone) |
368
- | `active` | `active` |
369
- | providers keyed by `widget_key` | `WidgetProviders` keyed by `key` |
370
-
371
- - `TEXT` and `GAUGE` payloads are unchanged.
372
- - `TABLE` columns and cells became objects (`{label, numeric?}`,
373
- `{text, href?}`), and `GRAPH` became multi-series with a chart type and
374
- value format. `upgradeLegacyWidgetData()` converts the older
375
- `{columns: string[], rows: string[][]}` and `{points}` shapes, and
376
- `fromLegacyDefinition()` maps a served definition.
377
- - `KPI`, `BAR_LIST` and `ALERT_LIST` are new kinds. Add them to the
378
- `WidgetViewType` enum before seeding widgets that use them.
379
- - The desktop app's `dashboard-layout.ts` (order, hidden, minimized) is
380
- `layout.ts` here, with the same JSON shape, so saved layouts carry over.
381
- It now appends new widgets by `sortOrder` and tolerates corrupt values per
382
- field.
383
-
384
- ```ts
385
- import {
386
- fromLegacyDefinition,
387
- upgradeLegacyWidgetData,
388
- validateWidgetData,
389
- } from '@richardmcquiston01/dashboard-widgets-toolkit';
390
-
391
- const result = validateWidgetData(upgradeLegacyWidgetData(apiPayload));
392
- ```
393
-
394
- ## Development
395
-
396
- ```bash
397
- bun install
398
- bun run typecheck
399
- bun run lint
400
- bun run format:check
401
- bun run test
402
- bun run build # dist/: ESM (.js), CommonJS (.cjs), .d.ts/.d.cts, styles.css
403
- ```
404
-
405
- Branches: `dev` is the integration branch. Branch features off `dev` and open
406
- pull requests into `dev`; `dev` is merged to `main` for a release. Add a
407
- changeset (`bun run changeset`) with each user-facing change.
408
-
409
- Releases are published to npm by GitHub Actions when a `vX.Y.Z` tag matching
410
- `package.json` is pushed to `main`. **The `NPM_TOKEN` repository secret (an
411
- npm automation token) must be added under Settings → Secrets and variables →
412
- Actions** before the first release. See [CLAUDE.md](./CLAUDE.md) for the
413
- full flow.
46
+ See [GETTING_STARTED.md](./GETTING_STARTED.md)
414
47
 
415
48
  ## Buy Me a Coffee
416
49
 
package/dist/react.cjs CHANGED
@@ -1785,22 +1785,30 @@ function WidgetDetailDialog({
1785
1785
  }
1786
1786
  );
1787
1787
  }
1788
- function useDetailData(definition, load, enabled) {
1788
+ function useDetailData(definition, load, enabled, fallback) {
1789
1789
  const [state, setState] = react.useState({ status: "loading" });
1790
1790
  const [attempt, setAttempt] = react.useState(0);
1791
1791
  react.useEffect(() => {
1792
- if (!enabled || load === void 0) return void 0;
1792
+ if (!enabled) return void 0;
1793
1793
  const controller = new AbortController();
1794
1794
  setState({ status: "loading" });
1795
1795
  Promise.resolve().then(
1796
- () => load(definition, {
1796
+ () => load === void 0 ? void 0 : load(definition, {
1797
1797
  signal: controller.signal,
1798
1798
  query: defaultDetailQuery()
1799
1799
  })
1800
1800
  ).then((raw) => {
1801
1801
  if (controller.signal.aborted) return;
1802
+ const data = raw ?? fallback;
1803
+ if (data === void 0) {
1804
+ setState({
1805
+ status: "error",
1806
+ error: `Widget "${definition.key}" detail: no data. The loader returned nothing for this widget and its card does not hold the full data (use a loader that returns rows, or a TABLE without a footer or a BAR_LIST).`
1807
+ });
1808
+ return;
1809
+ }
1802
1810
  const checked = validateDetailData(
1803
- raw,
1811
+ data,
1804
1812
  `Widget "${definition.key}" detail`
1805
1813
  );
1806
1814
  setState(
@@ -1814,7 +1822,7 @@ function useDetailData(definition, load, enabled) {
1814
1822
  });
1815
1823
  });
1816
1824
  return () => controller.abort();
1817
- }, [enabled, load, definition, attempt]);
1825
+ }, [enabled, load, fallback, definition, attempt]);
1818
1826
  return { state, reload: () => setAttempt((count) => count + 1) };
1819
1827
  }
1820
1828
  var NARROW_VIEWPORT_QUERY = "(max-width: 640px)";
@@ -1912,12 +1920,16 @@ function DetailHost({
1912
1920
  const options = resolveDetailOptions(definition);
1913
1921
  const pageSize = options?.pageSize ?? 25;
1914
1922
  const [query, setQuery] = react.useState(defaultDetailQuery(pageSize));
1915
- const derived = widget.status === "ok" ? deriveDetailData(widget.data, locale) : void 0;
1916
- const load = react.useMemo(
1917
- () => loadDetail ?? (derived === void 0 ? void 0 : () => derived),
1918
- [loadDetail, derived]
1923
+ const derived = react.useMemo(
1924
+ () => widget.status === "ok" ? deriveDetailData(widget.data, locale) : void 0,
1925
+ [widget, locale]
1926
+ );
1927
+ const { state, reload } = useDetailData(
1928
+ definition,
1929
+ loadDetail,
1930
+ true,
1931
+ derived
1919
1932
  );
1920
- const { state, reload } = useDetailData(definition, load, true);
1921
1933
  const title = options?.title ?? definition.title;
1922
1934
  return /* @__PURE__ */ jsxRuntime.jsx(WidgetDetailDialog, { title, open: true, onClose, children: /* @__PURE__ */ jsxRuntime.jsx(
1923
1935
  WidgetDetail,
@@ -1925,10 +1937,7 @@ function DetailHost({
1925
1937
  title,
1926
1938
  query,
1927
1939
  onQueryChange: setQuery,
1928
- status: load === void 0 ? "error" : state.status,
1929
- ...load === void 0 ? {
1930
- error: `Widget "${definition.key}" has no detail data: pass loadDetail, or use a TABLE without a footer or a BAR_LIST.`
1931
- } : {},
1940
+ status: state.status,
1932
1941
  ...state.data === void 0 ? {} : { data: state.data },
1933
1942
  ...state.error === void 0 ? {} : { error: state.error },
1934
1943
  onRetry: reload