@cdc/dashboard 4.26.4 → 4.26.5

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 (91) hide show
  1. package/CONFIG.md +77 -30
  2. package/LICENSE +201 -0
  3. package/dist/cdcdashboard.js +49936 -49166
  4. package/examples/dashboard-conditions-filters-incomplete.json +221 -0
  5. package/examples/dashboard-missing-datasets-multi.json +174 -0
  6. package/examples/dashboard-missing-datasets-single.json +121 -0
  7. package/examples/dashboard-multi-dashboard-version-regression.json +146 -0
  8. package/examples/dashboard-shared-filter-row-delete-cleanup.json +186 -0
  9. package/examples/dashboard-stale-dataset-keys.json +181 -0
  10. package/examples/dashboard-tiered-filter-regression.json +190 -0
  11. package/examples/private/cfa-dashboard.json +651 -0
  12. package/examples/private/data-bite-wrap.json +6936 -0
  13. package/examples/private/multi-dash-fix.json +16963 -0
  14. package/examples/private/versions.json +41612 -0
  15. package/examples/us-map-filter-example.json +1074 -0
  16. package/package.json +9 -9
  17. package/src/CdcDashboard.tsx +6 -2
  18. package/src/CdcDashboardComponent.tsx +178 -87
  19. package/src/DashboardCopyPasteContext.test.tsx +33 -0
  20. package/src/DashboardCopyPasteContext.tsx +48 -0
  21. package/src/_stories/Dashboard.EditorRegression.stories.tsx +72 -0
  22. package/src/_stories/Dashboard.Regression.stories.tsx +196 -0
  23. package/src/_stories/Dashboard.Zoom.stories.tsx +88 -0
  24. package/src/_stories/Dashboard.stories.tsx +294 -0
  25. package/src/_stories/FilteredTextMigrationComparison.stories.tsx +87 -0
  26. package/src/components/Column.test.tsx +176 -0
  27. package/src/components/Column.tsx +214 -13
  28. package/src/components/DashboardConditionModal.test.tsx +420 -0
  29. package/src/components/DashboardConditionModal.tsx +367 -0
  30. package/src/components/DashboardConditionSummary.tsx +59 -0
  31. package/src/components/DashboardEditors.tsx +8 -0
  32. package/src/components/DashboardFilters/DashboardFilters.test.tsx +139 -1
  33. package/src/components/DashboardFilters/DashboardFilters.tsx +192 -174
  34. package/src/components/DashboardFilters/DashboardFiltersEditor/DashboardFiltersEditor.test.tsx +164 -0
  35. package/src/components/DashboardFilters/DashboardFiltersEditor/DashboardFiltersEditor.tsx +41 -2
  36. package/src/components/DashboardFilters/DashboardFiltersEditor/components/FilterEditor.test.tsx +180 -3
  37. package/src/components/DashboardFilters/DashboardFiltersEditor/components/FilterEditor.tsx +15 -32
  38. package/src/components/DashboardFilters/DashboardFiltersWrapper.test.tsx +142 -0
  39. package/src/components/DashboardFilters/DashboardFiltersWrapper.tsx +32 -27
  40. package/src/components/DashboardFilters/dashboardfilter.styles.css +42 -27
  41. package/src/components/DataDesignerModal.tsx +2 -1
  42. package/src/components/Grid.tsx +8 -4
  43. package/src/components/Header/Header.tsx +36 -17
  44. package/src/components/Row.test.tsx +228 -0
  45. package/src/components/Row.tsx +93 -18
  46. package/src/components/VisualizationRow.test.tsx +396 -0
  47. package/src/components/VisualizationRow.tsx +110 -35
  48. package/src/components/VisualizationsPanel/VisualizationsPanel.test.tsx +49 -0
  49. package/src/components/VisualizationsPanel/VisualizationsPanel.tsx +14 -13
  50. package/src/components/Widget/Widget.test.tsx +218 -0
  51. package/src/components/Widget/Widget.tsx +119 -17
  52. package/src/components/Widget/widget.styles.css +31 -18
  53. package/src/components/dashboard-condition-modal.css +76 -0
  54. package/src/components/dashboard-condition-summary.css +87 -0
  55. package/src/helpers/addValuesToDashboardFilters.ts +3 -5
  56. package/src/helpers/addVisualization.ts +15 -4
  57. package/src/helpers/cloneDashboardWidget.ts +127 -0
  58. package/src/helpers/dashboardColumnWidgets.ts +99 -0
  59. package/src/helpers/dashboardConditionUi.ts +47 -0
  60. package/src/helpers/dashboardConditions.ts +200 -0
  61. package/src/helpers/dashboardFilterTargets.ts +156 -0
  62. package/src/helpers/filterData.ts +4 -9
  63. package/src/helpers/filterVisibility.ts +20 -0
  64. package/src/helpers/formatConfigBeforeSave.ts +2 -2
  65. package/src/helpers/getFilteredData.ts +18 -5
  66. package/src/helpers/getUpdateConfig.ts +43 -12
  67. package/src/helpers/getVizRowColumnLocator.ts +11 -1
  68. package/src/helpers/iconHash.tsx +9 -3
  69. package/src/helpers/mapDataToConfig.ts +31 -29
  70. package/src/helpers/reloadURLHelpers.ts +25 -5
  71. package/src/helpers/removeDashboardFilter.ts +33 -33
  72. package/src/helpers/tests/addVisualization.test.ts +53 -9
  73. package/src/helpers/tests/cloneDashboardWidget.test.ts +136 -0
  74. package/src/helpers/tests/dashboardColumnWidgets.test.ts +99 -0
  75. package/src/helpers/tests/dashboardConditionUi.test.ts +41 -0
  76. package/src/helpers/tests/dashboardConditions.test.ts +428 -0
  77. package/src/helpers/tests/formatConfigBeforeSave.test.ts +51 -0
  78. package/src/helpers/tests/getFilteredData.test.ts +265 -86
  79. package/src/helpers/tests/getUpdateConfig.test.ts +338 -0
  80. package/src/helpers/tests/reloadURLHelpers.test.ts +394 -238
  81. package/src/index.tsx +6 -3
  82. package/src/scss/grid.scss +249 -20
  83. package/src/scss/main.scss +108 -29
  84. package/src/store/dashboard.actions.ts +17 -4
  85. package/src/store/dashboard.reducer.test.ts +538 -0
  86. package/src/store/dashboard.reducer.ts +135 -22
  87. package/src/test/CdcDashboard.test.tsx +148 -0
  88. package/src/test/CdcDashboardComponent.test.tsx +935 -2
  89. package/src/types/ConfigRow.ts +15 -0
  90. package/src/types/DashboardFilters.ts +4 -0
  91. package/src/types/SharedFilter.ts +1 -0
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "@cdc/dashboard",
3
- "version": "4.26.4",
3
+ "version": "4.26.5",
4
4
  "description": "React component for combining multiple visualizations into a single dashboard",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Matthew Pallansch <mpallansch@adittech.com>",
