@cdc/dashboard 4.24.2 → 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.
Files changed (52) hide show
  1. package/dist/cdcdashboard.js +128512 -99417
  2. package/examples/chart-data.json +5409 -0
  3. package/examples/full-dash-test.json +14643 -0
  4. package/examples/full-dashboard.json +10036 -0
  5. package/examples/sankey.json +5218 -0
  6. package/index.html +4 -3
  7. package/package.json +11 -10
  8. package/src/CdcDashboard.tsx +129 -124
  9. package/src/CdcDashboardComponent.tsx +316 -441
  10. package/src/DashboardContext.tsx +4 -1
  11. package/src/_stories/Dashboard.stories.tsx +79 -36
  12. package/src/_stories/_mock/api-filter-chart.json +11 -35
  13. package/src/_stories/_mock/api-filter-map.json +17 -31
  14. package/src/_stories/_mock/dashboard-gallery.json +523 -534
  15. package/src/_stories/_mock/multi-viz.json +378 -0
  16. package/src/_stories/_mock/pivot-filter.json +161 -0
  17. package/src/_stories/_mock/standalone-table.json +122 -0
  18. package/src/_stories/_mock/toggle-example.json +4035 -0
  19. package/src/components/DataDesignerModal.tsx +145 -0
  20. package/src/components/EditorWrapper/EditorWrapper.tsx +52 -0
  21. package/src/components/EditorWrapper/editor-wrapper.style.css +13 -0
  22. package/src/components/Filters.tsx +88 -0
  23. package/src/components/Grid.tsx +3 -1
  24. package/src/components/Header/FilterModal.tsx +506 -0
  25. package/src/components/Header/Header.tsx +25 -465
  26. package/src/components/Row.tsx +65 -29
  27. package/src/components/Toggle/Toggle.tsx +36 -0
  28. package/src/components/Toggle/index.tsx +1 -0
  29. package/src/components/Toggle/toggle-style.css +34 -0
  30. package/src/components/VisualizationRow.tsx +174 -0
  31. package/src/components/VisualizationsPanel.tsx +13 -3
  32. package/src/components/Widget.tsx +28 -126
  33. package/src/helpers/filterData.ts +75 -50
  34. package/src/helpers/generateValuesForFilter.ts +2 -12
  35. package/src/helpers/getApiFilterKey.ts +5 -0
  36. package/src/helpers/getFilteredData.ts +39 -0
  37. package/src/helpers/getUpdateConfig.ts +39 -22
  38. package/src/helpers/getVizConfig.ts +31 -0
  39. package/src/helpers/getVizRowColumnLocator.ts +9 -0
  40. package/src/helpers/iconHash.tsx +34 -0
  41. package/src/helpers/tests/filterData.test.ts +149 -0
  42. package/src/images/icon-toggle.svg +1 -0
  43. package/src/scss/grid.scss +10 -3
  44. package/src/scss/main.scss +11 -0
  45. package/src/store/dashboard.actions.ts +35 -3
  46. package/src/store/dashboard.reducer.ts +33 -2
  47. package/src/types/APIFilter.ts +4 -5
  48. package/src/types/ConfigRow.ts +13 -2
  49. package/src/types/DataSet.ts +11 -8
  50. package/src/types/InitialState.ts +2 -1
  51. package/src/types/SharedFilter.ts +6 -3
  52. package/src/types/Tab.ts +1 -0
