@cdc/dashboard 4.25.11 → 4.26.2

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.
Files changed (77) hide show
  1. package/Dynamic_Data.md +66 -0
  2. package/dist/cdcdashboard-8NmHlKRI.es.js +15 -0
  3. package/dist/cdcdashboard-BPoPzKPz.es.js +6 -0
  4. package/dist/cdcdashboard-Cf9_fbQf.es.js +6 -0
  5. package/dist/{cdcdashboard-dgT_1dIT.es.js → cdcdashboard-DQ00cQCm.es.js} +1 -20
  6. package/dist/cdcdashboard-jiQQPkty.es.js +6 -0
  7. package/dist/cdcdashboard.js +83537 -86913
  8. package/examples/api-dashboard-data.json +272 -0
  9. package/examples/api-dashboard-years.json +11 -0
  10. package/examples/api-geographies-data.json +11 -0
  11. package/examples/default.json +522 -133
  12. package/examples/nested-dropdown.json +6985 -0
  13. package/examples/private/abc.json +467 -0
  14. package/examples/private/cat-y.json +1235 -0
  15. package/examples/private/chronic-dash.json +1584 -0
  16. package/examples/private/dash.json +12696 -0
  17. package/examples/private/map-issue.json +2260 -0
  18. package/examples/private/mpinc-state-reports.json +2260 -0
  19. package/examples/private/npcr.json +1 -0
  20. package/examples/private/nwss/rsv.json +1240 -0
  21. package/examples/private/simple-dash.json +490 -0
  22. package/examples/private/test-dash.json +0 -0
  23. package/examples/private/test.json +125407 -0
  24. package/examples/private/test123.json +491 -0
  25. package/examples/private/timeline-data.json +4994 -0
  26. package/examples/private/timeline.json +1708 -0
  27. package/examples/test-api-filter-reset.json +8 -4
  28. package/examples/test-dashboard-simple.json +503 -0
  29. package/examples/tp5-gauges.json +196 -0
  30. package/examples/tp5-test.json +266 -0
  31. package/index.html +1 -30
  32. package/package.json +39 -40
  33. package/src/CdcDashboardComponent.tsx +18 -5
  34. package/src/_stories/Dashboard.DataSetup.stories.tsx +204 -0
  35. package/src/_stories/Dashboard.stories.tsx +407 -1
  36. package/src/_stories/_mock/dashboard-line-chart-angles.json +1030 -0
  37. package/src/_stories/_mock/filter-cascade.json +3350 -0
  38. package/src/_stories/_mock/gallery-data-bite-dashboard.json +3500 -0
  39. package/src/_stories/_mock/nested-parent-child-filters.json +392 -0
  40. package/src/_stories/_mock/parent-child-filters.json +233 -0
  41. package/src/_stories/_mock/tp5-test.json +267 -0
  42. package/src/components/DashboardFilters/DashboardFilters.tsx +20 -11
  43. package/src/components/DashboardFilters/DashboardFiltersEditor/DashboardFiltersEditor.tsx +92 -38
  44. package/src/components/DashboardFilters/DashboardFiltersEditor/components/FilterEditor.tsx +56 -30
  45. package/src/components/DashboardFilters/DashboardFiltersEditor/components/NestedDropDownDashboard.tsx +151 -10
  46. package/src/components/DashboardFilters/DashboardFiltersWrapper.tsx +11 -7
  47. package/src/components/DataDesignerModal.tsx +6 -1
  48. package/src/components/Header/Header.tsx +51 -20
  49. package/src/components/VisualizationRow.tsx +76 -5
  50. package/src/components/VisualizationsPanel/VisualizationsPanel.tsx +2 -20
  51. package/src/components/Widget/Widget.tsx +1 -1
  52. package/src/data/initial-state.js +1 -0
  53. package/src/helpers/addValuesToDashboardFilters.ts +30 -31
  54. package/src/helpers/apiFilterHelpers.ts +28 -32
  55. package/src/helpers/changeFilterActive.ts +67 -65
  56. package/src/helpers/formatConfigBeforeSave.ts +6 -5
  57. package/src/helpers/getUpdateConfig.ts +91 -91
  58. package/src/helpers/tests/addValuesToDashboardFilters.test.ts +141 -44
  59. package/src/helpers/tests/apiFilterHelpers.test.ts +523 -420
  60. package/src/helpers/tests/updatesChildFilters.test.ts +53 -22
  61. package/src/helpers/updateChildFilters.ts +50 -27
  62. package/src/scss/main.scss +144 -1
  63. package/src/test/CdcDashboard.test.jsx +9 -4
  64. package/src/types/Dashboard.ts +1 -0
  65. package/src/types/FilterStyles.ts +8 -7
  66. package/src/types/SharedFilter.ts +13 -0
  67. package/vite.config.js +7 -1
  68. package/LICENSE +0 -201
  69. package/dist/cdcdashboard-BnB1QM5d.es.js +0 -361528
  70. package/dist/cdcdashboard-Ct2SB0vL.es.js +0 -231049
  71. package/dist/cdcdashboard-D6CG2-Hb.es.js +0 -39377
  72. package/dist/cdcdashboard-MXgURbdZ.es.js +0 -39194
  73. package/examples/private/DEV-10538.json +0 -407
  74. package/examples/private/DEV-11072.json +0 -7591
  75. package/examples/private/DEV-11405.json +0 -39112
  76. package/examples/private/delete.json +0 -32919
  77. package/examples/private/pedro.json +0 -1
