@cdc/chart 4.25.10 → 4.25.11
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/dist/{cdcchart-1a1724a1.es.js → cdcchart-dgT_1dIT.es.js} +136 -151
- package/dist/cdcchart.js +36258 -34658
- package/examples/feature/__data__/planet-example-data.json +1 -1
- package/examples/feature/boxplot/valid-boxplot.csv +38 -17
- package/examples/private/DEV-11825.json +573 -0
- package/examples/private/na.json +913 -0
- package/examples/private/test-data.csv +28 -0
- package/index.html +2 -121
- package/package.json +4 -4
- package/src/CdcChart.tsx +8 -11
- package/src/CdcChartComponent.tsx +256 -87
- package/src/_stories/Chart.Combo.stories.tsx +18 -0
- package/src/_stories/Chart.Forecast.stories.tsx +36 -0
- package/src/_stories/Chart.HTMLInDataTable.stories.tsx +520 -0
- package/src/_stories/Chart.Patterns.stories.tsx +2 -1
- package/src/_stories/Chart.PreserveDecimals.stories.tsx +220 -0
- package/src/_stories/Chart.SmallMultiples.stories.tsx +47 -0
- package/src/_stories/ChartAnnotation.stories.tsx +6 -3
- package/src/_stories/ChartBar.Editor.stories.tsx +3580 -0
- package/src/_stories/ChartEditor.Editor.stories.tsx +658 -0
- package/src/_stories/ChartEditor.stories.tsx +1 -2
- package/src/_stories/_mock/combo.json +451 -0
- package/src/_stories/_mock/editor-test-configs.json +376 -0
- package/src/_stories/_mock/editor-test-datasets.json +477 -0
- package/src/_stories/_mock/editor-tests/bar-chart-editor-test.json +255 -0
- package/src/_stories/_mock/editor-tests/bar-chart-general-test.json +267 -0
- package/src/_stories/_mock/editor-tests/bar-chart-test.json +237 -0
- package/src/_stories/_mock/forecast_combo_with_gaps.json +913 -0
- package/src/_stories/_mock/pie_config.json +257 -62
- package/src/_stories/_mock/small_multiples/small_multiples_bars.json +1944 -0
- package/src/_stories/_mock/small_multiples/small_multiples_big_data_bars.json +1114 -0
- package/src/_stories/_mock/small_multiples/small_multiples_lines.json +2646 -0
- package/src/_stories/_mock/small_multiples/small_multiples_lines_colors.json +1305 -0
- package/src/_stories/_mock/small_multiples/small_multiples_stacked_bars.json +1936 -0
- package/src/components/Annotations/components/findNearestDatum.ts +6 -41
- package/src/components/AreaChart/components/AreaChart.Stacked.jsx +10 -6
- package/src/components/AreaChart/index.tsx +1 -2
- package/src/components/BarChart/components/BarChart.Horizontal.tsx +4 -4
- package/src/components/BarChart/components/BarChart.Vertical.tsx +3 -2
- package/src/components/BoxPlot/helpers/index.ts +3 -3
- package/src/components/Brush/BrushChart.tsx +1 -1
- package/src/components/EditorPanel/EditorPanel.tsx +199 -190
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +96 -111
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +19 -1
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +102 -55
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +54 -49
- package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +422 -0
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +75 -21
- package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
- package/src/components/EditorPanel/editor-panel.scss +0 -20
- package/src/components/EditorPanel/useEditorPermissions.ts +7 -15
- package/src/components/Forecasting/Forecasting.tsx +139 -21
- package/src/components/Legend/Legend.Component.tsx +16 -9
- package/src/components/Legend/helpers/createFormatLabels.tsx +181 -181
- package/src/components/Legend/helpers/getLegendClasses.ts +0 -1
- package/src/components/LineChart/LineChartProps.ts +0 -3
- package/src/components/LineChart/helpers.ts +1 -1
- package/src/components/LineChart/index.tsx +36 -13
- package/src/components/LinearChart.tsx +75 -80
- package/src/components/Regions/components/Regions.tsx +3 -24
- package/src/components/Sankey/types/index.ts +1 -1
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +198 -0
- package/src/components/SmallMultiples/SmallMultiples.css +32 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +271 -0
- package/src/components/SmallMultiples/index.ts +2 -0
- package/src/data/initial-state.js +13 -1
- package/src/helpers/buildForecastPaletteOptions.ts +0 -38
- package/src/helpers/getColorScale.ts +10 -0
- package/src/{hooks/useMinMax.ts → helpers/getMinMax.ts} +14 -7
- package/src/helpers/getYAxisAutoPadding.ts +53 -0
- package/src/helpers/smallMultiplesHelpers.ts +529 -0
- package/src/hooks/useProgrammaticTooltip.ts +96 -0
- package/src/hooks/useScales.ts +88 -34
- package/src/hooks/useSmallMultipleSynchronization.ts +59 -0
- package/src/hooks/useTooltip.tsx +60 -15
- package/src/scss/main.scss +1 -80
- package/src/store/chart.actions.ts +2 -0
- package/src/store/chart.reducer.ts +4 -0
- package/src/types/ChartConfig.ts +24 -6
- package/src/types/ChartContext.ts +3 -0
- package/src/_stories/_mock/pie_data.json +0 -218
- package/src/components/AreaChart/components/AreaChart.jsx +0 -109
- package/src/helpers/sort.ts +0 -7
- package/src/hooks/useActiveElement.js +0 -19
- package/src/hooks/useChartClasses.js +0 -41
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { within, userEvent, expect } from 'storybook/test'
|
|
3
|
+
import { performAndAssert, waitForEditor, openAccordion, MIN_ANIMATION_DELAY_MS } from '@cdc/core/helpers/testing'
|
|
4
|
+
|
|
5
|
+
import Chart from '../CdcChartComponent'
|
|
6
|
+
import mockScatterPlot from './_mock/scatterplot_mock.json'
|
|
7
|
+
import smallMultiplesLinesColorsConfig from './_mock/small_multiples/small_multiples_lines_colors.json'
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof Chart> = {
|
|
10
|
+
title: 'Components/Templates/Chart/Editor Tests',
|
|
11
|
+
component: Chart
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Story = StoryObj<typeof Chart>
|
|
15
|
+
|
|
16
|
+
// Simple test - just check if chart loads without any interactions
|
|
17
|
+
export const GeneralSectionTests: Story = {
|
|
18
|
+
name: 'General Section Tests',
|
|
19
|
+
parameters: {
|
|
20
|
+
test: {
|
|
21
|
+
timeout: 30000 // 30 second timeout for comprehensive test
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
args: {
|
|
25
|
+
config: {
|
|
26
|
+
...mockScatterPlot,
|
|
27
|
+
visualizationType: 'Bar',
|
|
28
|
+
title: 'Bar Chart Editor Test',
|
|
29
|
+
orientation: 'vertical',
|
|
30
|
+
xAxis: {
|
|
31
|
+
...mockScatterPlot.xAxis,
|
|
32
|
+
type: 'categorical',
|
|
33
|
+
sortDates: false
|
|
34
|
+
},
|
|
35
|
+
yAxis: {
|
|
36
|
+
...mockScatterPlot.yAxis,
|
|
37
|
+
type: 'linear'
|
|
38
|
+
},
|
|
39
|
+
series: mockScatterPlot.series.map(s => ({
|
|
40
|
+
...s,
|
|
41
|
+
type: 'Bar'
|
|
42
|
+
}))
|
|
43
|
+
},
|
|
44
|
+
isEditor: true
|
|
45
|
+
},
|
|
46
|
+
play: async ({ canvasElement }) => {
|
|
47
|
+
const canvas = within(canvasElement)
|
|
48
|
+
|
|
49
|
+
// Wait for editor to load
|
|
50
|
+
await waitForEditor(canvas)
|
|
51
|
+
|
|
52
|
+
// Wait for chart to fully render
|
|
53
|
+
if (MIN_ANIMATION_DELAY_MS > 0) {
|
|
54
|
+
await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Open General accordion
|
|
58
|
+
await openAccordion(canvas, 'General')
|
|
59
|
+
|
|
60
|
+
// Wait for accordion to open
|
|
61
|
+
if (MIN_ANIMATION_DELAY_MS > 0) {
|
|
62
|
+
await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// TEST: Chart Height Change
|
|
67
|
+
// Verifies: Chart visualization height changes when height input is modified
|
|
68
|
+
// ============================================================================
|
|
69
|
+
const getChartVisualizationHeight = () => {
|
|
70
|
+
// Target the tooltip-boundary div which directly wraps just the chart SVG
|
|
71
|
+
const chartContainer = canvasElement.querySelector('.tooltip-boundary')
|
|
72
|
+
// Target the main chart SVG specifically (not brush or other SVGs)
|
|
73
|
+
const chartSvg =
|
|
74
|
+
canvasElement.querySelector('.tooltip-boundary svg[role="img"]') ||
|
|
75
|
+
canvasElement.querySelector('svg.linear') ||
|
|
76
|
+
canvasElement.querySelector('.cdc-open-viz-module svg:not(.brush-container svg)')
|
|
77
|
+
return {
|
|
78
|
+
containerHeight: chartContainer?.getBoundingClientRect().height || 0,
|
|
79
|
+
svgHeight: parseInt(chartSvg?.getAttribute('height') || '0', 10),
|
|
80
|
+
containerExists: Boolean(chartContainer),
|
|
81
|
+
svgExists: Boolean(chartSvg)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const chartHeightInput = canvasElement.querySelector(
|
|
86
|
+
'input[name*="height"]:not([name*="mobile"]), input[name*="vertical"]'
|
|
87
|
+
) as HTMLInputElement
|
|
88
|
+
expect(chartHeightInput).toBeTruthy()
|
|
89
|
+
|
|
90
|
+
await performAndAssert(
|
|
91
|
+
'Chart Height Change',
|
|
92
|
+
getChartVisualizationHeight,
|
|
93
|
+
async () => {
|
|
94
|
+
await userEvent.clear(chartHeightInput)
|
|
95
|
+
await userEvent.type(chartHeightInput, '600', { delay: 50 })
|
|
96
|
+
chartHeightInput.blur()
|
|
97
|
+
|
|
98
|
+
// Try triggering form submission by pressing Enter
|
|
99
|
+
await userEvent.type(chartHeightInput, '{Enter}')
|
|
100
|
+
|
|
101
|
+
// Wait longer for chart to re-render with proper timing
|
|
102
|
+
if (MIN_ANIMATION_DELAY_MS > 0) {
|
|
103
|
+
await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS * 4))
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
(before, after) => {
|
|
107
|
+
// Test that the visualization height changed (could increase or decrease)
|
|
108
|
+
const heightChanged = Math.abs(after.containerHeight - before.containerHeight) > 10
|
|
109
|
+
|
|
110
|
+
const svgExists = after.svgExists && before.svgExists
|
|
111
|
+
return heightChanged && svgExists && after.containerExists
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// ============================================================================
|
|
116
|
+
// NOTE: Mobile Height Testing
|
|
117
|
+
// Mobile Height requires manual verification due to responsive behavior:
|
|
118
|
+
// 1. Open this story in Storybook browser
|
|
119
|
+
// 2. Set Mobile Height to a different value (e.g., 300px)
|
|
120
|
+
// 3. Use browser dev tools to simulate mobile viewport (< 768px width)
|
|
121
|
+
// 4. Verify chart uses mobile height instead of regular height
|
|
122
|
+
// The mobile height input field is confirmed to exist and accept values.
|
|
123
|
+
// ============================================================================
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// Title Tests - Universal fields supported by all chart types
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
// Test 1: Title Text Change
|
|
130
|
+
const getTitleText = () => {
|
|
131
|
+
// Look for the chart title using the specific class from CdcChartComponent.tsx line 913
|
|
132
|
+
const titleElement = canvasElement.querySelector('.chart-title')
|
|
133
|
+
const result = {
|
|
134
|
+
titleExists: Boolean(titleElement),
|
|
135
|
+
titleText: titleElement?.textContent?.trim() || '',
|
|
136
|
+
titleVisible: titleElement ? window.getComputedStyle(titleElement).display !== 'none' : false
|
|
137
|
+
}
|
|
138
|
+
return result
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const titleInput = canvasElement.querySelector('input[name="title"], input#title') as HTMLInputElement
|
|
142
|
+
expect(titleInput).toBeTruthy()
|
|
143
|
+
|
|
144
|
+
await performAndAssert(
|
|
145
|
+
'Title Text Change',
|
|
146
|
+
getTitleText,
|
|
147
|
+
async () => {
|
|
148
|
+
await userEvent.clear(titleInput)
|
|
149
|
+
await userEvent.type(titleInput, 'Updated Chart Title', { delay: 50 })
|
|
150
|
+
titleInput.blur()
|
|
151
|
+
// Add delay to make the change visible in Storybook
|
|
152
|
+
if (MIN_ANIMATION_DELAY_MS > 0) {
|
|
153
|
+
await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
(before, after) => {
|
|
157
|
+
// Test that the title text in the chart updated
|
|
158
|
+
const titleChanged = after.titleText !== before.titleText
|
|
159
|
+
const titleExists = after.titleExists && before.titleExists
|
|
160
|
+
const newTitleCorrect = after.titleText.includes('Updated Chart Title')
|
|
161
|
+
return titleChanged && titleExists && newTitleCorrect
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
// Test 2: Show Title Toggle
|
|
166
|
+
const showTitleCheckbox = canvasElement.querySelector('input[name="showTitle"]') as HTMLInputElement
|
|
167
|
+
expect(showTitleCheckbox).toBeTruthy()
|
|
168
|
+
|
|
169
|
+
await performAndAssert(
|
|
170
|
+
'Show Title Toggle',
|
|
171
|
+
getTitleText,
|
|
172
|
+
async () => {
|
|
173
|
+
// Toggle the show title checkbox (assuming it starts checked)
|
|
174
|
+
await userEvent.click(showTitleCheckbox)
|
|
175
|
+
// Add delay to make the change visible in Storybook
|
|
176
|
+
if (MIN_ANIMATION_DELAY_MS > 0) {
|
|
177
|
+
await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
(before, after) => {
|
|
181
|
+
// Test that the title visibility changed in the chart
|
|
182
|
+
// When showTitle is toggled off, the title element is removed from DOM entirely
|
|
183
|
+
const titleWasVisible = before.titleExists && before.titleVisible
|
|
184
|
+
const titleIsNowHidden = !after.titleExists || !after.titleVisible
|
|
185
|
+
return titleWasVisible && titleIsNowHidden
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
// ============================================================================
|
|
190
|
+
// Super Title Tests
|
|
191
|
+
// ============================================================================
|
|
192
|
+
|
|
193
|
+
// IMPORTANT: Re-enable "Show Title" first (it was toggled off in previous test)
|
|
194
|
+
const showTitleCheckboxForSuperTitle = canvasElement.querySelector('input[name="showTitle"]') as HTMLInputElement
|
|
195
|
+
if (showTitleCheckboxForSuperTitle && !showTitleCheckboxForSuperTitle.checked) {
|
|
196
|
+
await userEvent.click(showTitleCheckboxForSuperTitle)
|
|
197
|
+
if (MIN_ANIMATION_DELAY_MS > 0) {
|
|
198
|
+
await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Test 1: Super Title Text Change
|
|
203
|
+
const getSuperTitleText = () => {
|
|
204
|
+
// Super title is part of the .chart-title component but rendered separately
|
|
205
|
+
const titleContainer = canvasElement.querySelector('.chart-title')
|
|
206
|
+
const superTitleElement =
|
|
207
|
+
titleContainer?.querySelector('.super-title, [class*="super"]') || titleContainer?.children[0] // First child might be super title
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
superTitleExists: Boolean(superTitleElement),
|
|
211
|
+
superTitleText: superTitleElement?.textContent?.trim() || '',
|
|
212
|
+
titleContainerExists: Boolean(titleContainer)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Find the Super Title input using multiple fallback strategies
|
|
217
|
+
const superTitleInput = (() => {
|
|
218
|
+
// Strategy 1: Find by name attribute containing superTitle
|
|
219
|
+
const byName = canvasElement.querySelector('input[name*="superTitle"], textarea[name*="superTitle"]')
|
|
220
|
+
if (byName) return byName
|
|
221
|
+
|
|
222
|
+
// Strategy 2: Find input within a label that contains "Super Title"
|
|
223
|
+
const inputs = Array.from(canvasElement.querySelectorAll('input, textarea'))
|
|
224
|
+
for (const input of inputs) {
|
|
225
|
+
const label = input.closest('label')
|
|
226
|
+
const labelText = label?.textContent?.trim()
|
|
227
|
+
if (label && labelText && labelText.includes('Super Title')) {
|
|
228
|
+
return input
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return null
|
|
233
|
+
})() as HTMLInputElement
|
|
234
|
+
|
|
235
|
+
if (superTitleInput) {
|
|
236
|
+
await performAndAssert(
|
|
237
|
+
'Super Title Text Change',
|
|
238
|
+
getSuperTitleText,
|
|
239
|
+
async () => {
|
|
240
|
+
await userEvent.clear(superTitleInput)
|
|
241
|
+
await userEvent.type(superTitleInput, 'Updated Super Title', { delay: 50 })
|
|
242
|
+
superTitleInput.blur()
|
|
243
|
+
if (MIN_ANIMATION_DELAY_MS > 0) {
|
|
244
|
+
await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
(before, after) => {
|
|
248
|
+
const superTitleChanged = after.superTitleText !== before.superTitleText
|
|
249
|
+
const newSuperTitleCorrect = after.superTitleText.includes('Updated Super Title')
|
|
250
|
+
return superTitleChanged && newSuperTitleCorrect && after.titleContainerExists
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ============================================================================
|
|
256
|
+
// Message (Intro Text) Tests
|
|
257
|
+
// ============================================================================
|
|
258
|
+
|
|
259
|
+
// Test 1: Message Text Change
|
|
260
|
+
const getMessageText = () => {
|
|
261
|
+
const messageElement = canvasElement.querySelector('.introText')
|
|
262
|
+
return {
|
|
263
|
+
messageExists: Boolean(messageElement),
|
|
264
|
+
messageText: messageElement?.textContent?.trim() || '',
|
|
265
|
+
messageVisible: messageElement ? window.getComputedStyle(messageElement).display !== 'none' : false
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Find the Message/Intro Text textarea using multiple fallback strategies
|
|
270
|
+
const messageInput = (() => {
|
|
271
|
+
// Strategy 1: Find by name attribute containing introText
|
|
272
|
+
const byName = canvasElement.querySelector('textarea[name*="introText"]')
|
|
273
|
+
if (byName) return byName
|
|
274
|
+
|
|
275
|
+
// Strategy 2: Find textarea within a label that contains "Message"
|
|
276
|
+
const textareas = Array.from(canvasElement.querySelectorAll('textarea'))
|
|
277
|
+
for (const textarea of textareas) {
|
|
278
|
+
const label = textarea.closest('label')
|
|
279
|
+
const labelText = label?.textContent?.trim()
|
|
280
|
+
if (label && labelText && (labelText.includes('Message') || labelText.includes('Intro Text'))) {
|
|
281
|
+
return textarea
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Strategy 3: Find by proximity to "Message" text
|
|
286
|
+
const labels = Array.from(canvasElement.querySelectorAll('label'))
|
|
287
|
+
for (const label of labels) {
|
|
288
|
+
const labelText = label.textContent?.trim()
|
|
289
|
+
if (labelText && labelText.includes('Message')) {
|
|
290
|
+
const textarea = label.querySelector('textarea')
|
|
291
|
+
if (textarea) return textarea
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return null
|
|
296
|
+
})() as HTMLTextAreaElement
|
|
297
|
+
expect(messageInput).toBeTruthy()
|
|
298
|
+
|
|
299
|
+
await performAndAssert(
|
|
300
|
+
'Message Text Change',
|
|
301
|
+
getMessageText,
|
|
302
|
+
async () => {
|
|
303
|
+
await userEvent.clear(messageInput)
|
|
304
|
+
await userEvent.type(messageInput, 'This is an updated message for the chart.', { delay: 30 })
|
|
305
|
+
messageInput.blur()
|
|
306
|
+
if (MIN_ANIMATION_DELAY_MS > 0) {
|
|
307
|
+
await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
(before, after) => {
|
|
311
|
+
const messageChanged = after.messageText !== before.messageText
|
|
312
|
+
const messageVisible = after.messageExists && after.messageVisible
|
|
313
|
+
const newMessageCorrect = after.messageText.includes('This is an updated message')
|
|
314
|
+
return messageChanged && messageVisible && newMessageCorrect
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
// ============================================================================
|
|
319
|
+
// Subtext/Citation Tests
|
|
320
|
+
// ============================================================================
|
|
321
|
+
|
|
322
|
+
// Test 1: Subtext Text Change
|
|
323
|
+
const getSubtextText = () => {
|
|
324
|
+
// Subtext is typically rendered as .subtext or similar class
|
|
325
|
+
const subtextElement = canvasElement.querySelector('.subtext, .section-subtext, [class*="subtext"]')
|
|
326
|
+
return {
|
|
327
|
+
subtextExists: Boolean(subtextElement),
|
|
328
|
+
subtextText: subtextElement?.textContent?.trim() || '',
|
|
329
|
+
subtextVisible: subtextElement ? window.getComputedStyle(subtextElement).display !== 'none' : false
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Find the Subtext/Citation textarea
|
|
334
|
+
const subtextInput = (() => {
|
|
335
|
+
// Strategy 1: Find by name attribute containing description
|
|
336
|
+
const byName = canvasElement.querySelector('textarea[name*="description"]')
|
|
337
|
+
if (byName) return byName
|
|
338
|
+
|
|
339
|
+
// Strategy 2: Find textarea within a label that contains "Subtext" or "Citation"
|
|
340
|
+
const textareas = Array.from(canvasElement.querySelectorAll('textarea'))
|
|
341
|
+
for (const textarea of textareas) {
|
|
342
|
+
const label = textarea.closest('label')
|
|
343
|
+
const labelText = label?.textContent?.trim()
|
|
344
|
+
if (label && labelText && (labelText.includes('Subtext') || labelText.includes('Citation'))) {
|
|
345
|
+
return textarea
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return null
|
|
350
|
+
})() as HTMLTextAreaElement
|
|
351
|
+
|
|
352
|
+
if (subtextInput) {
|
|
353
|
+
await performAndAssert(
|
|
354
|
+
'Subtext Text Change',
|
|
355
|
+
getSubtextText,
|
|
356
|
+
async () => {
|
|
357
|
+
await userEvent.clear(subtextInput)
|
|
358
|
+
await userEvent.type(subtextInput, 'Updated subtext and citation information.', { delay: 30 })
|
|
359
|
+
subtextInput.blur()
|
|
360
|
+
if (MIN_ANIMATION_DELAY_MS > 0) {
|
|
361
|
+
await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
(before, after) => {
|
|
365
|
+
const subtextChanged = after.subtextText !== before.subtextText
|
|
366
|
+
const subtextVisible = after.subtextExists && after.subtextVisible
|
|
367
|
+
const newSubtextCorrect = after.subtextText.includes('Updated subtext')
|
|
368
|
+
return subtextChanged && subtextVisible && newSubtextCorrect
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ============================================================================
|
|
374
|
+
// Footnotes Tests
|
|
375
|
+
// ============================================================================
|
|
376
|
+
|
|
377
|
+
// Test 1: Footnotes Text Change
|
|
378
|
+
const getFootnotesText = () => {
|
|
379
|
+
// Footnotes could be rendered in various ways - check multiple selectors
|
|
380
|
+
const footnotesElement = canvasElement.querySelector(
|
|
381
|
+
'.footnotes, .footnote, [class*="footnote"], .chart-footnotes'
|
|
382
|
+
)
|
|
383
|
+
return {
|
|
384
|
+
footnotesExists: Boolean(footnotesElement),
|
|
385
|
+
footnotesText: footnotesElement?.textContent?.trim() || '',
|
|
386
|
+
footnotesVisible: footnotesElement ? window.getComputedStyle(footnotesElement).display !== 'none' : false
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Find the Footnotes textarea
|
|
391
|
+
const footnotesInput = (() => {
|
|
392
|
+
// Strategy 1: Find by name attribute containing footnotes
|
|
393
|
+
const byName = canvasElement.querySelector('textarea[name*="footnotes"], textarea[name*="Footnotes"]')
|
|
394
|
+
if (byName) return byName
|
|
395
|
+
|
|
396
|
+
// Strategy 2: Find textarea within a label that contains "Footnotes"
|
|
397
|
+
const textareas = Array.from(canvasElement.querySelectorAll('textarea'))
|
|
398
|
+
for (const textarea of textareas) {
|
|
399
|
+
const label = textarea.closest('label')
|
|
400
|
+
const labelText = label?.textContent?.trim()
|
|
401
|
+
if (label && labelText && labelText.includes('Footnotes')) {
|
|
402
|
+
return textarea
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return null
|
|
407
|
+
})() as HTMLTextAreaElement
|
|
408
|
+
|
|
409
|
+
if (footnotesInput) {
|
|
410
|
+
await performAndAssert(
|
|
411
|
+
'Footnotes Text Change',
|
|
412
|
+
getFootnotesText,
|
|
413
|
+
async () => {
|
|
414
|
+
await userEvent.clear(footnotesInput)
|
|
415
|
+
await userEvent.type(footnotesInput, 'Updated footnotes with important data disclaimers.', { delay: 30 })
|
|
416
|
+
footnotesInput.blur()
|
|
417
|
+
if (MIN_ANIMATION_DELAY_MS > 0) {
|
|
418
|
+
await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
(before, after) => {
|
|
422
|
+
const footnotesChanged = after.footnotesText !== before.footnotesText
|
|
423
|
+
const footnotesVisible = after.footnotesExists && after.footnotesVisible
|
|
424
|
+
const newFootnotesCorrect = after.footnotesText.includes('Updated footnotes')
|
|
425
|
+
return footnotesChanged && footnotesVisible && newFootnotesCorrect
|
|
426
|
+
}
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Add final delay to see the end state
|
|
431
|
+
if (MIN_ANIMATION_DELAY_MS > 0) {
|
|
432
|
+
await new Promise(resolve => setTimeout(resolve, MIN_ANIMATION_DELAY_MS))
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// NOTE: Data Series tests have been moved to Bar/Bar.Editor.stories.tsx
|
|
438
|
+
// since series behavior is chart-type-specific
|
|
439
|
+
|
|
440
|
+
// ============================================================================
|
|
441
|
+
// SMALL MULTIPLES SECTION TESTS
|
|
442
|
+
// Tests the Small Multiples accordion section following best practices:
|
|
443
|
+
// - Tests visualization output changes, not control state
|
|
444
|
+
// - Uses performAndAssert pattern for all interactions
|
|
445
|
+
// - Tests specific visual changes in small multiples layout and tiles
|
|
446
|
+
// - Focuses on testing what reliably works
|
|
447
|
+
// ============================================================================
|
|
448
|
+
|
|
449
|
+
export const SmallMultiplesSectionTests: Story = {
|
|
450
|
+
name: 'Small Multiples Section Tests',
|
|
451
|
+
parameters: {},
|
|
452
|
+
args: {
|
|
453
|
+
config: {
|
|
454
|
+
...smallMultiplesLinesColorsConfig,
|
|
455
|
+
title: 'Line Chart Small Multiples Test',
|
|
456
|
+
smallMultiples: {
|
|
457
|
+
...smallMultiplesLinesColorsConfig.smallMultiples,
|
|
458
|
+
mode: '',
|
|
459
|
+
showAreaUnderLine: false
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
isEditor: true
|
|
463
|
+
},
|
|
464
|
+
play: async ({ canvasElement }) => {
|
|
465
|
+
const canvas = within(canvasElement)
|
|
466
|
+
|
|
467
|
+
await waitForEditor(canvas)
|
|
468
|
+
|
|
469
|
+
await openAccordion(canvas, 'Small Multiples')
|
|
470
|
+
|
|
471
|
+
// ============================================================================
|
|
472
|
+
// TEST: Enable Small Multiples Mode
|
|
473
|
+
// Verifies: Chart visualization changes from single chart to multiple tiles
|
|
474
|
+
// ============================================================================
|
|
475
|
+
|
|
476
|
+
const getChartCounts = () => {
|
|
477
|
+
const chartContainer = canvasElement.querySelector('.cove-component__content, .chart-container, .visualization')
|
|
478
|
+
const tiles = canvasElement.querySelectorAll('.small-multiple-tile, [class*="tile"]')
|
|
479
|
+
const svgElements = chartContainer?.querySelectorAll('svg[role="img"], svg.chart') || []
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
svgCount: svgElements.length,
|
|
483
|
+
tileCount: tiles.length
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const modeSelect = canvas.getByLabelText(/tile mode/i) as HTMLSelectElement
|
|
488
|
+
|
|
489
|
+
await performAndAssert(
|
|
490
|
+
'Enable Small Multiples Mode - Chart splits into multiple tiles',
|
|
491
|
+
getChartCounts,
|
|
492
|
+
async () => {
|
|
493
|
+
await userEvent.selectOptions(modeSelect, 'by-series')
|
|
494
|
+
},
|
|
495
|
+
(before, after) => {
|
|
496
|
+
return before.svgCount === 1 && before.tileCount === 0 && after.svgCount > 1 && after.tileCount > 1
|
|
497
|
+
}
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
// ============================================================================
|
|
501
|
+
// TEST: Tiles Per Row Desktop
|
|
502
|
+
// Verifies: Grid layout changes from 3 tiles per row to 2 tiles per row
|
|
503
|
+
// ============================================================================
|
|
504
|
+
|
|
505
|
+
const getTilesInFirstRow = () => {
|
|
506
|
+
const tiles = Array.from(canvasElement.querySelectorAll('.small-multiples-grid > .small-multiple-tile'))
|
|
507
|
+
if (tiles.length === 0) return { tilesInFirstRow: 0 }
|
|
508
|
+
|
|
509
|
+
const firstTileTop = tiles[0].getBoundingClientRect().top
|
|
510
|
+
const tilesInFirstRow = tiles.filter(tile => {
|
|
511
|
+
const tileTop = tile.getBoundingClientRect().top
|
|
512
|
+
return Math.abs(tileTop - firstTileTop) < 5
|
|
513
|
+
}).length
|
|
514
|
+
|
|
515
|
+
return { tilesInFirstRow }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const tilesPerRowInput = canvas.getByLabelText(/tiles per row/i) as HTMLInputElement
|
|
519
|
+
|
|
520
|
+
await performAndAssert(
|
|
521
|
+
'Tiles Per Row Desktop - Layout changes from 3 to 2 tiles per row',
|
|
522
|
+
getTilesInFirstRow,
|
|
523
|
+
async () => {
|
|
524
|
+
await userEvent.clear(tilesPerRowInput)
|
|
525
|
+
await userEvent.type(tilesPerRowInput, '2')
|
|
526
|
+
tilesPerRowInput.blur()
|
|
527
|
+
},
|
|
528
|
+
(before, after) => {
|
|
529
|
+
return before.tilesInFirstRow === 3 && after.tilesInFirstRow === 2
|
|
530
|
+
}
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
// ============================================================================
|
|
534
|
+
// TEST: Tile Order
|
|
535
|
+
// Verifies: Changing tile order from ascending to descending reverses tile titles
|
|
536
|
+
// ============================================================================
|
|
537
|
+
|
|
538
|
+
const getTileTitles = () => {
|
|
539
|
+
const tiles = canvasElement.querySelectorAll('.small-multiples-grid > .small-multiple-tile')
|
|
540
|
+
const titles = Array.from(tiles).map(tile => {
|
|
541
|
+
const titleElement = tile.querySelector('.tile-title')
|
|
542
|
+
return titleElement?.textContent?.trim() || ''
|
|
543
|
+
})
|
|
544
|
+
return { titles }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const tileOrderSelect = canvas.getByLabelText(/tile order/i) as HTMLSelectElement
|
|
548
|
+
|
|
549
|
+
await performAndAssert(
|
|
550
|
+
'Tile Order - Descending reverses tile titles',
|
|
551
|
+
getTileTitles,
|
|
552
|
+
async () => {
|
|
553
|
+
await userEvent.selectOptions(tileOrderSelect, 'desc')
|
|
554
|
+
},
|
|
555
|
+
(before, after) => {
|
|
556
|
+
const beforeTitles = before.titles.join(',')
|
|
557
|
+
const afterTitlesReversed = after.titles.slice().reverse().join(',')
|
|
558
|
+
return beforeTitles === afterTitlesReversed && before.titles.length > 0
|
|
559
|
+
}
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
// ============================================================================
|
|
563
|
+
// TEST: Color Mode
|
|
564
|
+
// Verifies: Same color mode makes all line colors match
|
|
565
|
+
// ============================================================================
|
|
566
|
+
|
|
567
|
+
const getLineColors = () => {
|
|
568
|
+
const svgElements = canvasElement.querySelectorAll('svg[role="img"], svg.chart')
|
|
569
|
+
const lineColors = Array.from(svgElements).map(svg => {
|
|
570
|
+
const linePath = svg.querySelector('path[class*="visx-linepath"], path[stroke]')
|
|
571
|
+
return linePath?.getAttribute('stroke') || ''
|
|
572
|
+
})
|
|
573
|
+
const allSameColor = lineColors.length > 1 && lineColors.every(color => color === lineColors[0])
|
|
574
|
+
return { lineColors, allSameColor }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const colorModeSelect = canvas.getByLabelText(/color mode/i) as HTMLSelectElement
|
|
578
|
+
|
|
579
|
+
await performAndAssert(
|
|
580
|
+
'Color Mode - Same color makes all lines match',
|
|
581
|
+
getLineColors,
|
|
582
|
+
async () => {
|
|
583
|
+
await userEvent.selectOptions(colorModeSelect, 'same')
|
|
584
|
+
},
|
|
585
|
+
(before, after) => {
|
|
586
|
+
return before.allSameColor === false && after.allSameColor === true
|
|
587
|
+
}
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
// ============================================================================
|
|
591
|
+
// TEST: Independent Y-Axis
|
|
592
|
+
// Verifies: Y-axis scales differ across tiles when enabled
|
|
593
|
+
// ============================================================================
|
|
594
|
+
|
|
595
|
+
const getYAxisScales = () => {
|
|
596
|
+
const svgElements = canvasElement.querySelectorAll('svg[role="img"], svg.chart')
|
|
597
|
+
const yAxisMaxValues = Array.from(svgElements).map(svg => {
|
|
598
|
+
const yAxisTicks = svg.querySelectorAll('g.visx-axis-left g.visx-axis-tick text, g[class*="axis"] text')
|
|
599
|
+
const tickValues = Array.from(yAxisTicks)
|
|
600
|
+
.map(tick => parseFloat(tick.textContent || '0'))
|
|
601
|
+
.filter(val => !isNaN(val))
|
|
602
|
+
return tickValues.length > 0 ? Math.max(...tickValues) : 0
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
const allSameScale = yAxisMaxValues.length > 1 && yAxisMaxValues.every(val => val === yAxisMaxValues[0])
|
|
606
|
+
const hasDifferentScales = yAxisMaxValues.length > 1 && !allSameScale
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
yAxisMaxValues,
|
|
610
|
+
allSameScale,
|
|
611
|
+
hasDifferentScales
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const independentYAxisCheckbox = canvas.getByLabelText(/independent y-axis scales/i) as HTMLInputElement
|
|
616
|
+
|
|
617
|
+
await performAndAssert(
|
|
618
|
+
'Independent Y-Axis Toggle - Y-axis scales become different across tiles',
|
|
619
|
+
getYAxisScales,
|
|
620
|
+
async () => {
|
|
621
|
+
await userEvent.click(independentYAxisCheckbox)
|
|
622
|
+
},
|
|
623
|
+
(before, after) => {
|
|
624
|
+
return before.allSameScale === true && after.hasDifferentScales === true
|
|
625
|
+
}
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
// ============================================================================
|
|
629
|
+
// TEST: Shade Area Under Lines
|
|
630
|
+
// Verifies: Area path elements appear under lines when enabled
|
|
631
|
+
// ============================================================================
|
|
632
|
+
|
|
633
|
+
const getAreaUnderLineCount = () => {
|
|
634
|
+
const svgElements = canvasElement.querySelectorAll('svg[role="img"], svg.chart')
|
|
635
|
+
let areaPathCount = 0
|
|
636
|
+
svgElements.forEach(svg => {
|
|
637
|
+
const areaPaths = svg.querySelectorAll('path[fill-opacity="0.3"]')
|
|
638
|
+
areaPathCount += areaPaths.length
|
|
639
|
+
})
|
|
640
|
+
return { areaPathCount }
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const shadeAreaCheckbox = canvas.getByLabelText(/shade area under lines/i) as HTMLInputElement
|
|
644
|
+
|
|
645
|
+
await performAndAssert(
|
|
646
|
+
'Shade Area Under Lines - Area paths appear when enabled',
|
|
647
|
+
getAreaUnderLineCount,
|
|
648
|
+
async () => {
|
|
649
|
+
await userEvent.click(shadeAreaCheckbox)
|
|
650
|
+
},
|
|
651
|
+
(before, after) => {
|
|
652
|
+
return before.areaPathCount === 0 && after.areaPathCount > 0
|
|
653
|
+
}
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export default meta
|
|
@@ -2,7 +2,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
|
2
2
|
import { userEvent, within } from 'storybook/test'
|
|
3
3
|
import Chart from '../CdcChartComponent'
|
|
4
4
|
import pieChartExample from './_mock/pie_config.json'
|
|
5
|
-
import pieData from './_mock/pie_data.json'
|
|
6
5
|
import urlFilterExample from './_mock/url_filter.json'
|
|
7
6
|
import mockScatterPlot from './_mock/scatterplot_mock.json'
|
|
8
7
|
|
|
@@ -15,7 +14,7 @@ type Story = StoryObj<typeof Chart>
|
|
|
15
14
|
|
|
16
15
|
export const Primary: Story = {
|
|
17
16
|
args: {
|
|
18
|
-
config: { ...pieChartExample
|
|
17
|
+
config: { ...pieChartExample },
|
|
19
18
|
isEditor: true
|
|
20
19
|
}
|
|
21
20
|
}
|