7
7
  "bugs": "https://github.com/CDCgov/cdc-open-viz/issues",
8
8
  "dependencies": {
9
- "@cdc/chart": "^4.26.4",
10
- "@cdc/core": "^4.26.4",
11
- "@cdc/data-bite": "^4.26.4",
12
- "@cdc/filtered-text": "^4.26.4",
13
- "@cdc/map": "^4.26.4",
14
- "@cdc/markup-include": "^4.26.4",
15
- "@cdc/waffle-chart": "^4.26.4",
9
+ "@cdc/chart": "^4.26.5",
10
+ "@cdc/core": "^4.26.5",
11
+ "@cdc/data-bite": "^4.26.5",
12
+ "@cdc/filtered-text": "^4.26.5",
13
+ "@cdc/map": "^4.26.5",
14
+ "@cdc/markup-include": "^4.26.5",
15
+ "@cdc/waffle-chart": "^4.26.5",
16
16
  "js-base64": "^2.5.2",
17
17
  "react-accessible-accordion": "^5.0.1",
18
18
  "react-dnd": "^16.0.1",
@@ -26,7 +26,7 @@
26
26
  "vite-plugin-css-injected-by-js": "^2.4.0",
27
27
  "vite-plugin-svgr": "^4.2.0"
28
28
  },
29
- "gitHead": "6097de1ff814001880d9ac64bd66becdc092d63c",
29
+ "gitHead": "61c025165d96b45a6002c34582c5a622a9d865a9",
30
30
  "main": "dist/cdcdashboard",
31
31
  "moduleName": "CdcDashboard",
