@cdc/dashboard 4.26.3 → 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 (151) hide show
  1. package/CONFIG.md +219 -0
  2. package/README.md +60 -20
  3. package/dist/cdcdashboard-CY9IcPSi.es.js +6 -0
  4. package/dist/cdcdashboard-DlpiY3fQ.es.js +4 -0
  5. package/dist/cdcdashboard.js +61559 -58048
  6. package/examples/__data__/data-2.json +6 -0
  7. package/examples/__data__/data.json +6 -0
  8. package/examples/dashboard-conditions-filters-incomplete.json +221 -0
  9. package/examples/dashboard-missing-datasets-multi.json +174 -0
  10. package/examples/dashboard-missing-datasets-single.json +121 -0
  11. package/examples/dashboard-multi-dashboard-version-regression.json +146 -0
  12. package/examples/dashboard-shared-filter-row-delete-cleanup.json +186 -0
  13. package/examples/dashboard-stale-dataset-keys.json +181 -0
  14. package/examples/dashboard-tiered-filter-regression.json +190 -0
  15. package/examples/legend-issue.json +1 -1
  16. package/examples/minimal-example.json +34 -0
  17. package/examples/private/cfa-dashboard.json +651 -0
  18. package/examples/private/data-bite-wrap.json +6936 -0
  19. package/examples/private/dengue.json +4640 -0
  20. package/examples/private/link_to_file.json +16662 -0
  21. package/examples/private/multi-dash-fix.json +16963 -0
  22. package/examples/private/versions.json +41612 -0
  23. package/examples/sankey.json +3 -3
  24. package/examples/test-api-filter-reset.json +4 -4
  25. package/examples/tp5-test.json +86 -4
  26. package/examples/us-map-filter-example.json +1074 -0
  27. package/package.json +9 -9
  28. package/src/CdcDashboard.tsx +6 -2
  29. package/src/CdcDashboardComponent.tsx +179 -88
  30. package/src/DashboardCopyPasteContext.test.tsx +33 -0
  31. package/src/DashboardCopyPasteContext.tsx +48 -0
  32. package/src/_stories/Dashboard.EditorRegression.stories.tsx +72 -0
  33. package/src/_stories/Dashboard.Regression.stories.tsx +196 -0
  34. package/src/_stories/Dashboard.Zoom.stories.tsx +88 -0
  35. package/src/_stories/Dashboard.smoke.stories.tsx +33 -0
  36. package/src/_stories/Dashboard.stories.tsx +337 -2
  37. package/src/_stories/FilteredTextMigrationComparison.stories.tsx +87 -0
  38. package/src/_stories/_mock/dashboard-data-driven-colors.json +171 -0
  39. package/src/_stories/_mock/tp5-test.json +86 -5
  40. package/src/components/Column.test.tsx +176 -0
  41. package/src/components/Column.tsx +214 -13
  42. package/src/components/DashboardConditionModal.test.tsx +420 -0
  43. package/src/components/DashboardConditionModal.tsx +367 -0
  44. package/src/components/DashboardConditionSummary.tsx +59 -0
  45. package/src/components/DashboardEditors.tsx +23 -0
  46. package/src/components/DashboardFilters/DashboardFilters.test.tsx +267 -0
  47. package/src/components/DashboardFilters/DashboardFilters.tsx +193 -172
  48. package/src/components/DashboardFilters/DashboardFiltersEditor/DashboardFiltersEditor.test.tsx +164 -0
  49. package/src/components/DashboardFilters/DashboardFiltersEditor/DashboardFiltersEditor.tsx +46 -6
  50. package/src/components/DashboardFilters/DashboardFiltersEditor/components/APIModal.tsx +5 -3
  51. package/src/components/DashboardFilters/DashboardFiltersEditor/components/DeleteFilterModal.tsx +59 -58
  52. package/src/components/DashboardFilters/DashboardFiltersEditor/components/FilterEditor.test.tsx +304 -0
  53. package/src/components/DashboardFilters/DashboardFiltersEditor/components/FilterEditor.tsx +43 -36
  54. package/src/components/DashboardFilters/DashboardFiltersEditor/components/NestedDropDownDashboard.tsx +2 -2
  55. package/src/components/DashboardFilters/DashboardFiltersWrapper.test.tsx +142 -0
  56. package/src/components/DashboardFilters/DashboardFiltersWrapper.tsx +32 -27
  57. package/src/components/DashboardFilters/dashboardfilter.styles.css +42 -27
  58. package/src/components/DataDesignerModal.tsx +2 -1
  59. package/src/components/ExpandCollapseButtons.tsx +6 -4
  60. package/src/components/Grid.tsx +12 -7
  61. package/src/components/Header/Header.tsx +36 -17
  62. package/src/components/MultiConfigTabs/MultiConfigTabs.tsx +141 -140
  63. package/src/components/Row.test.tsx +228 -0
  64. package/src/components/Row.tsx +104 -28
  65. package/src/components/VisualizationRow.test.tsx +396 -0
  66. package/src/components/VisualizationRow.tsx +177 -51
  67. package/src/components/VisualizationsPanel/VisualizationsPanel.test.tsx +49 -0
  68. package/src/components/VisualizationsPanel/VisualizationsPanel.tsx +14 -13
  69. package/src/components/Widget/Widget.test.tsx +218 -0
  70. package/src/components/Widget/Widget.tsx +123 -20
  71. package/src/components/Widget/widget.styles.css +58 -14
  72. package/src/components/dashboard-condition-modal.css +76 -0
  73. package/src/components/dashboard-condition-summary.css +87 -0
  74. package/src/data/initial-state.js +1 -0
  75. package/src/helpers/addValuesToDashboardFilters.ts +3 -5
  76. package/src/helpers/addVisualization.ts +17 -4
  77. package/src/helpers/cloneDashboardWidget.ts +127 -0
  78. package/src/helpers/dashboardColumnWidgets.ts +99 -0
  79. package/src/helpers/dashboardConditionUi.ts +47 -0
  80. package/src/helpers/dashboardConditions.ts +200 -0
  81. package/src/helpers/dashboardFilterTargets.ts +156 -0
  82. package/src/helpers/filterData.ts +4 -9
  83. package/src/helpers/filterVisibility.ts +20 -0
  84. package/src/helpers/formatConfigBeforeSave.ts +2 -2
  85. package/src/helpers/getFilteredData.ts +18 -5
  86. package/src/helpers/getUpdateConfig.ts +43 -12
  87. package/src/helpers/getVizRowColumnLocator.ts +11 -1
  88. package/src/helpers/iconHash.tsx +9 -3
  89. package/src/helpers/mapDataToConfig.ts +31 -29
  90. package/src/helpers/reloadURLHelpers.ts +25 -5
  91. package/src/helpers/removeDashboardFilter.ts +33 -33
  92. package/src/helpers/tests/addVisualization.test.ts +53 -9
  93. package/src/helpers/tests/cloneDashboardWidget.test.ts +136 -0
  94. package/src/helpers/tests/dashboardColumnWidgets.test.ts +99 -0
  95. package/src/helpers/tests/dashboardConditionUi.test.ts +41 -0
  96. package/src/helpers/tests/dashboardConditions.test.ts +428 -0
  97. package/src/helpers/tests/formatConfigBeforeSave.test.ts +51 -0
  98. package/src/helpers/tests/getFilteredData.test.ts +265 -86
  99. package/src/helpers/tests/getUpdateConfig.test.ts +338 -0
  100. package/src/helpers/tests/reloadURLHelpers.test.ts +394 -238
  101. package/src/index.tsx +6 -3
  102. package/src/scss/grid.scss +281 -22
  103. package/src/scss/main.scss +215 -64
  104. package/src/store/dashboard.actions.ts +17 -4
  105. package/src/store/dashboard.reducer.test.ts +538 -0
  106. package/src/store/dashboard.reducer.ts +136 -22
  107. package/src/test/CdcDashboard.test.jsx +24 -0
  108. package/src/test/CdcDashboard.test.tsx +148 -0
  109. package/src/test/CdcDashboardComponent.test.tsx +935 -2
  110. package/src/types/ConfigRow.ts +15 -0
  111. package/src/types/DashboardFilters.ts +4 -0
  112. package/src/types/SharedFilter.ts +2 -0
  113. package/tests/fixtures/dashboard-config-with-metadata.json +1 -1
  114. package/dist/cdcdashboard-vr9HZwRt.es.js +0 -6
  115. package/examples/DEV-6574.json +0 -2224
  116. package/examples/api-dashboard-data.json +0 -272
  117. package/examples/api-dashboard-years.json +0 -11
  118. package/examples/api-geographies-data.json +0 -11
  119. package/examples/chart-data.json +0 -5409
  120. package/examples/custom/css/respiratory.css +0 -236
  121. package/examples/custom/js/respiratory.js +0 -242
  122. package/examples/default-data.json +0 -368
  123. package/examples/default-filter-control.json +0 -209
  124. package/examples/default-multi-dataset-shared-filter.json +0 -1729
  125. package/examples/default-multi-dataset.json +0 -506
  126. package/examples/ed-visits-county-file.json +0 -402
  127. package/examples/filters/Alabama.json +0 -72
  128. package/examples/filters/Alaska.json +0 -1737
  129. package/examples/filters/Arkansas.json +0 -4713
  130. package/examples/filters/California.json +0 -212
  131. package/examples/filters/Colorado.json +0 -1500
  132. package/examples/filters/Connecticut.json +0 -559
  133. package/examples/filters/Delaware.json +0 -63
  134. package/examples/filters/DistrictofColumbia.json +0 -63
  135. package/examples/filters/Florida.json +0 -4217
  136. package/examples/filters/States.json +0 -146
  137. package/examples/state-level.json +0 -90136
  138. package/examples/state-points.json +0 -10474
  139. package/examples/temp-example-data.json +0 -130
  140. package/examples/test-dashboard-simple.json +0 -503
  141. package/examples/test-example.json +0 -752
  142. package/examples/test-file.json +0 -147
  143. package/examples/test.json +0 -752
  144. package/examples/testing.json +0 -94456
  145. /package/examples/{data → __data__}/data-with-metadata.json +0 -0
  146. /package/examples/{legend-issue-data.json → __data__/legend-issue-data.json} +0 -0
  147. /package/examples/api-test/{categories.json → __data__/categories.json} +0 -0
  148. /package/examples/api-test/{chart-data.json → __data__/chart-data.json} +0 -0
  149. /package/examples/api-test/{topics.json → __data__/topics.json} +0 -0
  150. /package/examples/api-test/{years.json → __data__/years.json} +0 -0
  151. /package/src/_stories/{Dashboard.Pages.stories.tsx → Dashboard.Pages.smoke.stories.tsx} +0 -0
