@opendata-ai/openchart-react 2.0.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 +95 -0
- package/dist/index.d.ts +292 -0
- package/dist/index.js +574 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/src/Chart.tsx +191 -0
- package/src/DataTable.tsx +165 -0
- package/src/Graph.tsx +186 -0
- package/src/ThemeContext.tsx +40 -0
- package/src/Visualization.tsx +54 -0
- package/src/__tests__/Chart.test.tsx +134 -0
- package/src/__tests__/DataTable.test.tsx +126 -0
- package/src/__tests__/Graph.test.tsx +130 -0
- package/src/__tests__/ThemeContext.test.tsx +238 -0
- package/src/__tests__/hooks.test.tsx +160 -0
- package/src/__tests__/update-vs-remount.test.tsx +133 -0
- package/src/__tests__/useTableState.test.tsx +218 -0
- package/src/hooks/useGraph.ts +85 -0
- package/src/hooks/useTable.ts +98 -0
- package/src/hooks/useTableState.ts +76 -0
- package/src/hooks.ts +145 -0
- package/src/index.ts +37 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useChart and useDarkMode hooks.
|
|
3
|
+
*
|
|
4
|
+
* Note: renderHook from @testing-library/react doesn't work in this project
|
|
5
|
+
* due to bun's React dual-instance issue (renderHook's internal TestComponent
|
|
6
|
+
* uses a different React copy than the hooks under test). Instead, we render
|
|
7
|
+
* thin wrapper components that expose hook state via the DOM, then assert
|
|
8
|
+
* using render + waitFor.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ChartSpec } from '@opendata-ai/openchart-core';
|
|
12
|
+
import { render, waitFor } from '@testing-library/react';
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import { useChart, useDarkMode } from '../hooks';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Test data
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const barSpec: ChartSpec = {
|
|
21
|
+
type: 'bar',
|
|
22
|
+
data: [
|
|
23
|
+
{ name: 'A', value: 10 },
|
|
24
|
+
{ name: 'B', value: 30 },
|
|
25
|
+
],
|
|
26
|
+
encoding: {
|
|
27
|
+
x: { field: 'value', type: 'quantitative' },
|
|
28
|
+
y: { field: 'name', type: 'nominal' },
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// useDarkMode
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function DarkModeHarness({ mode }: { mode?: 'auto' | 'force' | 'off' }) {
|
|
37
|
+
const isDark = useDarkMode(mode);
|
|
38
|
+
return <div data-testid="dark-mode">{String(isDark)}</div>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('useDarkMode', () => {
|
|
42
|
+
it('returns false when no mode is provided', async () => {
|
|
43
|
+
const { container } = render(<DarkModeHarness />);
|
|
44
|
+
await waitFor(() => {
|
|
45
|
+
expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('false');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns false for "off" mode', async () => {
|
|
50
|
+
const { container } = render(<DarkModeHarness mode="off" />);
|
|
51
|
+
await waitFor(() => {
|
|
52
|
+
expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('false');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns true for "force" mode', async () => {
|
|
57
|
+
const { container } = render(<DarkModeHarness mode="force" />);
|
|
58
|
+
await waitFor(() => {
|
|
59
|
+
expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('true');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('reflects system preference for "auto" mode when dark', async () => {
|
|
64
|
+
const spy = vi.spyOn(window, 'matchMedia').mockImplementation(
|
|
65
|
+
(query: string) =>
|
|
66
|
+
({
|
|
67
|
+
matches: query === '(prefers-color-scheme: dark)',
|
|
68
|
+
media: query,
|
|
69
|
+
addEventListener: vi.fn(),
|
|
70
|
+
removeEventListener: vi.fn(),
|
|
71
|
+
}) as unknown as MediaQueryList,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const { container } = render(<DarkModeHarness mode="auto" />);
|
|
75
|
+
await waitFor(() => {
|
|
76
|
+
expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('true');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
spy.mockRestore();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('reflects system preference for "auto" mode when light', async () => {
|
|
83
|
+
const spy = vi.spyOn(window, 'matchMedia').mockImplementation(
|
|
84
|
+
(query: string) =>
|
|
85
|
+
({
|
|
86
|
+
matches: false,
|
|
87
|
+
media: query,
|
|
88
|
+
addEventListener: vi.fn(),
|
|
89
|
+
removeEventListener: vi.fn(),
|
|
90
|
+
}) as unknown as MediaQueryList,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const { container } = render(<DarkModeHarness mode="auto" />);
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('false');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
spy.mockRestore();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('switches from "off" to "force" when mode prop changes', async () => {
|
|
102
|
+
const { container, rerender } = render(<DarkModeHarness mode="off" />);
|
|
103
|
+
await waitFor(() => {
|
|
104
|
+
expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('false');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
rerender(<DarkModeHarness mode="force" />);
|
|
108
|
+
await waitFor(() => {
|
|
109
|
+
expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('true');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// useChart
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
function ChartHookHarness({ spec }: { spec: ChartSpec }) {
|
|
119
|
+
const { ref, chart, layout } = useChart(spec);
|
|
120
|
+
return (
|
|
121
|
+
<div>
|
|
122
|
+
<div ref={ref} data-testid="chart-container" />
|
|
123
|
+
<span data-testid="has-chart">{String(chart !== null)}</span>
|
|
124
|
+
<span data-testid="has-layout">{String(layout !== null)}</span>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('useChart', () => {
|
|
130
|
+
it('mounts a chart instance into the ref container', async () => {
|
|
131
|
+
const { container } = render(<ChartHookHarness spec={barSpec} />);
|
|
132
|
+
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
const chartContainer = container.querySelector('[data-testid="chart-container"]');
|
|
135
|
+
const svg = chartContainer?.querySelector('svg');
|
|
136
|
+
expect(svg).not.toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('produces a non-null layout after mounting', async () => {
|
|
141
|
+
const { container } = render(<ChartHookHarness spec={barSpec} />);
|
|
142
|
+
|
|
143
|
+
await waitFor(() => {
|
|
144
|
+
expect(container.querySelector('[data-testid="has-layout"]')?.textContent).toBe('true');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('cleans up the chart on unmount', async () => {
|
|
149
|
+
const { container, unmount } = render(<ChartHookHarness spec={barSpec} />);
|
|
150
|
+
|
|
151
|
+
await waitFor(() => {
|
|
152
|
+
const svg = container.querySelector('svg');
|
|
153
|
+
expect(svg).not.toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
unmount();
|
|
157
|
+
|
|
158
|
+
expect(container.querySelector('svg')).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests that spec changes go through the update() path rather than
|
|
3
|
+
* destroying and recreating the chart/table instance.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ChartSpec, TableSpec } from '@opendata-ai/openchart-core';
|
|
7
|
+
import { cleanup, render, waitFor } from '@testing-library/react';
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { Chart } from '../Chart';
|
|
10
|
+
import { DataTable } from '../DataTable';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Mock the vanilla adapter to track create/update/destroy calls
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const mockChartInstance = {
|
|
17
|
+
update: vi.fn(),
|
|
18
|
+
resize: vi.fn(),
|
|
19
|
+
destroy: vi.fn(),
|
|
20
|
+
export: vi.fn(),
|
|
21
|
+
layout: {} as unknown,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const mockTableInstance = {
|
|
25
|
+
update: vi.fn(),
|
|
26
|
+
setState: vi.fn(),
|
|
27
|
+
destroy: vi.fn(),
|
|
28
|
+
layout: {} as unknown,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
vi.mock('@opendata-ai/openchart-vanilla', () => ({
|
|
32
|
+
createChart: vi.fn(() => ({ ...mockChartInstance })),
|
|
33
|
+
createTable: vi.fn(() => ({ ...mockTableInstance })),
|
|
34
|
+
createGraph: vi.fn(),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
// Import after mock setup
|
|
38
|
+
const { createChart, createTable } = await import('@opendata-ai/openchart-vanilla');
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Test data
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const lineSpec: ChartSpec = {
|
|
45
|
+
type: 'line',
|
|
46
|
+
data: [
|
|
47
|
+
{ x: '2020', y: 10 },
|
|
48
|
+
{ x: '2021', y: 20 },
|
|
49
|
+
],
|
|
50
|
+
encoding: {
|
|
51
|
+
x: { field: 'x', type: 'temporal' },
|
|
52
|
+
y: { field: 'y', type: 'quantitative' },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const barSpec: ChartSpec = {
|
|
57
|
+
type: 'bar',
|
|
58
|
+
data: [
|
|
59
|
+
{ name: 'A', value: 10 },
|
|
60
|
+
{ name: 'B', value: 20 },
|
|
61
|
+
],
|
|
62
|
+
encoding: {
|
|
63
|
+
x: { field: 'value', type: 'quantitative' },
|
|
64
|
+
y: { field: 'name', type: 'nominal' },
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const tableSpec: TableSpec = {
|
|
69
|
+
type: 'table',
|
|
70
|
+
data: [{ a: 1 }],
|
|
71
|
+
columns: [{ key: 'a', label: 'A' }],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const updatedTableSpec: TableSpec = {
|
|
75
|
+
type: 'table',
|
|
76
|
+
data: [{ a: 1 }, { a: 2 }],
|
|
77
|
+
columns: [{ key: 'a', label: 'A' }],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Tests
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
cleanup();
|
|
86
|
+
vi.clearAllMocks();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('Chart update-vs-remount', () => {
|
|
90
|
+
it('calls createChart once on initial mount', async () => {
|
|
91
|
+
render(<Chart spec={lineSpec} />);
|
|
92
|
+
await waitFor(() => {
|
|
93
|
+
expect(createChart).toHaveBeenCalledTimes(1);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('calls update() not createChart() on spec change', async () => {
|
|
98
|
+
const { rerender } = render(<Chart spec={lineSpec} />);
|
|
99
|
+
await waitFor(() => {
|
|
100
|
+
expect(createChart).toHaveBeenCalledTimes(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
rerender(<Chart spec={barSpec} />);
|
|
104
|
+
await waitFor(() => {
|
|
105
|
+
// createChart should NOT be called again
|
|
106
|
+
expect(createChart).toHaveBeenCalledTimes(1);
|
|
107
|
+
// update should be called with the new spec
|
|
108
|
+
expect(mockChartInstance.update).toHaveBeenCalledWith(barSpec);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('DataTable update-vs-remount', () => {
|
|
114
|
+
it('calls createTable once on initial mount', async () => {
|
|
115
|
+
render(<DataTable spec={tableSpec} />);
|
|
116
|
+
await waitFor(() => {
|
|
117
|
+
expect(createTable).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('calls update() not createTable() on spec change', async () => {
|
|
122
|
+
const { rerender } = render(<DataTable spec={tableSpec} />);
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
expect(createTable).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
rerender(<DataTable spec={updatedTableSpec} />);
|
|
128
|
+
await waitFor(() => {
|
|
129
|
+
expect(createTable).toHaveBeenCalledTimes(1);
|
|
130
|
+
expect(mockTableInstance.update).toHaveBeenCalledWith(updatedTableSpec);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useTableState hook.
|
|
3
|
+
*
|
|
4
|
+
* Uses thin wrapper components that expose hook state via the DOM,
|
|
5
|
+
* since renderHook is broken by bun's React dual-instance issue.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { fireEvent, render, waitFor } from '@testing-library/react';
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { type UseTableStateOptions, useTableState } from '../hooks/useTableState';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Test harness: renders hook state to the DOM
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
interface HarnessProps {
|
|
17
|
+
initialState?: UseTableStateOptions;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function TableStateHarness({ initialState }: HarnessProps) {
|
|
21
|
+
const { sort, search, page, setSort, setSearch, setPage, resetState } =
|
|
22
|
+
useTableState(initialState);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div>
|
|
26
|
+
<span data-testid="sort">{sort ? `${sort.column}:${sort.direction}` : 'null'}</span>
|
|
27
|
+
<span data-testid="search">{search}</span>
|
|
28
|
+
<span data-testid="page">{String(page)}</span>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
data-testid="set-sort-name-asc"
|
|
32
|
+
onClick={() => setSort({ column: 'name', direction: 'asc' })}
|
|
33
|
+
>
|
|
34
|
+
sort name asc
|
|
35
|
+
</button>
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
data-testid="set-sort-age-desc"
|
|
39
|
+
onClick={() => setSort({ column: 'age', direction: 'desc' })}
|
|
40
|
+
>
|
|
41
|
+
sort age desc
|
|
42
|
+
</button>
|
|
43
|
+
<button type="button" data-testid="clear-sort" onClick={() => setSort(null)}>
|
|
44
|
+
clear sort
|
|
45
|
+
</button>
|
|
46
|
+
<button type="button" data-testid="set-search" onClick={() => setSearch('filter text')}>
|
|
47
|
+
set search
|
|
48
|
+
</button>
|
|
49
|
+
<button type="button" data-testid="set-page-5" onClick={() => setPage(5)}>
|
|
50
|
+
page 5
|
|
51
|
+
</button>
|
|
52
|
+
<button type="button" data-testid="reset" onClick={() => resetState()}>
|
|
53
|
+
reset
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Helper
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
async function renderHarness(initialState?: UseTableStateOptions) {
|
|
64
|
+
const result = render(<TableStateHarness initialState={initialState} />);
|
|
65
|
+
// Wait for initial render to complete
|
|
66
|
+
await waitFor(() => {
|
|
67
|
+
expect(result.container.querySelector('[data-testid="page"]')).not.toBeNull();
|
|
68
|
+
});
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getState(container: HTMLElement) {
|
|
73
|
+
return {
|
|
74
|
+
sort: container.querySelector('[data-testid="sort"]')?.textContent ?? '',
|
|
75
|
+
search: container.querySelector('[data-testid="search"]')?.textContent ?? '',
|
|
76
|
+
page: container.querySelector('[data-testid="page"]')?.textContent ?? '',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Tests
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
describe('useTableState', () => {
|
|
85
|
+
// -------------------------------------------------------------------------
|
|
86
|
+
// Default initial state
|
|
87
|
+
// -------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
it('initializes with default values when no options provided', async () => {
|
|
90
|
+
const { container } = await renderHarness();
|
|
91
|
+
const state = getState(container);
|
|
92
|
+
|
|
93
|
+
expect(state.sort).toBe('null');
|
|
94
|
+
expect(state.search).toBe('');
|
|
95
|
+
expect(state.page).toBe('0');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// -------------------------------------------------------------------------
|
|
99
|
+
// Custom initial state
|
|
100
|
+
// -------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
it('initializes with provided sort state', async () => {
|
|
103
|
+
const { container } = await renderHarness({
|
|
104
|
+
sort: { column: 'name', direction: 'asc' },
|
|
105
|
+
});
|
|
106
|
+
expect(getState(container).sort).toBe('name:asc');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('initializes with provided search string', async () => {
|
|
110
|
+
const { container } = await renderHarness({ search: 'hello' });
|
|
111
|
+
expect(getState(container).search).toBe('hello');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('initializes with provided page number', async () => {
|
|
115
|
+
const { container } = await renderHarness({ page: 3 });
|
|
116
|
+
expect(getState(container).page).toBe('3');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// -------------------------------------------------------------------------
|
|
120
|
+
// State setters
|
|
121
|
+
// -------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
it('setSort updates the sort state', async () => {
|
|
124
|
+
const { container } = await renderHarness();
|
|
125
|
+
|
|
126
|
+
fireEvent.click(container.querySelector('[data-testid="set-sort-age-desc"]')!);
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(getState(container).sort).toBe('age:desc');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('setSort can clear sort by passing null', async () => {
|
|
133
|
+
const { container } = await renderHarness({
|
|
134
|
+
sort: { column: 'name', direction: 'asc' },
|
|
135
|
+
});
|
|
136
|
+
expect(getState(container).sort).toBe('name:asc');
|
|
137
|
+
|
|
138
|
+
fireEvent.click(container.querySelector('[data-testid="clear-sort"]')!);
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
expect(getState(container).sort).toBe('null');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('setSearch updates the search query', async () => {
|
|
145
|
+
const { container } = await renderHarness();
|
|
146
|
+
|
|
147
|
+
fireEvent.click(container.querySelector('[data-testid="set-search"]')!);
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(getState(container).search).toBe('filter text');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('setPage updates the current page', async () => {
|
|
154
|
+
const { container } = await renderHarness();
|
|
155
|
+
|
|
156
|
+
fireEvent.click(container.querySelector('[data-testid="set-page-5"]')!);
|
|
157
|
+
await waitFor(() => {
|
|
158
|
+
expect(getState(container).page).toBe('5');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// -------------------------------------------------------------------------
|
|
163
|
+
// resetState
|
|
164
|
+
// -------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
it('resetState restores default initial values', async () => {
|
|
167
|
+
const { container } = await renderHarness();
|
|
168
|
+
|
|
169
|
+
// Change all values
|
|
170
|
+
fireEvent.click(container.querySelector('[data-testid="set-sort-name-asc"]')!);
|
|
171
|
+
fireEvent.click(container.querySelector('[data-testid="set-search"]')!);
|
|
172
|
+
fireEvent.click(container.querySelector('[data-testid="set-page-5"]')!);
|
|
173
|
+
|
|
174
|
+
await waitFor(() => {
|
|
175
|
+
expect(getState(container).sort).toBe('name:asc');
|
|
176
|
+
expect(getState(container).search).toBe('filter text');
|
|
177
|
+
expect(getState(container).page).toBe('5');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Reset
|
|
181
|
+
fireEvent.click(container.querySelector('[data-testid="reset"]')!);
|
|
182
|
+
|
|
183
|
+
await waitFor(() => {
|
|
184
|
+
expect(getState(container).sort).toBe('null');
|
|
185
|
+
expect(getState(container).search).toBe('');
|
|
186
|
+
expect(getState(container).page).toBe('0');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('resetState restores custom initial values', async () => {
|
|
191
|
+
const initialState = {
|
|
192
|
+
sort: { column: 'age', direction: 'desc' as const },
|
|
193
|
+
search: 'initial',
|
|
194
|
+
page: 1,
|
|
195
|
+
};
|
|
196
|
+
const { container } = await renderHarness(initialState);
|
|
197
|
+
|
|
198
|
+
// Change all values
|
|
199
|
+
fireEvent.click(container.querySelector('[data-testid="clear-sort"]')!);
|
|
200
|
+
fireEvent.click(container.querySelector('[data-testid="set-search"]')!);
|
|
201
|
+
fireEvent.click(container.querySelector('[data-testid="set-page-5"]')!);
|
|
202
|
+
|
|
203
|
+
await waitFor(() => {
|
|
204
|
+
expect(getState(container).sort).toBe('null');
|
|
205
|
+
expect(getState(container).search).toBe('filter text');
|
|
206
|
+
expect(getState(container).page).toBe('5');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Reset to initial
|
|
210
|
+
fireEvent.click(container.querySelector('[data-testid="reset"]')!);
|
|
211
|
+
|
|
212
|
+
await waitFor(() => {
|
|
213
|
+
expect(getState(container).sort).toBe('age:desc');
|
|
214
|
+
expect(getState(container).search).toBe('initial');
|
|
215
|
+
expect(getState(container).page).toBe('1');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useGraph: hook for imperative graph control.
|
|
3
|
+
*
|
|
4
|
+
* Provides a ref to pass to <Graph /> and exposes graph methods
|
|
5
|
+
* (search, zoom, select) for programmatic control of the graph instance.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GraphInstance } from '@opendata-ai/openchart-vanilla';
|
|
9
|
+
import { useCallback, useRef } from 'react';
|
|
10
|
+
|
|
11
|
+
export interface UseGraphReturn {
|
|
12
|
+
/** Ref to pass to <Graph ref={ref} />. */
|
|
13
|
+
ref: React.RefObject<GraphHandle | null>;
|
|
14
|
+
/** Search for nodes matching a query string. */
|
|
15
|
+
search: (query: string) => void;
|
|
16
|
+
/** Clear the current search. */
|
|
17
|
+
clearSearch: () => void;
|
|
18
|
+
/** Zoom to fit all nodes in view. */
|
|
19
|
+
zoomToFit: () => void;
|
|
20
|
+
/** Zoom and center on a specific node. */
|
|
21
|
+
zoomToNode: (nodeId: string) => void;
|
|
22
|
+
/** Select a node by id. */
|
|
23
|
+
selectNode: (nodeId: string) => void;
|
|
24
|
+
/** Get the currently selected node ids. */
|
|
25
|
+
getSelectedNodes: () => string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Handle exposed by Graph component via forwardRef. */
|
|
29
|
+
export interface GraphHandle {
|
|
30
|
+
search: (query: string) => void;
|
|
31
|
+
clearSearch: () => void;
|
|
32
|
+
zoomToFit: () => void;
|
|
33
|
+
zoomToNode: (nodeId: string) => void;
|
|
34
|
+
selectNode: (nodeId: string) => void;
|
|
35
|
+
getSelectedNodes: () => string[];
|
|
36
|
+
/** The underlying GraphInstance from the vanilla adapter. */
|
|
37
|
+
instance: GraphInstance | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Hook for imperative graph control.
|
|
42
|
+
*
|
|
43
|
+
* Usage:
|
|
44
|
+
* ```tsx
|
|
45
|
+
* const { ref, search, zoomToFit } = useGraph();
|
|
46
|
+
* return <Graph ref={ref} spec={spec} />;
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function useGraph(): UseGraphReturn {
|
|
50
|
+
const ref = useRef<GraphHandle | null>(null);
|
|
51
|
+
|
|
52
|
+
const search = useCallback((query: string) => {
|
|
53
|
+
ref.current?.search(query);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const clearSearch = useCallback(() => {
|
|
57
|
+
ref.current?.clearSearch();
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
const zoomToFit = useCallback(() => {
|
|
61
|
+
ref.current?.zoomToFit();
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const zoomToNode = useCallback((nodeId: string) => {
|
|
65
|
+
ref.current?.zoomToNode(nodeId);
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const selectNode = useCallback((nodeId: string) => {
|
|
69
|
+
ref.current?.selectNode(nodeId);
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const getSelectedNodes = useCallback((): string[] => {
|
|
73
|
+
return ref.current?.getSelectedNodes() ?? [];
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
ref,
|
|
78
|
+
search,
|
|
79
|
+
clearSearch,
|
|
80
|
+
zoomToFit,
|
|
81
|
+
zoomToNode,
|
|
82
|
+
selectNode,
|
|
83
|
+
getSelectedNodes,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTable: hook for manual table lifecycle control.
|
|
3
|
+
*
|
|
4
|
+
* Attaches to a container ref, mounts a vanilla table instance,
|
|
5
|
+
* and exposes the instance and current state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TableSpec } from '@opendata-ai/openchart-core';
|
|
9
|
+
import {
|
|
10
|
+
createTable,
|
|
11
|
+
type TableInstance,
|
|
12
|
+
type TableMountOptions,
|
|
13
|
+
type TableState,
|
|
14
|
+
} from '@opendata-ai/openchart-vanilla';
|
|
15
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
export interface UseTableReturn {
|
|
18
|
+
/** Ref to attach to the container div. */
|
|
19
|
+
ref: React.RefObject<HTMLDivElement | null>;
|
|
20
|
+
/** The table instance (null until mounted). */
|
|
21
|
+
table: TableInstance | null;
|
|
22
|
+
/** The current table state (sort, search, page). */
|
|
23
|
+
state: TableState;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hook for manual table lifecycle control.
|
|
28
|
+
*
|
|
29
|
+
* Attach the returned ref to a container div. The table mounts
|
|
30
|
+
* automatically and updates when the spec changes.
|
|
31
|
+
*
|
|
32
|
+
* @param spec - The table spec.
|
|
33
|
+
* @param options - Mount options.
|
|
34
|
+
* @returns { ref, table, state }
|
|
35
|
+
*/
|
|
36
|
+
export function useTable(spec: TableSpec, options?: TableMountOptions): UseTableReturn {
|
|
37
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
38
|
+
const tableRef = useRef<TableInstance | null>(null);
|
|
39
|
+
const [state, setState] = useState<TableState>({
|
|
40
|
+
sort: null,
|
|
41
|
+
search: '',
|
|
42
|
+
page: 0,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const originalOnStateChange = options?.onStateChange;
|
|
46
|
+
|
|
47
|
+
const handleStateChange = useCallback(
|
|
48
|
+
(newState: TableState) => {
|
|
49
|
+
setState(newState);
|
|
50
|
+
originalOnStateChange?.(newState);
|
|
51
|
+
},
|
|
52
|
+
[originalOnStateChange],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Mount / unmount
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const container = ref.current;
|
|
58
|
+
if (!container) return;
|
|
59
|
+
|
|
60
|
+
const mountOpts: TableMountOptions = {
|
|
61
|
+
...options,
|
|
62
|
+
onStateChange: handleStateChange,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const table = createTable(container, spec, mountOpts);
|
|
66
|
+
tableRef.current = table;
|
|
67
|
+
setState(table.getState());
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
table.destroy();
|
|
71
|
+
tableRef.current = null;
|
|
72
|
+
};
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [
|
|
75
|
+
options?.theme,
|
|
76
|
+
options?.darkMode,
|
|
77
|
+
options?.onRowClick,
|
|
78
|
+
options?.responsive,
|
|
79
|
+
handleStateChange,
|
|
80
|
+
options,
|
|
81
|
+
spec,
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
// Update on spec change
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const table = tableRef.current;
|
|
87
|
+
if (!table) return;
|
|
88
|
+
|
|
89
|
+
table.update(spec);
|
|
90
|
+
setState(table.getState());
|
|
91
|
+
}, [spec]);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
ref,
|
|
95
|
+
table: tableRef.current,
|
|
96
|
+
state,
|
|
97
|
+
};
|
|
98
|
+
}
|