@cdc/dashboard 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.
Files changed (56) hide show
  1. package/dist/{cdcdashboard-fce76882.es.js → cdcdashboard-BnB1QM5d.es.js} +6 -13
  2. package/dist/{cdcdashboard-c55ac1ea.es.js → cdcdashboard-D6CG2-Hb.es.js} +5 -12
  3. package/dist/{cdcdashboard-31a33da1.es.js → cdcdashboard-MXgURbdZ.es.js} +6 -13
  4. package/dist/{cdcdashboard-1a1724a1.es.js → cdcdashboard-dgT_1dIT.es.js} +136 -151
  5. package/dist/cdcdashboard.js +48574 -46414
  6. package/examples/api-test/categories.json +18 -0
  7. package/examples/api-test/chart-data.json +602 -0
  8. package/examples/api-test/topics.json +47 -0
  9. package/examples/api-test/years.json +22 -0
  10. package/examples/markup-axis-label.json +4167 -0
  11. package/examples/private/DEV-10538.json +407 -0
  12. package/examples/private/DEV-11405.json +39112 -0
  13. package/examples/private/big-dashboard.json +39095 -39077
  14. package/examples/private/clade-2.json +430 -0
  15. package/examples/private/delete.json +32919 -0
  16. package/examples/private/diabetes.json +546 -196
  17. package/examples/private/markup-footer/mortality-deaths-footnotes-age.csv +3 -0
  18. package/examples/private/mpox.json +38128 -0
  19. package/examples/private/reset.json +32920 -0
  20. package/examples/test-api-filter-reset.json +132 -0
  21. package/index.html +2 -2
  22. package/package.json +9 -10
  23. package/src/CdcDashboardComponent.tsx +17 -8
  24. package/src/DashboardContext.tsx +3 -1
  25. package/src/_stories/Dashboard.stories.tsx +17 -0
  26. package/src/_stories/_mock/custom-order-new-values.json +116 -0
  27. package/src/components/DashboardFilters/DashboardFilters.tsx +34 -20
  28. package/src/components/DashboardFilters/DashboardFiltersEditor/DashboardFiltersEditor.tsx +29 -12
  29. package/src/components/DashboardFilters/DashboardFiltersEditor/components/FilterEditor.tsx +77 -111
  30. package/src/components/DashboardFilters/DashboardFiltersEditor/components/NestedDropDownDashboard.tsx +51 -51
  31. package/src/components/DashboardFilters/DashboardFiltersWrapper.tsx +120 -24
  32. package/src/components/DashboardFilters/_stories/DashboardFilters.stories.tsx +62 -3
  33. package/src/components/DataDesignerModal.tsx +12 -5
  34. package/src/components/Header/Header.tsx +10 -9
  35. package/src/components/Toggle/Toggle.tsx +48 -48
  36. package/src/components/VisualizationRow.tsx +4 -3
  37. package/src/helpers/addValuesToDashboardFilters.ts +29 -4
  38. package/src/helpers/apiFilterHelpers.ts +26 -2
  39. package/src/helpers/filterData.ts +52 -7
  40. package/src/helpers/filterResetHelpers.ts +102 -0
  41. package/src/helpers/getVizConfig.ts +2 -2
  42. package/src/helpers/loadAPIFilters.ts +109 -99
  43. package/src/helpers/tests/filterResetHelpers.test.ts +532 -0
  44. package/src/index.tsx +1 -0
  45. package/src/scss/editor-panel.scss +3 -431
  46. package/src/scss/main.scss +1 -24
  47. package/src/store/errorMessage/errorMessage.reducer.ts +1 -1
  48. package/src/types/DashboardFilters.ts +9 -8
  49. package/examples/private/burden_toolkit_mortality_diabetes_attributable_deaths_data.csv +0 -14041
  50. package/examples/private/burden_toolkit_mortality_diabetes_attributable_deaths_per_100000_data.csv +0 -14041
  51. package/examples/private/burden_toolkit_mortality_qaly_data.csv +0 -18721
  52. package/examples/private/burden_toolkit_mortality_yll_data.csv +0 -18721
  53. package/src/helpers/getAutoLoadVisualization.ts +0 -11
  54. package/src/scss/mixins.scss +0 -47
  55. package/src/scss/variables.scss +0 -5
  56. /package/dist/{cdcdashboard-548642e6.es.js → cdcdashboard-Ct2SB0vL.es.js} +0 -0