@@ -1,12 +1,14 @@
1
1
  import React, { useContext } from 'react'
2
2
  import { useDrop } from 'react-dnd'
3
3
 
4
- import { DashboardContext } from '../DashboardContext'
4
+ import { DashboardContext, DashboardDispatchContext } from '../DashboardContext'
5
+ import { DashboardCopyPasteContext } from '../DashboardCopyPasteContext'
5
6
  import Widget from './Widget/Widget'
7
+ import { getColumnWidgetEntries, hasConditionalWidgets } from '../helpers/dashboardColumnWidgets'
6
8
 
7
9
  type ColumnProps = {
8
10
  // column data passed from parent
9
- data: Object[]
11
+ data: any
10
12
  // row index
11
13
  rowIdx: number
12
14
  // column index
@@ -15,8 +17,54 @@ type ColumnProps = {
15
17
  toggleRow: boolean
16
18
  }
17
19
 
18
- const Column: React.FC<ColumnProps> = ({ data, rowIdx, colIdx, toggleRow }) => {
20
+ const formatSummaryList = items => {
21
+ const summaryItems = items.filter(Boolean)
22
+ if (!summaryItems.length) return undefined
23
+
24
+ return summaryItems.join(', ')
25
+ }
26
+
27
+ const getDashboardFiltersTitle = (config, sharedFilters = []) => {
28
+ const filters = config.sharedFilterIndexes?.map(index => sharedFilters[Number(index)]).filter(Boolean) || []
29
+ return formatSummaryList(
30
+ filters.map(
31
+ filter =>
32
+ filter.key?.trim() ||
33
+ filter.columnName ||
34
+ filter.apiFilter?.textSelector ||
35
+ filter.apiFilter?.valueSelector ||
36
+ filter.queryParameter
37
+ )
38
+ )
39
+ }
40
+
41
+ const handleTitle = (config, sharedFilters = []) => {
42
+ if (!config) return
43
+ if (config.type === 'map') return config.general.title
44
+ if (config.type === 'markup-include') return config.contentEditor?.title
45
+ if (config.type === 'dashboardFilters') return getDashboardFiltersTitle(config, sharedFilters)
46
+ if (config.type === 'table') return config.table?.label
47
+ return config.title
48
+ }
49
+
50
+ type ConditionalColumnProps = {
51
+ data: any
52
+ rowIdx: number
53
+ colIdx: number
54
+ toggleRow: boolean
55
+ }
56
+
57
+ type SimpleColumnProps = {
58
+ data: any
59
+ rowIdx: number
60
+ colIdx: number
61
+ toggleRow: boolean
62
+ }
63
+
64
+ const SimpleColumn: React.FC<SimpleColumnProps> = ({ data, rowIdx, colIdx, toggleRow }) => {
19
65
  const { config } = useContext(DashboardContext)
66
+ const dispatch = useContext(DashboardDispatchContext)
67
+ const { copiedWidget, clearCopiedWidget } = useContext(DashboardCopyPasteContext)
20
68
 
21
69
  const [{ isOver, canDrop }, drop] = useDrop(
22
70
  () => ({
@@ -32,7 +80,7 @@ const Column: React.FC<ColumnProps> = ({ data, rowIdx, colIdx, toggleRow }) => {
32
80
  canDrop: !!monitor.canDrop()
33
81
  })
34
82
  }),
35
- [config.activeDashboard]
83
+ [config.activeDashboard, rowIdx, colIdx, data.widget]
36
84
  )
37
85
 
38
86
  const widget = data.widget ? config?.visualizations[data.widget] : null
@@ -46,21 +94,38 @@ const Column: React.FC<ColumnProps> = ({ data, rowIdx, colIdx, toggleRow }) => {
46
94
 
47
95
  if (widget) {
48
96
  classNames.push('column--populated')
97
+ } else if (copiedWidget) {
98
+ classNames.push('column--paste-ready')
49
99
  }
50
100
 
51
- const handleTitle = config => {
52
- if (!config) return
53
- if (config.type === 'map') return config.general.title
54
- if (config.type === 'markup-include') return config.contentEditor?.title
55
- return config.title
101
+ const pasteCopiedWidget = () => {
102
+ if (!copiedWidget || widget) return
103
+
104
+ dispatch({
105
+ type: 'CLONE_VISUALIZATION',
106
+ payload: { sourceWidgetKey: copiedWidget.sourceWidgetKey, rowIdx, colIdx }
107
+ })
108
+ clearCopiedWidget()
56
109
  }
57
110
 
58
111
  return (
59
- <div className={classNames.join(' ')} ref={drop}>
112
+ <div
113
+ className={classNames.join(' ')}
114
+ ref={drop}
115
+ onClick={pasteCopiedWidget}
116
+ onKeyDown={event => {
117
+ if (!copiedWidget || widget) return
118
+ if (event.key === 'Enter' || event.key === ' ') {
119
+ event.preventDefault()
120
+ pasteCopiedWidget()
121
+ }
122
+ }}
123
+ role={!widget && copiedWidget ? 'button' : undefined}
124
+ tabIndex={!widget && copiedWidget ? 0 : undefined}
125
+ >
60
126
  {widget ? (
61
127
  <Widget
62
- columnData={data}
63
- title={handleTitle(widget)}
128
+ title={handleTitle(widget, config.dashboard?.sharedFilters)}
64
129
  widgetConfig={{ rowIdx, colIdx, ...widget }}
65
130
  type={widget.visualizationType ?? widget.general?.geoType}
66
131
  toggleRow={toggleRow}
@@ -68,11 +133,147 @@ const Column: React.FC<ColumnProps> = ({ data, rowIdx, colIdx, toggleRow }) => {
68
133
  />
69
134
  ) : (
70
135
  <p className='builder-column__text'>
71
- Drag and drop <br /> visualization
136
+ {copiedWidget ? (
137
+ 'Click here to paste copied component or drag and drop a new visualization'
138
+ ) : (
139
+ <>
140
+ Drag and drop <br /> visualization
141
+ </>
142
+ )}
143
+ </p>
144
+ )}
145
+ </div>
146
+ )
147
+ }
148
+
149
+ type ConditionalColumnSlotProps = {
150
+ rowIdx: number
151
+ colIdx: number
152
+ toggleRow: boolean
153
+ entryIndex?: number
154
+ widgetKey?: string
155
+ }
156
+
157
+ const ConditionalColumnSlot: React.FC<ConditionalColumnSlotProps> = ({
158
+ rowIdx,
159
+ colIdx,
160
+ toggleRow,
161
+ entryIndex,
162
+ widgetKey
163
+ }) => {
164
+ const { config } = useContext(DashboardContext)
165
+ const dispatch = useContext(DashboardDispatchContext)
166
+ const { copiedWidget, clearCopiedWidget } = useContext(DashboardCopyPasteContext)
167
+
168
+ const [{ isOver, canDrop }, drop] = useDrop(
169
+ () => ({
170
+ accept: 'vis-widget',
171
+ drop: () => ({
172
+ rowIdx,
173
+ colIdx,
174
+ entryIdx: entryIndex,
175
+ canDrop
176
+ }),
177
+ canDrop: () => !widgetKey,
178
+ collect: monitor => ({
179
+ isOver: monitor.isOver(),
180
+ canDrop: !!monitor.canDrop()
181
+ })
182
+ }),
183
+ [config.activeDashboard, rowIdx, colIdx, entryIndex, widgetKey]
184
+ )
185
+
186
+ const widget = widgetKey ? config?.visualizations[widgetKey] : null
187
+ if (widget && !widget.uid) widget.uid = widgetKey
188
+ const classNames = ['builder-column--conditional__slot']
189
+
190
+ if (isOver && canDrop) {
191
+ classNames.push('column--drop')
192
+ }
193
+
194
+ if (widget) {
195
+ classNames.push('column--populated')
196
+ } else if (copiedWidget) {
197
+ classNames.push('column--paste-ready')
198
+ }
199
+
200
+ const pasteCopiedWidget = () => {
201
+ if (!copiedWidget || widget) return
202
+
203
+ dispatch({
204
+ type: 'CLONE_VISUALIZATION',
205
+ payload: { sourceWidgetKey: copiedWidget.sourceWidgetKey, rowIdx, colIdx, entryIdx: entryIndex }
206
+ })
207
+ clearCopiedWidget()
208
+ }
209
+
210
+ return (
211
+ <div
212
+ className={classNames.join(' ')}
213
+ ref={drop}
214
+ onClick={pasteCopiedWidget}
215
+ onKeyDown={event => {
216
+ if (!copiedWidget || widget) return
217
+ if (event.key === 'Enter' || event.key === ' ') {
218
+ event.preventDefault()
219
+ pasteCopiedWidget()
220
+ }
221
+ }}
222
+ role={!widget && copiedWidget ? 'button' : undefined}
223
+ tabIndex={!widget && copiedWidget ? 0 : undefined}
224
+ >
225
+ {widget ? (
226
+ <Widget
227
+ title={handleTitle(widget, config.dashboard?.sharedFilters)}
228
+ widgetConfig={{ rowIdx, colIdx, entryIdx: entryIndex, ...widget }}
229
+ type={widget.visualizationType ?? widget.general?.geoType}
230
+ toggleRow={toggleRow}
231
+ widgetInRow
232
+ />
233
+ ) : (
234
+ <p className='builder-column__text'>
235
+ {copiedWidget ? (
236
+ 'Click here to paste copied alternate visualization or drag and drop a new alternate'
237
+ ) : (
238
+ <>
239
+ Drag and drop an alternate visualization.
240
+ <span className='builder-column__hint'>
241
+ If multiple conditions match, only the first match in this column is shown.
242
+ </span>
243
+ </>
244
+ )}
72
245
  </p>
73
246
  )}
74
247
  </div>
75
248
  )
76
249
  }
77
250
 
251
+ const ConditionalColumn: React.FC<ConditionalColumnProps> = ({ data, rowIdx, colIdx, toggleRow }) => {
252
+ const widgetEntries = getColumnWidgetEntries(data)
253
+
254
+ return (
255
+ <div className={['builder-column', 'builder-column--conditional', 'column-size--' + data.width].join(' ')}>
256
+ {widgetEntries.map((entry, entryIndex) => (
257
+ <ConditionalColumnSlot
258
+ key={`${rowIdx}-${colIdx}-${entryIndex}-${entry.widget}`}
259
+ rowIdx={rowIdx}
260
+ colIdx={colIdx}
261
+ toggleRow={toggleRow}
262
+ entryIndex={entryIndex}
263
+ widgetKey={entry.widget}
264
+ />
265
+ ))}
266
+ <ConditionalColumnSlot rowIdx={rowIdx} colIdx={colIdx} toggleRow={toggleRow} entryIndex={widgetEntries.length} />
267
+ </div>
268
+ )
269
+ }
270
+
271
+ const Column: React.FC<ColumnProps> = ({ data, rowIdx, colIdx, toggleRow }) => {
272
+ if (hasConditionalWidgets(data)) {
273
+ return <ConditionalColumn data={data} rowIdx={rowIdx} colIdx={colIdx} toggleRow={toggleRow} />
274
+ }
275
+
276
+ return <SimpleColumn data={data} rowIdx={rowIdx} colIdx={colIdx} toggleRow={toggleRow} />
277
+ }
278
+
78
279
  export default Column
@@ -0,0 +1,420 @@
1
+ import React from 'react'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { DashboardContext, DashboardDispatchContext, initialState } from '../DashboardContext'
5
+ import { GlobalContext } from '@cdc/core/components/GlobalContext'
6
+ import { DashboardConditionModal } from './DashboardConditionModal'
7
+
8
+ vi.mock('@cdc/core/components/ui/Icon', () => ({
9
+ default: props => <span data-testid='mock-icon' {...props} />
10
+ }))
11
+
12
+ vi.mock('@cdc/core/components/ui/Tooltip', () => {
13
+ const Tooltip = ({ children }) => <div data-testid='tooltip'>{children}</div>
14
+ Tooltip.Target = ({ children }) => <>{children}</>
15
+ Tooltip.Content = ({ children }) => <>{children}</>
16
+ return { default: Tooltip }
17
+ })
18
+
19
+ const buildConfig = () =>
20
+ ({
21
+ type: 'dashboard',
22
+ dashboard: {
23
+ sharedFilters: []
24
+ },
25
+ datasets: {
26
+ 'condition-data': {
27
+ data: [
28
+ { region: 'North', status: 'visible' },
29
+ { region: 'South', status: 'hidden' }
30
+ ]
31
+ }
32
+ },
33
+ rows: [
34
+ {
35
+ columns: [{ width: 12, widget: 'markup-1' }],
36
+ expandCollapseAllButtons: false
37
+ }
38
+ ],
39
+ visualizations: {
40
+ 'markup-1': {
41
+ uid: 'markup-1',
42
+ type: 'markup-include',
43
+ visualizationType: 'markup-include',
44
+ contentEditor: {
45
+ inlineHTML: '<p>Example</p>',
46
+ showHeader: true,
47
+ srcUrl: '',
48
+ title: 'Example',
49
+ useInlineHTML: true
50
+ }
51
+ }
52
+ }
53
+ } as any)
54
+
55
+ const buildConditionalConfig = (dashboardCondition = undefined) =>
56
+ ({
57
+ ...buildConfig(),
58
+ rows: [
59
+ {
60
+ columns: [
61
+ {
62
+ width: 12,
63
+ conditionalWidgets: [{ widget: 'markup-1', dashboardCondition }]
64
+ }
65
+ ],
66
+ expandCollapseAllButtons: false
67
+ }
68
+ ]
69
+ } as any)
70
+
71
+ const renderModal = () => {
72
+ const dispatch = vi.fn()
73
+ const toggleOverlay = vi.fn()
74
+
75
+ render(
76
+ <GlobalContext.Provider
77
+ value={{
78
+ overlay: {
79
+ object: null,
80
+ show: true,
81
+ disableBgClose: false,
82
+ actions: {
83
+ openOverlay: vi.fn(),
84
+ toggleOverlay
85
+ }
86
+ }
87
+ }}
88
+ >
89
+ <DashboardContext.Provider
90
+ value={{
91
+ ...initialState,
92
+ config: buildConfig(),
93
+ outerContainerRef: vi.fn(),
94
+ setParentConfig: vi.fn(),
95
+ isDebug: false,
96
+ isEditor: true,
97
+ reloadURLData: vi.fn(),
98
+ loadAPIFilters: vi.fn(),
99
+ setAPIFilterDropdowns: vi.fn(),
100
+ setAPILoading: vi.fn()
101
+ }}
102
+ >
103
+ <DashboardDispatchContext.Provider value={dispatch}>
104
+ <DashboardConditionModal rowIndex={0} columnIndex={0} />
105
+ </DashboardDispatchContext.Provider>
106
+ </DashboardContext.Provider>
107
+ </GlobalContext.Provider>
108
+ )
109
+
110
+ return { dispatch, toggleOverlay }
111
+ }
112
+
113
+ const renderRowModal = (dashboardCondition = undefined) => {
114
+ const dispatch = vi.fn()
115
+ const toggleOverlay = vi.fn()
116
+
117
+ render(
118
+ <GlobalContext.Provider
119
+ value={{
120
+ overlay: {
121
+ object: null,
122
+ show: true,
123
+ disableBgClose: false,
124
+ actions: {
125
+ openOverlay: vi.fn(),
126
+ toggleOverlay
127
+ }
128
+ }
129
+ }}
130
+ >
131
+ <DashboardContext.Provider
132
+ value={{
133
+ ...initialState,
134
+ config: {
135
+ ...buildConfig(),
136
+ rows: [
137
+ {
138
+ columns: [{ width: 12, widget: 'markup-1' }],
139
+ dashboardCondition,
140
+ expandCollapseAllButtons: false
141
+ }
142
+ ]
143
+ },
144
+ outerContainerRef: vi.fn(),
145
+ setParentConfig: vi.fn(),
146
+ isDebug: false,
147
+ isEditor: true,
148
+ reloadURLData: vi.fn(),
149
+ loadAPIFilters: vi.fn(),
150
+ setAPIFilterDropdowns: vi.fn(),
151
+ setAPILoading: vi.fn()
152
+ }}
153
+ >
154
+ <DashboardDispatchContext.Provider value={dispatch}>
155
+ <DashboardConditionModal rowIndex={0} />
156
+ </DashboardDispatchContext.Provider>
157
+ </DashboardContext.Provider>
158
+ </GlobalContext.Provider>
159
+ )
160
+
161
+ return { dispatch, toggleOverlay }
162
+ }
163
+
164
+ const renderConditionalEntryModal = (dashboardCondition = undefined) => {
165
+ const dispatch = vi.fn()
166
+
167
+ render(
168
+ <GlobalContext.Provider
169
+ value={{
170
+ overlay: {
171
+ object: null,
172
+ show: true,
173
+ disableBgClose: false,
174
+ actions: {
175
+ openOverlay: vi.fn(),
176
+ toggleOverlay: vi.fn()
177
+ }
178
+ }
179
+ }}
180
+ >
181
+ <DashboardContext.Provider
182
+ value={{
183
+ ...initialState,
184
+ config: buildConditionalConfig(dashboardCondition),
185
+ outerContainerRef: vi.fn(),
186
+ setParentConfig: vi.fn(),
187
+ isDebug: false,
188
+ isEditor: true,
189
+ reloadURLData: vi.fn(),
190
+ loadAPIFilters: vi.fn(),
191
+ setAPIFilterDropdowns: vi.fn(),
192
+ setAPILoading: vi.fn()
193
+ }}
194
+ >
195
+ <DashboardDispatchContext.Provider value={dispatch}>
196
+ <DashboardConditionModal rowIndex={0} columnIndex={0} entryIndex={0} />
197
+ </DashboardDispatchContext.Provider>
198
+ </DashboardContext.Provider>
199
+ </GlobalContext.Provider>
200
+ )
201
+
202
+ return { dispatch }
203
+ }
204
+
205
+ describe('DashboardConditionModal', () => {
206
+ it('shows component-specific condition type tooltip copy and delayed field guidance', () => {
207
+ renderModal()
208
+
209
+ expect(screen.getByRole('combobox', { name: /Condition Type/i })).toBeInTheDocument()
210
+ expect(screen.getByText(/Choose whether this component should appear/)).toBeInTheDocument()
211
+
212
+ fireEvent.change(screen.getByRole('combobox', { name: /Condition Type/i }), {
213
+ target: { value: 'columnHasAnyValue' }
214
+ })
215
+
216
+ expect(screen.queryByText(/Select the dataset column to inspect for this condition/)).not.toBeInTheDocument()
217
+ expect(screen.queryByText(/Choose one or more matching values from the selected column/)).not.toBeInTheDocument()
218
+ expect(screen.queryByText('Column Values')).not.toBeInTheDocument()
219
+
220
+ fireEvent.change(screen.getAllByRole('combobox')[1], {
221
+ target: { value: 'condition-data' }
222
+ })
223
+
224
+ expect(screen.getByText(/Select the dataset column to inspect for this condition/)).toBeInTheDocument()
225
+ expect(screen.queryByText(/Choose one or more matching values from the selected column/)).not.toBeInTheDocument()
226
+
227
+ fireEvent.change(screen.getAllByRole('combobox')[2], { target: { value: 'region' } })
228
+
229
+ expect(screen.getByText('Column Values')).toBeInTheDocument()
230
+ expect(screen.getByText(/Choose one or more matching values from the selected column/)).toBeInTheDocument()
231
+ })
232
+
233
+ it('shows row-specific condition type tooltip copy for row editing', () => {
234
+ renderRowModal()
235
+
236
+ expect(screen.getByText(/Choose whether this row should appear/)).toBeInTheDocument()
237
+ expect(screen.queryByText(/Choose whether this component should appear/)).not.toBeInTheDocument()
238
+ })
239
+
240
+ it('saves a hasData condition', () => {
241
+ const { dispatch, toggleOverlay } = renderModal()
242
+
243
+ fireEvent.change(screen.getByRole('combobox', { name: /Condition Type/i }), { target: { value: 'hasData' } })
244
+ fireEvent.change(screen.getAllByRole('combobox')[1], {
245
+ target: { value: 'condition-data' }
246
+ })
247
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
248
+
249
+ expect(dispatch).toHaveBeenCalledWith({
250
+ type: 'UPDATE_ROW',
251
+ payload: {
252
+ rowIndex: 0,
253
+ rowData: {
254
+ columns: [
255
+ expect.objectContaining({
256
+ conditionalWidgets: [
257
+ expect.objectContaining({
258
+ widget: 'markup-1',
259
+ dashboardCondition: expect.objectContaining({
260
+ datasetKey: 'condition-data',
261
+ operator: 'hasData'
262
+ })
263
+ })
264
+ ]
265
+ })
266
+ ]
267
+ }
268
+ }
269
+ })
270
+ expect(toggleOverlay).toHaveBeenCalledWith(false)
271
+ })
272
+
273
+ it('converts a simple column into conditional mode only after save', () => {
274
+ const { dispatch } = renderModal()
275
+
276
+ fireEvent.change(screen.getByRole('combobox', { name: /Condition Type/i }), { target: { value: 'hasData' } })
277
+ fireEvent.change(screen.getAllByRole('combobox')[1], {
278
+ target: { value: 'condition-data' }
279
+ })
280
+
281
+ expect(dispatch).not.toHaveBeenCalled()
282
+
283
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
284
+
285
+ expect(dispatch).toHaveBeenCalledWith({
286
+ type: 'UPDATE_ROW',
287
+ payload: {
288
+ rowIndex: 0,
289
+ rowData: {
290
+ columns: [
291
+ expect.objectContaining({
292
+ widget: undefined,
293
+ conditionalWidgets: [
294
+ expect.objectContaining({
295
+ widget: 'markup-1',
296
+ dashboardCondition: expect.objectContaining({
297
+ datasetKey: 'condition-data',
298
+ operator: 'hasData'
299
+ })
300
+ })
301
+ ]
302
+ })
303
+ ]
304
+ }
305
+ }
306
+ })
307
+ })
308
+
309
+ it('collapses a one-entry conditional column back to simple mode when its condition is removed', () => {
310
+ const { dispatch } = renderConditionalEntryModal({
311
+ id: 'existing-condition',
312
+ datasetKey: 'condition-data',
313
+ operator: 'hasData'
314
+ })
315
+
316
+ fireEvent.change(screen.getByRole('combobox', { name: /Condition Type/i }), { target: { value: '' } })
317
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
318
+
319
+ expect(dispatch).toHaveBeenCalledWith({
320
+ type: 'UPDATE_ROW',
321
+ payload: {
322
+ rowIndex: 0,
323
+ rowData: {
324
+ columns: [
325
+ expect.objectContaining({
326
+ widget: 'markup-1',
327
+ conditionalWidgets: undefined
328
+ })
329
+ ]
330
+ }
331
+ }
332
+ })
333
+ })
334
+
335
+ it('collapses conditional mode back to simple mode when the last condition is removed', () => {
336
+ const { dispatch } = renderConditionalEntryModal({
337
+ id: 'existing-condition',
338
+ datasetKey: 'condition-data',
339
+ operator: 'hasData'
340
+ })
341
+
342
+ fireEvent.change(screen.getByRole('combobox', { name: /Condition Type/i }), { target: { value: '' } })
343
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
344
+
345
+ expect(dispatch).toHaveBeenCalledWith({
346
+ type: 'UPDATE_ROW',
347
+ payload: {
348
+ rowIndex: 0,
349
+ rowData: {
350
+ columns: [expect.objectContaining({ widget: 'markup-1', conditionalWidgets: undefined })]
351
+ }
352
+ }
353
+ })
354
+ })
355
+
356
+ it('requires dataset, column, and values for Column Has Any Value', () => {
357
+ renderModal()
358
+
359
+ fireEvent.change(screen.getByRole('combobox', { name: /Condition Type/i }), {
360
+ target: { value: 'columnHasAnyValue' }
361
+ })
362
+
363
+ const saveButton = screen.getByRole('button', { name: 'Save' })
364
+ expect(saveButton).toBeDisabled()
365
+
366
+ fireEvent.change(screen.getAllByRole('combobox')[1], {
367
+ target: { value: 'condition-data' }
368
+ })
369
+ expect(saveButton).toBeDisabled()
370
+
371
+ fireEvent.change(screen.getAllByRole('combobox')[2], { target: { value: 'region' } })
372
+ expect(saveButton).toBeDisabled()
373
+
374
+ fireEvent.click(screen.getByLabelText('Expand'))
375
+ fireEvent.click(screen.getByText('North'))
376
+
377
+ expect(saveButton).not.toBeDisabled()
378
+ })
379
+
380
+ it('allows filtersIncomplete without dataset, column, or values', () => {
381
+ const { dispatch } = renderModal()
382
+
383
+ fireEvent.change(screen.getByRole('combobox', { name: /Condition Type/i }), {
384
+ target: { value: 'filtersIncomplete' }
385
+ })
386
+
387
+ expect(screen.getByRole('option', { name: 'Show when filters are incomplete' })).toBeInTheDocument()
388
+ expect(screen.getAllByRole('combobox')).toHaveLength(1)
389
+ expect(screen.queryByText('Column Values')).not.toBeInTheDocument()
390
+
391
+ const saveButton = screen.getByRole('button', { name: 'Save' })
392
+ expect(saveButton).not.toBeDisabled()
393
+
394
+ fireEvent.click(saveButton)
395
+
396
+ expect(dispatch).toHaveBeenCalledWith({
397
+ type: 'UPDATE_ROW',
398
+ payload: {
399
+ rowIndex: 0,
400
+ rowData: {
401
+ columns: [
402
+ expect.objectContaining({
403
+ conditionalWidgets: [
404
+ expect.objectContaining({
405
+ widget: 'markup-1',
406
+ dashboardCondition: expect.objectContaining({
407
+ operator: 'filtersIncomplete'
408
+ })
409
+ })
410
+ ]
411
+ })
412
+ ]
413
+ }
414
+ }
415
+ })
416
+ expect(
417
+ dispatch.mock.calls[0][0].payload.rowData.columns[0].conditionalWidgets[0].dashboardCondition
418
+ ).not.toHaveProperty('datasetKey')
419
+ })
420
+ })