@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.
- package/CONFIG.md +77 -30
- package/LICENSE +201 -0
- package/dist/cdcdashboard.js +49936 -49166
- package/examples/dashboard-conditions-filters-incomplete.json +221 -0
- package/examples/dashboard-missing-datasets-multi.json +174 -0
- package/examples/dashboard-missing-datasets-single.json +121 -0
- package/examples/dashboard-multi-dashboard-version-regression.json +146 -0
- package/examples/dashboard-shared-filter-row-delete-cleanup.json +186 -0
- package/examples/dashboard-stale-dataset-keys.json +181 -0
- package/examples/dashboard-tiered-filter-regression.json +190 -0
- package/examples/private/cfa-dashboard.json +651 -0
- package/examples/private/data-bite-wrap.json +6936 -0
- package/examples/private/multi-dash-fix.json +16963 -0
- package/examples/private/versions.json +41612 -0
- package/examples/us-map-filter-example.json +1074 -0
- package/package.json +9 -9
- package/src/CdcDashboard.tsx +6 -2
- package/src/CdcDashboardComponent.tsx +178 -87
- package/src/DashboardCopyPasteContext.test.tsx +33 -0
- package/src/DashboardCopyPasteContext.tsx +48 -0
- package/src/_stories/Dashboard.EditorRegression.stories.tsx +72 -0
- package/src/_stories/Dashboard.Regression.stories.tsx +196 -0
- package/src/_stories/Dashboard.Zoom.stories.tsx +88 -0
- package/src/_stories/Dashboard.stories.tsx +294 -0
- package/src/_stories/FilteredTextMigrationComparison.stories.tsx +87 -0
- package/src/components/Column.test.tsx +176 -0
- package/src/components/Column.tsx +214 -13
- package/src/components/DashboardConditionModal.test.tsx +420 -0
- package/src/components/DashboardConditionModal.tsx +367 -0
- package/src/components/DashboardConditionSummary.tsx +59 -0
- package/src/components/DashboardEditors.tsx +8 -0
- package/src/components/DashboardFilters/DashboardFilters.test.tsx +139 -1
- package/src/components/DashboardFilters/DashboardFilters.tsx +192 -174
- package/src/components/DashboardFilters/DashboardFiltersEditor/DashboardFiltersEditor.test.tsx +164 -0
- package/src/components/DashboardFilters/DashboardFiltersEditor/DashboardFiltersEditor.tsx +41 -2
- package/src/components/DashboardFilters/DashboardFiltersEditor/components/FilterEditor.test.tsx +180 -3
- package/src/components/DashboardFilters/DashboardFiltersEditor/components/FilterEditor.tsx +15 -32
- package/src/components/DashboardFilters/DashboardFiltersWrapper.test.tsx +142 -0
- package/src/components/DashboardFilters/DashboardFiltersWrapper.tsx +32 -27
- package/src/components/DashboardFilters/dashboardfilter.styles.css +42 -27
- package/src/components/DataDesignerModal.tsx +2 -1
- package/src/components/Grid.tsx +8 -4
- package/src/components/Header/Header.tsx +36 -17
- package/src/components/Row.test.tsx +228 -0
- package/src/components/Row.tsx +93 -18
- package/src/components/VisualizationRow.test.tsx +396 -0
- package/src/components/VisualizationRow.tsx +110 -35
- package/src/components/VisualizationsPanel/VisualizationsPanel.test.tsx +49 -0
- package/src/components/VisualizationsPanel/VisualizationsPanel.tsx +14 -13
- package/src/components/Widget/Widget.test.tsx +218 -0
- package/src/components/Widget/Widget.tsx +119 -17
- package/src/components/Widget/widget.styles.css +31 -18
- package/src/components/dashboard-condition-modal.css +76 -0
- package/src/components/dashboard-condition-summary.css +87 -0
- package/src/helpers/addValuesToDashboardFilters.ts +3 -5
- package/src/helpers/addVisualization.ts +15 -4
- package/src/helpers/cloneDashboardWidget.ts +127 -0
- package/src/helpers/dashboardColumnWidgets.ts +99 -0
- package/src/helpers/dashboardConditionUi.ts +47 -0
- package/src/helpers/dashboardConditions.ts +200 -0
- package/src/helpers/dashboardFilterTargets.ts +156 -0
- package/src/helpers/filterData.ts +4 -9
- package/src/helpers/filterVisibility.ts +20 -0
- package/src/helpers/formatConfigBeforeSave.ts +2 -2
- package/src/helpers/getFilteredData.ts +18 -5
- package/src/helpers/getUpdateConfig.ts +43 -12
- package/src/helpers/getVizRowColumnLocator.ts +11 -1
- package/src/helpers/iconHash.tsx +9 -3
- package/src/helpers/mapDataToConfig.ts +31 -29
- package/src/helpers/reloadURLHelpers.ts +25 -5
- package/src/helpers/removeDashboardFilter.ts +33 -33
- package/src/helpers/tests/addVisualization.test.ts +53 -9
- package/src/helpers/tests/cloneDashboardWidget.test.ts +136 -0
- package/src/helpers/tests/dashboardColumnWidgets.test.ts +99 -0
- package/src/helpers/tests/dashboardConditionUi.test.ts +41 -0
- package/src/helpers/tests/dashboardConditions.test.ts +428 -0
- package/src/helpers/tests/formatConfigBeforeSave.test.ts +51 -0
- package/src/helpers/tests/getFilteredData.test.ts +265 -86
- package/src/helpers/tests/getUpdateConfig.test.ts +338 -0
- package/src/helpers/tests/reloadURLHelpers.test.ts +394 -238
- package/src/index.tsx +6 -3
- package/src/scss/grid.scss +249 -20
- package/src/scss/main.scss +108 -29
- package/src/store/dashboard.actions.ts +17 -4
- package/src/store/dashboard.reducer.test.ts +538 -0
- package/src/store/dashboard.reducer.ts +135 -22
- package/src/test/CdcDashboard.test.tsx +148 -0
- package/src/test/CdcDashboardComponent.test.tsx +935 -2
- package/src/types/ConfigRow.ts +15 -0
- package/src/types/DashboardFilters.ts +4 -0
- 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 = (
|
|
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
|
package/src/helpers/iconHash.tsx
CHANGED
|
@@ -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
|
-
|
|
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':
|
|
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(
|
|
6
|
-
const viz = config.visualizations[vizKey]
|
|
7
|
-
if (viz.dataKey && !viz.data) {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
config.visualizations[vizKey].
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
config.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
|
130
|
+
const dashboardConditionTargets = getDashboardConditionTargets(rows)
|
|
117
131
|
|
|
118
|
-
return
|
|
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
|
-
|
|
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(
|
|
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: '
|
|
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(
|
|
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: '
|
|
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('
|
|
46
|
-
vi.spyOn(
|
|
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('
|
|
50
|
-
|
|
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
|
+
})
|