@@ -0,0 +1,204 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { within, expect, waitFor, userEvent } from 'storybook/test'
3
+ import CdcEditor from '@cdc/editor/src/CdcEditor'
4
+
5
+ const meta: Meta<typeof CdcEditor> = {
6
+ title: 'Components/Pages/Dashboard/Data Setup Integration Tests',
7
+ component: CdcEditor
8
+ }
9
+
10
+ export default meta
11
+
12
+ type Story = StoryObj<typeof CdcEditor>
13
+
14
+ // Helper function to wait
15
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
16
+
17
+ // ============================================================================
18
+ // Multi-Visualization Configuration Workflow
19
+ // ============================================================================
20
+ // This test matches the Playwright codegen test exactly
21
+
22
+ export const MultiVizConfigurationWorkflow: Story = {
23
+ args: {
24
+ config: {}
25
+ },
26
+ parameters: {
27
+ docs: {
28
+ description: {
29
+ story:
30
+ 'Tests multi-visualization configuration workflow (matches Playwright codegen):\n' +
31
+ '1. Click "Choose Visualization Type"\n' +
32
+ '2. Click "Dashboard" button\n' +
33
+ '3. Click "Load from URL"\n' +
34
+ '4. Enter "Demo" in dataset name textbox\n' +
35
+ '5. Fill "/custom-order-demo-data.csv" in URL textbox\n' +
36
+ '6. Click "Always load from URL"\n' +
37
+ '7. Click "Save & Load" button\n' +
38
+ '8. Click "Configure your visualization" button\n' +
39
+ '9. Click "gearMulti" button\n' +
40
+ '10. Select "Demo" from combobox\n' +
41
+ '11. Click "Vertical Values for map" button\n' +
42
+ '12. Click "No" button\n' +
43
+ '13. Click "Configure Multiple"\n' +
44
+ '14. Select "Location" from Multi-Visualization Column dropdown\n' +
45
+ '15. Click "Continue" button'
46
+ }
47
+ }
48
+ },
49
+ play: async ({ canvasElement }) => {
50
+ const canvas = within(canvasElement)
51
+ const user = userEvent.setup()
52
+
53
+ // ========================================================================
54
+ // STEP 1: Click "Choose Visualization Type" (verify it's visible)
55
+ // ========================================================================
56
+ await waitFor(
57
+ async () => {
58
+ const chooseVizText = canvas.queryByText(/choose visualization type/i)
59
+ expect(chooseVizText).toBeTruthy()
60
+ },
61
+ { timeout: 5000 }
62
+ )
63
+
64
+ const chooseVizText = canvas.getByText(/choose visualization type/i)
65
+ await user.click(chooseVizText)
66
+ await sleep(500)
67
+
68
+ // ========================================================================
69
+ // STEP 2: Click "Dashboard" button
70
+ // ========================================================================
71
+ const dashboardButton = canvas.getByRole('button', { name: /dashboard/i })
72
+ await user.click(dashboardButton)
73
+ await sleep(1500)
74
+
75
+ // ========================================================================
76
+ // STEP 3: Click "Load from URL"
77
+ // ========================================================================
78
+ await sleep(500)
79
+
80
+ const loadFromUrlButton =
81
+ canvas.queryAllByText(/load from url/i)[0] ||
82
+ canvas.queryByRole('button', { name: /url/i }) ||
83
+ canvas.queryByRole('tab', { name: /url/i })
84
+
85
+ if (loadFromUrlButton) {
86
+ await user.click(loadFromUrlButton)
87
+ await sleep(500)
88
+ }
89
+
90
+ // ========================================================================
91
+ // STEP 4: Enter "Demo" in dataset name textbox
92
+ // ========================================================================
93
+ await sleep(500)
94
+
95
+ const datasetNameInput = canvas.getByRole('textbox', { name: /enter dataset name/i })
96
+ await user.click(datasetNameInput)
97
+ await user.clear(datasetNameInput)
98
+ await user.type(datasetNameInput, 'Demo')
99
+ await sleep(300)
100
+
101
+ // ========================================================================
102
+ // STEP 5: Fill "/custom-order-demo-data.csv" in URL textbox
103
+ // ========================================================================
104
+
105
+ // Press Tab to move to URL field
106
+ await user.tab()
107
+
108
+ const urlInput = canvas.getByRole('textbox', { name: /load data from external url/i })
109
+ await user.clear(urlInput)
110
+ await user.type(urlInput, '/custom-order-demo-data.csv')
111
+ await sleep(300)
112
+
113
+ // ========================================================================
114
+ // STEP 6: Click "Always load from URL"
115
+ // ========================================================================
116
+ await sleep(500)
117
+
118
+ const alwaysLoadText = canvas.getByText(/always load from url/i)
119
+ await user.click(alwaysLoadText)
120
+ await sleep(300)
121
+
122
+ // ========================================================================
123
+ // STEP 7: Click "Save & Load" button
124
+ // ========================================================================
125
+ await sleep(500)
126
+
127
+ const saveLoadButton = canvas.getByRole('button', { name: /save & load/i })
128
+ await user.click(saveLoadButton)
129
+ await sleep(2000) // Wait for data to load
130
+
131
+ // ========================================================================
132
+ // STEP 8: Click "Configure your visualization" button
133
+ // ========================================================================
134
+ await sleep(1000)
135
+
136
+ const configureButton = canvas.getByRole('button', { name: /configure your visualization/i })
137
+ await user.click(configureButton)
138
+ await sleep(500)
139
+
140
+ // ========================================================================
141
+ // STEP 9: Click "gearMulti" button
142
+ // ========================================================================
143
+ await sleep(500)
144
+
145
+ const gearMultiButton = canvas.getByRole('button', { name: /gearMulti/i })
146
+ await user.click(gearMultiButton)
147
+ await sleep(500)
148
+
149
+ // ========================================================================
150
+ // STEP 10: Select "Demo" from combobox
151
+ // ========================================================================
152
+ await sleep(500)
153
+
154
+ const comboboxes = canvas.getAllByRole('combobox')
155
+ const dataSourceSelect = comboboxes[comboboxes.length - 1]
156
+ await user.selectOptions(dataSourceSelect, 'Demo')
157
+ await sleep(500)
158
+
159
+ // ========================================================================
160
+ // STEP 11: Click "Vertical Values for map" button
161
+ // ========================================================================
162
+ await sleep(500)
163
+
164
+ const verticalValuesButton = canvas.getByRole('button', { name: /vertical values for map/i })
165
+ await user.click(verticalValuesButton)
166
+ await sleep(500)
167
+
168
+ // ========================================================================
169
+ // STEP 12: Click "No" button
170
+ // ========================================================================
171
+ await sleep(500)
172
+
173
+ const noButton = canvas.getByRole('button', { name: /^no$/i })
174
+ await user.click(noButton)
175
+ await sleep(500)
176
+
177
+ // ========================================================================
178
+ // STEP 13: Click "Configure Multiple"
179
+ // ========================================================================
180
+ await sleep(500)
181
+
182
+ const configureMultipleText = canvas.getByText(/configure multiple/i)
183
+ await user.click(configureMultipleText)
184
+ await sleep(500)
185
+
186
+ // ========================================================================
187
+ // STEP 14: Select "Location" from Multi-Visualization Column dropdown
188
+ // ========================================================================
189
+ await sleep(500)
190
+
191
+ const multiVizColumnSelect = canvas.getByLabelText(/multi-visualization column/i)
192
+ await user.selectOptions(multiVizColumnSelect, 'Location')
193
+ await sleep(500)
194
+
195
+ // ========================================================================
196
+ // STEP 15: Click "Continue" button
197
+ // ========================================================================
198
+ await sleep(500)
199
+
200
+ const continueButton = canvas.getByRole('button', { name: /continue/i })
201
+ await user.click(continueButton)
202
+ await sleep(500)
203
+ }
204
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { faker } from '@faker-js/faker'
3
+ import { waitForOptionsToPopulate, performAndAssert } from '@cdc/core/helpers/testing'
3
4
  import APIFiltersMapData from './_mock/api-filter-map.json'
4
5
  import APIFiltersChartData from './_mock/api-filter-chart.json'
5
6
  import APIFilterErrorConfig from './_mock/api-filter-error.json'
@@ -15,7 +16,7 @@ import StandaloneTable from './_mock/standalone-table.json'
15
16
  import GroupPivotConfig from './_mock/group-pivot-filter.json'
16
17
  import PivotFitlerConfig from './_mock/pivot-filter.json'
17
18
  import { type DashboardConfig as Config } from '../types/DashboardConfig'
18
- import { userEvent, within } from 'storybook/test'
19
+ import { userEvent, within, expect } from 'storybook/test'
19
20
  import ToggleExampleConfig from './_mock/toggle-example.json'
20
21
  import _ from 'lodash'
21
22
  import { footnotesSymbols } from '@cdc/core/helpers/footnoteSymbols'
@@ -30,6 +31,12 @@ import TopSpacing_3 from './_mock/data-bite-dash-test_1_1.json'
30
31
  import TopSpacing_4 from './_mock/data-bite-dash-test_1_1_1.json'
31
32
  import CustomOrderNewValues from './_mock/custom-order-new-values.json'
32
33
  import APIFilterResetConfig from '../../examples/test-api-filter-reset.json'
34
+ import CascadingDataFilters from './_mock/filter-cascade.json'
35
+ import ParentChildFilters from './_mock/parent-child-filters.json'
36
+ import NestedParentChildFilters from './_mock/nested-parent-child-filters.json'
37
+ import GalleryDataBiteDashboard from './_mock/gallery-data-bite-dashboard.json'
38
+ import TP5TestConfig from './_mock/tp5-test.json'
39
+ import LineChartAnglesConfig from './_mock/dashboard-line-chart-angles.json'
33
40
 
34
41
  // Dashboard Filter Updates for Ascending, Descending, and Custom Order
35
42
  import DashboardFilterAsc from './_mock/dashboard-filter-asc.json'
@@ -98,6 +105,20 @@ export const Example_3: Story = {
98
105
  }
99
106
  }
