@cdc/dashboard 4.25.11 → 4.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/Dynamic_Data.md +66 -0
  2. package/dist/cdcdashboard.js +78783 -76370
  3. package/examples/api-dashboard-data.json +272 -0
  4. package/examples/api-dashboard-years.json +11 -0
  5. package/examples/api-geographies-data.json +11 -0
  6. package/examples/private/cat-y.json +1235 -0
  7. package/examples/private/chronic-dash.json +1584 -0
  8. package/examples/private/map-issue.json +2260 -0
  9. package/examples/private/mpinc-state-reports.json +2260 -0
  10. package/examples/private/nwss/rsv.json +1240 -0
  11. package/examples/private/simple-dash.json +490 -0
  12. package/examples/private/test-dash.json +0 -0
  13. package/examples/private/test123.json +491 -0
  14. package/examples/test-dashboard-simple.json +503 -0
  15. package/index.html +24 -25
  16. package/package.json +12 -11
  17. package/src/CdcDashboardComponent.tsx +18 -2
  18. package/src/_stories/Dashboard.DataSetup.stories.tsx +203 -0
  19. package/src/_stories/Dashboard.stories.tsx +385 -1
  20. package/src/_stories/_mock/filter-cascade.json +3350 -0
  21. package/src/_stories/_mock/gallery-data-bite-dashboard.json +3500 -0
  22. package/src/_stories/_mock/nested-parent-child-filters.json +392 -0
  23. package/src/_stories/_mock/parent-child-filters.json +233 -0
  24. package/src/components/DashboardFilters/DashboardFilters.tsx +20 -11
  25. package/src/components/DashboardFilters/DashboardFiltersEditor/DashboardFiltersEditor.tsx +89 -38
  26. package/src/components/DashboardFilters/DashboardFiltersEditor/components/FilterEditor.tsx +51 -29
  27. package/src/components/DashboardFilters/DashboardFiltersEditor/components/NestedDropDownDashboard.tsx +146 -9
  28. package/src/components/DashboardFilters/DashboardFiltersWrapper.tsx +11 -7
  29. package/src/components/DataDesignerModal.tsx +6 -1
  30. package/src/components/Header/Header.tsx +51 -20
  31. package/src/components/VisualizationRow.tsx +71 -5
  32. package/src/components/VisualizationsPanel/VisualizationsPanel.tsx +2 -3
  33. package/src/components/Widget/Widget.tsx +1 -1
  34. package/src/data/initial-state.js +1 -0
  35. package/src/helpers/addValuesToDashboardFilters.ts +15 -22
  36. package/src/helpers/changeFilterActive.ts +67 -65
  37. package/src/helpers/formatConfigBeforeSave.ts +6 -5
  38. package/src/helpers/getUpdateConfig.ts +91 -91
  39. package/src/helpers/tests/updatesChildFilters.test.ts +53 -22
  40. package/src/helpers/updateChildFilters.ts +50 -27
  41. package/src/scss/main.scss +141 -1
  42. package/src/test/CdcDashboard.test.jsx +9 -4
  43. package/src/types/Dashboard.ts +1 -0
  44. package/src/types/FilterStyles.ts +8 -7
  45. package/src/types/SharedFilter.ts +13 -0
  46. package/LICENSE +0 -201
  47. package/examples/private/DEV-10538.json +0 -407
  48. package/examples/private/DEV-11072.json +0 -7591
  49. package/examples/private/DEV-11405.json +0 -39112
  50. package/examples/private/delete.json +0 -32919
  51. package/examples/private/pedro.json +0 -1
