@r0hitsharma/dashboard-kit 0.12.0-rohit-fork-ci.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,95 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * Has to render: the bug this guards against is about COMMIT ORDERING
5
+ * (does a value published this commit reflect a `keyMap` change made in
6
+ * that same commit?), which a pure-logic test over `aliasesFor` alone
7
+ * can't observe — React's actual effect-flush order is the thing under
8
+ * test, matching how `ThemeProvider.test.ts` justifies its own jsdom use.
9
+ */
10
+ import {
11
+ DashboardInteractionProvider,
12
+ useDashboardInteraction,
13
+ type DashboardInteractionApi,
14
+ } from '@r0hitsharma/charting';
15
+ import { act, cleanup, render } from '@testing-library/react';
16
+ import { createElement, useEffect, type MutableRefObject } from 'react';
17
+ import { afterEach, describe, expect, it } from 'vitest';
18
+
19
+ import {
20
+ useChartingInteraction,
21
+ type InteractionKeyMap,
22
+ } from './charting-interaction.js';
23
+ import type { InteractionContextValue } from './interaction.js';
24
+
25
+ afterEach(cleanup);
26
+
27
+ const KEY_MAP_HIGHLIGHT: InteractionKeyMap = { myAlias: 'highlightedKey' };
28
+ const KEY_MAP_TIME_RANGE: InteractionKeyMap = { myAlias: 'timeRange' };
29
+
30
+ type Captured = {
31
+ chartApi: DashboardInteractionApi;
32
+ interaction: InteractionContextValue;
33
+ };
34
+
35
+ // Exposed to the test via a ref written inside an effect, not a module-scope
36
+ // binding reassigned during render: the latter is exactly the pattern this
37
+ // PR eliminates from the library code, so the test holds itself to it too.
38
+ function Inner({
39
+ keyMap,
40
+ capturedRef,
41
+ }: {
42
+ keyMap: InteractionKeyMap;
43
+ capturedRef: MutableRefObject<Captured | null>;
44
+ }) {
45
+ const chartApi = useDashboardInteraction();
46
+ const { interaction, InteractionSync } = useChartingInteraction(keyMap);
47
+
48
+ useEffect(() => {
49
+ capturedRef.current = { chartApi, interaction };
50
+ });
51
+
52
+ return createElement(InteractionSync);
53
+ }
54
+
55
+ function App({
56
+ keyMap,
57
+ capturedRef,
58
+ }: {
59
+ keyMap: InteractionKeyMap;
60
+ capturedRef: MutableRefObject<Captured | null>;
61
+ }) {
62
+ return createElement(
63
+ DashboardInteractionProvider,
64
+ null,
65
+ createElement(Inner, { keyMap, capturedRef }),
66
+ );
67
+ }
68
+
69
+ describe('ChartingInteractionSync', () => {
70
+ it('publishes under a keyMap change immediately, in the same commit as a chart value change', () => {
71
+ const capturedRef: MutableRefObject<Captured | null> = { current: null };
72
+ const { rerender } = render(
73
+ createElement(App, { keyMap: KEY_MAP_HIGHLIGHT, capturedRef }),
74
+ );
75
+
76
+ act(() => {
77
+ capturedRef.current!.chartApi.setHighlightedKey('initial');
78
+ });
79
+ expect(capturedRef.current!.interaction.read('myAlias')).toBe('initial');
80
+
81
+ // Both happen in the SAME commit: `myAlias` is remapped from
82
+ // `highlightedKey` to `timeRange`, and `timeRange` itself changes. A
83
+ // stale (previous-commit) keyMap read would still resolve `myAlias` to
84
+ // `highlightedKey` here and miss the new value entirely.
85
+ act(() => {
86
+ capturedRef.current!.chartApi.setTimeRange({ start: 1, end: 2 });
87
+ rerender(createElement(App, { keyMap: KEY_MAP_TIME_RANGE, capturedRef }));
88
+ });
89
+
90
+ expect(capturedRef.current!.interaction.read('myAlias')).toEqual({
91
+ start: 1,
92
+ end: 2,
93
+ });
94
+ });
95
+ });
@@ -0,0 +1,199 @@
1
+ import {
2
+ useDashboardInteraction,
3
+ type InteractionKey,
4
+ type TimeRange,
5
+ } from '@r0hitsharma/charting';
6
+ import {
7
+ useCallback,
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ type ReactElement,
12
+ type RefObject,
13
+ } from 'react';
14
+
15
+ import type { InteractionContextValue } from './interaction.js';
16
+
17
+ /**
18
+ * Adapts a manifest's free-form interaction vocabulary onto charting's REAL
19
+ * per-key store (`DashboardInteractionProvider` / `useDashboardInteraction` /
20
+ * `useInteractionValue`), so a declarative dashboard cross-filters the same
21
+ * synced chart group a hand-built one would.
22
+ *
23
+ * The reconciliation this file owns: a manifest names keys in an FDC3-shaped
24
+ * vocabulary (`highlightedAsset`, `selectedTimeRange`) while charting's store
25
+ * is typed to `highlightedKey` / `timeRange` / `hoveredTimestamp`. The key map
26
+ * below is the single, declared place that translation happens — not a rename
27
+ * of either side. Consumers can pass their own map to extend the vocabulary.
28
+ *
29
+ * ── Why a bridge rather than calling `useDashboardInteraction` in each widget
30
+ * Charting's `DashboardInteractionContext` value changes identity on every
31
+ * pointer move (`hoveredTimestamp` lives in it), so anything that consumes it
32
+ * re-renders at hover frequency. {@link InteractionSync} is the SOLE consumer:
33
+ * a null-rendering component that republishes the mapped keys into a tiny
34
+ * per-key store. A widget that declared `interaction.reads: ['highlightedAsset']`
35
+ * then re-renders when the highlight changes and at no other time — matching
36
+ * the discipline of charting's own `useInteractionValue`.
37
+ */
38
+ export type InteractionKeyMap = Record<string, InteractionKey>;
39
+
40
+ /**
41
+ * Default manifest-key -> charting-key aliases. Native charting keys map to
42
+ * themselves so a manifest may also name them directly.
43
+ */
44
+ export const DEFAULT_INTERACTION_KEY_MAP: InteractionKeyMap = {
45
+ highlightedAsset: 'highlightedKey',
46
+ highlightedKey: 'highlightedKey',
47
+ selectedTimeRange: 'timeRange',
48
+ timeRange: 'timeRange',
49
+ hoveredTimestamp: 'hoveredTimestamp',
50
+ };
51
+
52
+ export type ChartingInteraction = {
53
+ /** The generic surface to hand the renderer's `interaction` prop. */
54
+ interaction: InteractionContextValue;
55
+ /**
56
+ * Render ONCE inside a `SyncedChartGroup` / `DashboardInteractionProvider`.
57
+ * It renders nothing observable; it is the sole subscriber to the
58
+ * high-frequency charting context and republishes the mapped keys into the
59
+ * per-key store that {@link interaction} reads.
60
+ */
61
+ InteractionSync: () => ReactElement;
62
+ };
63
+
64
+ type ChartingInteractionSyncProps = {
65
+ keyMap: InteractionKeyMap;
66
+ writerRef: RefObject<(key: string, value: unknown) => void>;
67
+ publish: (key: string, value: unknown) => void;
68
+ };
69
+
70
+ /**
71
+ * Module-scope, not a closure created per `useChartingInteraction` call:
72
+ * hooks nested inside a `useMemo`/`useCallback` are ambiguous to the rules-of
73
+ * -hooks check (it can't tell they belong to a separate component render, not
74
+ * the enclosing hook's), so this needs to be a real, statically top-level
75
+ * component. Everything it needs comes in as props instead of a closure.
76
+ *
77
+ * `keyMap` is a plain prop, not a ref synced from a parent effect: React
78
+ * flushes child effects before parent effects, so a ref updated by
79
+ * `useChartingInteraction`'s own effect would still hold the PREVIOUS
80
+ * `keyMap` when this component's effects below run in the commit where it
81
+ * actually changed. A prop is simply current already.
82
+ */
83
+ function ChartingInteractionSync({
84
+ keyMap,
85
+ writerRef,
86
+ publish,
87
+ }: ChartingInteractionSyncProps): null {
88
+ const chart = useDashboardInteraction();
89
+
90
+ // Every manifest alias that resolves to `highlightedKey` gets the
91
+ // charting value republished under it; likewise for the other keys.
92
+ const aliasesFor = useCallback(
93
+ (chartingKey: InteractionKey): string[] =>
94
+ Object.entries(keyMap)
95
+ .filter(([, target]) => target === chartingKey)
96
+ .map(([alias]) => alias),
97
+ [keyMap],
98
+ );
99
+
100
+ useEffect(() => {
101
+ for (const alias of aliasesFor('highlightedKey')) {
102
+ publish(alias, chart.highlightedKey ?? undefined);
103
+ }
104
+ }, [chart.highlightedKey, publish, aliasesFor]);
105
+ useEffect(() => {
106
+ for (const alias of aliasesFor('timeRange')) {
107
+ publish(alias, chart.timeRange ?? undefined);
108
+ }
109
+ }, [chart.timeRange, publish, aliasesFor]);
110
+ useEffect(() => {
111
+ for (const alias of aliasesFor('hoveredTimestamp')) {
112
+ publish(alias, chart.hoveredTimestamp ?? undefined);
113
+ }
114
+ }, [chart.hoveredTimestamp, publish, aliasesFor]);
115
+
116
+ // The write side, held in a ref so `interaction.write` stays
117
+ // identity-stable for every consumer.
118
+ useEffect(() => {
119
+ writerRef.current = (key: string, value: unknown) => {
120
+ switch (keyMap[key]) {
121
+ case 'highlightedKey':
122
+ chart.setHighlightedKey(value == null ? null : String(value));
123
+ return;
124
+ case 'timeRange':
125
+ chart.setTimeRange((value as TimeRange | null) ?? null);
126
+ return;
127
+ case 'hoveredTimestamp':
128
+ chart.setHoveredTimestamp(value == null ? null : Number(value));
129
+ return;
130
+ default:
131
+ return;
132
+ }
133
+ };
134
+ }, [chart, keyMap, writerRef]);
135
+
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Builds a charting-backed {@link InteractionContextValue}. Call it above a
141
+ * `SyncedChartGroup`, hand `interaction` to `DashboardRenderer`, and render
142
+ * `<InteractionSync />` inside the group.
143
+ */
144
+ export function useChartingInteraction(
145
+ keyMap: InteractionKeyMap = DEFAULT_INTERACTION_KEY_MAP,
146
+ ): ChartingInteraction {
147
+ const valuesRef = useRef(new Map<string, unknown>());
148
+ const listenersRef = useRef(new Map<string, Set<() => void>>());
149
+ const writerRef = useRef<(key: string, value: unknown) => void>(() => {});
150
+
151
+ const read = useCallback((key: string) => valuesRef.current.get(key), []);
152
+
153
+ const write = useCallback((key: string, value: unknown) => {
154
+ writerRef.current(key, value);
155
+ }, []);
156
+
157
+ const subscribe = useCallback((key: string, onChange: () => void) => {
158
+ let listeners = listenersRef.current.get(key);
159
+ if (!listeners) {
160
+ listeners = new Set();
161
+ listenersRef.current.set(key, listeners);
162
+ }
163
+ listeners.add(onChange);
164
+ return () => {
165
+ listeners.delete(onChange);
166
+ };
167
+ }, []);
168
+
169
+ const publish = useCallback((key: string, value: unknown) => {
170
+ if (Object.is(valuesRef.current.get(key), value)) return;
171
+ valuesRef.current.set(key, value);
172
+ for (const listener of listenersRef.current.get(key) ?? []) listener();
173
+ }, []);
174
+
175
+ const interaction = useMemo<InteractionContextValue>(
176
+ () => ({ read, write, subscribe }),
177
+ [read, write, subscribe],
178
+ );
179
+
180
+ // A stable function identity, so React doesn't unmount/remount
181
+ // `ChartingInteractionSync` (and its charting subscription) across the
182
+ // dashboard's renders — as long as `keyMap` doesn't churn identity either.
183
+ // Pass a memoized `keyMap` (module-scoped, like the default, or your own
184
+ // `useMemo`); an inline object here remounts the subscription every render,
185
+ // the same discipline `usePlayback`'s `source` and `useDataTable`'s
186
+ // `columns` already require of their callers.
187
+ const InteractionSync = useCallback(
188
+ () => (
189
+ <ChartingInteractionSync
190
+ keyMap={keyMap}
191
+ writerRef={writerRef}
192
+ publish={publish}
193
+ />
194
+ ),
195
+ [publish, keyMap],
196
+ );
197
+
198
+ return { interaction, InteractionSync };
199
+ }
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
1
+ // The declarative-dashboard schema — the data contract a manifest conforms to.
2
+ export type {
3
+ DashboardSpec,
4
+ DashboardDataSources,
5
+ DataBinding,
6
+ LayoutDirection,
7
+ LayoutNode,
8
+ SplitLayoutNode,
9
+ ThresholdRule,
10
+ ThresholdSeverity,
11
+ WidgetInteraction,
12
+ WidgetLayoutNode,
13
+ WidgetNode,
14
+ WidgetTableColumn,
15
+ } from './schema.js';
16
+
17
+ // The recursive renderer.
18
+ export {
19
+ DashboardRenderer,
20
+ renderLayoutNode,
21
+ type DashboardRendererProps,
22
+ } from './renderer.js';
23
+
24
+ // The string -> component registry mechanism + the default generic adapters.
25
+ export {
26
+ DEFAULT_REGISTRY,
27
+ UnknownWidget,
28
+ mergeRegistries,
29
+ type ComponentRegistry,
30
+ type RegistryComponent,
31
+ type RegistryComponentProps,
32
+ } from './registry.js';
33
+
34
+ // Structural + referential manifest validation, and the agent-exposure query.
35
+ export {
36
+ collectAgentWritableKeys,
37
+ dashboardSpecSchema,
38
+ validateDashboardSpec,
39
+ type ManifestIssue,
40
+ type ManifestValidation,
41
+ type ValidateOptions,
42
+ } from './validate.js';
43
+
44
+ // The generic interaction surface: the local store + the per-key read hook.
45
+ export {
46
+ useInteractionField,
47
+ useLocalInteraction,
48
+ type InteractionContextValue,
49
+ } from './interaction.js';
50
+
51
+ // The adapter onto charting's real per-key interaction store.
52
+ export {
53
+ DEFAULT_INTERACTION_KEY_MAP,
54
+ useChartingInteraction,
55
+ type ChartingInteraction,
56
+ type InteractionKeyMap,
57
+ } from './charting-interaction.js';
@@ -0,0 +1,89 @@
1
+ import {
2
+ useCallback,
3
+ useMemo,
4
+ useRef,
5
+ useState,
6
+ useSyncExternalStore,
7
+ } from 'react';
8
+
9
+ /**
10
+ * The interaction surface a manifest's widgets read and write, addressed by
11
+ * the free-form string keys a `WidgetNode.interaction.reads`/`writes` names.
12
+ *
13
+ * This is deliberately a small, transport-agnostic `{read, write, subscribe}`
14
+ * contract rather than charting's concrete `DashboardInteractionApi` (which is
15
+ * typed to a fixed set of keys: `timeRange`, `hoveredTimestamp`, `filters`,
16
+ * `highlightedKey`). A manifest names its OWN vocabulary (`highlightedAsset`,
17
+ * `selectedTimeRange`, ...); {@link module:charting-interaction} is the adapter
18
+ * that maps that vocabulary onto charting's real per-key store. Keeping the
19
+ * engine's contract generic is what lets a consumer back interaction with
20
+ * charting, with their own store, or with the built-in local store below —
21
+ * without the engine caring which.
22
+ */
23
+ export type InteractionContextValue = {
24
+ read: (key: string) => unknown;
25
+ write: (key: string, value: unknown) => void;
26
+ /**
27
+ * Optional per-key change subscription. When present, widgets reach values
28
+ * through {@link useInteractionField} rather than calling `read` during
29
+ * render, so only the widgets bound to a changed key re-render — the same
30
+ * hover-perf discipline charting's own per-key store enforces.
31
+ */
32
+ subscribe?: (key: string, onChange: () => void) => () => void;
33
+ };
34
+
35
+ type InteractionState = Record<string, unknown>;
36
+
37
+ /**
38
+ * A self-contained interaction store backed by React state — the default the
39
+ * renderer falls back to when no `interaction` prop is supplied. Correct and
40
+ * cheap for a standalone manifest (a story, a preview) that isn't synced to a
41
+ * live chart group; for chart-synced interaction, pass the adapter from
42
+ * {@link module:charting-interaction} instead.
43
+ */
44
+ export function useLocalInteraction(): InteractionContextValue {
45
+ const [, setState] = useState<InteractionState>({});
46
+ // Keep `read`/`write` identity-stable so the context value doesn't change
47
+ // for reasons other than a real state change.
48
+ const stateRef = useRef<InteractionState>({});
49
+
50
+ const read = useCallback((key: string) => stateRef.current[key], []);
51
+ const write = useCallback((key: string, value: unknown) => {
52
+ if (Object.is(stateRef.current[key], value)) return;
53
+ stateRef.current = { ...stateRef.current, [key]: value };
54
+ setState(stateRef.current);
55
+ }, []);
56
+
57
+ return useMemo(() => ({ read, write }), [read, write]);
58
+ }
59
+
60
+ /**
61
+ * Reads ONE interaction key, subscribing to just that key when the provider
62
+ * supports it. `key` may be `undefined` (a widget that declares no
63
+ * `interaction.reads`), in which case this is a no-op returning `undefined`.
64
+ *
65
+ * Named `useInteractionField` (not `useInteractionValue`) so a consumer file
66
+ * can import both this and charting's own `useInteractionValue` without a name
67
+ * collision.
68
+ */
69
+ export function useInteractionField(
70
+ interaction: InteractionContextValue,
71
+ key: string | undefined,
72
+ ): unknown {
73
+ const { read, subscribe } = interaction;
74
+
75
+ const subscribeToKey = useCallback(
76
+ (onChange: () => void) => {
77
+ if (!key || !subscribe) return () => {};
78
+ return subscribe(key, onChange);
79
+ },
80
+ [key, subscribe],
81
+ );
82
+
83
+ const getSnapshot = useCallback(
84
+ () => (key ? read(key) : undefined),
85
+ [key, read],
86
+ );
87
+
88
+ return useSyncExternalStore(subscribeToKey, getSnapshot, getSnapshot);
89
+ }