@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
@@ -0,0 +1,87 @@
1
+ .cove-visualization button.cove-button.dashboard-condition-summary {
2
+ align-items: center !important;
3
+ align-self: stretch;
4
+ background: var(--dashboard-condition-active-blue-bg, #f2f7fb) !important;
5
+ border: 1px solid var(--dashboard-condition-active-blue, var(--blue)) !important;
6
+ border-radius: 4px;
7
+ box-shadow: none;
8
+ box-sizing: border-box;
9
+ color: var(--dashboard-condition-active-blue, var(--blue)) !important;
10
+ display: inline-flex !important;
11
+ flex-direction: row !important;
12
+ flex-wrap: nowrap !important;
13
+ font-family: var(--app-font-main, sans-serif) !important;
14
+ font-size: 0.8rem !important;
15
+ font-weight: 400 !important;
16
+ gap: 0.35rem;
17
+ justify-content: flex-start;
18
+ line-height: 1.2;
19
+ margin: 0 0 0.5rem;
20
+ min-height: 24px !important;
21
+ overflow: visible;
22
+ padding: 0.2rem 0.45rem !important;
23
+ text-align: left;
24
+ transform: none;
25
+ white-space: normal;
26
+ width: 100%;
27
+ }
28
+
29
+ .cove-visualization button.cove-button.dashboard-condition-summary:hover,
30
+ .cove-visualization button.cove-button.dashboard-condition-summary:active,
31
+ .cove-visualization button.cove-button.dashboard-condition-summary:focus {
32
+ background: var(--dashboard-condition-active-blue-bg-hover, #e6eff7) !important;
33
+ box-shadow: none;
34
+ color: var(--dashboard-condition-active-blue, var(--blue)) !important;
35
+ transform: none;
36
+ }
37
+
38
+ .cove-visualization button.cove-button.dashboard-condition-summary .dashboard-condition-summary__icon {
39
+ align-items: center;
40
+ display: inline-flex;
41
+ flex: 0 0 auto;
42
+ line-height: 1.2;
43
+ margin-top: 0.05rem;
44
+ }
45
+
46
+ .cove-visualization button.cove-button.dashboard-condition-summary .dashboard-condition-summary__icon svg {
47
+ display: block;
48
+ fill: currentColor;
49
+ height: 1em;
50
+ margin: 0 !important;
51
+ position: static;
52
+ top: auto;
53
+ transform: none;
54
+ width: 1em;
55
+ }
56
+
57
+ .cove-visualization button.cove-button.dashboard-condition-summary .dashboard-condition-summary__text {
58
+ display: block;
59
+ flex: 1 1 auto;
60
+ font-family: var(--app-font-main, sans-serif) !important;
61
+ font-size: 0.8rem !important;
62
+ font-weight: 400 !important;
63
+ line-height: 1.2;
64
+ min-width: 0;
65
+ overflow-wrap: break-word;
66
+ white-space: normal;
67
+ word-break: normal;
68
+ }
69
+
70
+ .cove-visualization button.cove-button.dashboard-condition-summary .dashboard-condition-summary__text strong {
71
+ font-family: inherit;
72
+ font-size: inherit;
73
+ font-weight: 700;
74
+ line-height: inherit;
75
+ }
76
+
77
+ .cove-visualization button.cove-button.dashboard-condition-summary.dashboard-condition-summary--row {
78
+ margin-top: 0.5rem;
79
+ }
80
+
81
+ .cove-visualization button.cove-button.dashboard-condition-summary.dashboard-condition-summary--widget {
82
+ align-items: flex-start !important;
83
+ flex: 0 0 auto;
84
+ margin: 0.5rem 0 0.4rem;
85
+ max-width: 100%;
86
+ width: 100%;
87
+ }
@@ -10,6 +10,7 @@ export default {
10
10
  label: 'Data Table',
11
11
  show: true,
12
12
  showDownloadUrl: false,
13
+ downloadUrlLabel: '',
13
14
  showDownloadLinkBelow: true,
14
15
  showVertical: true
15
16
  }
@@ -58,11 +58,9 @@ export const addValuesToDashboardFilters = (
58
58
  const active: string[] = Array.isArray(filterCopy.active) ? filterCopy.active : [filterCopy.active]
59
59
  filterCopy.active = active.filter(val => defaultValues.includes(val))
60
60
  } else {
61
- // Use defaultValue if set, otherwise keep existing active or use first value
62
- if (filterCopy.defaultValue) {
63
- filterCopy.active = filterCopy.defaultValue
64
- } else if (!filterCopy.active) {
65
- filterCopy.active = filterCopy.resetLabel || filterCopy.values[0]
61
+ // Only set active when it is not already set; prefer defaultValue, then resetLabel, then first value
62
+ if (!filterCopy.active) {
63
+ filterCopy.active = filterCopy.defaultValue || filterCopy.resetLabel || filterCopy.values[0]
66
64
  }
67
65
  }
68
66
  }
@@ -1,14 +1,22 @@
1
1
  import type { AnyVisualization } from '@cdc/core/types/Visualization'
2
2
  import type { Table } from '@cdc/core/types/Table'
3
+ import { createCoveId } from '@cdc/core/helpers/createCoveId'
4
+ import type { CreateCoveIdOptions } from '@cdc/core/helpers/createCoveId'
5
+
6
+ export const addVisualization = (type, subType, idOptions?: CreateCoveIdOptions) => {
7
+ if (type === 'filtered-text') {
8
+ throw new Error(
9
+ 'Cannot create new filtered-text visualizations. filtered-text is deprecated; use markup-include instead.'
10
+ )
11
+ }
3
12
 
4
- export const addVisualization = (type, subType) => {
5
13
  const modalWillOpen = type !== 'markup-include'
6
14
  const newVisualizationConfig: Partial<AnyVisualization> = {
7
15
  filters: [],
8
16
  filterBehavior: 'Filter Change',
9
17
  newViz: type !== 'table',
10
18
  openModal: modalWillOpen,
11
- uid: type + Date.now(),
19
+ uid: createCoveId(type, idOptions),
12
20
  type
13
21
  }
14
22
 
@@ -35,10 +43,12 @@ export const addVisualization = (type, subType) => {
35
43
  }
36
44
  break
37
45
  case 'data-bite':
38
- case 'waffle-chart':
39
- case 'filtered-text':
46
+ newVisualizationConfig.biteStyle = 'tp5'
40
47
  newVisualizationConfig.visualizationType = type
41
48
  break
49
+ case 'waffle-chart':
50
+ newVisualizationConfig.visualizationType = subType === 'Waffle' ? 'TP5 Waffle' : subType
51
+ break
42
52
  case 'table': {
43
53
  const tableConfig: Table = {
44
54
  label: 'Data Table',
@@ -59,6 +69,9 @@ export const addVisualization = (type, subType) => {
59
69
  break
60
70
  case 'dashboardFilters': {
61
71
  newVisualizationConfig.sharedFilterIndexes = []
72
+ newVisualizationConfig.visual = {
73
+ grayBackground: false
74
+ }
62
75
  newVisualizationConfig.visualizationType = type
63
76
  break
64
77
  }
@@ -0,0 +1,127 @@
1
+ import _ from 'lodash'
2
+ import type { AnyVisualization } from '@cdc/core/types/Visualization'
3
+ import { createCoveId } from '@cdc/core/helpers/createCoveId'
4
+ import type { DashboardConfig } from '../types/DashboardConfig'
5
+ import { ConfigRow, DashboardCondition } from '../types/ConfigRow'
6
+ import { getDashboardConditionIds } from './dashboardConditions'
7
+ import { getConditionalWidgets, hasConditionalWidgets, normalizeConditionalColumn } from './dashboardColumnWidgets'
8
+
9
+ export type CloneDashboardWidgetTarget = {
10
+ rowIdx: number
11
+ colIdx: number
12
+ entryIdx?: number
13
+ }
14
+
15
+ const normalizeTarget = (target: string | number) => `${target}`
16
+
17
+ const appendTarget = (targets: (string | number)[], target: string | number) => {
18
+ if (targets.some(existingTarget => normalizeTarget(existingTarget) === normalizeTarget(target))) return targets
19
+ return [...targets, target]
20
+ }
21
+
22
+ const createClonedWidgetKey = (sourceWidgetKey: string, visualizations: Record<string, AnyVisualization>) => {
23
+ const sourceVisualization = visualizations[sourceWidgetKey]
24
+ return createCoveId(sourceVisualization.type, { existingIds: Object.keys(visualizations) })
25
+ }
26
+
27
+ const getSourceDashboardCondition = (rows: ConfigRow[], sourceWidgetKey: string): DashboardCondition | undefined => {
28
+ for (const row of rows) {
29
+ for (const column of row.columns || []) {
30
+ if (!hasConditionalWidgets(column)) continue
31
+
32
+ const sourceEntry = getConditionalWidgets(column).find(entry => entry.widget === sourceWidgetKey)
33
+ if (sourceEntry?.dashboardCondition) return sourceEntry.dashboardCondition
34
+ }
35
+ }
36
+
37
+ return undefined
38
+ }
39
+
40
+ const getWidgetFilterTarget = (rows: ConfigRow[], widgetKey: string): string | number => {
41
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
42
+ const row = rows[rowIndex]
43
+ const widgetInRow = row.columns?.some(
44
+ column => column.widget === widgetKey || getConditionalWidgets(column).some(entry => entry.widget === widgetKey)
45
+ )
46
+
47
+ if (widgetInRow) {
48
+ return row.dataKey ? rowIndex : widgetKey
49
+ }
50
+ }
51
+
52
+ return widgetKey
53
+ }
54
+
55
+ export const cloneDashboardWidget = (
56
+ config: DashboardConfig,
57
+ sourceWidgetKey: string,
58
+ target: CloneDashboardWidgetTarget
59
+ ): DashboardConfig => {
60
+ const sourceVisualization = config.visualizations?.[sourceWidgetKey]
61
+ const targetColumn = config.rows?.[target.rowIdx]?.columns?.[target.colIdx]
62
+
63
+ if (!sourceVisualization || !targetColumn) return config
64
+
65
+ if (target.entryIdx !== undefined) {
66
+ const existingEntry = getConditionalWidgets(targetColumn)[target.entryIdx]
67
+ if (existingEntry?.widget) return config
68
+ } else if (targetColumn.widget || hasConditionalWidgets(targetColumn)) {
69
+ return config
70
+ }
71
+
72
+ const clonedWidgetKey = createClonedWidgetKey(sourceWidgetKey, config.visualizations)
73
+ const clonedVisualization = { ..._.cloneDeep(sourceVisualization), uid: clonedWidgetKey }
74
+ const sourceDashboardCondition = getSourceDashboardCondition(config.rows, sourceWidgetKey)
75
+ const clonedDashboardCondition = sourceDashboardCondition
76
+ ? {
77
+ ..._.cloneDeep(sourceDashboardCondition),
78
+ id: createCoveId('condition', { existingIds: getDashboardConditionIds(config.rows) })
79
+ }
80
+ : undefined
81
+
82
+ const nextRows = _.cloneDeep(config.rows)
83
+ const nextTargetColumn = nextRows[target.rowIdx].columns[target.colIdx]
84
+
85
+ if (target.entryIdx !== undefined || hasConditionalWidgets(nextTargetColumn) || clonedDashboardCondition) {
86
+ const nextConditionalWidgets = [...(nextTargetColumn.conditionalWidgets || [])]
87
+ const targetEntryIndex = target.entryIdx ?? nextConditionalWidgets.length
88
+ nextConditionalWidgets[targetEntryIndex] = {
89
+ widget: clonedWidgetKey,
90
+ ...(clonedDashboardCondition ? { dashboardCondition: clonedDashboardCondition } : {})
91
+ }
92
+ nextRows[target.rowIdx].columns[target.colIdx] = normalizeConditionalColumn({
93
+ ...nextTargetColumn,
94
+ widget: undefined,
95
+ conditionalWidgets: nextConditionalWidgets.filter(entry => !!entry?.widget)
96
+ })
97
+ } else {
98
+ nextRows[target.rowIdx].columns[target.colIdx].widget = clonedWidgetKey
99
+ }
100
+
101
+ const sourceFilterTarget = getWidgetFilterTarget(config.rows, sourceWidgetKey)
102
+ const clonedFilterTarget = nextRows[target.rowIdx]?.dataKey ? target.rowIdx : clonedWidgetKey
103
+ const sharedFilters = config.dashboard.sharedFilters?.map(sharedFilter => {
104
+ if (!sharedFilter.usedBy?.length) return sharedFilter
105
+
106
+ let nextUsedBy = sharedFilter.usedBy
107
+
108
+ if (sharedFilter.usedBy.some(target => normalizeTarget(target) === normalizeTarget(sourceFilterTarget))) {
109
+ nextUsedBy = appendTarget(nextUsedBy, clonedFilterTarget)
110
+ }
111
+
112
+ return nextUsedBy === sharedFilter.usedBy ? sharedFilter : { ...sharedFilter, usedBy: nextUsedBy }
113
+ })
114
+
115
+ return {
116
+ ...config,
117
+ dashboard: {
118
+ ...config.dashboard,
119
+ ...(sharedFilters ? { sharedFilters } : {})
120
+ },
121
+ rows: nextRows,
122
+ visualizations: {
123
+ ...config.visualizations,
124
+ [clonedWidgetKey]: clonedVisualization
125
+ }
126
+ }
127
+ }
@@ -0,0 +1,99 @@
1
+ import type { ConditionalWidget, DashboardCondition } from '../types/ConfigRow'
2
+
3
+ type ColumnWithWidgets = {
4
+ widget?: string | null
5
+ conditionalWidgets?: ConditionalWidget[]
6
+ }
7
+
8
+ export const hasConditionalWidgets = (column?: ColumnWithWidgets): boolean => !!column?.conditionalWidgets?.length
9
+
10
+ export const hasAuthoredWidgetEntries = (column?: ColumnWithWidgets): boolean =>
11
+ getColumnWidgetEntries(column).length > 0
12
+
13
+ export const getConditionalWidgets = (column?: ColumnWithWidgets): ConditionalWidget[] =>
14
+ column?.conditionalWidgets?.filter(entry => !!entry?.widget) || []
15
+
16
+ export const getColumnWidgetEntries = (column?: ColumnWithWidgets): ConditionalWidget[] => {
17
+ if (!column) return []
18
+ if (hasConditionalWidgets(column)) {
19
+ return getConditionalWidgets(column)
20
+ }
21
+
22
+ return column.widget ? [{ widget: column.widget }] : []
23
+ }
24
+
25
+ export const getColumnWidgetKeys = (column?: ColumnWithWidgets): string[] =>
26
+ getColumnWidgetEntries(column)
27
+ .map(entry => entry.widget)
28
+ .filter(Boolean)
29
+
30
+ export const getColumnPrimaryWidget = (column?: ColumnWithWidgets): string | undefined =>
31
+ getColumnWidgetEntries(column)[0]?.widget
32
+
33
+ export type DashboardConditionMatch = {
34
+ matches: boolean
35
+ resolved: boolean
36
+ }
37
+
38
+ export type ResolvedColumnWidgetEntry = {
39
+ widget?: string
40
+ dashboardCondition?: DashboardCondition
41
+ } & DashboardConditionMatch
42
+
43
+ type ResolveColumnWidgetOptions = {
44
+ evaluateCondition?: (dashboardCondition?: DashboardCondition) => DashboardConditionMatch
45
+ }
46
+
47
+ export const resolveColumnWidgetEntry = (
48
+ column?: ColumnWithWidgets,
49
+ { evaluateCondition = () => ({ matches: true, resolved: true }) }: ResolveColumnWidgetOptions = {}
50
+ ): ResolvedColumnWidgetEntry | undefined => {
51
+ const widgetEntries = getColumnWidgetEntries(column)
52
+
53
+ for (const entry of widgetEntries) {
54
+ if (!entry.dashboardCondition) {
55
+ return { ...entry, matches: true, resolved: true }
56
+ }
57
+
58
+ const evaluation = evaluateCondition(entry.dashboardCondition)
59
+ if (evaluation.matches) {
60
+ return { ...entry, ...evaluation }
61
+ }
62
+ }
63
+
64
+ return widgetEntries.length ? { widget: undefined, matches: false, resolved: true } : undefined
65
+ }
66
+
67
+ export const normalizeConditionalColumn = <T extends ColumnWithWidgets>(column: T): T => {
68
+ const conditionalWidgets = getConditionalWidgets(column)
69
+
70
+ if (conditionalWidgets.length > 1) {
71
+ return {
72
+ ...column,
73
+ widget: undefined,
74
+ conditionalWidgets
75
+ }
76
+ }
77
+
78
+ if (conditionalWidgets.length === 1) {
79
+ const [entry] = conditionalWidgets
80
+ if (entry.dashboardCondition) {
81
+ return {
82
+ ...column,
83
+ widget: undefined,
84
+ conditionalWidgets: [entry]
85
+ }
86
+ }
87
+
88
+ return {
89
+ ...column,
90
+ widget: entry.widget,
91
+ conditionalWidgets: undefined
92
+ }
93
+ }
94
+
95
+ return {
96
+ ...column,
97
+ conditionalWidgets: undefined
98
+ }
99
+ }
@@ -0,0 +1,47 @@
1
+ import { DashboardCondition } from '../types/ConfigRow'
2
+
3
+ export type DashboardConditionTypeOption = DashboardCondition['operator'] | ''
4
+ type DashboardConditionOperator = NonNullable<DashboardCondition['operator']>
5
+
6
+ export const DASHBOARD_CONDITION_TYPE_LABELS: Record<DashboardConditionOperator, string> = {
7
+ hasData: "Show when there's data",
8
+ hasNoData: "Show when there's no data",
9
+ columnHasAnyValue: 'Show when column has a value',
10
+ filtersIncomplete: 'Show when filters are incomplete'
11
+ }
12
+
13
+ export const DASHBOARD_CONDITION_TYPE_OPTIONS: { value: DashboardConditionTypeOption; label: string }[] = [
14
+ { value: '', label: 'Always show' },
15
+ { value: 'hasData', label: DASHBOARD_CONDITION_TYPE_LABELS.hasData },
16
+ { value: 'hasNoData', label: DASHBOARD_CONDITION_TYPE_LABELS.hasNoData },
17
+ { value: 'columnHasAnyValue', label: DASHBOARD_CONDITION_TYPE_LABELS.columnHasAnyValue },
18
+ { value: 'filtersIncomplete', label: DASHBOARD_CONDITION_TYPE_LABELS.filtersIncomplete }
19
+ ]
20
+
21
+ export const getColumnHasAnyValueSummaryParts = (columnName?: string) => {
22
+ if (!columnName) return undefined
23
+
24
+ return {
25
+ prefix: 'Show based on the value in the ',
26
+ columnName,
27
+ suffix: ' column'
28
+ }
29
+ }
30
+
31
+ export const getColumnHasAnyValueSummary = (columnName?: string) => {
32
+ const summaryParts = getColumnHasAnyValueSummaryParts(columnName)
33
+
34
+ if (!summaryParts) return DASHBOARD_CONDITION_TYPE_LABELS.columnHasAnyValue
35
+
36
+ return `${summaryParts.prefix}${summaryParts.columnName}${summaryParts.suffix}`
37
+ }
38
+
39
+ export const getDashboardConditionSummary = (dashboardCondition?: DashboardCondition) => {
40
+ if (!dashboardCondition?.operator) return 'Dashboard condition configured'
41
+
42
+ if (dashboardCondition.operator === 'columnHasAnyValue') {
43
+ return getColumnHasAnyValueSummary(dashboardCondition.columnName)
44
+ }
45
+
46
+ return DASHBOARD_CONDITION_TYPE_LABELS[dashboardCondition.operator] || 'Dashboard condition configured'
47
+ }
@@ -0,0 +1,200 @@
1
+ import { Dashboard } from '../types/Dashboard'
2
+ import { ConfigRow, DashboardCondition } from '../types/ConfigRow'
3
+ import { createCoveId } from '@cdc/core/helpers/createCoveId'
4
+ import { getConditionalWidgets, hasConditionalWidgets } from './dashboardColumnWidgets'
5
+ import { filterData, isFilterAtResetState } from './filterData'
6
+ import {
7
+ dashboardConditionsSupportedForRow,
8
+ getApplicableFiltersForTarget,
9
+ SharedFilterTarget
10
+ } from './dashboardFilterTargets'
11
+
12
+ export type DashboardConditionEvaluation = {
13
+ matches: boolean
14
+ resolved: boolean
15
+ }
16
+
17
+ export const getDashboardConditionIds = (rows: ConfigRow[] = []) =>
18
+ rows.flatMap(row => {
19
+ if (Array.isArray(row)) return []
20
+
21
+ const rowConditionIds = row.dashboardCondition?.id ? [row.dashboardCondition.id] : []
22
+ const columnConditionIds =
23
+ row.columns?.flatMap(column =>
24
+ getConditionalWidgets(column).flatMap(entry =>
25
+ entry.dashboardCondition?.id ? [entry.dashboardCondition.id] : []
26
+ )
27
+ ) || []
28
+
29
+ return [...rowConditionIds, ...columnConditionIds]
30
+ })
31
+
32
+ // This runs during initial config preparation before coveUpdateWorker normalizes legacy dashboard rows.
33
+ // Preserve pre-4.24.3 array-shaped rows for packages/core/helpers/ver/4.24.3.ts, and tolerate rows
34
+ // without normalized columns. This constraint can be removed if CdcDashboard.tsx runs coveUpdateWorker
35
+ // before getUpdateConfig in formatInitialState.
36
+ export const ensureRowConditionIds = (rows: ConfigRow[]): ConfigRow[] => {
37
+ const existingConditionIds = new Set(getDashboardConditionIds(rows).map(id => String(id)))
38
+
39
+ return rows.map(row => {
40
+ if (Array.isArray(row)) return row
41
+
42
+ const nextRow = { ...row }
43
+
44
+ if (nextRow.dashboardCondition && !nextRow.dashboardCondition.id) {
45
+ const id = createCoveId('condition', { existingIds: existingConditionIds })
46
+ existingConditionIds.add(id)
47
+ nextRow.dashboardCondition = { ...nextRow.dashboardCondition, id }
48
+ }
49
+
50
+ if (Array.isArray(nextRow.columns)) {
51
+ nextRow.columns = nextRow.columns.map(column => {
52
+ if (hasConditionalWidgets(column)) {
53
+ return {
54
+ ...column,
55
+ conditionalWidgets: getConditionalWidgets(column).map(entry => {
56
+ if (!entry.dashboardCondition || entry.dashboardCondition.id) return entry
57
+
58
+ const id = createCoveId('condition', { existingIds: existingConditionIds })
59
+ existingConditionIds.add(id)
60
+
61
+ return {
62
+ ...entry,
63
+ dashboardCondition: {
64
+ ...entry.dashboardCondition,
65
+ id
66
+ }
67
+ }
68
+ })
69
+ }
70
+ }
71
+ return column
72
+ })
73
+ }
74
+
75
+ return nextRow
76
+ })
77
+ }
78
+
79
+ const dashboardConditionHasRequiredInputs = (dashboardCondition?: DashboardCondition) => {
80
+ if (!dashboardCondition?.operator) return false
81
+ if (dashboardCondition.operator === 'filtersIncomplete') return true
82
+ if (!dashboardCondition.datasetKey) return false
83
+ if (dashboardCondition.operator !== 'columnHasAnyValue') return true
84
+
85
+ return !!dashboardCondition.columnName && !!dashboardCondition.values?.length
86
+ }
87
+
88
+ export const dashboardConditionUsesFiltersIncomplete = (dashboardCondition?: DashboardCondition) =>
89
+ dashboardCondition?.operator === 'filtersIncomplete'
90
+
91
+ export const dashboardRowsUseFiltersIncomplete = (rows: ConfigRow[] = []) =>
92
+ rows.some(row => {
93
+ if (!dashboardConditionsSupportedForRow(row)) return false
94
+ if (dashboardConditionUsesFiltersIncomplete(row.dashboardCondition)) return true
95
+
96
+ return row.columns?.some(column =>
97
+ getConditionalWidgets(column).some(entry => dashboardConditionUsesFiltersIncomplete(entry.dashboardCondition))
98
+ )
99
+ })
100
+
101
+ export const getDashboardConditionDatasetKeys = (rows: ConfigRow[] = []) =>
102
+ rows.flatMap(row => {
103
+ if (!dashboardConditionsSupportedForRow(row)) return []
104
+
105
+ const rowDatasetKey = row.dashboardCondition?.datasetKey ? [row.dashboardCondition.datasetKey] : []
106
+ const columnDatasetKeys =
107
+ row.columns?.flatMap(column =>
108
+ getConditionalWidgets(column).flatMap(entry =>
109
+ entry.dashboardCondition?.datasetKey ? [entry.dashboardCondition.datasetKey] : []
110
+ )
111
+ ) || []
112
+
113
+ return [...rowDatasetKey, ...columnDatasetKeys]
114
+ })
115
+
116
+ const getDashboardConditionApplicableFilters = (
117
+ dashboard: Dashboard,
118
+ filterTarget: SharedFilterTarget,
119
+ datasetColumns: string[]
120
+ ) => {
121
+ const candidateFilters = getApplicableFiltersForTarget(dashboard, filterTarget, { includeUnscoped: true })
122
+ if (!candidateFilters) return []
123
+
124
+ return candidateFilters.filter(filter => !!filter.columnName && datasetColumns.includes(filter.columnName))
125
+ }
126
+
127
+ export const hasIncompleteFiltersForDashboardCondition = (
128
+ dashboardCondition: DashboardCondition | undefined,
129
+ dashboard: Dashboard,
130
+ filterTarget: SharedFilterTarget = ''
131
+ ) => {
132
+ if (!dashboardCondition?.id) return false
133
+
134
+ const applicableFilters = getApplicableFiltersForTarget(dashboard, filterTarget, { includeUnscoped: true })
135
+ if (!applicableFilters) return false
136
+
137
+ return applicableFilters.some(isFilterAtResetState)
138
+ }
139
+
140
+ export const getDashboardConditionFilteredData = (
141
+ dashboardCondition: DashboardCondition | undefined,
142
+ dashboard: Dashboard,
143
+ data: Record<string, any[]>,
144
+ filterTarget: SharedFilterTarget = ''
145
+ ): Record<string, any>[] | undefined => {
146
+ if (!dashboardConditionHasRequiredInputs(dashboardCondition)) {
147
+ return undefined
148
+ }
149
+
150
+ if (dashboardCondition.operator === 'filtersIncomplete') {
151
+ return hasIncompleteFiltersForDashboardCondition(dashboardCondition, dashboard, filterTarget) ? [{}] : []
152
+ }
153
+
154
+ const rawDataset = data[dashboardCondition.datasetKey]
155
+ if (!Array.isArray(rawDataset)) {
156
+ return undefined
157
+ }
158
+ const dataset = rawDataset || []
159
+
160
+ const datasetColumns = dataset[0] ? Object.keys(dataset[0]) : []
161
+ const applicableFilters = getDashboardConditionApplicableFilters(dashboard, filterTarget, datasetColumns)
162
+
163
+ if (applicableFilters.some(isFilterAtResetState)) {
164
+ return undefined
165
+ }
166
+
167
+ return applicableFilters.length ? filterData(applicableFilters, dataset) : dataset
168
+ }
169
+
170
+ export const evaluateDashboardCondition = (
171
+ dashboardCondition: DashboardCondition | undefined,
172
+ filteredData?: Record<string, any>[]
173
+ ): DashboardConditionEvaluation => {
174
+ if (!dashboardConditionHasRequiredInputs(dashboardCondition) || filteredData === undefined) {
175
+ return { matches: false, resolved: false }
176
+ }
177
+
178
+ if (dashboardCondition.operator === 'hasData') {
179
+ return { matches: filteredData.length > 0, resolved: true }
180
+ }
181
+
182
+ if (dashboardCondition.operator === 'hasNoData') {
183
+ return { matches: filteredData.length === 0, resolved: true }
184
+ }
185
+
186
+ if (dashboardCondition.operator === 'filtersIncomplete') {
187
+ return { matches: filteredData.length > 0, resolved: true }
188
+ }
189
+
190
+ const matches = filteredData.some(row => {
191
+ const currentValue = row?.[dashboardCondition.columnName]
192
+ return (
193
+ currentValue !== undefined &&
194
+ currentValue !== null &&
195
+ dashboardCondition.values?.some(value => String(currentValue) === String(value))
196
+ )
197
+ })
198
+
199
+ return { matches, resolved: true }
200
+ }