@cdc/dashboard 4.24.3 → 4.24.4
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/dist/cdcdashboard.js +119414 -103311
- package/examples/chart-data.json +5409 -0
- package/examples/full-dash-test.json +14643 -0
- package/examples/full-dashboard.json +10036 -0
- package/index.html +2 -2
- package/package.json +9 -9
- package/src/CdcDashboard.tsx +8 -3
- package/src/CdcDashboardComponent.tsx +232 -344
- package/src/_stories/Dashboard.stories.tsx +59 -38
- package/src/_stories/_mock/api-filter-chart.json +11 -35
- package/src/_stories/_mock/api-filter-map.json +17 -31
- package/src/_stories/_mock/dashboard-gallery.json +523 -534
- package/src/_stories/_mock/multi-viz.json +378 -0
- package/src/_stories/_mock/pivot-filter.json +0 -2
- package/src/components/DataDesignerModal.tsx +145 -0
- package/src/components/Grid.tsx +3 -1
- package/src/components/Header/FilterModal.tsx +49 -23
- package/src/components/Row.tsx +50 -25
- package/src/components/Toggle/Toggle.tsx +6 -7
- package/src/components/VisualizationRow.tsx +174 -0
- package/src/components/Widget.tsx +21 -103
- package/src/helpers/filterData.ts +16 -14
- package/src/helpers/getFilteredData.ts +39 -0
- package/src/helpers/getUpdateConfig.ts +15 -0
- package/src/helpers/getVizConfig.ts +31 -0
- package/src/helpers/getVizRowColumnLocator.ts +9 -0
- package/src/scss/grid.scss +9 -2
- package/src/scss/main.scss +5 -0
- package/src/store/dashboard.actions.ts +16 -1
- package/src/store/dashboard.reducer.ts +25 -2
- package/src/types/APIFilter.ts +4 -5
- package/src/types/ConfigRow.ts +12 -3
- package/src/types/DataSet.ts +11 -8
- package/src/types/SharedFilter.ts +1 -1
|
@@ -60,8 +60,11 @@ import EditorWrapper from './components/EditorWrapper/EditorWrapper'
|
|
|
60
60
|
import DataTableEditorPanel from '@cdc/core/components/DataTable/components/DataTableEditorPanel'
|
|
61
61
|
import DataTableStandAlone from '@cdc/core/components/DataTable/DataTableStandAlone'
|
|
62
62
|
import { ViewPort } from '@cdc/core/types/ViewPort'
|
|
63
|
-
import
|
|
64
|
-
import {
|
|
63
|
+
import VisualizationRow from './components/VisualizationRow'
|
|
64
|
+
import { getVizConfig } from './helpers/getVizConfig'
|
|
65
|
+
import { getApplicableFilters, getFilteredData } from './helpers/getFilteredData'
|
|
66
|
+
import { getVizRowColumnLocator } from './helpers/getVizRowColumnLocator'
|
|
67
|
+
import Layout from '@cdc/core/components/Layout'
|
|
65
68
|
|
|
66
69
|
type DashboardProps = Omit<WCMSProps, 'configUrl'> & {
|
|
67
70
|
initialState: InitialState
|
|
@@ -87,6 +90,8 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
87
90
|
return vals.some(val => val === undefined)
|
|
88
91
|
}, [state.data])
|
|
89
92
|
|
|
93
|
+
const vizRowColumnLocator = getVizRowColumnLocator(state.config.rows)
|
|
94
|
+
|
|
90
95
|
const getAutoLoadVisualization = (): Visualization | undefined => {
|
|
91
96
|
const autoLoadViz = Object.values(state.config.visualizations).filter(vis => {
|
|
92
97
|
return vis.autoLoad && vis.type === 'filter-dropdowns'
|
|
@@ -98,7 +103,7 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
98
103
|
|
|
99
104
|
const transform = new DataTransform()
|
|
100
105
|
|
|
101
|
-
const setAutoLoadDefaultValue = (sharedFilterIndex: number, filterDropdowns: DropdownOptions) => {
|
|
106
|
+
const setAutoLoadDefaultValue = (sharedFilterIndex: number, filterDropdowns: DropdownOptions, dashboardConfigOverride) => {
|
|
102
107
|
const autoLoadViz = getAutoLoadVisualization()
|
|
103
108
|
if (!autoLoadViz) return // no autoLoading happening
|
|
104
109
|
const notIncludedInAutoLoad = autoLoadViz.hide
|
|
@@ -106,20 +111,22 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
106
111
|
// we don't want to auto load it
|
|
107
112
|
return
|
|
108
113
|
} else {
|
|
109
|
-
const
|
|
114
|
+
const sharedFilters = dashboardConfigOverride.sharedFilters
|
|
115
|
+
const sharedFilter = sharedFilters[sharedFilterIndex]
|
|
110
116
|
if (sharedFilter.active) return // a value has already been selected.
|
|
111
|
-
const filterParents =
|
|
112
|
-
const notAllParentFiltersSelected = filterParents.some(p => !p.active)
|
|
117
|
+
const filterParents = sharedFilters.filter(f => sharedFilter.parents?.includes(f.key))
|
|
118
|
+
const notAllParentFiltersSelected = filterParents.some(p => !p.active && !p.queuedActive)
|
|
113
119
|
if (filterParents && notAllParentFiltersSelected) return
|
|
114
120
|
const defaultFilterDropdown = filterDropdowns.find(({ value }) => value === sharedFilter.apiFilter!.defaultValue)
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
const defaultValue = defaultFilterDropdown?.value || filterDropdowns[0].value
|
|
122
|
+
sharedFilter.active = defaultValue
|
|
117
123
|
}
|
|
118
124
|
}
|
|
119
125
|
|
|
120
|
-
const loadAPIFilters =
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
const loadAPIFilters = (dashboardConfigOverride = undefined) => {
|
|
127
|
+
const sharedFilters = (dashboardConfigOverride || state.config.dashboard).sharedFilters
|
|
128
|
+
if (sharedFilters) {
|
|
129
|
+
const sharedAPIFilters = sharedFilters.filter(f => f.apiFilter)
|
|
123
130
|
const loadingFilterMemo: APIFilterDropdowns = sharedAPIFilters.reduce((acc, curr) => {
|
|
124
131
|
const _key = getApiFilterKey(curr.apiFilter!)
|
|
125
132
|
if (apiFilterDropdowns[_key] != null) return acc // don't overwrite fetched data.
|
|
@@ -131,63 +138,69 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
131
138
|
const getParentParams = (childFilter: SharedFilter): Record<'key' | 'value', string>[] | null => {
|
|
132
139
|
const _parents = sharedAPIFilters.filter(parentFilter => childFilter.parents?.includes(parentFilter.key))
|
|
133
140
|
if (!_parents.length) return null
|
|
134
|
-
return _parents.map(({ queryParameter, queuedActive }) => ({ key: queryParameter || '', value: queuedActive || '' }))
|
|
141
|
+
return _parents.map(({ queryParameter, active, queuedActive }) => ({ key: queryParameter || '', value: active || queuedActive || '' }))
|
|
135
142
|
}
|
|
136
|
-
const getFilterValues = (data:
|
|
137
|
-
const { textSelector, valueSelector
|
|
138
|
-
if (heirarchyLookup) {
|
|
139
|
-
const heirarchy = heirarchyLookup!.split('.')
|
|
140
|
-
const selector = heirarchy.shift() // pop first element
|
|
141
|
-
return getFilterValues(selector ? data[selector] : data, { ...apiFilter, heirarchyLookup: heirarchy.join('.') })
|
|
142
|
-
}
|
|
143
|
-
if (!Array.isArray(data)) throw new Error('the filter data has requires a heirarchy path to access the filter values, This should be in the format key.subkey.subsubkey')
|
|
143
|
+
const getFilterValues = (data: Array<Object>, apiFilter: APIFilter): DropdownOptions => {
|
|
144
|
+
const { textSelector, valueSelector } = apiFilter
|
|
144
145
|
return data.map(v => ({ text: v[textSelector], value: v[valueSelector] }))
|
|
145
146
|
}
|
|
146
|
-
|
|
147
|
-
|
|
147
|
+
const toFetch = {}
|
|
148
|
+
sharedAPIFilters.forEach((filter, index) => {
|
|
148
149
|
const baseEndpoint = filter.apiFilter.apiEndpoint
|
|
149
150
|
const _key = getApiFilterKey(filter.apiFilter)
|
|
150
151
|
const params = getParentParams(filter)
|
|
151
152
|
const notAllParentsSelected = params?.some(({ value }) => value === '')
|
|
153
|
+
|
|
152
154
|
if (notAllParentsSelected) return // don't send request for dependent children filter options
|
|
153
|
-
if (apiFilterDropdowns[_key] && !params
|
|
154
|
-
const
|
|
155
|
+
if (apiFilterDropdowns[_key] && !params) return // don't reload filter unless it's a child
|
|
156
|
+
const topLevelDataAlreadyLoaded = apiFilterDropdowns[_key] && !filter.parents
|
|
157
|
+
if (topLevelDataAlreadyLoaded) return // don't reload top level filters
|
|
155
158
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
.then(data => {
|
|
159
|
-
const apiFilter = filterLookup.get(_key) as APIFilter
|
|
160
|
-
const _filterValues = getFilterValues(data, apiFilter)
|
|
161
|
-
setAPIFilterDropdowns(dropdowns => ({ ...dropdowns, [_key]: _filterValues }))
|
|
162
|
-
setAutoLoadDefaultValue(index, _filterValues)
|
|
163
|
-
})
|
|
159
|
+
const endpoint = baseEndpoint + (params ? gatherQueryParams(params) : '')
|
|
160
|
+
toFetch[endpoint] = [_key, index]
|
|
164
161
|
})
|
|
162
|
+
return Promise.all(
|
|
163
|
+
Object.keys(toFetch).map(
|
|
164
|
+
endpoint =>
|
|
165
|
+
new Promise<void>(resolve => {
|
|
166
|
+
fetch(endpoint)
|
|
167
|
+
.then(resp => resp.json())
|
|
168
|
+
.then(data => {
|
|
169
|
+
const [_key, index] = toFetch[endpoint]
|
|
170
|
+
if (!Array.isArray(data)) throw new Error('COVE only supports response data in the shape Array<Object>')
|
|
171
|
+
const apiFilter = filterLookup.get(_key) as APIFilter
|
|
172
|
+
const _filterValues = getFilterValues(data, apiFilter)
|
|
173
|
+
setAPIFilterDropdowns(dropdowns => ({ ...dropdowns, [_key]: _filterValues }))
|
|
174
|
+
setAutoLoadDefaultValue(index, _filterValues, dashboardConfigOverride)
|
|
175
|
+
})
|
|
176
|
+
.catch(console.error)
|
|
177
|
+
.finally(() => {
|
|
178
|
+
resolve()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
)
|
|
182
|
+
)
|
|
165
183
|
}
|
|
166
184
|
}
|
|
167
185
|
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
return c?.length > 0 ? c : false
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const reloadURLData = async () => {
|
|
174
|
-
const { config } = state
|
|
186
|
+
const reloadURLData = async (dashboardConfigOverride = undefined) => {
|
|
187
|
+
const config = _.cloneDeep(state.config)
|
|
175
188
|
if (!config.datasets) return
|
|
176
189
|
let newData = { ...state.data }
|
|
177
190
|
let newDatasets = { ...config.datasets }
|
|
178
191
|
let datasetsNeedsUpdate = false
|
|
179
192
|
let datasetKeys = Object.keys(config.datasets)
|
|
180
193
|
let newFileName = ''
|
|
181
|
-
|
|
194
|
+
const filters = (dashboardConfigOverride || config.dashboard)?.sharedFilters
|
|
182
195
|
for (let i = 0; i < datasetKeys.length; i++) {
|
|
183
196
|
const datasetKey = datasetKeys[i]
|
|
184
197
|
const dataset = config.datasets[datasetKey]
|
|
185
|
-
|
|
198
|
+
|
|
186
199
|
if (dataset.dataUrl && filters) {
|
|
187
200
|
const dataUrl = new URL(dataset.runtimeDataUrl || dataset.dataUrl, window.location.origin)
|
|
188
201
|
let currentQSParams = Object.fromEntries(new URLSearchParams(dataUrl.search))
|
|
189
202
|
let updatedQSParams = {}
|
|
190
|
-
let isUpdateNeeded =
|
|
203
|
+
let isUpdateNeeded = !!dashboardConfigOverride
|
|
191
204
|
|
|
192
205
|
filters.forEach(filter => {
|
|
193
206
|
// filter.active is always a string when filter.type is 'urlfilter'
|
|
@@ -259,25 +272,20 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
259
272
|
}
|
|
260
273
|
|
|
261
274
|
if (datasetsNeedsUpdate) {
|
|
275
|
+
const dashboardConfig = dashboardConfigOverride || config.dashboard
|
|
262
276
|
dispatch({ type: 'SET_DATA', payload: newData })
|
|
263
277
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
let dataKey = config.visualizations[key].dataKey
|
|
268
|
-
|
|
269
|
-
const applicableFilters = getApplicableFilters(config.dashboard, key)
|
|
270
|
-
if (applicableFilters) {
|
|
271
|
-
newFilteredData[key] = filterData(applicableFilters, newData[dataKey])
|
|
272
|
-
}
|
|
273
|
-
|
|
278
|
+
const newFilteredData = getFilteredData(state, {}, newData)
|
|
279
|
+
const visualizations = Object.keys(config.visualizations).reduce((acc, vizKey) => {
|
|
280
|
+
const dataKey = config.visualizations[vizKey].dataKey
|
|
274
281
|
if (newData[dataKey]) {
|
|
275
|
-
|
|
282
|
+
acc[vizKey].formattedData = newData[dataKey]
|
|
276
283
|
}
|
|
277
|
-
|
|
284
|
+
return acc
|
|
285
|
+
}, _.cloneDeep(config.visualizations))
|
|
278
286
|
|
|
279
287
|
dispatch({ type: 'SET_FILTERED_DATA', payload: newFilteredData })
|
|
280
|
-
dispatch({ type: 'SET_CONFIG', payload: {
|
|
288
|
+
dispatch({ type: 'SET_CONFIG', payload: { dashboard: dashboardConfig, datasets: newDatasets, visualizations } })
|
|
281
289
|
}
|
|
282
290
|
}
|
|
283
291
|
|
|
@@ -292,9 +300,8 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
292
300
|
}
|
|
293
301
|
|
|
294
302
|
const setSharedFilter = (key, datum) => {
|
|
295
|
-
const { config } = state
|
|
296
|
-
|
|
297
|
-
let newFilteredData = { ...state.filteredData }
|
|
303
|
+
const { config: newConfig, filteredData } = _.cloneDeep(state)
|
|
304
|
+
|
|
298
305
|
for (let i = 0; i < newConfig.dashboard.sharedFilters.length; i++) {
|
|
299
306
|
const filter = newConfig.dashboard.sharedFilters[i]
|
|
300
307
|
if (filter.setBy === key) {
|
|
@@ -305,19 +312,10 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
305
312
|
}
|
|
306
313
|
}
|
|
307
314
|
|
|
308
|
-
|
|
309
|
-
const applicableFilters = getApplicableFilters(newConfig.dashboard, visualizationKey)
|
|
310
|
-
if (applicableFilters) {
|
|
311
|
-
const visualization = newConfig.visualizations[visualizationKey]
|
|
312
|
-
|
|
313
|
-
const formattedData = visualization.dataDescription ? getFormattedData(state.data[visualization.dataKey] || visualization.data, visualization.dataDescription) : undefined
|
|
314
|
-
|
|
315
|
-
newFilteredData[visualizationKey] = filterData(applicableFilters, formattedData || state.data[visualization.dataKey])
|
|
316
|
-
}
|
|
317
|
-
})
|
|
315
|
+
const newFilteredData = getFilteredData({ ...state, config: newConfig }, filteredData)
|
|
318
316
|
|
|
319
317
|
dispatch({ type: 'SET_FILTERED_DATA', payload: newFilteredData })
|
|
320
|
-
dispatch({ type: '
|
|
318
|
+
dispatch({ type: 'SET_SHARED_FILTERS', payload: newConfig.dashboard.sharedFilters })
|
|
321
319
|
}
|
|
322
320
|
|
|
323
321
|
useEffect(() => {
|
|
@@ -326,19 +324,19 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
326
324
|
reloadURLData()
|
|
327
325
|
}
|
|
328
326
|
loadAPIFilters()
|
|
329
|
-
}, [
|
|
327
|
+
}, [])
|
|
330
328
|
|
|
331
329
|
const updateChildConfig = (visualizationKey, newConfig) => {
|
|
332
|
-
const
|
|
333
|
-
|
|
330
|
+
const config = _.cloneDeep(state.config)
|
|
331
|
+
const updatedConfig = _.pick(config, ['visualizations', 'multiDashboards'])
|
|
334
332
|
updatedConfig.visualizations[visualizationKey] = newConfig
|
|
335
333
|
updatedConfig.visualizations[visualizationKey].formattedData = config.visualizations[visualizationKey].formattedData
|
|
336
334
|
if (config.multiDashboards) {
|
|
337
335
|
const activeDashboard = config.activeDashboard
|
|
338
336
|
const multiDashboards = [...config.multiDashboards]
|
|
339
337
|
const label = multiDashboards[activeDashboard].label
|
|
340
|
-
const toSave = _.pick(
|
|
341
|
-
multiDashboards[activeDashboard] =
|
|
338
|
+
const toSave = { label, visualizations: updatedConfig.visualizations, ..._.pick(config, ['dashboard', 'rows']) }
|
|
339
|
+
multiDashboards[activeDashboard] = toSave
|
|
342
340
|
updatedConfig.multiDashboards = multiDashboards
|
|
343
341
|
}
|
|
344
342
|
|
|
@@ -350,13 +348,22 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
350
348
|
}
|
|
351
349
|
|
|
352
350
|
const applyFilters = () => {
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
351
|
+
const dashboardConfig = _.cloneDeep(state.config.dashboard)
|
|
352
|
+
const autoLoadViz = getAutoLoadVisualization()
|
|
353
|
+
const nonAutoLoadFilterIndexes = autoLoadViz?.hide || []
|
|
354
|
+
const allRequiredFiltersSelected = !dashboardConfig.sharedFilters.some((filter, filterIndex) => {
|
|
355
|
+
if (nonAutoLoadFilterIndexes.includes(filterIndex)) {
|
|
356
|
+
!filter.active && !filter.queuedActive
|
|
357
|
+
} else {
|
|
358
|
+
// autoload filters don't need to be selected to apply filters
|
|
359
|
+
return false
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
if (allRequiredFiltersSelected) {
|
|
356
363
|
if (state.config.filterBehavior === FilterBehavior.Apply) {
|
|
357
364
|
const queryParams = getQueryParams()
|
|
358
365
|
let needsQueryUpdate = false
|
|
359
|
-
|
|
366
|
+
dashboardConfig.sharedFilters.forEach((sharedFilter, index) => {
|
|
360
367
|
if (sharedFilter.queuedActive) {
|
|
361
368
|
dashboardConfig.sharedFilters[index].active = sharedFilter.queuedActive
|
|
362
369
|
delete dashboardConfig.sharedFilters[index].queuedActive
|
|
@@ -373,21 +380,28 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
373
380
|
}
|
|
374
381
|
}
|
|
375
382
|
|
|
376
|
-
dispatch({ type: '
|
|
383
|
+
dispatch({ type: 'SET_SHARED_FILTERS', payload: dashboardConfig.sharedFilters })
|
|
377
384
|
updateDataFilters()
|
|
378
|
-
|
|
385
|
+
loadAPIFilters(dashboardConfig)
|
|
386
|
+
.then(() => {
|
|
387
|
+
reloadURLData(dashboardConfig)
|
|
388
|
+
})
|
|
389
|
+
.catch(e => {
|
|
390
|
+
console.error(e)
|
|
391
|
+
})
|
|
379
392
|
} else {
|
|
380
393
|
// TODO noftify of required fields
|
|
381
394
|
}
|
|
382
395
|
}
|
|
383
396
|
|
|
384
397
|
const changeFilterActive = (index: number, value: string | string[]) => {
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
398
|
+
const sharedFilters = _.cloneDeep(state.config.dashboard.sharedFilters)
|
|
399
|
+
const filterActive = sharedFilters[index]
|
|
400
|
+
const nonAutoLoadFilterIndexes = getAutoLoadVisualization()?.hide
|
|
401
|
+
const isAutoLoad = nonAutoLoadFilterIndexes && !nonAutoLoadFilterIndexes.includes(index)
|
|
388
402
|
|
|
389
|
-
if (config.filterBehavior !== FilterBehavior.Apply) {
|
|
390
|
-
|
|
403
|
+
if (state.config.filterBehavior !== FilterBehavior.Apply || isAutoLoad) {
|
|
404
|
+
sharedFilters[index].active = value
|
|
391
405
|
|
|
392
406
|
const queryParams = getQueryParams()
|
|
393
407
|
if (filterActive.setByQueryParameter && queryParams[filterActive.setByQueryParameter] !== filterActive.active) {
|
|
@@ -396,47 +410,35 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
396
410
|
}
|
|
397
411
|
} else {
|
|
398
412
|
if (Array.isArray(value)) throw Error(`Cannot set active values on urlfilters. expected: ${JSON.stringify(value)} to be a single value.`)
|
|
399
|
-
|
|
413
|
+
sharedFilters[index].queuedActive = value
|
|
400
414
|
}
|
|
401
415
|
|
|
402
|
-
dispatch({ type: '
|
|
403
|
-
if (config.filterBehavior !== FilterBehavior.Apply) {
|
|
404
|
-
updateDataFilters()
|
|
416
|
+
dispatch({ type: 'SET_SHARED_FILTERS', payload: sharedFilters })
|
|
417
|
+
if (state.config.filterBehavior !== FilterBehavior.Apply) {
|
|
418
|
+
updateDataFilters(sharedFilters)
|
|
405
419
|
reloadURLData()
|
|
406
420
|
}
|
|
421
|
+
return sharedFilters
|
|
407
422
|
}
|
|
408
423
|
|
|
409
|
-
const updateDataFilters = () => {
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const newFilteredData = {}
|
|
414
|
-
getVizKeys(config).forEach(key => {
|
|
415
|
-
const applicableFilters = getApplicableFilters(dashboardConfig, key)
|
|
416
|
-
if (applicableFilters) {
|
|
417
|
-
const visualization = config.visualizations[key]
|
|
418
|
-
const _data = state.data[visualization.dataKey] || visualization.data
|
|
419
|
-
const formattedData = visualization.dataDescription ? getFormattedData(_data, visualization.dataDescription) : _data
|
|
420
|
-
|
|
421
|
-
newFilteredData[key] = filterData(applicableFilters, formattedData)
|
|
422
|
-
}
|
|
423
|
-
})
|
|
424
|
-
|
|
424
|
+
const updateDataFilters = (sharedFilters = undefined) => {
|
|
425
|
+
const clonedState = _.cloneDeep(state)
|
|
426
|
+
if (sharedFilters) clonedState.config.dashboard.sharedFilters = sharedFilters
|
|
427
|
+
const newFilteredData = getFilteredData(clonedState)
|
|
425
428
|
dispatch({ type: 'SET_FILTERED_DATA', payload: newFilteredData })
|
|
426
429
|
}
|
|
427
430
|
|
|
428
431
|
const handleOnChange = (index: number, value: string | string[]) => {
|
|
429
|
-
const
|
|
430
|
-
changeFilterActive(index, value)
|
|
432
|
+
const config = _.cloneDeep(state.config)
|
|
433
|
+
const newSharedFilters = changeFilterActive(index, value)
|
|
431
434
|
if (config.filterBehavior === FilterBehavior.Apply) {
|
|
432
435
|
const autoLoadViz = getAutoLoadVisualization()
|
|
433
|
-
|
|
434
|
-
const isAutoSelectFilter = !autoLoadViz.hide.includes(index)
|
|
436
|
+
const isAutoSelectFilter = !autoLoadViz?.hide.includes(index)
|
|
435
437
|
const missingFilterSelections = config.dashboard.sharedFilters.some(f => !f.active)
|
|
436
438
|
if (isAutoSelectFilter && !missingFilterSelections) {
|
|
437
439
|
// a dropdown has been selected that doesn't
|
|
438
440
|
// require the Go Button
|
|
439
|
-
reloadURLData()
|
|
441
|
+
reloadURLData({ sharedFilters: newSharedFilters })
|
|
440
442
|
} else {
|
|
441
443
|
// A parent filter was selected, reset filters by:
|
|
442
444
|
// set auto select filter dropdowns to null
|
|
@@ -458,11 +460,12 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
458
460
|
if (_isAutoSelectFilter) filter.active = ''
|
|
459
461
|
return filter
|
|
460
462
|
})
|
|
461
|
-
const _newConfig = {
|
|
463
|
+
const _newConfig = { dashboard: { ...config.dashboard, sharedFilters: newSharedFilters } }
|
|
462
464
|
dispatch({ type: 'SET_CONFIG', payload: _newConfig })
|
|
463
465
|
// setData to empty object because we no longer have a data state.
|
|
464
466
|
dispatch({ type: 'SET_DATA', payload: {} })
|
|
465
467
|
dispatch({ type: 'SET_FILTERED_DATA', payload: {} })
|
|
468
|
+
loadAPIFilters(_newConfig.dashboard)
|
|
466
469
|
}
|
|
467
470
|
}
|
|
468
471
|
}
|
|
@@ -499,23 +502,8 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
499
502
|
let subVisualizationEditing = false
|
|
500
503
|
|
|
501
504
|
getVizKeys(state.config).forEach(visualizationKey => {
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
const dataKey = visualizationConfig.dataKey || 'backwards-compatibility'
|
|
505
|
-
|
|
506
|
-
if (state.filteredData && state.filteredData[visualizationKey]) {
|
|
507
|
-
visualizationConfig.data = state.filteredData[visualizationKey]
|
|
508
|
-
if (visualizationConfig.formattedData) {
|
|
509
|
-
visualizationConfig.originalFormattedData = visualizationConfig.formattedData
|
|
510
|
-
visualizationConfig.formattedData = visualizationConfig.data
|
|
511
|
-
}
|
|
512
|
-
} else {
|
|
513
|
-
visualizationConfig.data = state.data[dataKey]
|
|
514
|
-
if (visualizationConfig.formattedData) {
|
|
515
|
-
visualizationConfig.originalFormattedData = visualizationConfig.formattedData
|
|
516
|
-
visualizationConfig.formattedData = transform.developerStandardize(visualizationConfig.data, visualizationConfig.dataDescription) || visualizationConfig.data
|
|
517
|
-
}
|
|
518
|
-
}
|
|
505
|
+
const rowNumber = vizRowColumnLocator[visualizationKey]?.row
|
|
506
|
+
const visualizationConfig = getVizConfig(visualizationKey, rowNumber, state.config, state.data, state.filteredData)
|
|
519
507
|
|
|
520
508
|
const setsSharedFilter = state.config.dashboard.sharedFilters && state.config.dashboard.sharedFilters.filter(sharedFilter => sharedFilter.setBy === visualizationKey).length > 0
|
|
521
509
|
const setSharedFilterValue = setsSharedFilter ? state.config.dashboard.sharedFilters.filter(sharedFilter => sharedFilter.setBy === visualizationKey)[0].active : undefined
|
|
@@ -649,226 +637,126 @@ export default function CdcDashboard({ initialState, isEditor = false, isDebug =
|
|
|
649
637
|
<>
|
|
650
638
|
{isEditor && <Header />}
|
|
651
639
|
<MultiTabs isEditor={isEditor && !isPreview} />
|
|
652
|
-
<
|
|
653
|
-
<
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
config.rows
|
|
668
|
-
.
|
|
669
|
-
|
|
670
|
-
|
|
640
|
+
<Layout.Responsive isEditor={isEditor}>
|
|
641
|
+
<div className={`cdc-dashboard-inner-container${isEditor ? ' is-editor' : ''}`}>
|
|
642
|
+
<Title title={title} isDashboard={true} classes={[`dashboard-title`, `${config.dashboard.theme ?? 'theme-blue'}`]} />
|
|
643
|
+
{/* Description */}
|
|
644
|
+
{description && <div className='subtext'>{parse(description)}</div>}
|
|
645
|
+
|
|
646
|
+
{/* Filters */}
|
|
647
|
+
{config.dashboard.sharedFilters && Object.values(config.visualizations || {}).filter(viz => viz.visualizationType === 'filter-dropdowns').length === 0 && (
|
|
648
|
+
<>
|
|
649
|
+
<Filters filters={state.config.dashboard.sharedFilters} apiFilterDropdowns={apiFilterDropdowns} handleOnChange={handleOnChange} />
|
|
650
|
+
<GoButton />
|
|
651
|
+
</>
|
|
652
|
+
)}
|
|
653
|
+
|
|
654
|
+
{/* Visualizations */}
|
|
655
|
+
{config.rows &&
|
|
656
|
+
config.rows
|
|
657
|
+
.filter(row => row.columns.filter(col => col.widget).length !== 0)
|
|
658
|
+
.map((row, index) => {
|
|
659
|
+
if (row.multiVizColumn && (isPreview || !isEditor)) {
|
|
660
|
+
const filteredData = getFilteredData(state)
|
|
661
|
+
const data = filteredData[index] ?? row.formattedData
|
|
662
|
+
const dataGroups = {}
|
|
663
|
+
data.forEach(d => {
|
|
664
|
+
const groupKey = d[row.multiVizColumn]
|
|
665
|
+
if (!dataGroups[groupKey]) dataGroups[groupKey] = []
|
|
666
|
+
dataGroups[groupKey].push(d)
|
|
667
|
+
})
|
|
668
|
+
return Object.keys(dataGroups).map(groupName => {
|
|
669
|
+
const dataValue = dataGroups[groupName]
|
|
670
|
+
return (
|
|
671
|
+
<React.Fragment key={`row__${index}__${groupName}`}>
|
|
672
|
+
<h1 className='h4'>{groupName}</h1>
|
|
673
|
+
<VisualizationRow
|
|
674
|
+
filteredDataOverride={dataValue}
|
|
675
|
+
row={row}
|
|
676
|
+
rowIndex={index}
|
|
677
|
+
setSharedFilter={setSharedFilter}
|
|
678
|
+
updateChildConfig={updateChildConfig}
|
|
679
|
+
applyFilters={applyFilters}
|
|
680
|
+
apiFilterDropdowns={apiFilterDropdowns}
|
|
681
|
+
handleOnChange={handleOnChange}
|
|
682
|
+
currentViewport={currentViewport}
|
|
683
|
+
/>
|
|
684
|
+
</React.Fragment>
|
|
685
|
+
)
|
|
686
|
+
})
|
|
687
|
+
} else {
|
|
688
|
+
return (
|
|
689
|
+
<VisualizationRow key={`row__${index}`} row={row} rowIndex={index} setSharedFilter={setSharedFilter} updateChildConfig={updateChildConfig} applyFilters={applyFilters} apiFilterDropdowns={apiFilterDropdowns} handleOnChange={handleOnChange} currentViewport={currentViewport} />
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
})}
|
|
693
|
+
|
|
694
|
+
{/* Image or PDF Inserts */}
|
|
695
|
+
<section className='download-buttons'>
|
|
696
|
+
{config.table?.downloadImageButton && <MediaControls.Button title='Download Dashboard as Image' type='image' state={config} text='Download Dashboard Image' elementToCapture={imageId} />}
|
|
697
|
+
{config.table?.downloadPdfButton && <MediaControls.Button title='Download Dashboard as PDF' type='pdf' state={config} text='Download Dashboard PDF' elementToCapture={imageId} />}
|
|
698
|
+
</section>
|
|
699
|
+
|
|
700
|
+
{/* Data Table */}
|
|
701
|
+
{config.table?.show && config.data && (
|
|
702
|
+
<DataTable
|
|
703
|
+
config={config}
|
|
704
|
+
rawData={config.data?.[0]?.tableData ? config.data?.[0]?.tableData : config.data}
|
|
705
|
+
runtimeData={config.data?.[0]?.tableData ? config.data?.[0]?.tableData : config.data || []}
|
|
706
|
+
expandDataTable={config.table.expanded}
|
|
707
|
+
showDownloadButton={config.table.download}
|
|
708
|
+
tableTitle={config.dashboard.title || ''}
|
|
709
|
+
viewport={currentViewport}
|
|
710
|
+
tabbingId={config.dashboard.title || ''}
|
|
711
|
+
outerContainerRef={outerContainerRef}
|
|
712
|
+
imageRef={imageId}
|
|
713
|
+
isDebug={isDebug}
|
|
714
|
+
isEditor={isEditor}
|
|
715
|
+
/>
|
|
716
|
+
)}
|
|
717
|
+
{config.table?.show &&
|
|
718
|
+
config.datasets &&
|
|
719
|
+
Object.keys(config.datasets).map(datasetKey => {
|
|
720
|
+
//For each dataset, find any shared filters that apply to all visualizations using the dataset
|
|
721
|
+
|
|
722
|
+
//Gets list of visuailzations using the dataset
|
|
723
|
+
const vizKeysUsingDataset: string[] = getVizKeys(config).filter(visualizationKey => {
|
|
724
|
+
return config.visualizations[visualizationKey].dataKey === datasetKey
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
//Checks shared filters against list to see if all visualizations are represented
|
|
728
|
+
let applicableFilters: SharedFilter[] = []
|
|
729
|
+
config.dashboard.sharedFilters?.forEach(sharedFilter => {
|
|
730
|
+
let allMatch = true
|
|
731
|
+
vizKeysUsingDataset.forEach(visualizationKey => {
|
|
732
|
+
if (sharedFilter.usedBy && sharedFilter.usedBy.indexOf(visualizationKey) === -1) {
|
|
733
|
+
allMatch = false
|
|
734
|
+
}
|
|
735
|
+
})
|
|
736
|
+
if (allMatch) {
|
|
737
|
+
applicableFilters.push(sharedFilter)
|
|
738
|
+
}
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
//Applys any applicable filters to the Table
|
|
742
|
+
const filteredTableData = applicableFilters.length > 0 ? filterData(applicableFilters, config.datasets[datasetKey].data) : undefined
|
|
671
743
|
return (
|
|
672
|
-
<div className=
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
visualizationConfig.data = state.filteredData[col.widget]
|
|
684
|
-
if (visualizationConfig.formattedData) {
|
|
685
|
-
visualizationConfig.originalFormattedData = visualizationConfig.formattedData
|
|
686
|
-
visualizationConfig.formattedData = visualizationConfig.data
|
|
687
|
-
}
|
|
688
|
-
} else {
|
|
689
|
-
visualizationConfig.data = state.data[dataKey]
|
|
690
|
-
if (visualizationConfig.formattedData) {
|
|
691
|
-
visualizationConfig.originalFormattedData = visualizationConfig.formattedData
|
|
692
|
-
visualizationConfig.formattedData = transform.developerStandardize(visualizationConfig.data, visualizationConfig.dataDescription) || visualizationConfig.data
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
const setsSharedFilter = config.dashboard.sharedFilters && config.dashboard.sharedFilters.filter(sharedFilter => sharedFilter.setBy === col.widget).length > 0
|
|
697
|
-
const setSharedFilterValue = setsSharedFilter ? config.dashboard.sharedFilters.filter(sharedFilter => sharedFilter.setBy === col.widget)[0].active : undefined
|
|
698
|
-
const tableLink = (
|
|
699
|
-
<a href={`#data-table-${visualizationConfig.dataKey}`} className='margin-left-href'>
|
|
700
|
-
{visualizationConfig.dataKey} (Go to Table)
|
|
701
|
-
</a>
|
|
702
|
-
)
|
|
703
|
-
const hideFilter = visualizationConfig.autoLoad && inNoDataState
|
|
704
|
-
|
|
705
|
-
const hiddenToggle = col.hide !== undefined ? col.hide : colIndex !== 0
|
|
706
|
-
const hidden = col.toggle ? hiddenToggle : false
|
|
707
|
-
return (
|
|
708
|
-
<React.Fragment key={`vis__${index}__${colIndex}`}>
|
|
709
|
-
<div className={`dashboard-col dashboard-col-${col.width} ${hidden ? 'hidden-toggle' : ''}`}>
|
|
710
|
-
{visualizationConfig.type === 'chart' && (
|
|
711
|
-
<CdcChart
|
|
712
|
-
key={col.widget}
|
|
713
|
-
config={visualizationConfig}
|
|
714
|
-
dashboardConfig={config}
|
|
715
|
-
isEditor={false}
|
|
716
|
-
setConfig={newConfig => {
|
|
717
|
-
updateChildConfig(col.widget, newConfig)
|
|
718
|
-
}}
|
|
719
|
-
setSharedFilter={setsSharedFilter ? setSharedFilter : undefined}
|
|
720
|
-
isDashboard={true}
|
|
721
|
-
link={config.table && config.table.show && config.datasets && visualizationConfig.table && visualizationConfig.table.showDataTableLink ? tableLink : undefined}
|
|
722
|
-
configUrl={undefined}
|
|
723
|
-
setEditing={undefined}
|
|
724
|
-
hostname={undefined}
|
|
725
|
-
setSharedFilterValue={undefined}
|
|
726
|
-
/>
|
|
727
|
-
)}
|
|
728
|
-
{visualizationConfig.type === 'map' && (
|
|
729
|
-
<CdcMap
|
|
730
|
-
key={col.widget}
|
|
731
|
-
config={visualizationConfig}
|
|
732
|
-
isEditor={false}
|
|
733
|
-
setConfig={newConfig => {
|
|
734
|
-
updateChildConfig(col.widget, newConfig)
|
|
735
|
-
}}
|
|
736
|
-
showLoader={false}
|
|
737
|
-
setSharedFilter={setsSharedFilter ? setSharedFilter : undefined}
|
|
738
|
-
setSharedFilterValue={setSharedFilterValue}
|
|
739
|
-
isDashboard={true}
|
|
740
|
-
link={config.table && config.table.show && config.datasets && visualizationConfig.table && visualizationConfig.table.showDataTableLink ? tableLink : undefined}
|
|
741
|
-
/>
|
|
742
|
-
)}
|
|
743
|
-
{visualizationConfig.type === 'data-bite' && (
|
|
744
|
-
<CdcDataBite
|
|
745
|
-
key={col.widget}
|
|
746
|
-
config={visualizationConfig}
|
|
747
|
-
isEditor={false}
|
|
748
|
-
setConfig={newConfig => {
|
|
749
|
-
updateChildConfig(col.widget, newConfig)
|
|
750
|
-
}}
|
|
751
|
-
isDashboard={true}
|
|
752
|
-
/>
|
|
753
|
-
)}
|
|
754
|
-
{visualizationConfig.type === 'waffle-chart' && (
|
|
755
|
-
<CdcWaffleChart
|
|
756
|
-
key={col.widget}
|
|
757
|
-
config={visualizationConfig}
|
|
758
|
-
isEditor={false}
|
|
759
|
-
setConfig={newConfig => {
|
|
760
|
-
updateChildConfig(col.widget, newConfig)
|
|
761
|
-
}}
|
|
762
|
-
isDashboard={true}
|
|
763
|
-
configUrl={config.table && config.table.show && config.datasets && visualizationConfig.table && visualizationConfig.table.showDataTableLink ? tableLink : undefined}
|
|
764
|
-
/>
|
|
765
|
-
)}
|
|
766
|
-
{visualizationConfig.type === 'markup-include' && (
|
|
767
|
-
<CdcMarkupInclude
|
|
768
|
-
key={col.widget}
|
|
769
|
-
config={visualizationConfig}
|
|
770
|
-
isEditor={false}
|
|
771
|
-
setConfig={newConfig => {
|
|
772
|
-
updateChildConfig(col.widget, newConfig)
|
|
773
|
-
}}
|
|
774
|
-
isDashboard={true}
|
|
775
|
-
configUrl={undefined}
|
|
776
|
-
/>
|
|
777
|
-
)}
|
|
778
|
-
{visualizationConfig.type === 'filtered-text' && (
|
|
779
|
-
<CdcFilteredText
|
|
780
|
-
key={col.widget}
|
|
781
|
-
config={visualizationConfig}
|
|
782
|
-
isEditor={false}
|
|
783
|
-
setConfig={newConfig => {
|
|
784
|
-
updateChildConfig(col.widget, newConfig)
|
|
785
|
-
}}
|
|
786
|
-
isDashboard={true}
|
|
787
|
-
configUrl={undefined}
|
|
788
|
-
/>
|
|
789
|
-
)}
|
|
790
|
-
{visualizationConfig.type === 'filter-dropdowns' && !hideFilter && (
|
|
791
|
-
<>
|
|
792
|
-
<Filters hide={visualizationConfig.hide} filters={state.config.dashboard.sharedFilters} apiFilterDropdowns={apiFilterDropdowns} handleOnChange={handleOnChange} />
|
|
793
|
-
<GoButton autoLoad={visualizationConfig.autoLoad} />
|
|
794
|
-
</>
|
|
795
|
-
)}
|
|
796
|
-
{visualizationConfig.type === 'table' && <DataTableStandAlone key={col.widget} visualizationKey={col.widget} config={visualizationConfig} viewport={currentViewport} />}
|
|
797
|
-
</div>
|
|
798
|
-
</React.Fragment>
|
|
799
|
-
)
|
|
800
|
-
}
|
|
801
|
-
return <React.Fragment key={`vis__${index}__${colIndex}`}></React.Fragment>
|
|
802
|
-
})}
|
|
744
|
+
<div className='multi-table-container' id={`data-table-${datasetKey}`} key={`data-table-${datasetKey}`}>
|
|
745
|
+
<DataTable
|
|
746
|
+
config={config as TableConfig}
|
|
747
|
+
dataConfig={config.datasets[datasetKey]}
|
|
748
|
+
rawData={config.datasets[datasetKey].data?.[0]?.tableData || config.datasets[datasetKey].data}
|
|
749
|
+
runtimeData={config.datasets[datasetKey].data?.[0]?.tableData || filteredTableData || config.datasets[datasetKey].data || []}
|
|
750
|
+
expandDataTable={config.table.expanded}
|
|
751
|
+
tableTitle={datasetKey}
|
|
752
|
+
viewport={currentViewport}
|
|
753
|
+
tabbingId={datasetKey}
|
|
754
|
+
/>
|
|
803
755
|
</div>
|
|
804
756
|
)
|
|
805
757
|
})}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
<section className='download-buttons'>
|
|
809
|
-
{config.table?.downloadImageButton && <MediaControls.Button title='Download Dashboard as Image' type='image' state={config} text='Download Dashboard Image' elementToCapture={imageId} />}
|
|
810
|
-
{config.table?.downloadPdfButton && <MediaControls.Button title='Download Dashboard as PDF' type='pdf' state={config} text='Download Dashboard PDF' elementToCapture={imageId} />}
|
|
811
|
-
</section>
|
|
812
|
-
|
|
813
|
-
{/* Data Table */}
|
|
814
|
-
{config.table?.show && config.data && (
|
|
815
|
-
<DataTable
|
|
816
|
-
config={config}
|
|
817
|
-
rawData={config.data?.[0]?.tableData ? config.data?.[0]?.tableData : config.data}
|
|
818
|
-
runtimeData={config.data?.[0]?.tableData ? config.data?.[0]?.tableData : config.data || []}
|
|
819
|
-
expandDataTable={config.table.expanded}
|
|
820
|
-
showDownloadButton={config.table.download}
|
|
821
|
-
tableTitle={config.dashboard.title || ''}
|
|
822
|
-
viewport={currentViewport}
|
|
823
|
-
tabbingId={config.dashboard.title || ''}
|
|
824
|
-
outerContainerRef={outerContainerRef}
|
|
825
|
-
imageRef={imageId}
|
|
826
|
-
isDebug={isDebug}
|
|
827
|
-
isEditor={isEditor}
|
|
828
|
-
/>
|
|
829
|
-
)}
|
|
830
|
-
{config.table?.show &&
|
|
831
|
-
config.datasets &&
|
|
832
|
-
Object.keys(config.datasets).map(datasetKey => {
|
|
833
|
-
//For each dataset, find any shared filters that apply to all visualizations using the dataset
|
|
834
|
-
|
|
835
|
-
//Gets list of visuailzations using the dataset
|
|
836
|
-
const vizKeysUsingDataset: string[] = getVizKeys(config).filter(visualizationKey => {
|
|
837
|
-
return config.visualizations[visualizationKey].dataKey === datasetKey
|
|
838
|
-
})
|
|
839
|
-
|
|
840
|
-
//Checks shared filters against list to see if all visualizations are represented
|
|
841
|
-
let applicableFilters: SharedFilter[] = []
|
|
842
|
-
config.dashboard.sharedFilters.forEach(sharedFilter => {
|
|
843
|
-
let allMatch = true
|
|
844
|
-
vizKeysUsingDataset.forEach(visualizationKey => {
|
|
845
|
-
if (sharedFilter.usedBy && sharedFilter.usedBy.indexOf(visualizationKey) === -1) {
|
|
846
|
-
allMatch = false
|
|
847
|
-
}
|
|
848
|
-
})
|
|
849
|
-
if (allMatch) {
|
|
850
|
-
applicableFilters.push(sharedFilter)
|
|
851
|
-
}
|
|
852
|
-
})
|
|
853
|
-
|
|
854
|
-
//Applys any applicable filters to the Table
|
|
855
|
-
const filteredTableData = applicableFilters.length > 0 ? filterData(applicableFilters, config.datasets[datasetKey].data) : undefined
|
|
856
|
-
return (
|
|
857
|
-
<div className='multi-table-container' id={`data-table-${datasetKey}`} key={`data-table-${datasetKey}`}>
|
|
858
|
-
<DataTable
|
|
859
|
-
config={config as TableConfig}
|
|
860
|
-
dataConfig={config.datasets[datasetKey]}
|
|
861
|
-
rawData={config.datasets[datasetKey].data?.[0]?.tableData || config.datasets[datasetKey].data}
|
|
862
|
-
runtimeData={config.datasets[datasetKey].data?.[0]?.tableData || filteredTableData || config.datasets[datasetKey].data || []}
|
|
863
|
-
expandDataTable={config.table.expanded}
|
|
864
|
-
tableTitle={datasetKey}
|
|
865
|
-
viewport={currentViewport}
|
|
866
|
-
tabbingId={datasetKey}
|
|
867
|
-
/>
|
|
868
|
-
</div>
|
|
869
|
-
)
|
|
870
|
-
})}
|
|
871
|
-
</div>
|
|
758
|
+
</div>
|
|
759
|
+
</Layout.Responsive>
|
|
872
760
|
</>
|
|
873
761
|
)
|
|
874
762
|
}
|