100
107
 
108
+ export const TP5_Test_Dashboard: Story = {
109
+ args: {
110
+ config: TP5TestConfig,
111
+ isEditor: false
112
+ }
113
+ }
114
+
115
+ export const Line_Chart_Angles: Story = {
116
+ args: {
117
+ config: LineChartAnglesConfig,
118
+ isEditor: false
119
+ }
120
+ }
121
+
101
122
  export const Bump_Chart_Dashboard: Story = {
102
123
  args: {
103
124
  config: BumpChartConfig,
@@ -482,6 +503,13 @@ export const Top_Spacing_4: Story = {
482
503
  }
483
504
  }
484
505
 
506
+ export const Gallery_Data_Bite_Dashboard: Story = {
507
+ args: {
508
+ config: GalleryDataBiteDashboard,
509
+ isEditor: false
510
+ }
511
+ }
512
+
485
513
  export const Clear_Filters_Button: Story = {
486
514
  args: {
487
515
  config: APIFilterResetConfig as unknown as Config,
@@ -489,4 +517,382 @@ export const Clear_Filters_Button: Story = {
489
517
  }
490
518
  }
491
519
 
520
+ export const Cascading_Multi_Parent_Data_Filters: Story = {
521
+ args: {
522
+ config: CascadingDataFilters as unknown as Config,
523
+ isEditor: false
524
+ },
525
+ parameters: {
526
+ docs: {
527
+ description: {
528
+ story:
529
+ 'Demonstrates cascading data filters with multiple parent relationships. The size filter depends on color, and weight depends on both color and size, creating a multi-tier cascading filter system with deterministic test data.'
530
+ }
531
+ }
532
+ },
533
+ play: async ({ canvasElement }) => {
534
+ const canvas = within(canvasElement)
535
+ const user = userEvent.setup()
536
+
537
+ // Get all filter dropdowns (using findBy to wait for them)
538
+ const colorFilter = (await canvas.findByLabelText('color', { selector: 'select' })) as HTMLSelectElement
539
+ const sizeFilter = (await canvas.findByLabelText('size', { selector: 'select' })) as HTMLSelectElement
540
+ const weightFilter = (await canvas.findByLabelText('weight', { selector: 'select' })) as HTMLSelectElement
541
+ const smellFilter = (await canvas.findByLabelText('smell', { selector: 'select' })) as HTMLSelectElement
542
+
543
+ // Helper to get non-empty filter options
544
+ const getFilterOptions = (filter: HTMLSelectElement) =>
545
+ Array.from(filter.querySelectorAll('option'))
546
+ .map(opt => opt.value)
547
+ .filter(v => v)
548
+
549
+ // Helper to get state of filters and chart
550
+ const getState = () => ({
551
+ colorOptions: getFilterOptions(colorFilter),
552
+ colorSelected: colorFilter.value,
553
+ sizeOptions: getFilterOptions(sizeFilter),
554
+ sizeSelected: sizeFilter.value,
555
+ weightOptions: getFilterOptions(weightFilter),
556
+ weightSelected: weightFilter.value,
557
+ smellOptions: getFilterOptions(smellFilter),
558
+ smellSelected: smellFilter.value,
559
+ chartRendered: !!canvasElement.querySelector('svg'),
560
+ noDataVisible: !!canvas.queryByText('No Data Available')
561
+ })
562
+
563
+ // Initial verification - wait for options to populate
564
+ await waitForOptionsToPopulate(colorFilter, 3)
565
+ const initialState = getState()
566
+ expect(initialState.chartRendered).toBe(true)
567
+ expect(initialState.noDataVisible).toBe(false)
568
+
569
+ // ============================================================================
570
+ // TEST: Color → Size cascade (single parent)
571
+ // ============================================================================
572
+
573
+ // Test 1: Select blue → size should show all 3 sizes
574
+ await performAndAssert(
575
+ 'Select color=blue → size shows large, medium, small',
576
+ getState,
577
+ async () => await user.selectOptions(colorFilter, ['blue']),
578
+ (before, after) =>
579
+ after.colorSelected === 'blue' &&
580
+ after.sizeOptions.includes('large') &&
581
+ after.sizeOptions.includes('medium') &&
582
+ after.sizeOptions.includes('small') &&
583
+ after.sizeOptions.length === 3 &&
584
+ after.chartRendered &&
585
+ !after.noDataVisible
586
+ )
587
+
588
+ // Test 2: Select red → size should show only small, medium
589
+ await performAndAssert(
590
+ 'Select color=red → size shows medium, small (no large)',
591
+ getState,
592
+ async () => await user.selectOptions(colorFilter, ['red']),
593
+ (before, after) =>
594
+ after.colorSelected === 'red' &&
595
+ after.sizeOptions.includes('medium') &&
596
+ after.sizeOptions.includes('small') &&
597
+ !after.sizeOptions.includes('large') &&
598
+ after.sizeOptions.length === 2 &&
599
+ after.chartRendered &&
600
+ !after.noDataVisible
601
+ )
602
+
603
+ // Test 3: Select green → size should show only large
604
+ await performAndAssert(
605
+ 'Select color=green → size shows only large',
606
+ getState,
607
+ async () => await user.selectOptions(colorFilter, ['green']),
608
+ (before, after) =>
609
+ after.colorSelected === 'green' &&
610
+ after.sizeOptions.length === 1 &&
611
+ after.sizeOptions[0] === 'large' &&
612
+ after.sizeSelected === 'large' &&
613
+ after.chartRendered &&
614
+ !after.noDataVisible
615
+ )
616
+
617
+ // ============================================================================
618
+ // TEST: Color + Size → Weight cascade (multiple parents)
619
+ // ============================================================================
620
+
621
+ // Test 4: Select blue + small → weight updates based on both parents
622
+ await performAndAssert(
623
+ 'Select color=blue → size options repopulate',
624
+ getState,
625
+ async () => await user.selectOptions(colorFilter, ['blue']),
626
+ (before, after) =>
627
+ after.colorSelected === 'blue' && after.sizeSelected === 'large' && after.sizeOptions.length === 3
628
+ )
629
+
630
+ await performAndAssert(
631
+ 'Select size=small → weight updates based on color+size',
632
+ getState,
633
+ async () => await user.selectOptions(sizeFilter, ['small']),
634
+ (before, after) =>
635
+ after.colorSelected === 'blue' &&
636
+ after.sizeSelected === 'small' &&
637
+ after.weightOptions.length === 3 &&
638
+ after.chartRendered &&
639
+ !after.noDataVisible
640
+ )
641
+
642
+ // ============================================================================
643
+ // TEST: Weight → Smell cascade
644
+ // ============================================================================
645
+
646
+ // Test 5: Select light → smell shows only neutral and sweet (2 options)
647
+ await performAndAssert(
648
+ 'Select weight=light → smell shows neutral, sweet (not bitter)',
649
+ getState,
650
+ async () => await user.selectOptions(weightFilter, ['light']),
651
+ (before, after) =>
652
+ after.colorSelected === 'blue' &&
653
+ after.sizeSelected === 'small' &&
654
+ after.weightSelected === 'light' &&
655
+ after.smellOptions.includes('neutral') &&
656
+ after.smellOptions.includes('sweet') &&
657
+ !after.smellOptions.includes('bitter') &&
658
+ after.smellOptions.length === 2 &&
659
+ after.chartRendered &&
660
+ !after.noDataVisible
661
+ )
662
+
663
+ // ============================================================================
664
+ // TEST: Full cascade verification - change parent and verify all children
665
+ // ============================================================================
666
+
667
+ // Test 6: Final - select red again and verify entire cascade updates
668
+ await performAndAssert(
669
+ 'Select color=red → all filters update; size shows medium, small',
670
+ getState,
671
+ async () => await user.selectOptions(colorFilter, ['red']),
672
+ (before, after) =>
673
+ after.colorSelected === 'red' &&
674
+ after.sizeOptions.length === 2 &&
675
+ after.sizeOptions.includes('medium') &&
676
+ after.sizeOptions.includes('small') &&
677
+ // Size, weight, and smell selected values should be valid in their updated options
678
+ after.sizeOptions.includes(after.sizeSelected) &&
679
+ after.weightOptions.includes(after.weightSelected) &&
680
+ after.smellOptions.includes(after.smellSelected) &&
681
+ after.chartRendered &&
682
+ !after.noDataVisible
683
+ )
684
+ }
685
+ }
686
+
687
+ export const Parent_Child_Filters_With_DefaultValue: Story = {
688
+ args: {
689
+ config: ParentChildFilters as unknown as Config,
690
+ isEditor: false
691
+ },
692
+ parameters: {
693
+ docs: {
694
+ description: {
695
+ story:
696
+ 'Demonstrates parent-child filter relationships (State → County → City) with defaultValue support. Shows how child filter options update based on parent selection, and how defaultValue is applied on initial load.'
697
+ }
698
+ }
699
+ },
700
+ play: async ({ canvasElement }) => {
701
+ const canvas = within(canvasElement)
702
+ const user = userEvent.setup()
703
+
704
+ // Get filter dropdowns
705
+ const stateFilter = (await canvas.findByLabelText('State', { selector: 'select' })) as HTMLSelectElement
706
+ const countyFilter = (await canvas.findByLabelText('County', { selector: 'select' })) as HTMLSelectElement
707
+ const cityFilter = (await canvas.findByLabelText('City', { selector: 'select' })) as HTMLSelectElement
708
+
709
+ const getFilterOptions = (select: HTMLSelectElement) =>
710
+ Array.from(select.options)
711
+ .map(o => o.value)
712
+ .filter(Boolean)
713
+
714
+ const getState = () => ({
715
+ stateOptions: getFilterOptions(stateFilter),
716
+ stateSelected: stateFilter.value,
717
+ countyOptions: getFilterOptions(countyFilter),
718
+ countySelected: countyFilter.value,
719
+ cityOptions: getFilterOptions(cityFilter),
720
+ citySelected: cityFilter.value,
721
+ chartRendered: !!canvasElement.querySelector('svg')
722
+ })
723
+
724
+ // Wait for initial load
725
+ await waitForOptionsToPopulate(stateFilter, 3)
726
+ const initialState = getState()
727
+
728
+ // Verify defaultValue is applied on initial load
729
+ expect(initialState.stateSelected).toBe('California')
730
+ expect(initialState.countySelected).toBe('Los Angeles')
731
+ expect(initialState.citySelected).toBe('Los Angeles')
732
+ expect(initialState.chartRendered).toBe(true)
733
+
734
+ // Test 1: Change state to Texas → county and city should update
735
+ await performAndAssert(
736
+ 'Select state=Texas → county shows Texas counties',
737
+ getState,
738
+ async () => await user.selectOptions(stateFilter, ['Texas']),
739
+ (before, after) =>
740
+ after.stateSelected === 'Texas' &&
741
+ after.countyOptions.includes('Harris') &&
742
+ after.countyOptions.includes('Dallas') &&
743
+ after.countyOptions.includes('Bexar') &&
744
+ after.countyOptions.includes('Travis') &&
745
+ !after.countyOptions.includes('Los Angeles') &&
746
+ after.chartRendered
747
+ )
748
+
749
+ // Test 2: Select county → city options update
750
+ await performAndAssert(
751
+ 'Select county=Harris → city shows Houston, Pasadena',
752
+ getState,
753
+ async () => await user.selectOptions(countyFilter, ['Harris']),
754
+ (before, after) =>
755
+ after.countySelected === 'Harris' &&
756
+ after.cityOptions.includes('Houston') &&
757
+ after.cityOptions.includes('Pasadena') &&
758
+ after.cityOptions.length === 2 &&
759
+ after.chartRendered
760
+ )
761
+
762
+ // Test 3: Change state back to California → verify cascade updates
763
+ await performAndAssert(
764
+ 'Select state=California → filters reset to California data',
765
+ getState,
766
+ async () => await user.selectOptions(stateFilter, ['California']),
767
+ (before, after) =>
768
+ after.stateSelected === 'California' &&
769
+ after.countyOptions.includes('Los Angeles') &&
770
+ after.countyOptions.includes('San Diego') &&
771
+ after.countyOptions.includes('Orange') &&
772
+ after.chartRendered
773
+ )
774
+
775
+ // Test 4: Select Orange county → verify city options
776
+ await performAndAssert(
777
+ 'Select county=Orange → city shows Anaheim, Santa Ana, Irvine',
778
+ getState,
779
+ async () => await user.selectOptions(countyFilter, ['Orange']),
780
+ (before, after) =>
781
+ after.countySelected === 'Orange' &&
782
+ after.cityOptions.includes('Anaheim') &&
783
+ after.cityOptions.includes('Santa Ana') &&
784
+ after.cityOptions.includes('Irvine') &&
785
+ after.cityOptions.length === 3 &&
786
+ !after.cityOptions.includes('Los Angeles') &&
787
+ after.chartRendered
788
+ )
789
+ }
790
+ }
791
+
792
+ export const Nested_Dropdown_With_Parent_Child: Story = {
793
+ args: {
794
+ config: NestedParentChildFilters as unknown as Config,
795
+ isEditor: false
796
+ },
797
+ parameters: {
798
+ docs: {
799
+ description: {
800
+ story:
801
+ 'Demonstrates nested dropdown filters (Year/Quarter subGrouping) with parent-child relationships. The Year/Quarter filter depends on Region selection, and both Year and Quarter defaultValue properties are respected on initial load.'
802
+ }
803
+ }
804
+ },
805
+ play: async ({ canvasElement }) => {
806
+ const canvas = within(canvasElement)
807
+ const user = userEvent.setup()
808
+
809
+ // Get filter dropdowns
810
+ const regionFilter = (await canvas.findByLabelText('Region', { selector: 'select' })) as HTMLSelectElement
811
+ const yearQuarterInput = canvasElement.querySelector('.nested-dropdown input') as HTMLInputElement
812
+
813
+ const getFilterOptions = (select: HTMLSelectElement) =>
814
+ Array.from(select.options)
815
+ .map(o => o.text)
816
+ .filter(Boolean)
817
+
818
+ const getNestedDropdownOptions = () => {
819
+ const container = yearQuarterInput.closest('.nested-dropdown')
820
+ if (!container) return []
821
+ return Array.from(container.querySelectorAll('li[role="treeitem"]'))
822
+ .map(li => li.getAttribute('aria-label') || li.textContent?.trim() || '')
823
+ .filter(Boolean)
824
+ }
825
+
826
+ const getState = () => ({
827
+ regionOptions: getFilterOptions(regionFilter),
828
+ regionSelected: regionFilter.value,
829
+ yearQuarterOptions: getNestedDropdownOptions(),
830
+ yearQuarterSelected: yearQuarterInput.value,
831
+ chartRendered: !!canvasElement.querySelector('svg')
832
+ })
833
+
834
+ // Wait for initial load
835
+ await waitForOptionsToPopulate(regionFilter, 4)
836
+ const initialState = getState()
837
+
838
+ // Verify defaultValue is applied on initial load (North region, 2023 Q2)
839
+ expect(initialState.regionSelected).toBe('North')
840
+ expect(initialState.yearQuarterSelected).toContain('2023')
841
+ expect(initialState.yearQuarterSelected).toContain('Q2')
842
+ expect(initialState.chartRendered).toBe(true)
843
+
844
+ // Test 1: Change region to South → year options should update based on available data
845
+ await performAndAssert(
846
+ 'Select region=South → year/quarter filter updates',
847
+ getState,
848
+ async () => await user.selectOptions(regionFilter, ['South']),
849
+ (before, after) =>
850
+ after.regionSelected === 'South' &&
851
+ after.yearQuarterOptions.some(opt => opt.includes('2022')) &&
852
+ after.yearQuarterOptions.some(opt => opt.includes('2023')) &&
853
+ after.yearQuarterOptions.some(opt => opt.includes('2024')) &&
854
+ after.chartRendered
855
+ )
856
+
857
+ // Test 2: Change region to East → should only show 2023 and 2024 (no 2022 data for East)
858
+ await performAndAssert(
859
+ 'Select region=East → year shows only 2023, 2024',
860
+ getState,
861
+ async () => await user.selectOptions(regionFilter, ['East']),
862
+ (before, after) =>
863
+ after.regionSelected === 'East' &&
864
+ after.yearQuarterOptions.some(opt => opt.includes('2023')) &&
865
+ after.yearQuarterOptions.some(opt => opt.includes('2024')) &&
866
+ !after.yearQuarterOptions.some(opt => opt.includes('2022')) &&
867
+ after.chartRendered
868
+ )
869
+
870
+ // Test 3: Change region to West → should only show 2023 Q3-Q4 and 2024
871
+ await performAndAssert(
872
+ 'Select region=West → limited year/quarter options',
873
+ getState,
874
+ async () => await user.selectOptions(regionFilter, ['West']),
875
+ (before, after) =>
876
+ after.regionSelected === 'West' &&
877
+ (after.yearQuarterOptions.some(opt => opt.includes('2023')) ||
878
+ after.yearQuarterOptions.some(opt => opt.includes('2024'))) &&
879
+ !after.yearQuarterOptions.some(opt => opt.includes('2022')) &&
880
+ after.chartRendered
881
+ )
882
+
883
+ // Test 4: Change back to North → verify all years available again
884
+ await performAndAssert(
885
+ 'Select region=North → all years (2022-2024) available',
886
+ getState,
887
+ async () => await user.selectOptions(regionFilter, ['North']),
888
+ (before, after) =>
889
+ after.regionSelected === 'North' &&
890
+ after.yearQuarterOptions.some(opt => opt.includes('2022')) &&
891
+ after.yearQuarterOptions.some(opt => opt.includes('2023')) &&
892
+ after.yearQuarterOptions.some(opt => opt.includes('2024')) &&
893
+ after.chartRendered
894
+ )
895
+ }
896
+ }
897
+
492
898
  export default meta