@@ -0,0 +1,203 @@
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 dataSourceSelect = canvas.getByRole('combobox')
155
+ await user.selectOptions(dataSourceSelect, 'Demo')
156
+ await sleep(500)
157
+
158
+ // ========================================================================
159
+ // STEP 11: Click "Vertical Values for map" button
160
+ // ========================================================================
161
+ await sleep(500)
162
+
163
+ const verticalValuesButton = canvas.getByRole('button', { name: /vertical values for map/i })
164
+ await user.click(verticalValuesButton)
165
+ await sleep(500)
166
+
167
+ // ========================================================================
168
+ // STEP 12: Click "No" button
169
+ // ========================================================================
170
+ await sleep(500)
171
+
172
+ const noButton = canvas.getByRole('button', { name: /^no$/i })
173
+ await user.click(noButton)
174
+ await sleep(500)
175
+
176
+ // ========================================================================
177
+ // STEP 13: Click "Configure Multiple"
178
+ // ========================================================================
179
+ await sleep(500)
180
+
181
+ const configureMultipleText = canvas.getByText(/configure multiple/i)
182
+ await user.click(configureMultipleText)
183
+ await sleep(500)
184
+
185
+ // ========================================================================
186
+ // STEP 14: Select "Location" from Multi-Visualization Column dropdown
187
+ // ========================================================================
188
+ await sleep(500)
189
+
190
+ const multiVizColumnSelect = canvas.getByLabelText(/multi-visualization column/i)
191
+ await user.selectOptions(multiVizColumnSelect, 'Location')
192
+ await sleep(500)
193
+
194
+ // ========================================================================
195
+ // STEP 15: Click "Continue" button
196
+ // ========================================================================
197
+ await sleep(500)
198
+
199
+ const continueButton = canvas.getByRole('button', { name: /continue/i })
200
+ await user.click(continueButton)
201
+ await sleep(500)
202
+ }
203
+ }
@@ -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,10 @@ 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'
33
38
 
34
39
  // Dashboard Filter Updates for Ascending, Descending, and Custom Order
35
40
  import DashboardFilterAsc from './_mock/dashboard-filter-asc.json'
@@ -482,6 +487,13 @@ export const Top_Spacing_4: Story = {
482
487
  }
483
488
  }
484
489
 
