@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
@@ -1,9 +1,19 @@
1
1
  import { ConfigRow } from '../types/ConfigRow'
2
+ import { getConditionalWidgets, hasConditionalWidgets } from './dashboardColumnWidgets'
2
3
 
3
4
  // returns a dictionary of widget names and their corresponding row and column index
4
- export const getVizRowColumnLocator = (rows: ConfigRow[]): Record<string, { row: number; column: number }> =>
5
+ export const getVizRowColumnLocator = (
6
+ rows: ConfigRow[]
7
+ ): Record<string, { row: number; column: number; entry?: number }> =>
5
8
  rows.reduce((acc, curr, index) => {
6
9
  curr.columns?.forEach((column, columnIndex) => {
10
+ if (hasConditionalWidgets(column)) {
11
+ getConditionalWidgets(column).forEach((entry, entryIndex) => {
12
+ acc[entry.widget] = { row: index, column: columnIndex, entry: entryIndex }
13
+ })
14
+ return
15
+ }
16
+
7
17
  if (column.widget !== undefined) acc[column.widget] = { row: index, column: columnIndex }
8
18
  })
9
19
  return acc
@@ -1,13 +1,18 @@
1
+ import React from 'react'
1
2
  import Icon from '@cdc/core/components/ui/Icon'
2
3
  import { AnyVisualization } from '@cdc/core/types/Visualization'
3
4
 
4
- export const iconHash = {
5
+ const waffleAliases = ['TP5 Waffle', 'Waffle', 'TP5 Gauge', 'Gauge']
6
+ const waffleIcon = <Icon display='grid' base />
7
+
8
+ export const iconHash: Record<string, React.ReactNode> = {
5
9
  'data-bite': <Icon display='databite' base />,
6
10
  Bar: <Icon display='chartBar' base />,
7
11
  'Spark Line': <Icon display='chartLine' />,
8
12
  'Bump Chart': <Icon display='chartLine' />,
9
- 'waffle-chart': <Icon display='grid' base />,
13
+ 'waffle-chart': waffleIcon,
10
14
  'markup-include': <Icon display='code' base />,
15
+ condition: <Icon display='condition' base />,
11
16
  Line: <Icon display='chartLine' base />,
12
17
  Pie: <Icon display='chartPie' base />,
13
18
  us: <Icon display='mapUsa' base />,
@@ -29,7 +34,8 @@ export const iconHash = {
29
34
  'Box Plot': <Icon display='chartBar' base />,
30
35
  'Forest Plot': <Icon display='chartBar' base />,
31
36
  Forecasting: <Icon display='chartLine' base />,
32
- 'Warming Stripes': <Icon display='chartBar' base />
37
+ 'Warming Stripes': <Icon display='chartBar' base />,
38
+ ...Object.fromEntries(waffleAliases.map(alias => [alias, waffleIcon]))
33
39
  }
34
40
 
35
41
  export const getIcon = (visualization: AnyVisualization) => {
@@ -1,29 +1,31 @@
1
- import { getFormattedData } from './getFormattedData'
2
- import { DashboardConfig } from '../types/DashboardConfig'
3
-
4
- const mapDataToVisualizations = (config: DashboardConfig) => {
5
- Object.keys(config.visualizations).forEach((vizKey, i) => {
6
- const viz = config.visualizations[vizKey]
7
- if (viz.dataKey && !viz.data) {
8
- const data = config.datasets[viz.dataKey].data
9
- config.visualizations[vizKey].data = data
10
- config.visualizations[vizKey].formattedData = getFormattedData(data, viz.dataDescription)
11
- }
12
- })
13
- }
14
-
15
- const mapDataToRows = (config: DashboardConfig) => {
16
- config.rows.forEach((row, i) => {
17
- if (row.dataKey && !row.data) {
18
- const data = config.datasets[row.dataKey].data
19
- config.rows[i].data = data
20
- config.rows[i].formattedData = getFormattedData(data, row.dataDescription)
21
- }
22
- })
23
- }
24
-
25
- export const mapDataToConfig = (config: DashboardConfig) => {
26
- mapDataToVisualizations(config)
27
- mapDataToRows(config)
28
- return config
29
- }
1
+ import { getFormattedData } from './getFormattedData'
2
+ import { DashboardConfig } from '../types/DashboardConfig'
3
+
4
+ const mapDataToVisualizations = (config: DashboardConfig) => {
5
+ Object.keys(config.visualizations).forEach(vizKey => {
6
+ const viz = config.visualizations[vizKey]
7
+ if (viz.dataKey && !viz.data) {
8
+ const dataset = config.datasets[viz.dataKey]
9
+ if (!dataset) return
10
+ config.visualizations[vizKey].data = dataset.data
11
+ config.visualizations[vizKey].formattedData = getFormattedData(dataset.data, viz.dataDescription)
12
+ }
13
+ })
14
+ }
15
+
16
+ const mapDataToRows = (config: DashboardConfig) => {
17
+ config.rows.forEach((row, i) => {
18
+ if (row.dataKey && !row.data) {
19
+ const dataset = config.datasets[row.dataKey]
20
+ if (!dataset) return
21
+ config.rows[i].data = dataset.data
22
+ config.rows[i].formattedData = getFormattedData(dataset.data, row.dataDescription)
23
+ }
24
+ })
25
+ }
26
+
27
+ export const mapDataToConfig = (config: DashboardConfig) => {
28
+ mapDataToVisualizations(config)
29
+ mapDataToRows(config)
30
+ return config
31
+ }
@@ -6,6 +6,8 @@ import _ from 'lodash'
6
6
  import { DashboardConfig } from '../types/DashboardConfig'
7
7
  import { ConfigRow } from '../types/ConfigRow'
8
8
  import { getVizRowColumnLocator } from './getVizRowColumnLocator'
9
+ import { getDashboardConditionDatasetKeys } from './dashboardConditions'
10
+ import { getDashboardConditionTargets } from './dashboardFilterTargets'
9
11
 
10
12
  export const isUpdateNeeded = (
11
13
  filters: SharedFilter[],
@@ -28,14 +30,26 @@ export const isUpdateNeeded = (
28
30
  }
29
31
 
30
32
  type GetDatasetKeysParams = Pick<DashboardConfig, 'visualizations' | 'datasets' | 'rows'>
31
- export const getDatasetKeys = ({ visualizations, datasets, rows }: GetDatasetKeysParams): string[] => {
33
+ type GetDatasetKeysOptions = {
34
+ includeDashboardConditionDatasetKeys?: boolean
35
+ }
36
+ export const getDatasetKeys = (
37
+ { visualizations, datasets, rows }: GetDatasetKeysParams,
38
+ { includeDashboardConditionDatasetKeys = true }: GetDatasetKeysOptions = {}
39
+ ): string[] => {
32
40
  const vizDataKeys = Object.values(visualizations).map(viz => viz.dataKey)
33
41
  const rowDataKeys = rows.map(row => row.dataKey)
42
+ const dashboardConditionDataKeys = includeDashboardConditionDatasetKeys ? getDashboardConditionDatasetKeys(rows) : []
34
43
  const footnoteDataKeys = Object.values(visualizations)
35
44
  .map(viz => viz.footnotes?.dataKey)
36
45
  .filter(Boolean)
37
46
  // ensure to only load datasets for the specific dashboard tab.
38
- const datasetsUsedByDashboard = _.uniq([...vizDataKeys, ...rowDataKeys, ...footnoteDataKeys])
47
+ const datasetsUsedByDashboard = _.uniq([
48
+ ...vizDataKeys,
49
+ ...rowDataKeys,
50
+ ...dashboardConditionDataKeys,
51
+ ...footnoteDataKeys
52
+ ])
39
53
  return Object.keys(datasets).filter(datasetKey => datasetsUsedByDashboard.includes(datasetKey))
40
54
  }
41
55
 
@@ -113,12 +127,18 @@ export const filterUsedByDataUrl = (
113
127
  rows: ConfigRow[]
114
128
  ) => {
115
129
  if (!filter.usedBy || !filter.usedBy.length) return true
116
- const vizUsingFilters = filter.usedBy?.map(vizOrRowKey => visualizations[vizOrRowKey] || rows[vizOrRowKey])
130
+ const dashboardConditionTargets = getDashboardConditionTargets(rows)
117
131
 
118
- return vizUsingFilters?.some(viz => {
132
+ return filter.usedBy.some(vizOrRowKey => {
133
+ const viz = visualizations[vizOrRowKey] || rows[vizOrRowKey]
119
134
  const usedByViz = viz?.dataKey === datasetKey
120
135
  // datasetKey might be a key to a dynamic footnotes URL
121
136
  const usedByVizFootnote = viz?.footnotes?.dataKey === datasetKey
122
- return usedByViz || usedByVizFootnote
137
+ const usedByDashboardCondition = dashboardConditionTargets.some(
138
+ conditionTarget =>
139
+ conditionTarget.dashboardCondition.datasetKey === datasetKey &&
140
+ `${conditionTarget.filterTarget}` === `${vizOrRowKey}`
141
+ )
142
+ return usedByViz || usedByVizFootnote || usedByDashboardCondition
123
143
  })
124
144
  }
@@ -1,33 +1,33 @@
1
- import { AnyVisualization } from '@cdc/core/types/Visualization'
2
- import _ from 'lodash'
3
- import { SharedFilter } from '../types/SharedFilter'
4
-
5
- type Viz = Record<string, AnyVisualization>
6
-
7
- export const removeDashboardFilter = (
8
- index,
9
- sharedFilters: SharedFilter[],
10
- visualizations: Viz
11
- ): [SharedFilter[], Viz] => {
12
- const newSharedFilters = _.cloneDeep(sharedFilters)
13
-
14
- newSharedFilters.splice(index, 1)
15
- const shiftDownIndexes = Object.keys(sharedFilters).slice(index + 1)
16
- const newVisualizations: Viz = _.cloneDeep(visualizations)
17
- Object.keys(newVisualizations).forEach(vizKey => {
18
- const viz = newVisualizations[vizKey]
19
- if (viz.type === 'dashboardFilters') {
20
- // shift the indexes down
21
- const sharedFilterIndexes = viz.sharedFilterIndexes
22
- .filter(filterIndex => filterIndex != index)
23
- .map(filterIndex => {
24
- if (shiftDownIndexes.includes(filterIndex.toString())) {
25
- return filterIndex - 1
26
- }
27
- return filterIndex
28
- })
29
- newVisualizations[vizKey].sharedFilterIndexes = sharedFilterIndexes
30
- }
31
- })
32
- return [newSharedFilters, newVisualizations]
33
- }
1
+ import { AnyVisualization } from '@cdc/core/types/Visualization'
2
+ import _ from 'lodash'
3
+ import { SharedFilter } from '../types/SharedFilter'
4
+
5
+ type Viz = Record<string, AnyVisualization>
6
+
7
+ export const removeDashboardFilter = (
8
+ index,
9
+ sharedFilters: SharedFilter[],
10
+ visualizations: Viz
11
+ ): [SharedFilter[], Viz] => {
12
+ const newSharedFilters = _.cloneDeep(sharedFilters)
13
+
14
+ newSharedFilters.splice(index, 1)
15
+ const shiftDownIndexes = Object.keys(sharedFilters).slice(index + 1)
16
+ const newVisualizations: Viz = _.cloneDeep(visualizations)
17
+ Object.keys(newVisualizations).forEach(vizKey => {
18
+ const viz = newVisualizations[vizKey]
19
+ if (viz.type === 'dashboardFilters') {
20
+ // shift the indexes down
21
+ const sharedFilterIndexes = (viz.sharedFilterIndexes ?? [])
22
+ .filter(filterIndex => filterIndex != index)
23
+ .map(filterIndex => {
24
+ if (shiftDownIndexes.includes(filterIndex.toString())) {
25
+ return filterIndex - 1
26
+ }
27
+ return filterIndex
28
+ })
29
+ newVisualizations[vizKey].sharedFilterIndexes = sharedFilterIndexes
30
+ }
31
+ })
32
+ return [newSharedFilters, newVisualizations]
33
+ }
@@ -1,14 +1,18 @@
1
- import { describe, expect, it, vi } from 'vitest'
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
2
  import { addVisualization } from '../addVisualization'
3
3
 
4
4
  describe('addVisualization', () => {
5
+ afterEach(() => {
6
+ vi.restoreAllMocks()
7
+ })
8
+
5
9
  it('creates chart visual settings with extra theme toggles disabled by default', () => {
6
- vi.spyOn(Date, 'now').mockReturnValue(12345)
10
+ vi.spyOn(Math, 'random').mockReturnValue(0.123456789)
7
11
 
8
12
  const visualization = addVisualization('chart', 'Bar')
9
13
 
10
14
  expect(visualization).toMatchObject({
11
- uid: 'chart12345',
15
+ uid: 'chart-4fzzzxjy',
12
16
  type: 'chart',
13
17
  visualizationType: 'Bar',
14
18
  visual: {
@@ -22,12 +26,12 @@ describe('addVisualization', () => {
22
26
  })
23
27
 
24
28
  it('creates map visual settings with extra theme toggles disabled by default', () => {
25
- vi.spyOn(Date, 'now').mockReturnValue(12345)
29
+ vi.spyOn(Math, 'random').mockReturnValue(0.23456789)
26
30
 
27
31
  const visualization = addVisualization('map', 'single-state')
28
32
 
29
33
  expect(visualization).toMatchObject({
30
- uid: 'map12345',
34
+ uid: 'map-8fzzzbjm',
31
35
  type: 'map',
32
36
  general: {
33
37
  geoType: 'single-state'
@@ -42,11 +46,51 @@ describe('addVisualization', () => {
42
46
  })
43
47
  })
44
48
 
45
- it('preserves visualizationType for data-bite family visualizations', () => {
46
- vi.spyOn(Date, 'now').mockReturnValue(12345)
49
+ it('uses TP5 defaults for new dashboard data bites and waffle charts', () => {
50
+ vi.spyOn(Math, 'random').mockReturnValue(0.123456789)
51
+
52
+ expect(addVisualization('data-bite')).toMatchObject({ biteStyle: 'tp5', visualizationType: 'data-bite' })
53
+ expect(addVisualization('waffle-chart', 'Waffle')).toMatchObject({ visualizationType: 'TP5 Waffle' })
54
+ })
55
+
56
+ it('preserves other visualizationType defaults for related visualizations', () => {
57
+ vi.spyOn(Math, 'random').mockReturnValue(0.123456789)
58
+
59
+ expect(addVisualization('waffle-chart', 'Gauge')).toMatchObject({ visualizationType: 'Gauge' })
60
+ })
61
+
62
+ it('preserves visualizationType for current lightweight visualizations', () => {
63
+ vi.spyOn(Math, 'random').mockReturnValue(0.123456789)
47
64
 
48
65
  expect(addVisualization('data-bite')).toMatchObject({ visualizationType: 'data-bite' })
49
- expect(addVisualization('waffle-chart', 'Waffle')).toMatchObject({ visualizationType: 'Waffle' })
50
- expect(addVisualization('filtered-text')).toMatchObject({ visualizationType: 'filtered-text' })
66
+ expect(addVisualization('markup-include')).toMatchObject({ visualizationType: 'markup-include' })
67
+ })
68
+
69
+ it('throws when asked to create deprecated filtered-text visualizations', () => {
70
+ expect(() => addVisualization('filtered-text')).toThrow(
71
+ 'Cannot create new filtered-text visualizations. filtered-text is deprecated; use markup-include instead.'
72
+ )
73
+ })
74
+
75
+ it('creates dashboard filters with grey background disabled by default', () => {
76
+ vi.spyOn(Math, 'random').mockReturnValue(0.3456789)
77
+
78
+ expect(addVisualization('dashboardFilters', '')).toMatchObject({
79
+ uid: 'dashboardFilters-cfzzt7g4',
80
+ type: 'dashboardFilters',
81
+ sharedFilterIndexes: [],
82
+ visualizationType: 'dashboardFilters',
83
+ visual: {
84
+ grayBackground: false
85
+ }
86
+ })
87
+ })
88
+
89
+ it('avoids existing visualization ids when caller provides a uniqueness scope', () => {
90
+ vi.spyOn(Math, 'random').mockReturnValueOnce(0.123456789).mockReturnValueOnce(0.23456789)
91
+
92
+ const visualization = addVisualization('chart', 'Bar', { existingIds: ['chart-4fzzzxjy'] })
93
+
94
+ expect(visualization.uid).toBe('chart-8fzzzbjm')
51
95
  })
52
96
  })
@@ -0,0 +1,136 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { cloneDashboardWidget } from '../cloneDashboardWidget'
3
+
4
+ const makeConfig = () =>
5
+ ({
6
+ dashboard: {
7
+ sharedFilters: [
8
+ { key: 'scoped-to-source', usedBy: ['source-widget'], setBy: 'source-widget' },
9
+ { key: 'unknown-target', usedBy: ['legacy-footnote-target'] },
10
+ { key: 'unscoped' },
11
+ { key: 'empty-used-by', usedBy: [] },
12
+ { key: 'row-target', usedBy: [0] }
13
+ ]
14
+ },
15
+ rows: [
16
+ {
17
+ dashboardCondition: { id: 'row-condition', operator: 'hasData', datasetKey: 'row-condition-data' },
18
+ columns: [
19
+ {
20
+ width: 4,
21
+ conditionalWidgets: [
22
+ {
23
+ widget: 'source-widget',
24
+ dashboardCondition: {
25
+ id: 'source-condition',
26
+ operator: 'columnHasAnyValue',
27
+ datasetKey: 'condition-data',
28
+ columnName: 'state',
29
+ values: ['CA']
30
+ }
31
+ }
32
+ ]
33
+ },
34
+ { width: 4 },
35
+ {
36
+ width: 4,
37
+ conditionalWidgets: [
38
+ {
39
+ widget: 'existing-widget',
40
+ dashboardCondition: { id: 'existing-condition', operator: 'hasData', datasetKey: 'condition-data' }
41
+ }
42
+ ]
43
+ }
44
+ ],
45
+ expandCollapseAllButtons: false
46
+ }
47
+ ],
48
+ visualizations: {
49
+ 'source-widget': {
50
+ uid: 'source-widget',
51
+ type: 'markup-include',
52
+ visualizationType: 'markup-include',
53
+ contentEditor: { title: 'Source' }
54
+ },
55
+ 'existing-widget': {
56
+ uid: 'existing-widget',
57
+ type: 'markup-include',
58
+ visualizationType: 'markup-include',
59
+ contentEditor: { title: 'Existing' }
60
+ }
61
+ }
62
+ } as any)
63
+
64
+ describe('cloneDashboardWidget', () => {
65
+ afterEach(() => {
66
+ vi.restoreAllMocks()
67
+ })
68
+
69
+ it('clones a simple component into an empty simple column with a fresh key and uid', () => {
70
+ vi.spyOn(Math, 'random').mockReturnValue(0.123456789)
71
+ const config = makeConfig()
72
+ delete config.rows[0].columns[0].conditionalWidgets[0].dashboardCondition
73
+
74
+ const result = cloneDashboardWidget(config, 'source-widget', { rowIdx: 0, colIdx: 1 })
75
+ const clonedWidgetKey = result.rows[0].columns[1].widget
76
+
77
+ expect(clonedWidgetKey).toBeTruthy()
78
+ expect(clonedWidgetKey).toMatch(/^markup-include-[a-z0-9]{8}$/)
79
+ expect(clonedWidgetKey).not.toContain('copy')
80
+ expect(result.visualizations[clonedWidgetKey].uid).toBe(clonedWidgetKey)
81
+ expect(result.visualizations[clonedWidgetKey].contentEditor.title).toBe('Source')
82
+ expect(result.rows[0].columns[0].conditionalWidgets[0].widget).toBe('source-widget')
83
+ expect(config.rows[0].columns[1].widget).toBeUndefined()
84
+ })
85
+
86
+ it('clones a component into an empty conditional slot and copies its component condition with a fresh id', () => {
87
+ vi.spyOn(Math, 'random').mockReturnValueOnce(0.123456789).mockReturnValueOnce(0.23456789)
88
+ const config = makeConfig()
89
+
90
+ const result = cloneDashboardWidget(config, 'source-widget', { rowIdx: 0, colIdx: 2, entryIdx: 1 })
91
+ const clonedEntry = result.rows[0].columns[2].conditionalWidgets[1]
92
+
93
+ expect(clonedEntry.widget).toBeTruthy()
94
+ expect(clonedEntry.widget).toMatch(/^markup-include-[a-z0-9]{8}$/)
95
+ expect(clonedEntry.widget).not.toContain('copy')
96
+ expect(clonedEntry.dashboardCondition).toMatchObject({
97
+ operator: 'columnHasAnyValue',
98
+ datasetKey: 'condition-data',
99
+ columnName: 'state',
100
+ values: ['CA']
101
+ })
102
+ expect(clonedEntry.dashboardCondition.id).toMatch(/^condition-[a-z0-9]{8}$/)
103
+ expect(clonedEntry.dashboardCondition.id).not.toBe('source-condition')
104
+ expect(clonedEntry.dashboardCondition.id).not.toBe('row-condition')
105
+ })
106
+
107
+ it('avoids existing visualization keys when cloning', () => {
108
+ vi.spyOn(Math, 'random').mockReturnValueOnce(0.123456789).mockReturnValueOnce(0.23456789)
109
+ const config = makeConfig()
110
+ delete config.rows[0].columns[0].conditionalWidgets[0].dashboardCondition
111
+ config.visualizations['markup-include-4fzzzxjy'] = {
112
+ uid: 'markup-include-4fzzzxjy',
113
+ type: 'markup-include',
114
+ visualizationType: 'markup-include'
115
+ }
116
+
117
+ const result = cloneDashboardWidget(config, 'source-widget', { rowIdx: 0, colIdx: 1 })
118
+
119
+ expect(result.rows[0].columns[1].widget).toBe('markup-include-8fzzzbjm')
120
+ })
121
+
122
+ it('clones widget-scoped shared filter targets while leaving unknown and unscoped filters unchanged', () => {
123
+ vi.spyOn(Math, 'random').mockReturnValue(0.123456789)
124
+ const config = makeConfig()
125
+
126
+ const result = cloneDashboardWidget(config, 'source-widget', { rowIdx: 0, colIdx: 2, entryIdx: 1 })
127
+ const clonedEntry = result.rows[0].columns[2].conditionalWidgets[1]
128
+
129
+ expect(result.dashboard.sharedFilters[0].usedBy).toEqual(['source-widget', clonedEntry.widget])
130
+ expect(result.dashboard.sharedFilters[0].setBy).toBe('source-widget')
131
+ expect(result.dashboard.sharedFilters[1].usedBy).toEqual(['legacy-footnote-target'])
132
+ expect(result.dashboard.sharedFilters[2].usedBy).toBeUndefined()
133
+ expect(result.dashboard.sharedFilters[3].usedBy).toEqual([])
134
+ expect(result.dashboard.sharedFilters[4].usedBy).toEqual([0])
135
+ })
136
+ })
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ getColumnWidgetEntries,
4
+ hasAuthoredWidgetEntries,
5
+ normalizeConditionalColumn,
6
+ resolveColumnWidgetEntry
7
+ } from '../dashboardColumnWidgets'
8
+
9
+ describe('dashboardColumnWidgets', () => {
10
+ it('resolves simple columns through column.widget', () => {
11
+ expect(
12
+ resolveColumnWidgetEntry({
13
+ width: 12,
14
+ widget: 'viz-1'
15
+ } as any)
16
+ ).toMatchObject({ widget: 'viz-1', matches: true })
17
+ })
18
+
19
+ it('resolves the first matching conditional widget in author order', () => {
20
+ const evaluateCondition = vi
21
+ .fn()
22
+ .mockImplementation(condition => ({ matches: condition?.id === 'condition-2', resolved: true }))
23
+
24
+ const result = resolveColumnWidgetEntry(
25
+ {
26
+ width: 12,
27
+ conditionalWidgets: [
28
+ { widget: 'viz-1', dashboardCondition: { id: 'condition-1', operator: 'hasData' } },
29
+ { widget: 'viz-2', dashboardCondition: { id: 'condition-2', operator: 'hasData' } },
30
+ { widget: 'viz-3', dashboardCondition: { id: 'condition-3', operator: 'hasData' } }
31
+ ]
32
+ } as any,
33
+ { evaluateCondition }
34
+ )
35
+
36
+ expect(result).toMatchObject({ widget: 'viz-2', matches: true })
37
+ expect(evaluateCondition).toHaveBeenCalledTimes(2)
38
+ })
39
+
40
+ it('returns an empty resolution when no conditional widget matches', () => {
41
+ const result = resolveColumnWidgetEntry(
42
+ {
43
+ width: 12,
44
+ conditionalWidgets: [
45
+ { widget: 'viz-1', dashboardCondition: { id: 'condition-1', operator: 'hasData' } },
46
+ { widget: 'viz-2', dashboardCondition: { id: 'condition-2', operator: 'hasData' } }
47
+ ]
48
+ } as any,
49
+ {
50
+ evaluateCondition: () => ({ matches: false, resolved: true })
51
+ }
52
+ )
53
+
54
+ expect(result).toEqual({ widget: undefined, matches: false, resolved: true })
55
+ })
56
+
57
+ it('collapses a one-entry conditional column back to simple mode when the condition is removed', () => {
58
+ expect(
59
+ normalizeConditionalColumn({
60
+ width: 12,
61
+ widget: undefined,
62
+ conditionalWidgets: [{ widget: 'viz-1' }]
63
+ } as any)
64
+ ).toMatchObject({
65
+ width: 12,
66
+ widget: 'viz-1',
67
+ conditionalWidgets: undefined
68
+ })
69
+ })
70
+
71
+ it('keeps one-entry conditional columns in conditional mode when the remaining entry still has a condition', () => {
72
+ expect(
73
+ normalizeConditionalColumn({
74
+ width: 12,
75
+ conditionalWidgets: [{ widget: 'viz-1', dashboardCondition: { id: 'condition-1', operator: 'hasData' } }]
76
+ } as any)
77
+ ).toMatchObject({
78
+ width: 12,
79
+ widget: undefined,
80
+ conditionalWidgets: [{ widget: 'viz-1', dashboardCondition: { id: 'condition-1', operator: 'hasData' } }]
81
+ })
82
+ })
83
+
84
+ it('ignores column.widget when conditional widgets are present', () => {
85
+ expect(
86
+ getColumnWidgetEntries({
87
+ width: 12,
88
+ widget: 'simple-viz',
89
+ conditionalWidgets: [{ widget: 'conditional-viz', dashboardCondition: { id: 'condition-1' } }]
90
+ } as any)
91
+ ).toEqual([{ widget: 'conditional-viz', dashboardCondition: { id: 'condition-1' } }])
92
+ })
93
+
94
+ it('reports whether a column has authored widget entries', () => {
95
+ expect(hasAuthoredWidgetEntries({ width: 12, widget: 'viz-1' } as any)).toBe(true)
96
+ expect(hasAuthoredWidgetEntries({ width: 12, conditionalWidgets: [{ widget: 'viz-2' }] } as any)).toBe(true)
97
+ expect(hasAuthoredWidgetEntries({ width: 12 } as any)).toBe(false)
98
+ })
99
+ })
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ DASHBOARD_CONDITION_TYPE_LABELS,
4
+ getColumnHasAnyValueSummaryParts,
5
+ getDashboardConditionSummary
6
+ } from '../dashboardConditionUi'
7
+
8
+ describe('dashboardConditionUi', () => {
9
+ it('uses shared dropdown labels for simple condition summaries', () => {
10
+ expect(getDashboardConditionSummary({ operator: 'hasData' })).toBe(DASHBOARD_CONDITION_TYPE_LABELS.hasData)
11
+ expect(getDashboardConditionSummary({ operator: 'hasNoData' })).toBe(DASHBOARD_CONDITION_TYPE_LABELS.hasNoData)
12
+ expect(getDashboardConditionSummary({ operator: 'filtersIncomplete' })).toBe(
13
+ DASHBOARD_CONDITION_TYPE_LABELS.filtersIncomplete
14
+ )
15
+ })
16
+
17
+ it('summarizes columnHasAnyValue with only the inspected column name', () => {
18
+ expect(
19
+ getDashboardConditionSummary({
20
+ operator: 'columnHasAnyValue',
21
+ datasetKey: 'condition-data',
22
+ columnName: 'state',
23
+ values: ['Adams', 'Brown']
24
+ })
25
+ ).toBe('Show based on the value in the state column')
26
+ })
27
+
28
+ it('shares columnHasAnyValue summary parts for rich text rendering', () => {
29
+ expect(getColumnHasAnyValueSummaryParts('state')).toEqual({
30
+ prefix: 'Show based on the value in the ',
31
+ columnName: 'state',
32
+ suffix: ' column'
33
+ })
34
+ })
35
+
36
+ it('falls back safely for incomplete or unknown condition details', () => {
37
+ expect(getDashboardConditionSummary({ operator: 'columnHasAnyValue' })).toBe('Show when column has a value')
38
+ expect(getDashboardConditionSummary({ operator: 'unknownOperator' } as any)).toBe('Dashboard condition configured')
39
+ expect(getDashboardConditionSummary()).toBe('Dashboard condition configured')
40
+ })
41
+ })