@opendata-ai/openchart-vue 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 +98 -0
- package/dist/index.d.ts +521 -0
- package/dist/index.js +715 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
- package/src/Chart.ts +187 -0
- package/src/DataTable.ts +194 -0
- package/src/Graph.ts +179 -0
- package/src/ThemeProvider.ts +41 -0
- package/src/Visualization.ts +61 -0
- package/src/__tests__/Chart.test.ts +139 -0
- package/src/__tests__/DataTable.test.ts +131 -0
- package/src/__tests__/Graph.test.ts +133 -0
- package/src/__tests__/ThemeContext.test.ts +266 -0
- package/src/__tests__/composables.test.ts +158 -0
- package/src/__tests__/useTableState.test.ts +256 -0
- package/src/composables/useChart.ts +95 -0
- package/src/composables/useDarkMode.ts +71 -0
- package/src/composables/useGraph.ts +89 -0
- package/src/composables/useTable.ts +88 -0
- package/src/composables/useTableState.ts +80 -0
- package/src/context.ts +33 -0
- package/src/index.ts +41 -0
package/src/Graph.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue Graph component: thin wrapper around the vanilla adapter.
|
|
3
|
+
*
|
|
4
|
+
* Mounts a graph instance on render, updates when spec changes,
|
|
5
|
+
* and cleans up on unmount. All heavy lifting is done by the vanilla
|
|
6
|
+
* createGraph() function.
|
|
7
|
+
*
|
|
8
|
+
* Exposes imperative methods via defineExpose for use with useGraph().
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { DarkMode, GraphSpec, ThemeConfig } from '@opendata-ai/openchart-core';
|
|
12
|
+
import {
|
|
13
|
+
createGraph,
|
|
14
|
+
type GraphInstance,
|
|
15
|
+
type GraphMountOptions,
|
|
16
|
+
} from '@opendata-ai/openchart-vanilla';
|
|
17
|
+
import {
|
|
18
|
+
type CSSProperties,
|
|
19
|
+
defineComponent,
|
|
20
|
+
h,
|
|
21
|
+
inject,
|
|
22
|
+
onMounted,
|
|
23
|
+
onUnmounted,
|
|
24
|
+
type PropType,
|
|
25
|
+
ref,
|
|
26
|
+
watch,
|
|
27
|
+
} from 'vue';
|
|
28
|
+
import { VizDarkModeKey, VizThemeKey } from './context';
|
|
29
|
+
|
|
30
|
+
export interface GraphProps {
|
|
31
|
+
spec: GraphSpec;
|
|
32
|
+
theme?: ThemeConfig;
|
|
33
|
+
darkMode?: DarkMode;
|
|
34
|
+
class?: string;
|
|
35
|
+
style?: string | CSSProperties;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const Graph = defineComponent({
|
|
39
|
+
name: 'Graph',
|
|
40
|
+
props: {
|
|
41
|
+
spec: {
|
|
42
|
+
type: Object as PropType<GraphSpec>,
|
|
43
|
+
required: true,
|
|
44
|
+
},
|
|
45
|
+
theme: {
|
|
46
|
+
type: Object as PropType<ThemeConfig>,
|
|
47
|
+
default: undefined,
|
|
48
|
+
},
|
|
49
|
+
darkMode: {
|
|
50
|
+
type: String as PropType<DarkMode>,
|
|
51
|
+
default: undefined,
|
|
52
|
+
},
|
|
53
|
+
class: {
|
|
54
|
+
type: String,
|
|
55
|
+
default: undefined,
|
|
56
|
+
},
|
|
57
|
+
style: {
|
|
58
|
+
type: [String, Object] as PropType<string | CSSProperties>,
|
|
59
|
+
default: undefined,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
emits: {
|
|
63
|
+
'node-click': (_node: Record<string, unknown>) => true,
|
|
64
|
+
'node-double-click': (_node: Record<string, unknown>) => true,
|
|
65
|
+
'selection-change': (_nodeIds: string[]) => true,
|
|
66
|
+
},
|
|
67
|
+
setup(props, { emit, expose }) {
|
|
68
|
+
const containerRef = ref<HTMLDivElement | null>(null);
|
|
69
|
+
let instance: GraphInstance | null = null;
|
|
70
|
+
let prevSpec = '';
|
|
71
|
+
|
|
72
|
+
// Inject theme/darkMode from provider as fallbacks
|
|
73
|
+
const contextTheme = inject(VizThemeKey, undefined);
|
|
74
|
+
const contextDarkMode = inject(VizDarkModeKey, undefined);
|
|
75
|
+
|
|
76
|
+
function resolveTheme(): ThemeConfig | undefined {
|
|
77
|
+
return props.theme ?? contextTheme?.value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveDarkMode(): DarkMode | undefined {
|
|
81
|
+
return props.darkMode ?? contextDarkMode?.value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function mountGraph() {
|
|
85
|
+
const container = containerRef.value;
|
|
86
|
+
if (!container) return;
|
|
87
|
+
|
|
88
|
+
const options: GraphMountOptions = {
|
|
89
|
+
theme: resolveTheme(),
|
|
90
|
+
darkMode: resolveDarkMode(),
|
|
91
|
+
onNodeClick: (node: Record<string, unknown>) => emit('node-click', node),
|
|
92
|
+
onNodeDoubleClick: (node: Record<string, unknown>) => emit('node-double-click', node),
|
|
93
|
+
onSelectionChange: (nodeIds: string[]) => emit('selection-change', nodeIds),
|
|
94
|
+
responsive: true,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
instance = createGraph(container, props.spec, options);
|
|
98
|
+
prevSpec = JSON.stringify(props.spec);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function destroyGraph() {
|
|
102
|
+
instance?.destroy();
|
|
103
|
+
instance = null;
|
|
104
|
+
prevSpec = '';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Expose imperative methods for useGraph() composable
|
|
108
|
+
expose({
|
|
109
|
+
search(query: string) {
|
|
110
|
+
instance?.search(query);
|
|
111
|
+
},
|
|
112
|
+
clearSearch() {
|
|
113
|
+
instance?.clearSearch();
|
|
114
|
+
},
|
|
115
|
+
zoomToFit() {
|
|
116
|
+
instance?.zoomToFit();
|
|
117
|
+
},
|
|
118
|
+
zoomToNode(nodeId: string) {
|
|
119
|
+
instance?.zoomToNode(nodeId);
|
|
120
|
+
},
|
|
121
|
+
selectNode(nodeId: string) {
|
|
122
|
+
instance?.selectNode(nodeId);
|
|
123
|
+
},
|
|
124
|
+
getSelectedNodes(): string[] {
|
|
125
|
+
return instance?.getSelectedNodes() ?? [];
|
|
126
|
+
},
|
|
127
|
+
get instance() {
|
|
128
|
+
return instance;
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
onMounted(() => {
|
|
133
|
+
mountGraph();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
onUnmounted(() => {
|
|
137
|
+
destroyGraph();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Watch spec changes
|
|
141
|
+
watch(
|
|
142
|
+
() => JSON.stringify(props.spec),
|
|
143
|
+
(newVal) => {
|
|
144
|
+
if (!instance) return;
|
|
145
|
+
if (newVal !== prevSpec) {
|
|
146
|
+
prevSpec = newVal;
|
|
147
|
+
instance.update(props.spec);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Recreate graph when theme or darkMode change
|
|
153
|
+
watch(
|
|
154
|
+
[
|
|
155
|
+
() => props.theme,
|
|
156
|
+
() => props.darkMode,
|
|
157
|
+
() => contextTheme?.value,
|
|
158
|
+
() => contextDarkMode?.value,
|
|
159
|
+
],
|
|
160
|
+
() => {
|
|
161
|
+
if (!containerRef.value) return;
|
|
162
|
+
destroyGraph();
|
|
163
|
+
mountGraph();
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const rootClass = () => {
|
|
168
|
+
const base = 'viz-graph-root';
|
|
169
|
+
return props.class ? `${base} ${props.class}` : base;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return () =>
|
|
173
|
+
h('div', {
|
|
174
|
+
ref: containerRef,
|
|
175
|
+
class: rootClass(),
|
|
176
|
+
style: props.style,
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VizThemeProvider: provides a theme and dark mode preference to all
|
|
3
|
+
* descendant Chart, DataTable, and Graph components without prop drilling.
|
|
4
|
+
*
|
|
5
|
+
* Components use the context values as fallbacks when no explicit
|
|
6
|
+
* `theme` or `darkMode` prop is passed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DarkMode, ThemeConfig } from '@opendata-ai/openchart-core';
|
|
10
|
+
import { computed, defineComponent, type PropType, provide } from 'vue';
|
|
11
|
+
import { VizDarkModeKey, VizThemeKey } from './context';
|
|
12
|
+
|
|
13
|
+
export interface VizThemeProviderProps {
|
|
14
|
+
theme: ThemeConfig | undefined;
|
|
15
|
+
darkMode?: DarkMode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const VizThemeProvider = defineComponent({
|
|
19
|
+
name: 'VizThemeProvider',
|
|
20
|
+
props: {
|
|
21
|
+
theme: {
|
|
22
|
+
type: Object as PropType<ThemeConfig | undefined>,
|
|
23
|
+
default: undefined,
|
|
24
|
+
},
|
|
25
|
+
darkMode: {
|
|
26
|
+
type: String as PropType<DarkMode>,
|
|
27
|
+
default: undefined,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
setup(props, { slots }) {
|
|
31
|
+
// Wrap in computed() so changes to props propagate reactively
|
|
32
|
+
// through the injection system.
|
|
33
|
+
const themeRef = computed(() => props.theme);
|
|
34
|
+
const darkModeRef = computed(() => props.darkMode);
|
|
35
|
+
|
|
36
|
+
provide(VizThemeKey, themeRef);
|
|
37
|
+
provide(VizDarkModeKey, darkModeRef);
|
|
38
|
+
|
|
39
|
+
return () => slots.default?.();
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visualization routing component: renders Chart, DataTable, or Graph
|
|
3
|
+
* based on the spec type. Use this when rendering arbitrary VizSpec values.
|
|
4
|
+
*
|
|
5
|
+
* For event handlers, use the specific component (Chart, DataTable, Graph) directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DarkMode, ThemeConfig, VizSpec } from '@opendata-ai/openchart-core';
|
|
9
|
+
import { isGraphSpec, isTableSpec } from '@opendata-ai/openchart-core';
|
|
10
|
+
import { type CSSProperties, defineComponent, h, type PropType } from 'vue';
|
|
11
|
+
import { Chart } from './Chart';
|
|
12
|
+
import { DataTable } from './DataTable';
|
|
13
|
+
import { Graph } from './Graph';
|
|
14
|
+
|
|
15
|
+
export interface VisualizationProps {
|
|
16
|
+
spec: VizSpec;
|
|
17
|
+
theme?: ThemeConfig;
|
|
18
|
+
darkMode?: DarkMode;
|
|
19
|
+
class?: string;
|
|
20
|
+
style?: string | CSSProperties;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const Visualization = defineComponent({
|
|
24
|
+
name: 'Visualization',
|
|
25
|
+
props: {
|
|
26
|
+
spec: {
|
|
27
|
+
type: Object as PropType<VizSpec>,
|
|
28
|
+
required: true,
|
|
29
|
+
},
|
|
30
|
+
theme: {
|
|
31
|
+
type: Object as PropType<ThemeConfig>,
|
|
32
|
+
default: undefined,
|
|
33
|
+
},
|
|
34
|
+
darkMode: {
|
|
35
|
+
type: String as PropType<DarkMode>,
|
|
36
|
+
default: undefined,
|
|
37
|
+
},
|
|
38
|
+
class: {
|
|
39
|
+
type: String,
|
|
40
|
+
default: undefined,
|
|
41
|
+
},
|
|
42
|
+
style: {
|
|
43
|
+
type: [String, Object] as PropType<string | CSSProperties>,
|
|
44
|
+
default: undefined,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
setup(props) {
|
|
48
|
+
return () => {
|
|
49
|
+
const { spec, theme, darkMode, class: className, style } = props;
|
|
50
|
+
const sharedProps = { theme, darkMode, class: className, style };
|
|
51
|
+
|
|
52
|
+
if (isTableSpec(spec)) {
|
|
53
|
+
return h(DataTable, { ...sharedProps, spec });
|
|
54
|
+
}
|
|
55
|
+
if (isGraphSpec(spec)) {
|
|
56
|
+
return h(Graph, { ...sharedProps, spec });
|
|
57
|
+
}
|
|
58
|
+
return h(Chart, { ...sharedProps, spec });
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ChartSpec } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { flushPromises, mount } from '@vue/test-utils';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { Chart } from '../Chart';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Test data
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const lineSpec: ChartSpec = {
|
|
11
|
+
type: 'line',
|
|
12
|
+
data: [
|
|
13
|
+
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
14
|
+
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
15
|
+
{ date: '2020-01-01', value: 15, country: 'UK' },
|
|
16
|
+
{ date: '2021-01-01', value: 35, country: 'UK' },
|
|
17
|
+
],
|
|
18
|
+
encoding: {
|
|
19
|
+
x: { field: 'date', type: 'temporal' },
|
|
20
|
+
y: { field: 'value', type: 'quantitative' },
|
|
21
|
+
color: { field: 'country', type: 'nominal' },
|
|
22
|
+
},
|
|
23
|
+
chrome: {
|
|
24
|
+
title: 'GDP Growth',
|
|
25
|
+
subtitle: 'US vs UK over time',
|
|
26
|
+
source: 'World Bank',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const barSpec: ChartSpec = {
|
|
31
|
+
type: 'bar',
|
|
32
|
+
data: [
|
|
33
|
+
{ name: 'A', value: 10 },
|
|
34
|
+
{ name: 'B', value: 30 },
|
|
35
|
+
{ name: 'C', value: 20 },
|
|
36
|
+
],
|
|
37
|
+
encoding: {
|
|
38
|
+
x: { field: 'value', type: 'quantitative' },
|
|
39
|
+
y: { field: 'name', type: 'nominal' },
|
|
40
|
+
},
|
|
41
|
+
chrome: {
|
|
42
|
+
title: 'Updated Title',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Helper: mount Chart and wait for the vanilla adapter to render
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
async function mountChart(props: {
|
|
51
|
+
spec: ChartSpec;
|
|
52
|
+
class?: string;
|
|
53
|
+
darkMode?: string;
|
|
54
|
+
style?: string | Record<string, string>;
|
|
55
|
+
}) {
|
|
56
|
+
const wrapper = mount(Chart, { props: props as Record<string, unknown> });
|
|
57
|
+
await flushPromises();
|
|
58
|
+
return wrapper;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Tests
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe('Chart', () => {
|
|
66
|
+
it('renders an SVG element', async () => {
|
|
67
|
+
const wrapper = await mountChart({ spec: lineSpec });
|
|
68
|
+
const svg = wrapper.find('svg');
|
|
69
|
+
expect(svg.exists()).toBe(true);
|
|
70
|
+
expect(svg.attributes('class')).toBe('viz-chart');
|
|
71
|
+
wrapper.unmount();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('renders chrome text elements', async () => {
|
|
75
|
+
const wrapper = await mountChart({ spec: lineSpec });
|
|
76
|
+
|
|
77
|
+
const title = wrapper.find('.viz-title');
|
|
78
|
+
expect(title.exists()).toBe(true);
|
|
79
|
+
expect(title.text()).toBe('GDP Growth');
|
|
80
|
+
|
|
81
|
+
const subtitle = wrapper.find('.viz-subtitle');
|
|
82
|
+
expect(subtitle.text()).toBe('US vs UK over time');
|
|
83
|
+
|
|
84
|
+
const source = wrapper.find('.viz-source');
|
|
85
|
+
expect(source.text()).toBe('World Bank');
|
|
86
|
+
wrapper.unmount();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('spec changes trigger re-render', async () => {
|
|
90
|
+
const wrapper = await mountChart({ spec: lineSpec });
|
|
91
|
+
|
|
92
|
+
const titleBefore = wrapper.find('.viz-title');
|
|
93
|
+
expect(titleBefore.text()).toBe('GDP Growth');
|
|
94
|
+
|
|
95
|
+
await wrapper.setProps({ spec: barSpec });
|
|
96
|
+
await flushPromises();
|
|
97
|
+
|
|
98
|
+
const titleAfter = wrapper.find('.viz-title');
|
|
99
|
+
expect(titleAfter.text()).toBe('Updated Title');
|
|
100
|
+
wrapper.unmount();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('unmounting cleans up chart instance', async () => {
|
|
104
|
+
const wrapper = await mountChart({ spec: lineSpec });
|
|
105
|
+
|
|
106
|
+
const svgBefore = wrapper.find('svg');
|
|
107
|
+
expect(svgBefore.exists()).toBe(true);
|
|
108
|
+
|
|
109
|
+
wrapper.unmount();
|
|
110
|
+
|
|
111
|
+
// After unmounting, the wrapper element should be empty
|
|
112
|
+
expect(wrapper.find('svg').exists()).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('class prop passes through to wrapper div', async () => {
|
|
116
|
+
const wrapper = await mountChart({ spec: lineSpec, class: 'my-chart' });
|
|
117
|
+
|
|
118
|
+
expect(wrapper.classes()).toContain('my-chart');
|
|
119
|
+
wrapper.unmount();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('renders with dark mode option', async () => {
|
|
123
|
+
const wrapper = await mountChart({ spec: lineSpec, darkMode: 'force' });
|
|
124
|
+
|
|
125
|
+
const svg = wrapper.find('svg');
|
|
126
|
+
expect(svg.exists()).toBe(true);
|
|
127
|
+
wrapper.unmount();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('style prop passes through to wrapper div', async () => {
|
|
131
|
+
const wrapper = await mountChart({
|
|
132
|
+
spec: lineSpec,
|
|
133
|
+
style: { border: '1px solid red' },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(wrapper.attributes('style')).toContain('border');
|
|
137
|
+
wrapper.unmount();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { TableSpec } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { flushPromises, mount } from '@vue/test-utils';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { DataTable } from '../DataTable';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Test data
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const tableSpec: TableSpec = {
|
|
11
|
+
type: 'table',
|
|
12
|
+
data: [
|
|
13
|
+
{ name: 'Alice', age: 30, city: 'Portland' },
|
|
14
|
+
{ name: 'Bob', age: 25, city: 'Seattle' },
|
|
15
|
+
{ name: 'Charlie', age: 35, city: 'Denver' },
|
|
16
|
+
],
|
|
17
|
+
columns: [
|
|
18
|
+
{ key: 'name', label: 'Name' },
|
|
19
|
+
{ key: 'age', label: 'Age' },
|
|
20
|
+
{ key: 'city', label: 'City' },
|
|
21
|
+
],
|
|
22
|
+
chrome: { title: 'People Table' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const updatedSpec: TableSpec = {
|
|
26
|
+
type: 'table',
|
|
27
|
+
data: [
|
|
28
|
+
{ x: 1, y: 2 },
|
|
29
|
+
{ x: 3, y: 4 },
|
|
30
|
+
],
|
|
31
|
+
columns: [
|
|
32
|
+
{ key: 'x', label: 'X Value' },
|
|
33
|
+
{ key: 'y', label: 'Y Value' },
|
|
34
|
+
],
|
|
35
|
+
chrome: { title: 'Updated Table' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Helper: mount DataTable and wait for the vanilla adapter to render
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
async function mountTable(props: {
|
|
43
|
+
spec: TableSpec;
|
|
44
|
+
class?: string;
|
|
45
|
+
darkMode?: string;
|
|
46
|
+
style?: string | Record<string, string>;
|
|
47
|
+
}) {
|
|
48
|
+
const wrapper = mount(DataTable, { props: props as Record<string, unknown> });
|
|
49
|
+
await flushPromises();
|
|
50
|
+
return wrapper;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Tests
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
describe('DataTable', () => {
|
|
58
|
+
it('renders a table', async () => {
|
|
59
|
+
const wrapper = await mountTable({ spec: tableSpec });
|
|
60
|
+
const table = wrapper.find('table');
|
|
61
|
+
expect(table.exists()).toBe(true);
|
|
62
|
+
wrapper.unmount();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders correct number of columns', async () => {
|
|
66
|
+
const wrapper = await mountTable({ spec: tableSpec });
|
|
67
|
+
const headers = wrapper.findAll('thead th');
|
|
68
|
+
expect(headers.length).toBe(3);
|
|
69
|
+
wrapper.unmount();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('renders correct number of rows', async () => {
|
|
73
|
+
const wrapper = await mountTable({ spec: tableSpec });
|
|
74
|
+
const rows = wrapper.findAll('tbody tr');
|
|
75
|
+
expect(rows.length).toBe(3);
|
|
76
|
+
wrapper.unmount();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('spec changes trigger re-render', async () => {
|
|
80
|
+
const wrapper = await mountTable({ spec: tableSpec });
|
|
81
|
+
|
|
82
|
+
const titleBefore = wrapper.find('.viz-table-title');
|
|
83
|
+
expect(titleBefore.text()).toBe('People Table');
|
|
84
|
+
|
|
85
|
+
await wrapper.setProps({ spec: updatedSpec });
|
|
86
|
+
await flushPromises();
|
|
87
|
+
|
|
88
|
+
const titleAfter = wrapper.find('.viz-table-title');
|
|
89
|
+
expect(titleAfter.text()).toBe('Updated Table');
|
|
90
|
+
|
|
91
|
+
const headersAfter = wrapper.findAll('thead th');
|
|
92
|
+
expect(headersAfter.length).toBe(2);
|
|
93
|
+
wrapper.unmount();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('unmounting cleans up', async () => {
|
|
97
|
+
const wrapper = await mountTable({ spec: tableSpec });
|
|
98
|
+
|
|
99
|
+
const tableBefore = wrapper.find('table');
|
|
100
|
+
expect(tableBefore.exists()).toBe(true);
|
|
101
|
+
|
|
102
|
+
wrapper.unmount();
|
|
103
|
+
|
|
104
|
+
expect(wrapper.find('table').exists()).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('class prop passes through', async () => {
|
|
108
|
+
const wrapper = await mountTable({ spec: tableSpec, class: 'my-table' });
|
|
109
|
+
|
|
110
|
+
expect(wrapper.classes()).toContain('my-table');
|
|
111
|
+
wrapper.unmount();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('renders with dark mode option', async () => {
|
|
115
|
+
const wrapper = await mountTable({ spec: tableSpec, darkMode: 'force' });
|
|
116
|
+
|
|
117
|
+
const table = wrapper.find('table');
|
|
118
|
+
expect(table.exists()).toBe(true);
|
|
119
|
+
wrapper.unmount();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('style prop passes through to wrapper div', async () => {
|
|
123
|
+
const wrapper = await mountTable({
|
|
124
|
+
spec: tableSpec,
|
|
125
|
+
style: { border: '1px solid red' },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(wrapper.attributes('style')).toContain('border');
|
|
129
|
+
wrapper.unmount();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { GraphSpec } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { flushPromises, mount } from '@vue/test-utils';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { Graph } from '../Graph';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Test data
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const basicSpec: GraphSpec = {
|
|
11
|
+
type: 'graph',
|
|
12
|
+
nodes: [
|
|
13
|
+
{ id: 'a', label: 'Node A' },
|
|
14
|
+
{ id: 'b', label: 'Node B' },
|
|
15
|
+
{ id: 'c', label: 'Node C' },
|
|
16
|
+
],
|
|
17
|
+
edges: [
|
|
18
|
+
{ source: 'a', target: 'b' },
|
|
19
|
+
{ source: 'b', target: 'c' },
|
|
20
|
+
],
|
|
21
|
+
chrome: {
|
|
22
|
+
title: 'Test Graph',
|
|
23
|
+
subtitle: 'A simple test graph',
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const updatedSpec: GraphSpec = {
|
|
28
|
+
type: 'graph',
|
|
29
|
+
nodes: [
|
|
30
|
+
{ id: 'x', label: 'Node X' },
|
|
31
|
+
{ id: 'y', label: 'Node Y' },
|
|
32
|
+
],
|
|
33
|
+
edges: [{ source: 'x', target: 'y' }],
|
|
34
|
+
chrome: {
|
|
35
|
+
title: 'Updated Graph',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Helper: mount Graph and wait for the vanilla adapter to render
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
async function mountGraph(props: {
|
|
44
|
+
spec: GraphSpec;
|
|
45
|
+
class?: string;
|
|
46
|
+
darkMode?: string;
|
|
47
|
+
style?: string | Record<string, string>;
|
|
48
|
+
}) {
|
|
49
|
+
const wrapper = mount(Graph, { props: props as Record<string, unknown> });
|
|
50
|
+
await flushPromises();
|
|
51
|
+
return wrapper;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Tests
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe('Graph', () => {
|
|
59
|
+
it('renders a container div', async () => {
|
|
60
|
+
const wrapper = await mountGraph({ spec: basicSpec });
|
|
61
|
+
expect(wrapper.element.tagName.toLowerCase()).toBe('div');
|
|
62
|
+
wrapper.unmount();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('mounts graph instance with canvas element', async () => {
|
|
66
|
+
const wrapper = await mountGraph({ spec: basicSpec });
|
|
67
|
+
const canvas = wrapper.find('canvas');
|
|
68
|
+
expect(canvas.exists()).toBe(true);
|
|
69
|
+
wrapper.unmount();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('renders chrome text elements', async () => {
|
|
73
|
+
const wrapper = await mountGraph({ spec: basicSpec });
|
|
74
|
+
|
|
75
|
+
const title = wrapper.find('.viz-title');
|
|
76
|
+
expect(title.exists()).toBe(true);
|
|
77
|
+
expect(title.text()).toBe('Test Graph');
|
|
78
|
+
|
|
79
|
+
const subtitle = wrapper.find('.viz-subtitle');
|
|
80
|
+
expect(subtitle.text()).toBe('A simple test graph');
|
|
81
|
+
wrapper.unmount();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('spec changes trigger re-render', async () => {
|
|
85
|
+
const wrapper = await mountGraph({ spec: basicSpec });
|
|
86
|
+
|
|
87
|
+
const titleBefore = wrapper.find('.viz-title');
|
|
88
|
+
expect(titleBefore.text()).toBe('Test Graph');
|
|
89
|
+
|
|
90
|
+
await wrapper.setProps({ spec: updatedSpec });
|
|
91
|
+
await flushPromises();
|
|
92
|
+
|
|
93
|
+
const titleAfter = wrapper.find('.viz-title');
|
|
94
|
+
expect(titleAfter.text()).toBe('Updated Graph');
|
|
95
|
+
wrapper.unmount();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('unmounting cleans up graph instance', async () => {
|
|
99
|
+
const wrapper = await mountGraph({ spec: basicSpec });
|
|
100
|
+
|
|
101
|
+
const canvasBefore = wrapper.find('canvas');
|
|
102
|
+
expect(canvasBefore.exists()).toBe(true);
|
|
103
|
+
|
|
104
|
+
wrapper.unmount();
|
|
105
|
+
|
|
106
|
+
expect(wrapper.find('canvas').exists()).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('class prop passes through to wrapper div', async () => {
|
|
110
|
+
const wrapper = await mountGraph({ spec: basicSpec, class: 'my-graph' });
|
|
111
|
+
|
|
112
|
+
expect(wrapper.classes()).toContain('my-graph');
|
|
113
|
+
wrapper.unmount();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('style prop passes through to wrapper div', async () => {
|
|
117
|
+
const wrapper = await mountGraph({
|
|
118
|
+
spec: basicSpec,
|
|
119
|
+
style: { border: '1px solid red' },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(wrapper.attributes('style')).toContain('border');
|
|
123
|
+
wrapper.unmount();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('renders with dark mode option', async () => {
|
|
127
|
+
const wrapper = await mountGraph({ spec: basicSpec, darkMode: 'force' });
|
|
128
|
+
|
|
129
|
+
const canvas = wrapper.find('canvas');
|
|
130
|
+
expect(canvas.exists()).toBe(true);
|
|
131
|
+
wrapper.unmount();
|
|
132
|
+
});
|
|
133
|
+
});
|