490
+ export const Gallery_Data_Bite_Dashboard: Story = {
491
+ args: {
492
+ config: GalleryDataBiteDashboard,
493
+ isEditor: false
494
+ }
495
+ }
496
+
485
497
  export const Clear_Filters_Button: Story = {
486
498
  args: {
487
499
  config: APIFilterResetConfig as unknown as Config,
@@ -489,4 +501,376 @@ export const Clear_Filters_Button: Story = {
489
501
  }
490
502
  }
491
503
 
504
+ export const Cascading_Multi_Parent_Data_Filters: Story = {
505
+ args: {
506
+ config: CascadingDataFilters as unknown as Config,
507
+ isEditor: false
508
+ },
509
+ parameters: {
510
+ docs: {
511
+ description: {
512
+ story:
513
+ '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.'
514
+ }
515
+ }
516
+ },
517
+ play: async ({ canvasElement }) => {
518
+ const canvas = within(canvasElement)
519
+ const user = userEvent.setup()
520
+
521
+ // Get all filter dropdowns (using findBy to wait for them)
522
+ const colorFilter = (await canvas.findByLabelText('color', { selector: 'select' })) as HTMLSelectElement
523
+ const sizeFilter = (await canvas.findByLabelText('size', { selector: 'select' })) as HTMLSelectElement
524
+ const weightFilter = (await canvas.findByLabelText('weight', { selector: 'select' })) as HTMLSelectElement
525
+ const smellFilter = (await canvas.findByLabelText('smell', { selector: 'select' })) as HTMLSelectElement
526
+
527
+ // Helper to get non-empty filter options
528
+ const getFilterOptions = (filter: HTMLSelectElement) =>
529
+ Array.from(filter.querySelectorAll('option'))
530
+ .map(opt => opt.value)
531
+ .filter(v => v)
532
+
533
+ // Helper to get state of filters and chart
534
+ const getState = () => ({
535
+ colorOptions: getFilterOptions(colorFilter),
536
+ colorSelected: colorFilter.value,
537
+ sizeOptions: getFilterOptions(sizeFilter),
538
+ sizeSelected: sizeFilter.value,
539
+ weightOptions: getFilterOptions(weightFilter),
540
+ weightSelected: weightFilter.value,
541
+ smellOptions: getFilterOptions(smellFilter),
542
+ smellSelected: smellFilter.value,
543
+ chartRendered: !!canvasElement.querySelector('svg'),
544
+ noDataVisible: !!canvas.queryByText('No Data Available')
545
+ })
546
+
547
+ // Initial verification - wait for options to populate
548
+ await waitForOptionsToPopulate(colorFilter, 3)
549
+ const initialState = getState()
550
+ expect(initialState.chartRendered).toBe(true)
551
+ expect(initialState.noDataVisible).toBe(false)
552
+
553
+ // ============================================================================
554
+ // TEST: Color → Size cascade (single parent)
555
+ // ============================================================================
556
+
557
+ // Test 1: Select blue → size should show all 3 sizes
558
+ await performAndAssert(
559
+ 'Select color=blue → size shows large, medium, small',
560
+ getState,
561
+ async () => await user.selectOptions(colorFilter, ['blue']),
562
+ (before, after) =>
563
+ after.colorSelected === 'blue' &&
564
+ after.sizeOptions.includes('large') &&
565
+ after.sizeOptions.includes('medium') &&
566
+ after.sizeOptions.includes('small') &&
567
+ after.sizeOptions.length === 3 &&
568
+ after.chartRendered &&
569
+ !after.noDataVisible
570
+ )
571
+
572
+ // Test 2: Select red → size should show only small, medium
573
+ await performAndAssert(
574
+ 'Select color=red → size shows medium, small (no large)',
575
+ getState,
576
+ async () => await user.selectOptions(colorFilter, ['red']),
577
+ (before, after) =>
578
+ after.colorSelected === 'red' &&
579
+ after.sizeOptions.includes('medium') &&
580
+ after.sizeOptions.includes('small') &&
581
+ !after.sizeOptions.includes('large') &&
582
+ after.sizeOptions.length === 2 &&
583
+ after.chartRendered &&
584
+ !after.noDataVisible
585
+ )
586
+
587
+ // Test 3: Select green → size should show only large
588
+ await performAndAssert(
589
+ 'Select color=green → size shows only large',
590
+ getState,
591
+ async () => await user.selectOptions(colorFilter, ['green']),
592
+ (before, after) =>
593
+ after.colorSelected === 'green' &&
594
+ after.sizeOptions.length === 1 &&
595
+ after.sizeOptions[0] === 'large' &&
596
+ after.sizeSelected === 'large' &&
597
+ after.chartRendered &&
598
+ !after.noDataVisible
599
+ )
600
+
601
+ // ============================================================================
602
+ // TEST: Color + Size → Weight cascade (multiple parents)
603
+ // ============================================================================
604
+
605
+ // Test 4: Select blue + small → weight updates based on both parents
606
+ await performAndAssert(
607
+ 'Select color=blue → size options repopulate',
608
+ getState,
609
+ async () => await user.selectOptions(colorFilter, ['blue']),
610
+ (before, after) =>
611
+ after.colorSelected === 'blue' && after.sizeSelected === 'large' && after.sizeOptions.length === 3
612
+ )
613
+
614
+ await performAndAssert(
615
+ 'Select size=small → weight updates based on color+size',
616
+ getState,
617
+ async () => await user.selectOptions(sizeFilter, ['small']),
618
+ (before, after) =>
619
+ after.colorSelected === 'blue' &&
620
+ after.sizeSelected === 'small' &&
621
+ after.weightOptions.length === 3 &&
622
+ after.chartRendered &&
623
+ !after.noDataVisible
624
+ )
625
+
626
+ // ============================================================================
627
+ // TEST: Weight → Smell cascade
628
+ // ============================================================================
629
+
630
+ // Test 5: Select light → smell shows only neutral and sweet (2 options)
631
+ await performAndAssert(
632
+ 'Select weight=light → smell shows neutral, sweet (not bitter)',
633
+ getState,
634
+ async () => await user.selectOptions(weightFilter, ['light']),
635
+ (before, after) =>
636
+ after.colorSelected === 'blue' &&
637
+ after.sizeSelected === 'small' &&
638
+ after.weightSelected === 'light' &&
639
+ after.smellOptions.includes('neutral') &&
640
+ after.smellOptions.includes('sweet') &&
641
+ !after.smellOptions.includes('bitter') &&
642
+ after.smellOptions.length === 2 &&
643
+ after.chartRendered &&
644
+ !after.noDataVisible
645
+ )
646
+
647
+ // ============================================================================
648
+ // TEST: Full cascade verification - change parent and verify all children
649
+ // ============================================================================
650
+
651
+ // Test 6: Final - select red again and verify entire cascade updates
652
+ await performAndAssert(
653
+ 'Select color=red → all filters update; size shows medium, small',
654
+ getState,
655
+ async () => await user.selectOptions(colorFilter, ['red']),
656
+ (before, after) =>
657
+ after.colorSelected === 'red' &&
658
+ after.sizeOptions.length === 2 &&
659
+ after.sizeOptions.includes('medium') &&
660
+ after.sizeOptions.includes('small') &&
661
+ // Size, weight, and smell selected values should be valid in their updated options
662
+ after.sizeOptions.includes(after.sizeSelected) &&
663
+ after.weightOptions.includes(after.weightSelected) &&
664
+ after.smellOptions.includes(after.smellSelected) &&
665
+ after.chartRendered &&
666
+ !after.noDataVisible
667
+ )
668
+ }
669
+ }
670
+
671
+ export const Parent_Child_Filters_With_DefaultValue: Story = {
672
+ args: {
673
+ config: ParentChildFilters as unknown as Config,
674
+ isEditor: false
675
+ },
676
+ parameters: {
677
+ docs: {
678
+ description: {
679
+ story:
680
+ '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.'
681
+ }
682
+ }
683
+ },
684
+ play: async ({ canvasElement }) => {
685
+ const canvas = within(canvasElement)
686
+ const user = userEvent.setup()
687
+
688
+ // Get filter dropdowns
689
+ const stateFilter = (await canvas.findByLabelText('State', { selector: 'select' })) as HTMLSelectElement
690
+ const countyFilter = (await canvas.findByLabelText('County', { selector: 'select' })) as HTMLSelectElement
691
+ const cityFilter = (await canvas.findByLabelText('City', { selector: 'select' })) as HTMLSelectElement
692
+
693
+ const getFilterOptions = (select: HTMLSelectElement) =>
694
+ Array.from(select.options)
695
+ .map(o => o.value)
696
+ .filter(Boolean)
697
+
698
+ const getState = () => ({
699
+ stateOptions: getFilterOptions(stateFilter),
700
+ stateSelected: stateFilter.value,
701
+ countyOptions: getFilterOptions(countyFilter),
702
+ countySelected: countyFilter.value,
703
+ cityOptions: getFilterOptions(cityFilter),
704
+ citySelected: cityFilter.value,
705
+ chartRendered: !!canvasElement.querySelector('svg')
706
+ })
707
+
708
+ // Wait for initial load
709
+ await waitForOptionsToPopulate(stateFilter, 3)
710
+ const initialState = getState()
711
+
712
+ // Verify defaultValue is applied on initial load
713
+ expect(initialState.stateSelected).toBe('California')
714
+ expect(initialState.countySelected).toBe('Los Angeles')
715
+ expect(initialState.citySelected).toBe('Los Angeles')
716
+ expect(initialState.chartRendered).toBe(true)
717
+
718
+ // Test 1: Change state to Texas → county and city should update
719
+ await performAndAssert(
720
+ 'Select state=Texas → county shows Texas counties',
721
+ getState,
722
+ async () => await user.selectOptions(stateFilter, ['Texas']),
723
+ (before, after) =>
724
+ after.stateSelected === 'Texas' &&
725
+ after.countyOptions.includes('Harris') &&
726
+ after.countyOptions.includes('Dallas') &&
727
+ after.countyOptions.includes('Bexar') &&
728
+ after.countyOptions.includes('Travis') &&
729
+ !after.countyOptions.includes('Los Angeles') &&
730
+ after.chartRendered
731
+ )
732
+
733
+ // Test 2: Select county → city options update
734
+ await performAndAssert(
735
+ 'Select county=Harris → city shows Houston, Pasadena',
736
+ getState,
737
+ async () => await user.selectOptions(countyFilter, ['Harris']),
738
+ (before, after) =>
739
+ after.countySelected === 'Harris' &&
740
+ after.cityOptions.includes('Houston') &&
741
+ after.cityOptions.includes('Pasadena') &&
742
+ after.cityOptions.length === 2 &&
743
+ after.chartRendered
744
+ )
745
+
746
+ // Test 3: Change state back to California → verify cascade updates
747
+ await performAndAssert(
748
+ 'Select state=California → filters reset to California data',
749
+ getState,
750
+ async () => await user.selectOptions(stateFilter, ['California']),
751
+ (before, after) =>
752
+ after.stateSelected === 'California' &&
753
+ after.countyOptions.includes('Los Angeles') &&
754
+ after.countyOptions.includes('San Diego') &&
755
+ after.countyOptions.includes('Orange') &&
756
+ after.chartRendered
757
+ )
758
+
759
+ // Test 4: Select Orange county → verify city options
760
+ await performAndAssert(
761
+ 'Select county=Orange → city shows Anaheim, Santa Ana, Irvine',
762
+ getState,
763
+ async () => await user.selectOptions(countyFilter, ['Orange']),
764
+ (before, after) =>
765
+ after.countySelected === 'Orange' &&
766
+ after.cityOptions.includes('Anaheim') &&
767
+ after.cityOptions.includes('Santa Ana') &&
768
+ after.cityOptions.includes('Irvine') &&
769
+ after.cityOptions.length === 3 &&
770
+ !after.cityOptions.includes('Los Angeles') &&
771
+ after.chartRendered
772
+ )
773
+ }
774
+ }
775
+
776
+ export const Nested_Dropdown_With_Parent_Child: Story = {
777
+ args: {
778
+ config: NestedParentChildFilters as unknown as Config,
779
+ isEditor: false
780
+ },
781
+ parameters: {
782
+ docs: {
783
+ description: {
784
+ story:
785
+ '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.'
786
+ }
787
+ }
788
+ },
789
+ play: async ({ canvasElement }) => {
790
+ const canvas = within(canvasElement)
791
+ const user = userEvent.setup()
792
+
793
+ // Get filter dropdowns
794
+ const regionFilter = (await canvas.findByLabelText('Region', { selector: 'select' })) as HTMLSelectElement
795
+ const yearQuarterFilter = (await canvas.findByLabelText('Year and Quarter', {
796
+ selector: 'select'
797
+ })) as HTMLSelectElement
798
+
799
+ const getFilterOptions = (select: HTMLSelectElement) =>
800
+ Array.from(select.options)
801
+ .map(o => o.text)
802
+ .filter(Boolean)
803
+
804
+ const getState = () => ({
805
+ regionOptions: getFilterOptions(regionFilter),
806
+ regionSelected: regionFilter.value,
807
+ yearQuarterOptions: getFilterOptions(yearQuarterFilter),
808
+ yearQuarterSelected: yearQuarterFilter.value,
809
+ chartRendered: !!canvasElement.querySelector('svg')
810
+ })
811
+
812
+ // Wait for initial load
813
+ await waitForOptionsToPopulate(regionFilter, 4)
814
+ const initialState = getState()
815
+
816
+ // Verify defaultValue is applied on initial load (North region, 2023 Q2)
817
+ expect(initialState.regionSelected).toBe('North')
818
+ expect(initialState.yearQuarterSelected).toContain('2023')
819
+ expect(initialState.yearQuarterSelected).toContain('Q2')
820
+ expect(initialState.chartRendered).toBe(true)
821
+
822
+ // Test 1: Change region to South → year options should update based on available data
823
+ await performAndAssert(
824
+ 'Select region=South → year/quarter filter updates',
825
+ getState,
826
+ async () => await user.selectOptions(regionFilter, ['South']),
827
+ (before, after) =>
828
+ after.regionSelected === 'South' &&
829
+ after.yearQuarterOptions.some(opt => opt.includes('2022')) &&
830
+ after.yearQuarterOptions.some(opt => opt.includes('2023')) &&
831
+ after.yearQuarterOptions.some(opt => opt.includes('2024')) &&
832
+ after.chartRendered
833
+ )
834
+
835
+ // Test 2: Change region to East → should only show 2023 and 2024 (no 2022 data for East)
836
+ await performAndAssert(
837
+ 'Select region=East → year shows only 2023, 2024',
838
+ getState,
839
+ async () => await user.selectOptions(regionFilter, ['East']),
840
+ (before, after) =>
841
+ after.regionSelected === 'East' &&
842
+ after.yearQuarterOptions.some(opt => opt.includes('2023')) &&
843
+ after.yearQuarterOptions.some(opt => opt.includes('2024')) &&
844
+ !after.yearQuarterOptions.some(opt => opt.includes('2022')) &&
845
+ after.chartRendered
846
+ )
847
+
848
+ // Test 3: Change region to West → should only show 2023 Q3-Q4 and 2024
849
+ await performAndAssert(
850
+ 'Select region=West → limited year/quarter options',
851
+ getState,
852
+ async () => await user.selectOptions(regionFilter, ['West']),
853
+ (before, after) =>
854
+ after.regionSelected === 'West' &&
855
+ (after.yearQuarterOptions.some(opt => opt.includes('2023')) ||
856
+ after.yearQuarterOptions.some(opt => opt.includes('2024'))) &&
857
+ !after.yearQuarterOptions.some(opt => opt.includes('2022')) &&
858
+ after.chartRendered
859
+ )
860
+
861
+ // Test 4: Change back to North → verify all years available again
862
+ await performAndAssert(
863
+ 'Select region=North → all years (2022-2024) available',
864
+ getState,
865
+ async () => await user.selectOptions(regionFilter, ['North']),
866
+ (before, after) =>
867
+ after.regionSelected === 'North' &&
868
+ after.yearQuarterOptions.some(opt => opt.includes('2022')) &&
869
+ after.yearQuarterOptions.some(opt => opt.includes('2023')) &&
870
+ after.yearQuarterOptions.some(opt => opt.includes('2024')) &&
871
+ after.chartRendered
872
+ )
873
+ }
874
+ }
875
+
492
876
  export default meta