@@ -0,0 +1,506 @@
1
+ import { useContext, useEffect, useMemo, useState } from 'react'
2
+ import { MultiDashboardConfig } from '../../types/MultiDashboard'
3
+ import { SharedFilter } from '../../types/SharedFilter'
4
+ import { DashboardDispatchContext } from '../../DashboardContext'
5
+ import { useGlobalContext } from '@cdc/core/components/GlobalContext'
6
+ import { APIFilter } from '../../types/APIFilter'
7
+ import Modal from '@cdc/core/components/ui/Modal'
8
+ import { FilterBehavior } from './Header'
9
+ import Tooltip from '@cdc/core/components/ui/Tooltip'
10
+ import Icon from '@cdc/core/components/ui/Icon'
11
+ import Button from '@cdc/core/components/elements/Button'
12
+ import fetchRemoteData from '@cdc/core/helpers/fetchRemoteData'
13
+ import DataTransform from '@cdc/core/helpers/DataTransform'
14
+ import { getVizRowColumnLocator } from '../../helpers/getVizRowColumnLocator'
15
+ import _ from 'lodash'
16
+
17
+ type ModalProps = {
18
+ config: MultiDashboardConfig
19
+ filterState: SharedFilter
20
+ index: number
21
+ removeFilter: Function
22
+ }
23
+
24
+ const FilterModal: React.FC<ModalProps> = ({ config, filterState, index, removeFilter }) => {
25
+ const { overlay } = useGlobalContext()
26
+ const dispatch = useContext(DashboardDispatchContext)
27
+ const [filter, setFilter] = useState<SharedFilter>(filterState)
28
+ const [columns, setColumns] = useState<string[]>([])
29
+ const transform = new DataTransform()
30
+
31
+ const vizRowColumnLocator = getVizRowColumnLocator(config.rows)
32
+
33
+ const [usedByNameLookup, usedByOptions] = useMemo(() => {
34
+ const nameLookup = {}
35
+ const vizOptions = Object.keys(config.visualizations)
36
+ .filter(vizKey => {
37
+ const notAdded = !filter.usedBy || filter.usedBy.indexOf(vizKey) === -1
38
+ const usesSharedFilter = config.visualizations[vizKey].usesSharedFilter
39
+ const row = vizRowColumnLocator[vizKey].row
40
+ const dataConfiguredOnRow = config.rows[row].dataKey
41
+ return filter.setBy !== vizKey && notAdded && !usesSharedFilter && !dataConfiguredOnRow
42
+ })
43
+ .map(vizKey => {
44
+ const viz = config.visualizations[vizKey]
45
+ const vizName = viz.general?.title || viz.title || vizKey
46
+ nameLookup[vizKey] = vizName
47
+ return vizKey
48
+ })
49
+ const rowOptions: number[] = []
50
+
51
+ config.rows.forEach((row, rowIndex) => {
52
+ if (!!row.multiVizColumn) {
53
+ nameLookup[rowIndex] = `Row ${rowIndex + 1}`
54
+ rowOptions.push(rowIndex)
55
+ }
56
+ })
57
+
58
+ const rowsNotSelected = rowOptions.filter(row => !filter.usedBy || filter.usedBy.indexOf(row.toString()) === -1)
59
+ return [nameLookup, [...vizOptions, ...rowsNotSelected]]
60
+ }, [config.visualizations, filter.usedBy, filter.setBy, vizRowColumnLocator])
61
+
62
+ useEffect(() => {
63
+ const runSetColumns = async () => {
64
+ let columns = {}
65
+ let dataKeys = Object.keys(config.datasets)
66
+
67
+ for (let i = 0; i < dataKeys.length; i++) {
68
+ let _dataSet = config.datasets[dataKeys[i]]
69
+ if (!_dataSet.data && _dataSet.dataUrl) {
70
+ config.datasets[dataKeys[i]].data = await fetchRemoteData(config.datasets[dataKeys[i]].dataUrl)
71
+ _dataSet = config.datasets[dataKeys[i]]
72
+ if (_dataSet.dataDescription) {
73
+ try {
74
+ config.datasets[dataKeys[i]].data = transform.autoStandardize(_dataSet.data)
75
+ _dataSet = config.datasets[dataKeys[i]]
76
+ config.datasets[dataKeys[i]].data = transform.developerStandardize(_dataSet.data, _dataSet.dataDescription)
77
+ _dataSet = config.datasets[dataKeys[i]]
78
+ } catch (e) {
79
+ //Data not able to be standardized, leave as is
80
+ }
81
+ }
82
+ }
83
+
84
+ if (_dataSet.data) {
85
+ config.datasets[dataKeys[i]].data.forEach(row => {
86
+ Object.keys(row).forEach(columnName => (columns[columnName] = true))
87
+ })
88
+ }
89
+ }
90
+
91
+ setColumns(Object.keys(columns))
92
+ }
93
+
94
+ runSetColumns()
95
+ }, [config.datasets])
96
+
97
+ const saveChanges = () => {
98
+ let tempConfig = { ...config.dashboard }
99
+ tempConfig.sharedFilters[index] = filter
100
+
101
+ dispatch({ type: 'UPDATE_CONFIG', payload: [{ ...config, dashboard: tempConfig }] })
102
+ overlay?.actions.toggleOverlay()
103
+ }
104
+
105
+ const updateFilterProp = (name, value) => {
106
+ const newFilter = { ..._.cloneDeep(filter), [name]: value }
107
+
108
+ setFilter(newFilter)
109
+ }
110
+
111
+ const addFilterUsedBy = (filter, value) => {
112
+ if (value === '') return
113
+ if (!filter.usedBy) filter.usedBy = []
114
+ filter.usedBy.push(value)
115
+ updateFilterProp('usedBy', filter.usedBy)
116
+ }
117
+
118
+ const removeFilterUsedBy = (filter, value) => {
119
+ let usedByIndex = filter.usedBy.indexOf(value)
120
+ if (usedByIndex !== -1) {
121
+ filter.usedBy.splice(usedByIndex, 1)
122
+ updateFilterProp('usedBy', filter.usedBy)
123
+ }
124
+ }
125
+
126
+ const updateAPIFilter = (key: keyof APIFilter, value: string | boolean) => {
127
+ const filterClone = _.cloneDeep(filter)
128
+ const _filter = filterClone.apiFilter || { apiEndpoint: '', valueSelector: '', textSelector: '' }
129
+ const newAPIFilter: APIFilter = { ..._filter, [key]: value }
130
+ setFilter({ ...filterClone, apiFilter: newAPIFilter })
131
+ }
132
+
133
+ return (
134
+ <Modal>
135
+ <Modal.Content>
136
+ <h2 className='shared-filter-modal__title'>Dashboard Filter Settings</h2>
137
+ <fieldset className='shared-filter-modal shared-filter-modal__fieldset' key={filter.columnName + index}>
138
+ <label>
139
+ <span className='edit-label column-heading'>Filter Type: </span>
140
+ <select defaultValue={filter.type || ''} onChange={e => updateFilterProp('type', e.target.value)}>
141
+ <option value=''>- Select Option -</option>
142
+ <option value='urlfilter'>URL</option>
143
+ <option value='datafilter'>Data</option>
144
+ </select>
145
+ </label>
146
+ {filter.type === 'urlfilter' && (
147
+ <>
148
+ <label>
149
+ <span className='edit-label column-heading'>Label: </span>
150
+ <input
151
+ type='text'
152
+ value={filter.key}
153
+ onChange={e => {
154
+ updateFilterProp('key', e.target.value)
155
+ }}
156
+ />
157
+ </label>
158
+ {config.filterBehavior !== FilterBehavior.Apply && (
159
+ <>
160
+ <label>
161
+ <span className='edit-label column-heading'>URL to Filter: </span>
162
+ <select defaultValue={filter.datasetKey || ''} onChange={e => updateFilterProp('datasetKey', e.target.value)}>
163
+ <option value=''>- Select Option -</option>
164
+ {Object.keys(config.datasets).map(datasetKey => {
165
+ if (config.datasets[datasetKey].dataUrl) {
166
+ return (
167
+ <option key={datasetKey} value={datasetKey}>
168
+ {config.datasets[datasetKey].dataUrl}
169
+ </option>
170
+ )
171
+ }
172
+ return null
173
+ })}
174
+ </select>
175
+ </label>
176
+ <label>
177
+ <span className='edit-label column-heading'>Filter By: </span>
178
+ <select defaultValue={filter.filterBy || ''} onChange={e => updateFilterProp('filterBy', e.target.value)}>
179
+ <option value=''>- Select Option -</option>
180
+ <option key={'query-string'} value={'Query String'}>
181
+ Query String
182
+ </option>
183
+ <option key={'file-name'} value={'File Name'}>
184
+ File Name
185
+ </option>
186
+ </select>
187
+ </label>
188
+ {filter.filterBy === 'File Name' && (
189
+ <>
190
+ <label>
191
+ <span className='edit-label column-heading'>
192
+ File Name:
193
+ <Tooltip style={{ textTransform: 'none' }}>
194
+ <Tooltip.Target>
195
+ <Icon display='question' style={{ marginLeft: '0.5rem' }} />
196
+ </Tooltip.Target>
197
+ <Tooltip.Content>
198
+ <p>{`Add \${query}\ to replace the filename with the active dropdown value.`}</p>
199
+ </Tooltip.Content>
200
+ </Tooltip>
201
+ </span>
202
+
203
+ <input type='text' defaultValue={filter.fileName || ''} onChange={e => updateFilterProp('fileName', e.target.value)} />
204
+ </label>
205
+
206
+ <label>
207
+ <span className='edit-label column-heading'>
208
+ White Space Replacments
209
+ <Tooltip style={{ textTransform: 'none' }}>
210
+ <Tooltip.Target>
211
+ <Icon display='question' style={{ marginLeft: '0.5rem' }} />
212
+ </Tooltip.Target>
213
+ <Tooltip.Content>
214
+ <p>{`Set how whitespace characters will be handled in the file request`}</p>
215
+ </Tooltip.Content>
216
+ </Tooltip>
217
+ </span>
218
+ <select defaultValue={filter.whitespaceReplacement || 'Keep Spaces'} onChange={e => updateFilterProp('whitespaceReplacement', e.target.value)}>
219
+ <option key={'remove-spaces'} value={'Remove Spaces'}>
220
+ Remove Spaces
221
+ </option>
222
+ <option key={'replace-with-underscore'} value={'Replace With Underscore'}>
223
+ Replace With Underscore
224
+ </option>
225
+ <option key={'keep-spaces'} value={'Keep Spaces'}>
226
+ Keep Spaces
227
+ </option>
228
+ </select>
229
+ </label>
230
+ </>
231
+ )}
232
+ </>
233
+ )}
234
+ {filter.filterBy === 'Query String' && (
235
+ <label>
236
+ <span className='edit-label column-heading'>Query string parameter</span> <input type='text' defaultValue={filter.queryParameter} onChange={e => updateFilterProp('queryParameter', e.target.value)} />
237
+ </label>
238
+ )}
239
+ <label>
240
+ <span className='edit-label column-heading'>Filter API Endpoint: </span>
241
+ <input
242
+ type='text'
243
+ value={filter.apiFilter?.apiEndpoint}
244
+ onChange={e => {
245
+ updateAPIFilter('apiEndpoint', e.target.value)
246
+ }}
247
+ />
248
+ </label>
249
+ <label>
250
+ <span className='edit-label column-heading'>
251
+ Option Text Selector:
252
+ <Tooltip style={{ textTransform: 'none' }}>
253
+ <Tooltip.Target>
254
+ <Icon display='question' style={{ marginLeft: '0.5rem' }} />
255
+ </Tooltip.Target>
256
+ <Tooltip.Content>
257
+ <p>Text to use in the html option element</p>
258
+ </Tooltip.Content>
259
+ </Tooltip>
260
+ </span>
261
+ <input
262
+ type='text'
263
+ value={filter.apiFilter?.textSelector}
264
+ onChange={e => {
265
+ updateAPIFilter('textSelector', e.target.value)
266
+ }}
267
+ />
268
+ </label>
269
+ <label>
270
+ <span className='edit-label column-heading'>
271
+ Option Value Selector:
272
+ <Tooltip style={{ textTransform: 'none' }}>
273
+ <Tooltip.Target>
274
+ <Icon display='question' style={{ marginLeft: '0.5rem' }} />
275
+ </Tooltip.Target>
276
+ <Tooltip.Content>
277
+ <p>Value to use in the html option element</p>
278
+ </Tooltip.Content>
279
+ </Tooltip>
280
+ </span>
281
+ <input
282
+ type='text'
283
+ value={filter.apiFilter?.valueSelector}
284
+ onChange={e => {
285
+ updateAPIFilter('valueSelector', e.target.value)
286
+ }}
287
+ />
288
+ </label>
289
+ <label>
290
+ <span className='edit-label column-heading'>Parent Filter: </span>
291
+ <select
292
+ value={filter.parents || []}
293
+ onChange={e => {
294
+ updateFilterProp('parents', e.target.value)
295
+ }}
296
+ >
297
+ <option value=''>Select a filter</option>
298
+ {config.dashboard.sharedFilters &&
299
+ config.dashboard.sharedFilters.map(sharedFilter => {
300
+ if (sharedFilter.key !== filter.key && sharedFilter.type !== 'datafilter') {
301
+ return <option value={sharedFilter.key}>{sharedFilter.key}</option>
302
+ }
303
+ })}
304
+ </select>
305
+ </label>
306
+ <label>
307
+ <span className='edit-label column-heading'>Auto Load: </span>
308
+ <input
309
+ type='checkbox'
310
+ checked={filter.apiFilter?.autoLoad}
311
+ onChange={e => {
312
+ updateAPIFilter('autoLoad', !filter.apiFilter?.autoLoad)
313
+ }}
314
+ />
315
+ </label>
316
+ <label>
317
+ <span className='edit-label column-heading'>Default Value: </span>
318
+ <input
319
+ type='text'
320
+ value={filter.apiFilter?.defaultValue}
321
+ onChange={e => {
322
+ updateAPIFilter('defaultValue', e.target.value)
323
+ }}
324
+ />
325
+ </label>
326
+ <label>
327
+ <span className='edit-label column-heading'>Default Value Set By Query String Parameter: </span>
328
+ <input
329
+ type='text'
330
+ value={filter.setByQueryParameter || ''}
331
+ onChange={e => {
332
+ updateFilterProp('setByQueryParameter', e.target.value)
333
+ }}
334
+ />
335
+ </label>
336
+ </>
337
+ )}
338
+ {filter.type === 'datafilter' && (
339
+ <>
340
+ <label>
341
+ <span className='edit-label column-heading'>Filter: </span>
342
+ <select
343
+ value={filter.columnName}
344
+ onChange={e => {
345
+ updateFilterProp('columnName', e.target.value)
346
+ }}
347
+ >
348
+ <option value=''>- Select Option -</option>
349
+ {columns.map(dataKey => (
350
+ <option value={dataKey} key={`filter-column-select-item-${dataKey}`}>
351
+ {dataKey}
352
+ </option>
353
+ ))}
354
+ </select>
355
+ </label>
356
+ <label>
357
+ <span className='edit-label column-heading'>
358
+ Pivot:{' '}
359
+ <Tooltip style={{ textTransform: 'none' }}>
360
+ <Tooltip.Target>
361
+ <Icon display='question' style={{ marginLeft: '0.5rem' }} />
362
+ </Tooltip.Target>
363
+ <Tooltip.Content>
364
+ <p>The column whos values will be pivoted under the column selected as the Filter.</p>
365
+ </Tooltip.Content>
366
+ </Tooltip>
367
+ </span>
368
+ <select
369
+ value={filter.pivot}
370
+ onChange={e => {
371
+ updateFilterProp('pivot', e.target.value)
372
+ }}
373
+ >
374
+ <option value=''>- Select Option -</option>
375
+ {columns
376
+ .filter(col => filter.columnName !== col)
377
+ .map(dataKey => (
378
+ <option value={dataKey} key={`filter-column-select-item-${dataKey}`}>
379
+ {dataKey}
380
+ </option>
381
+ ))}
382
+ </select>
383
+ </label>
384
+ <label>
385
+ <span className='edit-label column-heading'>Label: </span>
386
+ <input
387
+ type='text'
388
+ value={filter.key}
389
+ onChange={e => {
390
+ updateFilterProp('key', e.target.value)
391
+ }}
392
+ />
393
+ </label>
394
+ <label>
395
+ <span className='edit-label column-heading'>Show Dropdown</span>
396
+ <input
397
+ type='checkbox'
398
+ defaultChecked={filter.showDropdown === true}
399
+ onChange={e => {
400
+ updateFilterProp('showDropdown', !filter.showDropdown)
401
+ }}
402
+ />
403
+ </label>
404
+ <label>
405
+ <span className='edit-label column-heading'>Set By: </span>
406
+ <select value={filter.setBy} onChange={e => updateFilterProp('setBy', e.target.value)}>
407
+ <option value=''>- Select Option -</option>
408
+ {Object.keys(config.visualizations).map(vizKey => (
409
+ <option value={vizKey} key={`set-by-select-item-${vizKey}`}>
410
+ {config.visualizations[vizKey].general && config.visualizations[vizKey].general.title ? config.visualizations[vizKey].general.title : config.visualizations[vizKey].title || vizKey}
411
+ </option>
412
+ ))}
413
+ </select>
414
+ </label>
415
+ <label>
416
+ <span className='edit-label column-heading'>Used By: </span>
417
+ <ul>
418
+ {filter.usedBy &&
419
+ filter.usedBy.map(opt => (
420
+ <li key={`used-by-list-item-${opt}`}>
421
+ <span>{usedByNameLookup[opt] || opt}</span>{' '}
422
+ <button
423
+ onClick={e => {
424
+ e.preventDefault()
425
+ removeFilterUsedBy(filter, opt)
426
+ }}
427
+ >
428
+ X
429
+ </button>
430
+ </li>
431
+ ))}
432
+ </ul>
433
+ <select value='' onChange={e => addFilterUsedBy(filter, e.target.value)}>
434
+ <option value=''>- Select Option -</option>
435
+ {usedByOptions.map(opt => (
436
+ <option value={opt} key={`used-by-select-item-${opt}`}>
437
+ {usedByNameLookup[opt] || opt}
438
+ </option>
439
+ ))}
440
+ </select>
441
+ </label>
442
+ <label>
443
+ <span className='edit-label column-heading'>Reset Label: </span>
444
+ <input
445
+ type='text'
446
+ value={filter.resetLabel || ''}
447
+ onChange={e => {
448
+ updateFilterProp('resetLabel', e.target.value)
449
+ }}
450
+ />
451
+ </label>
452
+ <label>
453
+ <span className='edit-label column-heading'>Parent Filter: </span>
454
+ <select
455
+ value={filter.parents || []}
456
+ onChange={e => {
457
+ updateFilterProp('parents', e.target.value)
458
+ }}
459
+ >
460
+ <option value=''>Select a filter</option>
461
+ {config.dashboard.sharedFilters &&
462
+ config.dashboard.sharedFilters.map(sharedFilter => {
463
+ if (sharedFilter.key !== filter.key) {
464
+ return <option>{sharedFilter.key}</option>
465
+ }
466
+ })}
467
+ </select>
468
+ </label>
469
+ <label>
470
+ <span className='edit-label column-heading'>Default Value Set By Query String Parameter: </span>
471
+ <input
472
+ type='text'
473
+ value={filter.setByQueryParameter || ''}
474
+ onChange={e => {
475
+ updateFilterProp('setByQueryParameter', e.target.value)
476
+ }}
477
+ />
478
+ </label>
479
+ </>
480
+ )}
481
+ </fieldset>
482
+
483
+ <Button
484
+ className='btn--remove warn'
485
+ onClick={() => {
486
+ removeFilter()
487
+ }}
488
+ >
489
+ Remove Filter
490
+ </Button>
491
+
492
+ <div className='shared-filter-modal__right-buttons'>
493
+ <Button className='btn--cancel muted' style={{ display: 'inline-block', marginRight: '1em' }} onClick={overlay?.actions.toggleOverlay}>
494
+ Cancel
495
+ </Button>
496
+
497
+ <Button type='button' className='btn--submit success' style={{ display: 'inline-block' }} onClick={saveChanges}>
498
+ Save
499
+ </Button>
500
+ </div>
501
+ </Modal.Content>
502
+ </Modal>
503
+ )
504
+ }
505
+
506
+ export default FilterModal