32
32
  "peerDependencies": {
@@ -71,7 +71,8 @@ const MultiDashboardWrapper: React.FC<MultiDashboardProps> = ({
71
71
  return acc
72
72
  }, {})
73
73
  getVizKeys(newConfig).forEach(vizKey => {
74
- const formattedData = datasets[newConfig.visualizations[vizKey].dataKey]
74
+ const dataKey = newConfig.visualizations[vizKey].dataKey
75
+ const formattedData = dataKey ? datasets[dataKey] : undefined
75
76
  if (formattedData) {
76
77
  newConfig.visualizations[vizKey].formattedData = formattedData
77
78
  }
@@ -87,7 +88,7 @@ const MultiDashboardWrapper: React.FC<MultiDashboardProps> = ({
87
88
  const loadSingleDashboard = async config => {
88
89
  let newConfig = { ...defaults, ...config } as DashboardConfig
89
90
 
90
- if (config.datasets) {
91
+ if (config.datasets && Object.keys(config.datasets).length > 0) {
91
92
  return prepareDatasets(newConfig)
92
93
  } else {
93
94
  const dataKey = newConfig.dataFileName || 'backwards-compatibility'
@@ -141,6 +142,9 @@ const MultiDashboardWrapper: React.FC<MultiDashboardProps> = ({
141
142
  multiDashboards: multiConfig.multiDashboards,
142
143
  activeDashboard: selectedConfig
143
144
  } as MultiDashboardConfig
145
+ if (!newConfig.datasets || Object.keys(newConfig.datasets).length === 0) {
146
+ return { newConfig, datasets: {} }
147
+ }
144
148
  return prepareDatasets(newConfig)
145
149
  }
146
150
 
@@ -46,8 +46,10 @@ import EditorContext from '@cdc/core/contexts/EditorContext'
46
46
  import { APIFilterDropdowns } from './components/DashboardFilters'
47
47
  import { ViewPort } from '@cdc/core/types/ViewPort'
48
48
  import VisualizationRow from './components/VisualizationRow'
49
+ import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
49
50
  import { getVizConfig } from './helpers/getVizConfig'
50
51
  import { getFilteredData } from './helpers/getFilteredData'
52
+ import { dashboardRowsUseFiltersIncomplete } from './helpers/dashboardConditions'
51
53
  import { getVizRowColumnLocator } from './helpers/getVizRowColumnLocator'
52
54
  import { Responsive, VisualizationContainer } from '@cdc/core/components/Layout'
53
55
  import * as reloadURLHelpers from './helpers/reloadURLHelpers'
@@ -59,7 +61,9 @@ import Alert from '@cdc/core/components/Alert'
59
61
  import { shouldLoadAllFilters } from './helpers/shouldLoadAllFilters'
60
62
  import { subscribe, unsubscribe } from '@cdc/core/helpers/events'
61
63
  import DashboardEditors from './components/DashboardEditors'
64
+ import { DashboardCopyPasteProvider } from './DashboardCopyPasteContext'
62
65
  import { updateChildFilters } from './helpers/updateChildFilters'
66
+ import { getColumnWidgetEntries } from './helpers/dashboardColumnWidgets'
63
67
 
64
68
  type DashboardProps = Omit<WCMSProps, 'configUrl'> & {
65
69
  initialState: InitialState
@@ -80,8 +84,30 @@ export default function CdcDashboard({
80
84
  const [allExpanded, setAllExpanded] = useState(true)
81
85
  const [apiLoading, setAPILoading] = useState(false)
82
86
 
87
+ // Capture initial filter values at mount (before user interactions mutate filter.active)
88
+ const initialFilterValues = useMemo(() => {
89
+ const values: Record<string, string> = {}
90
+ for (const filter of initialState.config.dashboard?.sharedFilters || []) {
91
+ if (filter.setBy) {
92
+ const active = Array.isArray(filter.active) ? filter.active[0] : filter.active
93
+ values[filter.setBy] = filter.defaultValue || active || filter.values?.[0] || ''
94
+ }
95
+ }
96
+ return values
97
+ }, [])
98
+
83
99
  const isPreview = state.tabSelected === 'Dashboard Preview'
84
100
 
101
+ const hasFiltersIncompleteCondition = useMemo(
102
+ () => dashboardRowsUseFiltersIncomplete(state.config.rows || []),
103
+ [state.config.rows]
104
+ )
105
+
106
+ const hasIncompleteSharedFilters = useMemo(() => {
107
+ const sharedFilters = state.config.dashboard?.sharedFilters || []
108
+ return sharedFilters.some(isFilterAtResetState)
109
+ }, [state.config.dashboard?.sharedFilters])
110
+
85
111
  const inNoDataState = useMemo(() => {
86
112
  const hasApplyBehavior = hasDashboardApplyBehavior(state.config.visualizations)
87
113
 
@@ -89,18 +115,18 @@ export default function CdcDashboard({
89
115
  return true
90
116
  }
91
117
 
92
- // Check if any filters are at their reset state (incomplete)
93
- const sharedFilters = state.config.dashboard?.sharedFilters || []
94
- const hasResetFilters = sharedFilters.some(isFilterAtResetState)
95
- if (hasResetFilters) {
118
+ if (hasIncompleteSharedFilters) {
96
119
  return true
97
120
  }
98
121
 
99
- const vals = reloadURLHelpers.getDatasetKeys(state.config).map(key => state.data[key])
122
+ const vals = reloadURLHelpers
123
+ .getDatasetKeys(state.config, { includeDashboardConditionDatasetKeys: false })
124
+ .map(key => state.data[key])
100
125
 
101
126
  // Check if there are any visualizations that actually need data
102
127
  // Markup-includes without dataKey don't require dashboard data
103
128
  const visualizationsNeedingData = Object.values(state.config.visualizations).filter(viz => {
129
+ if (viz.type === 'dashboardFilters') return false
104
130
  return viz.type !== 'markup-include' || viz.dataKey
105
131
  })
106
132
 
@@ -109,7 +135,15 @@ export default function CdcDashboard({
109
135
 
110
136
  if (!vals.length) return true
111
137
  return vals.some(val => val === undefined)
112
- }, [state.data, state.config.visualizations, state.config.dashboard?.sharedFilters, state.filtersApplied])
138
+ }, [
139
+ state.data,
140
+ state.config.visualizations,
141
+ state.config.datasets,
142
+ state.config.rows,
143
+ state.filtersApplied,
144
+ hasIncompleteSharedFilters,
145
+ hasFiltersIncompleteCondition
146
+ ])
113
147
 
114
148
  const vizRowColumnLocator = getVizRowColumnLocator(state.config.rows)
115
149
 
@@ -134,13 +168,9 @@ export default function CdcDashboard({
134
168
  const filters = newFilters || config.dashboard.sharedFilters
135
169
  const datasetKeys = reloadURLHelpers.getDatasetKeys(config)
136
170
 
137
- const emptyData = {}
138
- const emptyFilteredData = {}
139
- dispatch({ type: 'SET_DATA', payload: emptyData })
140
- dispatch({ type: 'SET_FILTERED_DATA', payload: emptyFilteredData })
141
-
142
- const newData = {} // Start with empty object instead of cloning existing data
143
- const newDatasets = config.datasets
171
+ setAPILoading(true)
172
+ const newData = {}
173
+ const newDatasets = { ...config.datasets }
144
174
  let dataWasFetched = false
145
175
  let newFileName = ''
146
176
 
@@ -207,7 +237,6 @@ export default function CdcDashboard({
207
237
  newFileName
208
238
  )
209
239
 
210
- setAPILoading(true)
211
240
  await fetchRemoteData(dataUrlFinal)
212
241
  .then(({ data: fetchedData, dataMetadata }) => {
213
242
  let data: any[] = fetchedData
@@ -220,9 +249,7 @@ export default function CdcDashboard({
220
249
  console.error('Error standardizing data:', e)
221
250
  }
222
251
  }
223
- newDatasets[datasetKey].data = data
224
- newDatasets[datasetKey].dataMetadata = dataMetadata
225
- newDatasets[datasetKey].runtimeDataUrl = dataUrlFinal
252
+ newDatasets[datasetKey] = { ...newDatasets[datasetKey], data, dataMetadata, runtimeDataUrl: dataUrlFinal }
226
253
  newData[datasetKey] = data
227
254
  })
228
255
  .catch(e => {
@@ -231,8 +258,7 @@ export default function CdcDashboard({
231
258
  type: 'ADD_ERROR_MESSAGE',
232
259
  payload: 'There was a problem returning data. Please try again.'
233
260
  })
234
- newDatasets[datasetKey].data = []
235
- newDatasets[datasetKey].runtimeDataUrl = dataUrlFinal
261
+ newDatasets[datasetKey] = { ...newDatasets[datasetKey], data: [], runtimeDataUrl: dataUrlFinal }
236
262
  newData[datasetKey] = []
237
263
  })
238
264
  }
@@ -247,7 +273,10 @@ export default function CdcDashboard({
247
273
  return acc
248
274
  }, {})
249
275
  const _newData = { ...newData, ...dataFiles }
250
- dispatch({ type: 'SET_DATA', payload: _newData })
276
+ dispatch({
277
+ type: 'SET_DATA',
278
+ payload: { data: _newData, activeDashboard: config.activeDashboard }
279
+ })
251
280
  const dataFilterIndexes = config.dashboard.sharedFilters.reduce((acc, filter, index) => {
252
281
  if (filter.type === 'datafilter') acc.push(index)
253
282
  return acc
@@ -264,7 +293,10 @@ export default function CdcDashboard({
264
293
  {},
265
294
  _newData
266
295
  )
267
- dispatch({ type: 'SET_FILTERED_DATA', payload: filteredData })
296
+ dispatch({
297
+ type: 'SET_FILTERED_DATA',
298
+ payload: { filteredData, activeDashboard: config.activeDashboard }
299
+ })
268
300
  const visualizations = reloadURLHelpers.getVisualizationsWithFormattedData(
269
301
  config.visualizations as Record<string, Visualization>,
270
302
  newData
@@ -308,11 +340,46 @@ export default function CdcDashboard({
308
340
 
309
341
  const newFilteredData = getFilteredData({ ...state, config: newConfig }, filteredData)
310
342
 
311
- dispatch({ type: 'SET_FILTERED_DATA', payload: newFilteredData })
343
+ dispatch({ type: 'SET_FILTERED_DATA', payload: { filteredData: newFilteredData } })
312
344
  dispatch({ type: 'SET_CONFIG', payload: newConfig })
313
345
  dispatch({ type: 'SET_SHARED_FILTERS', payload: newConfig.dashboard.sharedFilters })
314
346
  }
315
347
 
348
+ // Get the initial/reset value for a filter (captured at mount to avoid reading mutated state)
349
+ const getFilterInitialValue = (filter: SharedFilter): string => {
350
+ const key = filter.setBy || ''
351
+ return initialFilterValues[key] ?? filter.defaultValue ?? filter.values?.[0] ?? ''
352
+ }
353
+
354
+ const clearSharedFilter = (key: string) => {
355
+ const { config: newConfig, filteredData } = cloneDeep(state)
356
+
357
+ for (let i = 0; i < newConfig.dashboard.sharedFilters.length; i++) {
358
+ const filter = newConfig.dashboard.sharedFilters[i]
359
+ if (filter.setBy === key) {
360
+ filter.active = getFilterInitialValue(filter)
361
+ break
362
+ }
363
+ }
364
+
365
+ const newFilteredData = getFilteredData({ ...state, config: newConfig }, filteredData)
366
+
367
+ dispatch({ type: 'SET_FILTERED_DATA', payload: { filteredData: newFilteredData } })
368
+ dispatch({ type: 'SET_CONFIG', payload: newConfig })
369
+ dispatch({ type: 'SET_SHARED_FILTERS', payload: newConfig.dashboard.sharedFilters })
370
+ }
371
+
372
+ const hasActiveSharedFilter = (key: string): boolean => {
373
+ const filter = state.config.dashboard?.sharedFilters?.find(f => f.setBy === key)
374
+ if (!filter) return false
375
+
376
+ // Get the initial/default value for this filter
377
+ const initialValue = getFilterInitialValue(filter)
378
+
379
+ // Filter is "user-active" only if active differs from the initial value
380
+ return filter.active !== undefined && filter.active !== '' && filter.active !== initialValue
381
+ }
382
+
316
383
  const setEventData = ({ detail }, data, filteredData) => {
317
384
  try {
318
385
  const newDatasets = Object.keys(detail).reduce((acc, key) => {
@@ -323,8 +390,8 @@ export default function CdcDashboard({
323
390
  }, {})
324
391
  const newConfig = { ...state, data: { ...data, ...newDatasets } }
325
392
  const newFilteredData = getFilteredData(newConfig, cloneDeep(filteredData))
326
- dispatch({ type: 'SET_FILTERED_DATA', payload: newFilteredData })
327
- dispatch({ type: 'SET_DATA', payload: { ...data, ...newDatasets } })
393
+ dispatch({ type: 'SET_FILTERED_DATA', payload: { filteredData: newFilteredData } })
394
+ dispatch({ type: 'SET_DATA', payload: { data: { ...data, ...newDatasets } } })
328
395
  } catch (e) {
329
396
  console.error('Error setting event data: ', e)
330
397
  }
@@ -343,24 +410,29 @@ export default function CdcDashboard({
343
410
  const loadAllFilters = shouldLoadAllFilters(config, isEditor && !isPreview)
344
411
  let sharedFiltersWithValues = addValuesToDashboardFilters(config.dashboard.sharedFilters, state.data)
345
412
  sharedFiltersWithValues = updateChildFilters(sharedFiltersWithValues, state.data)
346
- setAPILoading(true)
347
- loadAPIFilters(sharedFiltersWithValues, apiFilterDropdowns, loadAllFilters)?.then(newFilters => {
348
- const allValuesSelected = newFilters.every(filter => {
349
- return filter.type === 'datafilter' || filter.active
413
+ const filterPromise = loadAPIFilters(sharedFiltersWithValues, apiFilterDropdowns, loadAllFilters)
414
+ if (!filterPromise) {
415
+ setAPILoading(false)
416
+ } else {
417
+ setAPILoading(true)
418
+ filterPromise.then(newFilters => {
419
+ const allValuesSelected = newFilters.every(filter => {
420
+ return filter.type === 'datafilter' || filter.active
421
+ })
422
+ if (allValuesSelected) {
423
+ reloadURLData(newFilters)
424
+ } else {
425
+ setAPILoading(false)
426
+ }
350
427
  })
351
- if (allValuesSelected) {
352
- reloadURLData(newFilters)
353
- } else {
354
- setAPILoading(false)
355
- }
356
- })
428
+ }
357
429
  }, [isEditor, isPreview, state.config?.activeDashboard])
358
430
 
359
431
  useEffect(() => {
360
432
  return () => {
361
433
  // Clear all data when component unmounts to prevent memory leaks
362
- dispatch({ type: 'SET_DATA', payload: {} })
363
- dispatch({ type: 'SET_FILTERED_DATA', payload: {} })
434
+ dispatch({ type: 'SET_DATA', payload: { data: {} } })
435
+ dispatch({ type: 'SET_FILTERED_DATA', payload: { filteredData: {} } })
364
436
 
365
437
  // Clear any pending API requests
366
438
  setAPILoading(false)
@@ -470,6 +542,8 @@ export default function CdcDashboard({
470
542
  _updateConfig={_updateConfig}
471
543
  isDebug={isDebug}
472
544
  setSharedFilter={setSharedFilter}
545
+ clearSharedFilter={clearSharedFilter}
546
+ hasActiveSharedFilter={hasActiveSharedFilter}
473
547
  apiFilterDropdowns={apiFilterDropdowns}
474
548
  state={state}
475
549
  interactionLabel={interactionLabel}
@@ -481,24 +555,30 @@ export default function CdcDashboard({
481
555
 
482
556
  if (!subVisualizationEditing) {
483
557
  body = (
484
- <DndProvider backend={HTML5Backend}>
485
- {apiLoading && <Loader fullScreen={true} />}
486
- <div className='header-container'>
487
- <Header />
488
- <VisualizationsPanel />
489
- </div>
490
-
491
- <div className='layout-container'>
492
- <Grid />
493
- </div>
494
- </DndProvider>
558
+ <DashboardCopyPasteProvider>
559
+ <DndProvider backend={HTML5Backend}>
560
+ {apiLoading && <Loader fullScreen={true} />}
561
+ <div className='header-container'>
562
+ <Header />
563
+ <VisualizationsPanel />
564
+ </div>
565
+
566
+ <div className='layout-container'>
567
+ <Grid />
568
+ </div>
569
+ </DndProvider>
570
+ </DashboardCopyPasteProvider>
495
571
  )
496
572
  }
497
573
  } else {
498
574
  const { config } = state
499
575
  const { title, description } = config.dashboard || {}
576
+ const hasDashboardDownloadButton = config.table?.downloadImageButton || config.table?.downloadPdfButton
500
577
 
501
- const filteredRows = config.rows?.filter(row => row.columns.filter(col => col.widget).length !== 0)
578
+ const filteredRows =
579
+ config.rows
580
+ ?.map((row, index) => ({ row, index }))
581
+ .filter(({ row }) => row.columns.some(col => getColumnWidgetEntries(col).length > 0)) || []
502
582
 
503
583
  body = (
504
584
  <>
@@ -525,49 +605,60 @@ export default function CdcDashboard({
525
605
  {/* Description */}
526
606
  {description && <div className='subtext cove-prose mb-4'>{parse(description)}</div>}
527
607
  {/* Visualizations */}
528
- {filteredRows?.map((row, index) => (
529
- <VisualizationRow
530
- key={`row__${index}`}
531
- allExpanded={allExpanded}
532
- groupName={''}
533
- row={row}
534
- rowIndex={index}
535
- setSharedFilter={setSharedFilter}
536
- setAllExpanded={setAllExpanded}
537
- updateChildConfig={updateChildConfig}
538
- apiFilterDropdowns={apiFilterDropdowns}
539
- currentViewport={currentViewport}
540
- inNoDataState={inNoDataState}
541
- interactionLabel={interactionLabel}
542
- isLastRow={index === filteredRows.length - 1}
543
- />
608
+ {filteredRows.map(({ row, index }, renderIndex) => (
609
+ <ErrorBoundary key={`row__${index}`} component={`VisualizationRow-${index}`}>
610
+ <VisualizationRow
611
+ allExpanded={allExpanded}
612
+ groupName={''}
613
+ row={row}
614
+ rowIndex={index}
615
+ setSharedFilter={setSharedFilter}
616
+ clearSharedFilter={clearSharedFilter}
617
+ hasActiveSharedFilter={hasActiveSharedFilter}
618
+ setAllExpanded={setAllExpanded}
619
+ updateChildConfig={updateChildConfig}
620
+ apiFilterDropdowns={apiFilterDropdowns}
621
+ currentViewport={currentViewport}
622
+ inNoDataState={inNoDataState}
623
+ interactionLabel={interactionLabel}
624
+ isLastRow={renderIndex === filteredRows.length - 1}
625
+ />
626
+ </ErrorBoundary>
544
627
  ))}
545
628
 
546
- {inNoDataState ? <div className='mt-5'>Please complete your selection to continue.</div> : <></>}
629
+ {inNoDataState && !(hasIncompleteSharedFilters && hasFiltersIncompleteCondition) ? (
630
+ <div className='mt-5'>Please complete your selection to continue.</div>
631
+ ) : (
632
+ <></>
633
+ )}
547
634
 
548
635
  {/* Image or PDF Inserts */}
549
- <section className='download-buttons'>
550
- {config.table?.downloadImageButton && (
551
- <MediaControls.Button
552
- title='Download Dashboard as Image'
553
- type='image'
554
- state={config}
555
- text='Download Dashboard Image'
556
- elementToCapture={imageId}
557
- interactionLabel={interactionLabel}
558
- />
559
- )}
560
- {config.table?.downloadPdfButton && (
561
- <MediaControls.Button
562
- title='Download Dashboard as PDF'
563
- type='pdf'
564
- state={config}
565
- text='Download Dashboard PDF'
566
- elementToCapture={imageId}
567
- interactionLabel={interactionLabel}
568
- />
569
- )}
570
- </section>
636
+ {hasDashboardDownloadButton && (
637
+ <section className='download-buttons'>
638
+ {config.table?.downloadImageButton && (
639
+ <MediaControls.Button
640
+ title='Download Dashboard as Image'
641
+ type='image'
642
+ state={config}
643
+ text='Download Dashboard Image'
644
+ elementToCapture={imageId}
645
+ interactionLabel={interactionLabel}
646
+ appearance={config.table?.downloadImageButtonStyle === 'link' ? 'link' : 'button'}
647
+ />
648
+ )}
649
+ {config.table?.downloadPdfButton && (
650
+ <MediaControls.Button
651
+ title='Download Dashboard as PDF'
652
+ type='pdf'
653
+ state={config}
654
+ text='Download Dashboard PDF'
655
+ elementToCapture={imageId}
656
+ interactionLabel={interactionLabel}
657
+ appearance={config.table?.downloadImageButtonStyle === 'link' ? 'link' : 'button'}
658
+ />
659
+ )}
660
+ </section>
661
+ )}
571
662
 
572
663
  {/* Data Table */}
573
664
  {config.table?.show && config.data && (
@@ -0,0 +1,33 @@
1
+ import React, { useContext } from 'react'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { DashboardCopyPasteContext, DashboardCopyPasteProvider } from './DashboardCopyPasteContext'
5
+
6
+ const CopyPasteHarness = () => {
7
+ const { copiedWidget, copyWidget } = useContext(DashboardCopyPasteContext)
8
+
9
+ return (
10
+ <>
11
+ <button onClick={() => copyWidget({ sourceWidgetKey: 'source-widget', label: 'Source Component' })}>
12
+ Copy Test Component
13
+ </button>
14
+ {copiedWidget && <span>{copiedWidget.label}</span>}
15
+ </>
16
+ )
17
+ }
18
+
19
+ describe('DashboardCopyPasteProvider', () => {
20
+ it('clears copy mode with Escape', () => {
21
+ render(
22
+ <DashboardCopyPasteProvider>
23
+ <CopyPasteHarness />
24
+ </DashboardCopyPasteProvider>
25
+ )
26
+
27
+ fireEvent.click(screen.getByText('Copy Test Component'))
28
+ expect(screen.getByText('Source Component')).toBeInTheDocument()
29
+
30
+ fireEvent.keyDown(window, { key: 'Escape' })
31
+ expect(screen.queryByText('Source Component')).not.toBeInTheDocument()
32
+ })
33
+ })
@@ -0,0 +1,48 @@
1
+ import React, { createContext, useCallback, useEffect, useMemo, useState } from 'react'
2
+
3
+ export type CopiedDashboardWidget = {
4
+ sourceWidgetKey: string
5
+ label: string
6
+ }
7
+
8
+ type DashboardCopyPasteContextValue = {
9
+ copiedWidget?: CopiedDashboardWidget
10
+ copyWidget: (widget: CopiedDashboardWidget) => void
11
+ clearCopiedWidget: () => void
12
+ }
13
+
14
+ const initialContext: DashboardCopyPasteContextValue = {
15
+ copyWidget: () => {},
16
+ clearCopiedWidget: () => {}
17
+ }
18
+
19
+ export const DashboardCopyPasteContext = createContext<DashboardCopyPasteContextValue>(initialContext)
20
+
21
+ export const DashboardCopyPasteProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
22
+ const [copiedWidget, setCopiedWidget] = useState<CopiedDashboardWidget | undefined>()
23
+ const clearCopiedWidget = useCallback(() => setCopiedWidget(undefined), [])
24
+
25
+ useEffect(() => {
26
+ if (!copiedWidget) return
27
+
28
+ const handleKeyDown = (event: KeyboardEvent) => {
29
+ if (event.key === 'Escape') {
30
+ clearCopiedWidget()
31
+ }
32
+ }
33
+
34
+ window.addEventListener('keydown', handleKeyDown)
35
+ return () => window.removeEventListener('keydown', handleKeyDown)
36
+ }, [clearCopiedWidget, copiedWidget])
37
+
38
+ const value = useMemo(
39
+ () => ({
40
+ copiedWidget,
41
+ copyWidget: setCopiedWidget,
42
+ clearCopiedWidget
43
+ }),
44
+ [clearCopiedWidget, copiedWidget]
45
+ )
46
+
47
+ return <DashboardCopyPasteContext.Provider value={value}>{children}</DashboardCopyPasteContext.Provider>
48
+ }
@@ -0,0 +1,72 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { expect, userEvent, within } from 'storybook/test'
3
+ import { performAndAssert, waitForPresence } from '@cdc/core/helpers/testing'
4
+ import CdcEditor from '@cdc/editor/src/CdcEditor'
5
+ import SharedFilterRowDeleteConfig from '../../examples/dashboard-shared-filter-row-delete-cleanup.json'
6
+
7
+ const meta: Meta<typeof CdcEditor> = {
8
+ title: 'Components/Pages/Dashboard/Regression Editor',
9
+ component: CdcEditor
10
+ }
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof CdcEditor>
14
+
15
+ export const Delete_Rows_With_Stale_Shared_Filter_Targets: Story = {
16
+ args: {
17
+ config: SharedFilterRowDeleteConfig
18
+ },
19
+ play: async ({ canvasElement }) => {
20
+ const canvas = within(canvasElement)
21
+ const user = userEvent.setup()
22
+
23
+ await waitForPresence('.builder-row', canvasElement)
24
+
25
+ const getState = () => ({
26
+ rowCount: canvasElement.querySelectorAll('.builder-row').length,
27
+ deleteButtonCount: canvasElement.querySelectorAll('[title="Delete Row"]').length,
28
+ hasFiltersRow: (canvasElement.textContent || '').includes('dashboard-filters'),
29
+ hasRowA: (canvasElement.textContent || '').includes('Row A'),
30
+ hasRowB: (canvasElement.textContent || '').includes('Row B'),
31
+ hasRowC: (canvasElement.textContent || '').includes('Row C'),
32
+ noDataVisible: (canvasElement.textContent || '').includes('No Data Available'),
33
+ crashTextVisible: (canvasElement.textContent || '').includes('Cannot read properties of undefined')
34
+ })
35
+
36
+ const initialState = getState()
37
+ expect(initialState.rowCount).toBe(4)
38
+ expect(initialState.deleteButtonCount).toBe(4)
39
+ expect(initialState.hasRowA).toBe(true)
40
+ expect(initialState.hasRowB).toBe(true)
41
+ expect(initialState.hasRowC).toBe(true)
42
+ expect(initialState.noDataVisible).toBe(false)
43
+ expect(initialState.crashTextVisible).toBe(false)
44
+
45
+ await performAndAssert(
46
+ 'Delete one shared-filter row without crashing',
47
+ getState,
48
+ async () => await user.click(canvas.getAllByTitle('Delete Row')[1]),
49
+ (_before, after) =>
50
+ after.rowCount === 3 &&
51
+ after.deleteButtonCount === 3 &&
52
+ !after.hasRowA &&
53
+ after.hasRowB &&
54
+ after.hasRowC &&
55
+ !after.noDataVisible &&
56
+ !after.crashTextVisible
57
+ )
58
+
59
+ await performAndAssert(
60
+ 'Delete another shared-filter row after indices shift',
61
+ getState,
62
+ async () => await user.click(canvas.getAllByTitle('Delete Row')[2]),
63
+ (_before, after) =>
64
+ after.rowCount === 2 &&
65
+ after.deleteButtonCount === 2 &&
66
+ after.hasRowB &&
67
+ !after.hasRowC &&
68
+ !after.noDataVisible &&
69
+ !after.crashTextVisible
70
+ )
71
+ }
72
+ }