@@ -1,9 +1,17 @@
1
1
  import { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import DashboardFilters from '../DashboardFilters'
3
+ import '../../../scss/main.scss'
3
4
 
4
5
  const meta: Meta<typeof DashboardFilters> = {
5
6
  title: 'Components/Atoms/Inputs/DashboardFilters',
6
- component: DashboardFilters
7
+ component: DashboardFilters,
8
+ decorators: [
9
+ Story => (
10
+ <div className='cdc-open-viz-module type-dashboard'>
11
+ <Story />
12
+ </div>
13
+ )
14
+ ]
7
15
  }
8
16
 
9
17
  type Story = StoryObj<typeof DashboardFilters>
@@ -11,11 +19,62 @@ type Story = StoryObj<typeof DashboardFilters>
11
19
  export const Example_1: Story = {
12
20
  args: {
13
21
  filters: [
14
- { type: 'datafilter', key: 'label here', values: [1, 2, 3, 4] },
15
- { type: 'datafilter', key: 'something' }
22
+ {
23
+ type: 'datafilter',
24
+ key: 'label here',
25
+ values: ['1', '2', '3', '4'],
26
+ columnName: 'label',
27
+ showDropdown: true,
28
+ id: 0,
29
+ parents: []
30
+ } as any,
31
+ {
32
+ type: 'datafilter',
33
+ key: 'something',
34
+ values: ['A', 'B', 'C'],
35
+ columnName: 'something',
36
+ showDropdown: true,
37
+ id: 1,
38
+ parents: []
39
+ } as any
16
40
  ],
41
+ show: [0, 1],
42
+ apiFilterDropdowns: {},
17
43
  handleOnChange: () => {}
18
44
  }
19
45
  }
20
46
 
47
+ export const WithClearButton: Story = {
48
+ args: {
49
+ filters: [
50
+ {
51
+ type: 'datafilter',
52
+ key: 'Category',
53
+ values: ['Option 1', 'Option 2', 'Option 3'],
54
+ active: 'Option 1',
55
+ columnName: 'category',
56
+ showDropdown: true,
57
+ id: 0,
58
+ parents: []
59
+ } as any,
60
+ {
61
+ type: 'datafilter',
62
+ key: 'Status',
63
+ values: ['Active', 'Inactive', 'Pending'],
64
+ active: 'Active',
65
+ columnName: 'status',
66
+ showDropdown: true,
67
+ id: 1,
68
+ parents: []
69
+ } as any
70
+ ],
71
+ show: [0, 1],
72
+ apiFilterDropdowns: {},
73
+ handleOnChange: () => {},
74
+ showSubmit: true,
75
+ applyFilters: () => {},
76
+ handleReset: () => {}
77
+ }
78
+ }
79
+
21
80
  export default meta
@@ -4,14 +4,13 @@ import { useContext, useMemo, useState } from 'react'
4
4
  import { DashboardContext, DashboardDispatchContext } from '../DashboardContext'
5
5
  import Modal from '@cdc/core/components/ui/Modal'
6
6
  import Loader from '@cdc/core/components/Loader'
7
- import { CheckBox } from '@cdc/core/components/EditorPanel/Inputs'
7
+ import { CheckBox, Select } from '@cdc/core/components/EditorPanel/Inputs'
8
8
  import Tooltip from '@cdc/core/components/ui/Tooltip'
9
9
  import _ from 'lodash'
10
10
  import fetchRemoteData from '@cdc/core/helpers/fetchRemoteData'
11
11
  import DataTransform from '@cdc/core/helpers/DataTransform'
12
12
  import { ConfigureData } from '@cdc/core/types/ConfigureData'
13
13
  import Icon from '@cdc/core/components/ui/Icon'
14
- import InputSelect from '@cdc/core/components/inputs/InputSelect'
15
14
 
16
15
  type DataDesignerModalProps = {
17
16
  rowIndex: number
@@ -134,10 +133,18 @@ export const DataDesignerModal: React.FC<DataDesignerModalProps> = ({ vizKey, ro
134
133
  {loadingAPIData && <Loader fullScreen />}
135
134
  <div className='dataset-selector-container'>
136
135
  Select a dataset:&nbsp;
137
- <select className='dataset-selector' value={configureData.dataKey || ''} onChange={changeDataset}>
136
+ <select
137
+ className='dataset-selector cove-form-select'
138
+ value={configureData.dataKey || ''}
139
+ onChange={changeDataset}
140
+ >
138
141
  <option value=''>Select a dataset</option>
139
142
  {config.datasets &&
140
- Object.keys(config.datasets).map(datasetKey => <option key={datasetKey}>{datasetKey}</option>)}
143
+ Object.keys(config.datasets).map(datasetKey => (
144
+ <option key={datasetKey} value={datasetKey}>
145
+ {datasetKey}
146
+ </option>
147
+ ))}
141
148
  </select>
142
149
  {vizKey && (
143
150
  // only shows for visualizations
@@ -186,7 +193,7 @@ export const DataDesignerModal: React.FC<DataDesignerModalProps> = ({ vizKey, ro
186
193
  />
187
194
  ) : (
188
195
  <>
189
- <InputSelect
196
+ <Select
190
197
  options={Object.keys(config.datasets[configureData.dataKey]?.data[0] || {})}
191
198
  value={config.rows[rowIndex].multiVizColumn}
192
199
  label='Multi-Visualization Column'
@@ -1,4 +1,4 @@
1
- import { useEffect, useContext } from 'react'
1
+ import { useContext, useRef } from 'react'
2
2
  import cloneConfig from '@cdc/core/helpers/cloneConfig'
3
3
  import { DashboardContext, DashboardDispatchContext } from '../../DashboardContext'
4
4
 
@@ -56,21 +56,22 @@ const Header = (props: HeaderProps) => {
56
56
  return strippedState
57
57
  }
58
58
 
59
- useEffect(() => {
60
- const parsedData = convertStateToConfig()
59
+ const configStringRef = useRef<string>()
61
60
 
62
- // Emit the data in a regular JS event so it can be consumed by anything.
63
- const event = new CustomEvent('updateVizConfig', { detail: JSON.stringify(parsedData) })
61
+ // Only update parent when config content actually changes (not just reference)
62
+ const configString = JSON.stringify(convertStateToConfig())
63
+ if (configStringRef.current !== configString) {
64
+ configStringRef.current = configString
64
65
 
66
+ // Emit the data in a regular JS event so it can be consumed by anything.
67
+ const event = new CustomEvent('updateVizConfig', { detail: configString })
65
68
  window.dispatchEvent(event)
66
69
 
67
70
  // Pass up to Editor if needed
68
71
  if (setParentConfig) {
69
- setParentConfig(parsedData)
72
+ setParentConfig(JSON.parse(configString))
70
73
  }
71
-
72
- // eslint-disable-next-line react-hooks/exhaustive-deps
73
- }, [config])
74
+ }
74
75
 
75
76
  const handleCheck = e => {
76
77
  const { checked } = e.currentTarget
@@ -1,48 +1,48 @@
1
- import { ConfigRow } from '../../types/ConfigRow'
2
- import { AnyVisualization } from '@cdc/core/types/Visualization'
3
- import { getIcon } from '../../helpers/iconHash'
4
- import { labelHash } from '@cdc/core/helpers/labelHash'
5
- import './toggle-style.css'
6
- import _ from 'lodash'
7
-
8
- type ToggleProps = {
9
- active: number
10
- row: ConfigRow
11
- visualizations: Record<string, AnyVisualization>
12
- setToggled: (colIndex: number) => void
13
- }
14
- const Toggle: React.FC<ToggleProps> = ({ active, row, visualizations, setToggled, text }) => {
15
- const selectItem = (colIndex, e = null) => {
16
- if (e?.key && e.key !== 'Enter' && e.key !== ' ') return
17
- if (e?.key === ' ') e.preventDefault() // Prevent page scroll
18
- setToggled(colIndex)
19
- }
20
-
21
- return (
22
- <div className='toggle-component' role='radiogroup' aria-label='Visualization options'>
23
- {row.columns.map((col, colIndex) => {
24
- if (!col.widget) return null
25
- const type = visualizations[col.widget].type
26
- // Get the column toggele Text or default to the type
27
- const text = col.toggleName ? col.toggleName : labelHash[type]
28
- const selected = colIndex === active
29
- return (
30
- <div
31
- role='radio'
32
- className={selected ? 'selected' : ''}
33
- key={colIndex}
34
- onClick={() => selectItem(colIndex)}
35
- onKeyUp={e => selectItem(colIndex, e)}
36
- aria-checked={selected}
37
- tabIndex={0}
38
- aria-label={`Toggle ${type}`}
39
- >
40
- <span aria-hidden='true'>{getIcon(visualizations[col.widget])}</span> <span>{text}</span>
41
- </div>
42
- )
43
- })}
44
- </div>
45
- )
46
- }
47
-
48
- export default Toggle
1
+ import { ConfigRow } from '../../types/ConfigRow'
2
+ import { AnyVisualization } from '@cdc/core/types/Visualization'
3
+ import { getIcon } from '../../helpers/iconHash'
4
+ import { labelHash } from '@cdc/core/helpers/labelHash'
5
+ import './toggle-style.css'
6
+ import _ from 'lodash'
7
+
8
+ type ToggleProps = {
9
+ active: number
10
+ row: ConfigRow
11
+ visualizations: Record<string, AnyVisualization>
12
+ setToggled: (colIndex: number) => void
13
+ }
14
+ const Toggle: React.FC<ToggleProps> = ({ active, row, visualizations, setToggled, text }) => {
15
+ const selectItem = (colIndex, e = null) => {
16
+ if (e?.key && e.key !== 'Enter' && e.key !== ' ') return
17
+ if (e?.key === ' ') e.preventDefault() // Prevent page scroll
18
+ setToggled(colIndex)
19
+ }
20
+
21
+ return (
22
+ <div className='toggle-component' role='radiogroup' aria-label='Visualization options'>
23
+ {row.columns.map((col, colIndex) => {
24
+ if (!col.widget) return null
25
+ const type = visualizations[col.widget].type
26
+ // Get the column toggele Text or default to the type
27
+ const text = col.toggleName ? col.toggleName : labelHash[type]
28
+ const selected = colIndex === active
29
+ return (
30
+ <div
31
+ role='radio'
32
+ className={selected ? 'selected' : ''}
33
+ key={colIndex}
34
+ onClick={() => selectItem(colIndex)}
35
+ onKeyUp={e => selectItem(colIndex, e)}
36
+ aria-checked={selected}
37
+ tabIndex={0}
38
+ aria-label={`Toggle ${text}`}
39
+ >
40
+ <span aria-hidden='true'>{getIcon(visualizations[col.widget])}</span> <span>{text}</span>
41
+ </div>
42
+ )
43
+ })}
44
+ </div>
45
+ )
46
+ }
47
+
48
+ export default Toggle
@@ -223,11 +223,12 @@ const VisualizationRow: React.FC<VizRowProps> = ({
223
223
  type === 'dashboardFilters' &&
224
224
  sharedFilterIndexes &&
225
225
  sharedFilterIndexes.filter(idx => config.dashboard.sharedFilters?.[idx]?.showDropdown === false).length ===
226
- sharedFilterIndexes.length
226
+ sharedFilterIndexes.length
227
227
  const hasMarginBottom = !isLastRow && !hiddenDashboardFilters
228
228
 
229
- const vizWrapperClass = `col-12 col-md-${col.width}${!shouldShow ? ' d-none' : ''}${hideVisualization ? ' hide-parent-visualization' : hasMarginBottom ? ' mb-4' : ''
230
- }`
229
+ const vizWrapperClass = `col-12 col-md-${col.width}${!shouldShow ? ' d-none' : ''}${
230
+ hideVisualization ? ' hide-parent-visualization' : hasMarginBottom ? ' mb-4' : ''
231
+ }`
231
232
  const link =
232
233
  config.table && config.table.show && config.datasets && table && table.showDataTableLink
233
234
  ? tableLink
@@ -2,6 +2,7 @@ import _ from 'lodash'
2
2
  import { getQueryStringFilterValue } from '@cdc/core/helpers/queryStringUtils'
3
3
  import { SharedFilter } from '../types/SharedFilter'
4
4
  import { handleSorting } from '@cdc/core/components/Filters'
5
+ import { mergeCustomOrderValues } from '@cdc/core/helpers/mergeCustomOrderValues'
5
6
 
6
7
  // Gets filter values from dataset
7
8
  const generateValuesForFilter = (columnName: string, data: Record<string, any[]>) => {
@@ -34,13 +35,16 @@ export const addValuesToDashboardFilters = (
34
35
  data: Record<string, any[]>,
35
36
  filtersToSkip: number[] = []
36
37
  ): Array<SharedFilter> => {
37
- return filters?.map((filter, index) => {
38
+ const result = filters?.map((filter, index) => {
38
39
  if (filtersToSkip.includes(index)) return filter
39
40
  if (filter.type === 'urlfilter') return filter
40
41
  const filterCopy = _.cloneDeep(filter)
41
42
  const filterValues = generateValuesForFilter(getSelector(filter), data)
42
43
  filterCopy.values = filterValues
43
44
 
45
+ // Merge new values with existing custom order (fixes DEV-11740 & DEV-11376)
46
+ filterCopy.orderedValues = mergeCustomOrderValues(filterValues, filterCopy.orderedValues, filterCopy.order)
47
+
44
48
  if (filterValues.length > 0) {
45
49
  const queryStringFilterValue = getQueryStringFilterValue(filterCopy)
46
50
  if (queryStringFilterValue) {
@@ -50,9 +54,29 @@ export const addValuesToDashboardFilters = (
50
54
  const active: string[] = Array.isArray(filterCopy.active) ? filterCopy.active : [filterCopy.active]
51
55
  filterCopy.active = active.filter(val => defaultValues.includes(val))
52
56
  } else {
53
- const hasResetLabel = filters.find(filter => filter.resetLabel)
54
- const defaultValue = hasResetLabel ? hasResetLabel.resetLabel : filterCopy.active || filterCopy.values[0]
55
- filterCopy.active = filterCopy.defaultValue || defaultValue
57
+ // Preserve existing active value if it's valid in the new filter values
58
+ const currentActive = filterCopy.active as string
59
+ const isResetLabelValue = currentActive && currentActive === filterCopy.resetLabel
60
+ const isCurrentActiveValid = currentActive && (filterValues.includes(currentActive) || isResetLabelValue)
61
+
62
+ // Check if this is an intentional clear (empty string, but not undefined during initial load)
63
+ const isIntentionalClear = currentActive === ''
64
+
65
+ // Priority: defaultValue > valid current active > reset label > first value
66
+ if (filterCopy.defaultValue) {
67
+ // If defaultValue is explicitly set, always use it
68
+ filterCopy.active = filterCopy.defaultValue
69
+ } else if (isCurrentActiveValid) {
70
+ // Keep the current active value if valid
71
+ filterCopy.active = currentActive
72
+ } else if (isIntentionalClear) {
73
+ // Don't override intentional clears
74
+ filterCopy.active = currentActive
75
+ } else {
76
+ // Set to reset label or first value
77
+ const defaultValue = filterCopy.resetLabel || filterCopy.values[0]
78
+ filterCopy.active = defaultValue
79
+ }
56
80
  }
57
81
  }
58
82
 
@@ -83,4 +107,5 @@ export const addValuesToDashboardFilters = (
83
107
 
84
108
  return handleSorting(filterCopy)
85
109
  })
110
+ return result
86
111
  }
@@ -53,7 +53,31 @@ export const getParentParams = (
53
53
  })
54
54
  }
55
55
 
56
- export const notAllParentsSelected = parentParams => parentParams?.some(({ value }) => value === '')
56
+ /**
57
+ * Checks if any parent filters are unselected or at their reset state.
58
+ * Returns true if at least one parent is not properly selected.
59
+ */
60
+ export const hasUnselectedParents = (parentParams, sharedFilters?: SharedFilter[]): boolean => {
61
+ if (!parentParams) return false
62
+
63
+ return parentParams.some(({ key, value }) => {
64
+ // Check if value is empty
65
+ if (value === '') return true
66
+
67
+ // Check if value equals the parent filter's resetLabel
68
+ if (sharedFilters) {
69
+ const parentFilter = sharedFilters.find(f => f.queryParameter === key || f.apiFilter?.valueSelector === key)
70
+ if (parentFilter?.resetLabel && value === parentFilter.resetLabel) {
71
+ return true
72
+ }
73
+ }
74
+
75
+ return false
76
+ })
77
+ }
78
+
79
+ // Keep old name for backward compatibility
80
+ export const notAllParentsSelected = hasUnselectedParents
57
81
 
58
82
  export const getFilterValues = (data: Array<Object>, apiFilter: APIFilter): DropdownOptions => {
59
83
  const { textSelector, valueSelector, subgroupTextSelector, subgroupValueSelector } = apiFilter
@@ -86,7 +110,7 @@ export const getToFetch = (
86
110
  if (apiFilterDropdowns[_key]) return // don't reload cached filter
87
111
  const parentParams = getParentParams(filter, sharedFilters)
88
112
 
89
- if (notAllParentsSelected(parentParams)) return // don't send request for dependent children filter options
113
+ if (notAllParentsSelected(parentParams, sharedFilters)) return // don't send request for dependent children filter options
90
114
 
91
115
  const endpoint = baseEndpoint + (parentParams ? gatherQueryParams(baseEndpoint, parentParams) : '')
92
116
  toFetch[endpoint] = [_key, index]
@@ -2,7 +2,11 @@ import _ from 'lodash'
2
2
  import { SharedFilter } from '../types/SharedFilter'
3
3
  import { FILTER_STYLE } from '../types/FilterStyles'
4
4
 
5
- const findFilterTier = (filters: SharedFilter[], sharedFilter: SharedFilter) => {
5
+ /**
6
+ * Recursively calculates the tier/depth of a filter based on its parent dependencies.
7
+ * Root filters (no parents) are tier 1, children of root filters are tier 2, etc.
8
+ */
9
+ const findFilterTier = (filters: SharedFilter[], sharedFilter: SharedFilter): number => {
6
10
  if (!sharedFilter.parents?.length) {
7
11
  return 1
8
12
  } else {
@@ -12,6 +16,10 @@ const findFilterTier = (filters: SharedFilter[], sharedFilter: SharedFilter) =>
12
16
  }
13
17
  }
14
18
 
19
+ /**
20
+ * Calculates and assigns tier values to all filters, returns the maximum tier.
21
+ * Mutates the filter objects by setting their tier property.
22
+ */
15
23
  function getMaxTierAndSetFilterTiers(filters: SharedFilter[]): number {
16
24
  let maxTier = 1
17
25
  filters.forEach(sharedFilter => {
@@ -23,7 +31,30 @@ function getMaxTierAndSetFilterTiers(filters: SharedFilter[]): number {
23
31
  return maxTier
24
32
  }
25
33
 
26
- function filter(data = [], filters: SharedFilter[], condition) {
34
+ /**
35
+ * Checks if a filter is currently at its reset/incomplete state.
36
+ * A filter is incomplete if it's visible AND:
37
+ * - The active value is empty/null/undefined, OR
38
+ * - The active value equals the resetLabel (if one is defined)
39
+ */
40
+ export const isFilterAtResetState = (filter: SharedFilter): boolean => {
41
+ // Only check filters that are visible to the user
42
+ if (!filter.showDropdown) return false
43
+
44
+ // Check if active value is empty/null/undefined
45
+ const isEmptyValue = filter.active === '' || filter.active === null || filter.active === undefined
46
+
47
+ // Check if active value equals the resetLabel
48
+ const equalsResetLabel = filter.resetLabel && filter.resetLabel === filter.active
49
+
50
+ return isEmptyValue || equalsResetLabel
51
+ }
52
+
53
+ /**
54
+ * Filters data by applying filters of a specific tier.
55
+ * Filters are applied hierarchically by tier to handle parent-child dependencies.
56
+ */
57
+ function filterDataByTier(data = [], filters: SharedFilter[], tier: number) {
27
58
  const activeFilters = _.filter(filters, f => (f.resetLabel === f.active ? f.values?.includes(f.resetLabel) : true))
28
59
  return data.filter(row => {
29
60
  const foundMatchingFilter = activeFilters.find(filter => {
@@ -51,8 +82,9 @@ function filter(data = [], filters: SharedFilter[], condition) {
51
82
  isNotTheSelectedValue = subGroupActive && selectedSubGroupValue !== subGroupActive
52
83
  }
53
84
 
54
- const isFirstOccurrenceOfTier = filter.tier === condition
55
- if (filter.type !== 'urlfilter' && isFirstOccurrenceOfTier && isNotTheSelectedValue) {
85
+ const isMatchingTier = filter.tier === tier
86
+ // Only apply client-side filtering for datafilter (urlfilters modify the API endpoint instead)
87
+ if (filter.type !== 'urlfilter' && isMatchingTier && isNotTheSelectedValue) {
56
88
  return true
57
89
  }
58
90
  })
@@ -60,17 +92,30 @@ function filter(data = [], filters: SharedFilter[], condition) {
60
92
  })
61
93
  }
62
94
 
95
+ /**
96
+ * Filters data based on shared filter configurations.
97
+ * Returns empty array if any filter is at its reset state (incomplete selection).
98
+ * Otherwise applies filters hierarchically by tier to handle parent-child dependencies.
99
+ */
63
100
  export const filterData = (filters: SharedFilter[], _data: Object[]): Object[] => {
64
101
  const maxTier = getMaxTierAndSetFilterTiers(filters)
65
102
 
103
+ // Check if any filters are currently at their reset state
104
+ const hasResetFilters = filters.some(isFilterAtResetState)
105
+
106
+ // If any filter is at reset state, return empty data to show "no data" message
107
+ if (hasResetFilters) {
108
+ return []
109
+ }
110
+
66
111
  for (let i = 0; i < maxTier; i++) {
67
112
  const lastIteration = i === maxTier - 1
68
113
 
69
- const filteredData = filter(_data, filters, i + 1)
114
+ const filteredData = filterDataByTier(_data, filters, i + 1)
70
115
 
71
116
  if (lastIteration) {
72
- // not sure if this last run of filter() function is necessary.
73
- return filter(filteredData, filters, maxTier - 1)
117
+ // not sure if this last run of filterDataByTier() function is necessary.
118
+ return filterDataByTier(filteredData, filters, maxTier - 1)
74
119
  }
75
120
  }
76
121
  }
@@ -0,0 +1,102 @@
1
+ import { SharedFilter } from '../types/SharedFilter'
2
+ import { APIFilterDropdowns } from '../components/DashboardFilters'
3
+ import { FILTER_STYLE } from '../types/FilterStyles'
4
+
5
+ /**
6
+ * Determines the reset value for a filter based on its configuration.
7
+ * When forceEmpty is true (like when clicking "Clear Filters"), always returns empty.
8
+ * Otherwise uses priority: defaultValue > empty string (for resetLabel) > first API option
9
+ * Note: resetLabel is for display purposes only. When present, we return empty string
10
+ * so the placeholder option renders correctly in the dropdown.
11
+ */
12
+ export const getFilterResetValue = (
13
+ filter: SharedFilter,
14
+ apiFilterDropdowns: APIFilterDropdowns,
15
+ forceEmpty: boolean = false
16
+ ): string | undefined => {
17
+ // When clearing filters, always reset to empty/resetLabel state
18
+ if (forceEmpty) {
19
+ // Return empty string to show reset label or placeholder, undefined falls back to first value
20
+ return ''
21
+ }
22
+
23
+ // If filter has a defaultValue, use that (for initial load)
24
+ if (filter.defaultValue) {
25
+ return filter.defaultValue
26
+ }
27
+ // If filter has a resetLabel, return empty string so placeholder renders
28
+ if (typeof filter.resetLabel === 'string') {
29
+ return ''
30
+ }
31
+ // Otherwise, use first available value if API filter
32
+ if (filter.apiFilter) {
33
+ const _key = filter.apiFilter.apiEndpoint
34
+ const options = apiFilterDropdowns[_key]
35
+ if (options && options.length > 0) {
36
+ return options[0].value
37
+ }
38
+ }
39
+ return undefined
40
+ }
41
+
42
+ /**
43
+ * Resets a filter's active and queuedActive values based on its filter style.
44
+ * Handles multi-select, nested-dropdown, and standard dropdown styles.
45
+ */
46
+ export const resetFilterToValue = (
47
+ filter: SharedFilter,
48
+ resetValue: string | undefined,
49
+ apiFilterDropdowns: APIFilterDropdowns
50
+ ): void => {
51
+ // Handle multi-select filters
52
+ if (filter.filterStyle === FILTER_STYLE.multiSelect) {
53
+ filter.active = resetValue ? [resetValue] : []
54
+ filter.queuedActive = undefined
55
+ } else if (filter.filterStyle === FILTER_STYLE.nestedDropdown) {
56
+ // For nested dropdowns, reset both group and subgroup
57
+ const _key = filter.apiFilter?.apiEndpoint
58
+ const options = apiFilterDropdowns[_key]
59
+
60
+ // When resetValue is explicitly empty/undefined, clear both group and subgroup
61
+ if (resetValue === '' || resetValue === undefined) {
62
+ filter.active = resetValue || ''
63
+ filter.subGrouping.active = ''
64
+ } else if (options && options.length > 0) {
65
+ // Use specific resetValue or fall back to first option
66
+ const selectedOption = options.find(opt => opt.value === resetValue) || options[0]
67
+ filter.active = selectedOption.value
68
+ if (selectedOption.subOptions && selectedOption.subOptions.length > 0) {
69
+ filter.subGrouping.active = selectedOption.subOptions[0].value
70
+ } else {
71
+ filter.subGrouping.active = ''
72
+ }
73
+ } else {
74
+ // No options available, use resetValue or empty
75
+ filter.active = resetValue || ''
76
+ filter.subGrouping.active = ''
77
+ }
78
+ filter.queuedActive = undefined
79
+ } else {
80
+ // Standard dropdown
81
+ filter.active = resetValue
82
+ filter.queuedActive = undefined
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Clears dropdown cache for child filters that have parent dependencies.
88
+ * Sets them to empty arrays so they appear disabled without loading state.
89
+ */
90
+ export const clearChildFilterDropdowns = (
91
+ sharedFilters: SharedFilter[],
92
+ apiFilterDropdowns: APIFilterDropdowns
93
+ ): APIFilterDropdowns => {
94
+ const updatedDropdowns = { ...apiFilterDropdowns }
95
+ sharedFilters.forEach(filter => {
96
+ if (filter.apiFilter && filter.parents && filter.parents.length > 0) {
97
+ // Set to empty array so they show as disabled without loading state
98
+ updatedDropdowns[filter.apiFilter.apiEndpoint] = []
99
+ }
100
+ })
101
+ return updatedDropdowns
102
+ }
@@ -8,7 +8,7 @@ import { AnyVisualization } from '@cdc/core/types/Visualization'
8
8
 
9
9
  const transform = new DataTransform()
10
10
 
11
- export const getFootnotesVizConfig = (
11
+ const getFootnotesVizConfig = (
12
12
  visualizationConfig: AnyVisualization,
13
13
  rowNumber: number,
14
14
  config: MultiDashboardConfig,
@@ -100,7 +100,7 @@ export const getVizConfig = (
100
100
  const dataKey = visualizationConfig.dataKey || 'backwards-compatibility'
101
101
  // Markup-includes need data even when shared filters exist (for markup variables)
102
102
  const shouldClearData = sharedFilterColumns.length && visualizationConfig.type !== 'markup-include'
103
- visualizationConfig.data = data[dataKey] || []
103
+ visualizationConfig.data = shouldClearData ? [] : data[dataKey] || []
104
104
  if (visualizationConfig.formattedData) {
105
105
  visualizationConfig.formattedData =
106
106
  transform.developerStandardize(visualizationConfig.data, visualizationConfig.dataDescription) ||