@cdc/dashboard 1.1.1 → 1.1.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 +378 -0
- package/examples/default-data.json +368 -0
- package/examples/default.json +156 -0
- package/examples/test-example.json +1 -0
- package/package.json +13 -11
- package/src/CdcDashboard.js +491 -0
- package/src/components/Column.js +46 -0
- package/src/components/DataTable.tsx +172 -0
- package/src/components/EditorPanel.js +354 -0
- package/src/components/Grid.js +28 -0
- package/src/components/Header.js +15 -0
- package/src/components/Row.js +137 -0
- package/src/components/Widget.js +112 -0
- package/src/context.tsx +5 -0
- package/src/data/initial-state.js +17 -0
- package/src/images/icon-close.svg +1 -0
- package/src/images/icon-code.svg +3 -0
- package/src/images/icon-col-12.svg +3 -0
- package/src/images/icon-col-4-8.svg +3 -0
- package/src/images/icon-col-4.svg +3 -0
- package/src/images/icon-col-6.svg +3 -0
- package/src/images/icon-col-8-4.svg +3 -0
- package/src/images/icon-down.svg +1 -0
- package/src/images/icon-edit.svg +3 -0
- package/src/images/icon-grid.svg +4 -0
- package/src/images/icon-move.svg +8 -0
- package/src/images/icon-up.svg +1 -0
- package/src/images/warning.svg +1 -0
- package/src/index.html +27 -0
- package/src/index.js +17 -0
- package/src/scss/editor-panel.scss +652 -0
- package/src/scss/grid.scss +321 -0
- package/src/scss/main.scss +214 -0
- package/src/scss/mixins.scss +0 -0
- package/src/scss/variables.scss +1 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
// IE11
|
|
4
|
+
import 'core-js/stable'
|
|
5
|
+
import 'whatwg-fetch'
|
|
6
|
+
import ResizeObserver from 'resize-observer-polyfill'
|
|
7
|
+
|
|
8
|
+
import { DndProvider } from 'react-dnd'
|
|
9
|
+
import { HTML5Backend } from 'react-dnd-html5-backend'
|
|
10
|
+
|
|
11
|
+
import parse from 'html-react-parser'
|
|
12
|
+
|
|
13
|
+
import Loading from '@cdc/core/components/Loading'
|
|
14
|
+
import DataTransform from '@cdc/core/components/DataTransform'
|
|
15
|
+
import getViewport from '@cdc/core/helpers/getViewport'
|
|
16
|
+
|
|
17
|
+
import CdcMap from '@cdc/map'
|
|
18
|
+
import CdcChart from '@cdc/chart'
|
|
19
|
+
import CdcDataBite from '@cdc/data-bite'
|
|
20
|
+
import CdcWaffleChart from '@cdc/waffle-chart'
|
|
21
|
+
import CdcMarkupInclude from '@cdc/markup-include'
|
|
22
|
+
|
|
23
|
+
import EditorPanel from './components/EditorPanel'
|
|
24
|
+
import Grid from './components/Grid'
|
|
25
|
+
import Header from './components/Header'
|
|
26
|
+
import Context from './context'
|
|
27
|
+
import defaults from './data/initial-state'
|
|
28
|
+
import Widget from './components/Widget'
|
|
29
|
+
import DataTable from './components/DataTable'
|
|
30
|
+
|
|
31
|
+
import Papa from 'papaparse'
|
|
32
|
+
|
|
33
|
+
import './scss/main.scss'
|
|
34
|
+
|
|
35
|
+
import { publish } from '@cdc/core/helpers/events'
|
|
36
|
+
|
|
37
|
+
const addVisualization = (type, subType) => {
|
|
38
|
+
let newVisualizationConfig = {
|
|
39
|
+
newViz: true,
|
|
40
|
+
uid: type + Date.now(),
|
|
41
|
+
type
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
switch (type) {
|
|
45
|
+
case 'chart':
|
|
46
|
+
newVisualizationConfig.visualizationType = subType
|
|
47
|
+
break
|
|
48
|
+
case 'map':
|
|
49
|
+
newVisualizationConfig.general = {}
|
|
50
|
+
newVisualizationConfig.general.geoType = subType
|
|
51
|
+
break
|
|
52
|
+
case 'data-bite':
|
|
53
|
+
newVisualizationConfig.visualizationType = type
|
|
54
|
+
break
|
|
55
|
+
case 'waffle-chart':
|
|
56
|
+
newVisualizationConfig.visualizationType = type
|
|
57
|
+
break
|
|
58
|
+
case 'markup-include':
|
|
59
|
+
newVisualizationConfig.visualizationType = type
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return newVisualizationConfig
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const VisualizationsPanel = () => (
|
|
67
|
+
<div className="visualizations-panel">
|
|
68
|
+
<p style={{ fontSize: '14px' }}>Click and drag an item onto the grid to add it to your dashboard.</p>
|
|
69
|
+
<span className="subheading-3">Chart</span>
|
|
70
|
+
<div className="drag-grid">
|
|
71
|
+
<Widget addVisualization={() => addVisualization('chart', 'Bar')} type="Bar"/>
|
|
72
|
+
<Widget addVisualization={() => addVisualization('chart', 'Line')} type="Line"/>
|
|
73
|
+
<Widget addVisualization={() => addVisualization('chart', 'Pie')} type="Pie"/>
|
|
74
|
+
</div>
|
|
75
|
+
<span className="subheading-3">Map</span>
|
|
76
|
+
<div className="drag-grid">
|
|
77
|
+
<Widget addVisualization={() => addVisualization('map', 'us')} type="us"/>
|
|
78
|
+
<Widget addVisualization={() => addVisualization('map', 'world')} type="world"/>
|
|
79
|
+
<Widget addVisualization={() => addVisualization('map', 'single-state')} type="single-state"/>
|
|
80
|
+
</div>
|
|
81
|
+
<span className="subheading-3">Misc.</span>
|
|
82
|
+
<div className="drag-grid">
|
|
83
|
+
<Widget addVisualization={() => addVisualization('data-bite', '')} type="data-bite"/>
|
|
84
|
+
<Widget addVisualization={() => addVisualization('waffle-chart', '')} type="waffle-chart"/>
|
|
85
|
+
<Widget addVisualization={() => addVisualization('markup-include', '')} type="markup-include"/>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
export default function CdcDashboard(
|
|
91
|
+
{ configUrl = '', config: configObj = undefined, isEditor = false, setConfig: setParentConfig, hostname }
|
|
92
|
+
) {
|
|
93
|
+
|
|
94
|
+
const transform = new DataTransform()
|
|
95
|
+
|
|
96
|
+
const [ config, setConfig ] = useState(configObj)
|
|
97
|
+
|
|
98
|
+
const [ data, setData ] = useState([])
|
|
99
|
+
|
|
100
|
+
const [ filteredData, setFilteredData ] = useState()
|
|
101
|
+
|
|
102
|
+
const [ loading, setLoading ] = useState(true)
|
|
103
|
+
|
|
104
|
+
const [ preview, setPreview ] = useState(false)
|
|
105
|
+
|
|
106
|
+
const [ currentViewport, setCurrentViewport ] = useState('lg')
|
|
107
|
+
|
|
108
|
+
const [ coveLoadedHasRan, setCoveLoadedHasRan ] = useState(false)
|
|
109
|
+
|
|
110
|
+
const [ container, setContainer ] = useState()
|
|
111
|
+
|
|
112
|
+
const { title, description } = config ? (config.dashboard || config) : {}
|
|
113
|
+
|
|
114
|
+
// Supports JSON or CSV
|
|
115
|
+
const fetchRemoteData = async (url) => {
|
|
116
|
+
try {
|
|
117
|
+
const urlObj = new URL(url)
|
|
118
|
+
const regex = /(?:\.([^.]+))?$/
|
|
119
|
+
|
|
120
|
+
let data = []
|
|
121
|
+
|
|
122
|
+
const ext = (regex.exec(urlObj.pathname)[1])
|
|
123
|
+
if ('csv' === ext) {
|
|
124
|
+
data = await fetch(url)
|
|
125
|
+
.then(response => response.text())
|
|
126
|
+
.then(responseText => {
|
|
127
|
+
const parsedCsv = Papa.parse(responseText, {
|
|
128
|
+
header: true,
|
|
129
|
+
dynamicTyping: true,
|
|
130
|
+
skipEmptyLines: true
|
|
131
|
+
})
|
|
132
|
+
return parsedCsv.data
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if ('json' === ext) {
|
|
137
|
+
data = await fetch(url)
|
|
138
|
+
.then(response => response.json())
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return data
|
|
142
|
+
} catch {
|
|
143
|
+
// If we can't parse it, still attempt to fetch it
|
|
144
|
+
try {
|
|
145
|
+
let response = await (await fetch(configUrl)).json()
|
|
146
|
+
return response
|
|
147
|
+
} catch {
|
|
148
|
+
console.error(`Cannot parse URL: ${url}`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const cacheBustingString = () => {
|
|
154
|
+
const round = 1000 * 60 * 15;
|
|
155
|
+
const date = new Date();
|
|
156
|
+
return new Date(date.getTime() - (date.getTime() % round)).toISOString();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const loadConfig = async (configObj) => {
|
|
160
|
+
// Set loading flag
|
|
161
|
+
if (!loading) setLoading(true)
|
|
162
|
+
|
|
163
|
+
let newState = configObj || await (await fetch(configUrl)).json()
|
|
164
|
+
|
|
165
|
+
// If a dataUrl property exists, always pull from that.
|
|
166
|
+
if (newState.dataUrl) {
|
|
167
|
+
|
|
168
|
+
if (newState.dataUrl[0] === '/') {
|
|
169
|
+
newState.dataUrl = 'https://' + hostname + newState.dataUrl
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let newData = await fetchRemoteData(newState.dataUrl + `?v=${cacheBustingString()}`)
|
|
173
|
+
|
|
174
|
+
if (newData && newState.dataDescription) {
|
|
175
|
+
newData = transform.autoStandardize(newData)
|
|
176
|
+
newData = transform.developerStandardize(newData, newState.dataDescription)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (newData) {
|
|
180
|
+
newState.data = newData
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// If data is included through a URL, fetch that and store
|
|
185
|
+
let data = newState.formattedData || newState.data || {}
|
|
186
|
+
|
|
187
|
+
setData(data)
|
|
188
|
+
|
|
189
|
+
let newConfig = { ...defaults, ...newState }
|
|
190
|
+
|
|
191
|
+
updateConfig(newConfig, data)
|
|
192
|
+
|
|
193
|
+
setLoading(false)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const filterData = (filters, data) => {
|
|
197
|
+
let filteredData = []
|
|
198
|
+
|
|
199
|
+
data.forEach((row) => {
|
|
200
|
+
let add = true
|
|
201
|
+
|
|
202
|
+
filters.forEach((filter) => {
|
|
203
|
+
if (row[filter.columnName] !== filter.active) {
|
|
204
|
+
add = false
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
if (add) filteredData.push(row)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
return filteredData
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Gets filer values from dataset
|
|
215
|
+
const generateValuesForFilter = (columnName, data = this.state.data) => {
|
|
216
|
+
const values = []
|
|
217
|
+
|
|
218
|
+
data.forEach((row) => {
|
|
219
|
+
const value = row[columnName]
|
|
220
|
+
if (value && false === values.includes(value)) {
|
|
221
|
+
values.push(value)
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
return values
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isEmpty(obj) {
|
|
229
|
+
return Object.keys(obj).length === 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const updateConfig = (newConfig, dataOverride = null) => {
|
|
233
|
+
// After data is grabbed, loop through and generate filter column values if there are any
|
|
234
|
+
if (newConfig.dashboard.filters) {
|
|
235
|
+
const filterList = []
|
|
236
|
+
|
|
237
|
+
newConfig.dashboard.filters.forEach((filter) => {
|
|
238
|
+
filterList.push(filter.columnName)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
filterList.forEach((filter, index) => {
|
|
242
|
+
const filterValues = generateValuesForFilter(filter, (dataOverride || data))
|
|
243
|
+
|
|
244
|
+
if (newConfig.dashboard.filters[index].order === 'asc') {
|
|
245
|
+
filterValues.sort()
|
|
246
|
+
}
|
|
247
|
+
if (newConfig.dashboard.filters[index].order === 'desc') {
|
|
248
|
+
filterValues.sort().reverse()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
newConfig.dashboard.filters[index].values = filterValues
|
|
252
|
+
|
|
253
|
+
// Initial filter should be active
|
|
254
|
+
newConfig.dashboard.filters[index].active = filterValues[0]
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
setFilteredData(filterData(newConfig.dashboard.filters, (dataOverride || data)))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
//Enforce default values that need to be calculated at runtime
|
|
261
|
+
newConfig.runtime = {}
|
|
262
|
+
|
|
263
|
+
setConfig(newConfig)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Load data when component first mounts
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
loadConfig(config)
|
|
269
|
+
}, [])
|
|
270
|
+
|
|
271
|
+
// Pass up to <CdcEditor /> if it exists when config state changes
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (setParentConfig && isEditor) {
|
|
274
|
+
setParentConfig(config)
|
|
275
|
+
}
|
|
276
|
+
}, [ config ])
|
|
277
|
+
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (config && !coveLoadedHasRan && container) {
|
|
280
|
+
publish('cove_loaded', { config: config })
|
|
281
|
+
setCoveLoadedHasRan(true)
|
|
282
|
+
}
|
|
283
|
+
}, [config, container]);
|
|
284
|
+
|
|
285
|
+
const updateChildConfig = (visualizationKey, newConfig) => {
|
|
286
|
+
let updatedConfig = { ...config }
|
|
287
|
+
|
|
288
|
+
updatedConfig.visualizations[visualizationKey] = newConfig
|
|
289
|
+
setConfig(updatedConfig)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const Filters = () => {
|
|
293
|
+
const changeFilterActive = (index, value) => {
|
|
294
|
+
let dashboardConfig = { ...config.dashboard }
|
|
295
|
+
|
|
296
|
+
dashboardConfig.filters[index].active = value
|
|
297
|
+
|
|
298
|
+
setConfig({ ...config, dashboard: dashboardConfig })
|
|
299
|
+
|
|
300
|
+
setFilteredData(filterData(dashboardConfig.filters, data))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const announceChange = (text) => {
|
|
304
|
+
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return config.dashboard.filters.map((singleFilter, index) => {
|
|
308
|
+
const values = []
|
|
309
|
+
|
|
310
|
+
singleFilter.values.forEach((filterOption, index) => {
|
|
311
|
+
values.push(<option
|
|
312
|
+
key={index}
|
|
313
|
+
value={filterOption}
|
|
314
|
+
>{filterOption}
|
|
315
|
+
</option>)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<section className="dashboard-filters-section" key={index}>
|
|
320
|
+
<label htmlFor={`filter-${index}`}>{singleFilter.label}</label>
|
|
321
|
+
<select
|
|
322
|
+
id={`filter-${index}`}
|
|
323
|
+
className="filter-select"
|
|
324
|
+
data-index="0"
|
|
325
|
+
value={singleFilter.active}
|
|
326
|
+
onChange={(val) => {
|
|
327
|
+
changeFilterActive(index, val.target.value)
|
|
328
|
+
announceChange(`Filter ${singleFilter.label} value has been changed to ${val.target.value}, please reference the data table to see updated values.`)
|
|
329
|
+
}}
|
|
330
|
+
>
|
|
331
|
+
{values}
|
|
332
|
+
</select>
|
|
333
|
+
</section>
|
|
334
|
+
)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const resizeObserver = new ResizeObserver(entries => {
|
|
340
|
+
for (let entry of entries) {
|
|
341
|
+
let newViewport = getViewport(entry.contentRect.width)
|
|
342
|
+
|
|
343
|
+
setCurrentViewport(newViewport)
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
const outerContainerRef = useCallback(node => {
|
|
348
|
+
if (node !== null) {
|
|
349
|
+
resizeObserver.observe(node)
|
|
350
|
+
}
|
|
351
|
+
setContainer(node)
|
|
352
|
+
}, [])
|
|
353
|
+
|
|
354
|
+
// Prevent render if loading
|
|
355
|
+
if (loading) return <Loading/>
|
|
356
|
+
|
|
357
|
+
let body = null
|
|
358
|
+
|
|
359
|
+
// Editor mode
|
|
360
|
+
if (isEditor && !preview) {
|
|
361
|
+
let subVisualizationEditing = false
|
|
362
|
+
|
|
363
|
+
Object.keys(config.visualizations).forEach(visualizationKey => {
|
|
364
|
+
let visualizationConfig = config.visualizations[visualizationKey]
|
|
365
|
+
|
|
366
|
+
visualizationConfig.data = filteredData || data
|
|
367
|
+
|
|
368
|
+
if (visualizationConfig.editing) {
|
|
369
|
+
subVisualizationEditing = true
|
|
370
|
+
|
|
371
|
+
const back = () => {
|
|
372
|
+
const newConfig = { ...config }
|
|
373
|
+
|
|
374
|
+
delete newConfig.visualizations[visualizationKey].editing
|
|
375
|
+
|
|
376
|
+
setConfig(newConfig)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const updateConfig = (newConfig) => updateChildConfig(visualizationKey, newConfig)
|
|
380
|
+
|
|
381
|
+
switch (visualizationConfig.type) {
|
|
382
|
+
case 'chart':
|
|
383
|
+
body = <><Header back={back} subEditor="Chart"/><CdcChart key={visualizationKey} config={visualizationConfig} isEditor={true} setConfig={updateConfig} isDashboard={true}/></>
|
|
384
|
+
break
|
|
385
|
+
case 'map':
|
|
386
|
+
body = <><Header back={back} subEditor="Map"/><CdcMap key={visualizationKey} config={visualizationConfig} isEditor={true} setConfig={updateConfig} isDashboard={true}/></>
|
|
387
|
+
break
|
|
388
|
+
case 'data-bite':
|
|
389
|
+
visualizationConfig = { ...visualizationConfig, newViz: true }
|
|
390
|
+
body = <><Header back={back} subEditor="Data Bite"/><CdcDataBite key={visualizationKey} config={visualizationConfig} isEditor={true} setConfig={updateConfig} isDashboard={true}/></>
|
|
391
|
+
break
|
|
392
|
+
case 'waffle-chart':
|
|
393
|
+
body = <><Header back={back} subEditor="Waffle Chart"/><CdcWaffleChart key={visualizationKey} config={visualizationConfig} isEditor={true} setConfig={updateConfig} isDashboard={true}/></>
|
|
394
|
+
break
|
|
395
|
+
case 'markup-include':
|
|
396
|
+
body = <><Header back={back} subEditor="Markup Include"/><CdcMarkupInclude key={visualizationKey} config={visualizationConfig} isEditor={true} setConfig={updateConfig} isDashboard={true}/></>
|
|
397
|
+
break
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
if (!subVisualizationEditing) {
|
|
403
|
+
body = (
|
|
404
|
+
<DndProvider backend={HTML5Backend}>
|
|
405
|
+
<Header preview={preview} setPreview={setPreview}/>
|
|
406
|
+
<div className="layout-container">
|
|
407
|
+
<VisualizationsPanel/>
|
|
408
|
+
<Grid/>
|
|
409
|
+
</div>
|
|
410
|
+
</DndProvider>
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
body = (
|
|
415
|
+
<>
|
|
416
|
+
{isEditor && <Header preview={preview} setPreview={setPreview}/>}
|
|
417
|
+
{isEditor && <EditorPanel/>}
|
|
418
|
+
<div className="cdc-dashboard-inner-container">
|
|
419
|
+
{/* Title */}
|
|
420
|
+
{title && <div role="heading" className={`dashboard-title ${config.dashboard.theme ?? 'theme-blue'}`}>{title}</div>}
|
|
421
|
+
|
|
422
|
+
{/* Filters */}
|
|
423
|
+
{config.dashboard.filters && <Filters/>}
|
|
424
|
+
|
|
425
|
+
{/* Visualizations */}
|
|
426
|
+
{config.rows && config.rows.map((row, index) => {
|
|
427
|
+
// Empty check
|
|
428
|
+
if (row.filter(col => col.widget).length === 0) return null
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<div className="dashboard-row" key={`row__${index}`}>
|
|
432
|
+
{row.map((col, index) => {
|
|
433
|
+
if (col.width) {
|
|
434
|
+
if (!col.widget) return <div className={`dashboard-col dashboard-col-${col.width}`}></div>
|
|
435
|
+
|
|
436
|
+
let visualizationConfig = config.visualizations[col.widget]
|
|
437
|
+
|
|
438
|
+
visualizationConfig.data = filteredData || data
|
|
439
|
+
|
|
440
|
+
return <div className={`dashboard-col dashboard-col-${col.width}`} key={`vis__${index}`}>
|
|
441
|
+
{visualizationConfig.type === 'chart' && <CdcChart key={col.widget} config={visualizationConfig} isEditor={false} setConfig={(newConfig) => {
|
|
442
|
+
updateChildConfig(col.widget, newConfig)
|
|
443
|
+
}} isDashboard={true}/>}
|
|
444
|
+
{visualizationConfig.type === 'map' && <CdcMap key={col.widget} config={visualizationConfig} isEditor={false} setConfig={(newConfig) => {
|
|
445
|
+
updateChildConfig(col.widget, newConfig)
|
|
446
|
+
}} isDashboard={true}/>}
|
|
447
|
+
{visualizationConfig.type === 'data-bite' && <CdcDataBite key={col.widget} config={visualizationConfig} isEditor={false} setConfig={(newConfig) => {
|
|
448
|
+
updateChildConfig(col.widget, newConfig)
|
|
449
|
+
}} isDashboard={true}/>}
|
|
450
|
+
{visualizationConfig.type === 'waffle-chart' && <CdcWaffleChart key={col.widget} config={visualizationConfig} isEditor={false} setConfig={(newConfig) => {
|
|
451
|
+
updateChildConfig(col.widget, newConfig)
|
|
452
|
+
}} isDashboard={true}/>}
|
|
453
|
+
{visualizationConfig.type === 'markup-include' && <CdcMarkupInclude key={col.widget} config={visualizationConfig} isEditor={false} setConfig={(newConfig) => {
|
|
454
|
+
updateChildConfig(col.widget, newConfig)
|
|
455
|
+
}} isDashboard={true}/>}
|
|
456
|
+
</div>
|
|
457
|
+
}
|
|
458
|
+
})}
|
|
459
|
+
</div>)
|
|
460
|
+
})}
|
|
461
|
+
|
|
462
|
+
{/* Data Table */}
|
|
463
|
+
{config.table.show && <DataTable/>}
|
|
464
|
+
|
|
465
|
+
{/* Description */}
|
|
466
|
+
{description && <div className="subtext">{parse(description)}</div>}
|
|
467
|
+
</div>
|
|
468
|
+
</>
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const contextValues = {
|
|
473
|
+
config,
|
|
474
|
+
rawData: data,
|
|
475
|
+
data: filteredData ?? data,
|
|
476
|
+
visualizations: config.visualizations,
|
|
477
|
+
rows: config.rows,
|
|
478
|
+
loading,
|
|
479
|
+
updateConfig,
|
|
480
|
+
setParentConfig,
|
|
481
|
+
setPreview
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return (
|
|
485
|
+
<Context.Provider value={contextValues}>
|
|
486
|
+
<div className={`cdc-open-viz-module type-dashboard ${currentViewport}`} ref={outerContainerRef}>
|
|
487
|
+
{body}
|
|
488
|
+
</div>
|
|
489
|
+
</Context.Provider>
|
|
490
|
+
)
|
|
491
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React, { useContext, memo } from 'react'
|
|
2
|
+
import { useDrop } from 'react-dnd'
|
|
3
|
+
|
|
4
|
+
import Context from '../context'
|
|
5
|
+
import Widget from './Widget'
|
|
6
|
+
|
|
7
|
+
const Column = ({ data, rowIdx, colIdx }) => {
|
|
8
|
+
const { visualizations } = useContext(Context)
|
|
9
|
+
|
|
10
|
+
const [ { isOver, canDrop }, drop ] = useDrop(() => ({
|
|
11
|
+
accept: 'vis-widget',
|
|
12
|
+
drop: () => ({
|
|
13
|
+
rowIdx,
|
|
14
|
+
colIdx,
|
|
15
|
+
canDrop
|
|
16
|
+
}),
|
|
17
|
+
canDrop: () => !data.widget,
|
|
18
|
+
collect: (monitor) => ({
|
|
19
|
+
isOver: monitor.isOver(),
|
|
20
|
+
canDrop: !!monitor.canDrop()
|
|
21
|
+
})
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
const widget = data.widget ? visualizations[data.widget] : null
|
|
25
|
+
|
|
26
|
+
let classNames = [
|
|
27
|
+
'builder-column',
|
|
28
|
+
'column-size--' + data.width,
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
if(isOver && canDrop) {
|
|
32
|
+
classNames.push('column--drop')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if(widget) {
|
|
36
|
+
classNames.push('column--populated')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className={classNames.join(' ')} ref={drop}>
|
|
41
|
+
{widget ? <Widget data={{rowIdx, colIdx, ...widget}} type={widget.visualizationType ?? widget.general?.geoType} /> : <p className="builder-column__text">Drag and drop <br/> visualization</p>}
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default Column
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useContext,
|
|
3
|
+
useEffect,
|
|
4
|
+
useState,
|
|
5
|
+
useMemo,
|
|
6
|
+
memo } from 'react';
|
|
7
|
+
import {
|
|
8
|
+
useTable,
|
|
9
|
+
useSortBy,
|
|
10
|
+
useResizeColumns,
|
|
11
|
+
useBlockLayout
|
|
12
|
+
} from 'react-table';
|
|
13
|
+
import Papa from 'papaparse';
|
|
14
|
+
import { Base64 } from 'js-base64';
|
|
15
|
+
|
|
16
|
+
import ErrorBoundary from '@cdc/core/components/ErrorBoundary';
|
|
17
|
+
|
|
18
|
+
import Context from '../context';
|
|
19
|
+
|
|
20
|
+
export default function DataTable() {
|
|
21
|
+
|
|
22
|
+
const { data, config } = useContext<any>(Context);
|
|
23
|
+
|
|
24
|
+
const [tableExpanded, setTableExpanded] = useState<boolean>(config.table ? config.table.expanded : false);
|
|
25
|
+
const [accessibilityLabel, setAccessibilityLabel] = useState('');
|
|
26
|
+
|
|
27
|
+
const DownloadButton = memo(({ data }: any) => {
|
|
28
|
+
const fileName = `${config.title ? config.title.substring(0, 50) : 'cdc-open-viz'}.csv`;
|
|
29
|
+
|
|
30
|
+
const csvData = Papa.unparse(data);
|
|
31
|
+
|
|
32
|
+
const saveBlob = () => {
|
|
33
|
+
//@ts-ignore
|
|
34
|
+
if (typeof window.navigator.msSaveBlob === 'function') {
|
|
35
|
+
const dataBlob = new Blob([csvData], { type: "text/csv;charset=utf-8;" });
|
|
36
|
+
//@ts-ignore
|
|
37
|
+
window.navigator.msSaveBlob(dataBlob, fileName);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<a
|
|
43
|
+
download={fileName}
|
|
44
|
+
onClick={saveBlob}
|
|
45
|
+
href={`data:text/csv;base64,${Base64.encode(csvData)}`}
|
|
46
|
+
aria-label="Download this data in a CSV file format."
|
|
47
|
+
className={`btn btn-download no-border`}
|
|
48
|
+
>
|
|
49
|
+
Download Data (CSV)
|
|
50
|
+
</a>
|
|
51
|
+
)
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Creates columns structure for the table
|
|
55
|
+
const tableColumns = useMemo(() => {
|
|
56
|
+
const newTableColumns = [];
|
|
57
|
+
|
|
58
|
+
// catch no data errors and update the table header.
|
|
59
|
+
if(data.length === 0) {
|
|
60
|
+
return [{
|
|
61
|
+
Header : 'No Data Found'
|
|
62
|
+
}];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
else {
|
|
66
|
+
Object.keys(data[0]).map((key) => {
|
|
67
|
+
const newCol = {
|
|
68
|
+
Header: key,
|
|
69
|
+
Cell: ({ row }) => {
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
{row.original[key]}
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
id: key,
|
|
77
|
+
canSort: true
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
newTableColumns.push(newCol);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return newTableColumns;
|
|
85
|
+
}, [config]);
|
|
86
|
+
|
|
87
|
+
const tableData = useMemo(
|
|
88
|
+
() => data,
|
|
89
|
+
[data]
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Change accessibility label depending on expanded status
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const expandedLabel = 'Accessible data table.';
|
|
95
|
+
const collapsedLabel = 'Accessible data table. This table is currently collapsed visually but can still be read using a screen reader.';
|
|
96
|
+
|
|
97
|
+
if (tableExpanded === true && accessibilityLabel !== expandedLabel) {
|
|
98
|
+
setAccessibilityLabel(expandedLabel);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (tableExpanded === false && accessibilityLabel !== collapsedLabel) {
|
|
102
|
+
setAccessibilityLabel(collapsedLabel);
|
|
103
|
+
}
|
|
104
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
105
|
+
}, [tableExpanded]);
|
|
106
|
+
|
|
107
|
+
const defaultColumn = useMemo(
|
|
108
|
+
() => ({
|
|
109
|
+
minWidth: 150,
|
|
110
|
+
width: 200,
|
|
111
|
+
maxWidth: 400,
|
|
112
|
+
}),
|
|
113
|
+
[]
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const {
|
|
117
|
+
getTableProps,
|
|
118
|
+
getTableBodyProps,
|
|
119
|
+
headerGroups,
|
|
120
|
+
rows,
|
|
121
|
+
prepareRow,
|
|
122
|
+
} = useTable({ columns: tableColumns, data: tableData, defaultColumn }, useSortBy, useBlockLayout, useResizeColumns);
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<ErrorBoundary component="DataTable">
|
|
126
|
+
<section className={`data-table-container`} aria-label={accessibilityLabel}>
|
|
127
|
+
<div
|
|
128
|
+
className={tableExpanded ? 'data-table-heading' : 'collapsed data-table-heading'}
|
|
129
|
+
onClick={() => { setTableExpanded(!tableExpanded); }}
|
|
130
|
+
tabIndex={0}
|
|
131
|
+
onKeyDown={(e) => { if (e.keyCode === 13) { setTableExpanded(!tableExpanded); } }}
|
|
132
|
+
>
|
|
133
|
+
{config.table.label}
|
|
134
|
+
</div>
|
|
135
|
+
<div className="table-container">
|
|
136
|
+
<table className={tableExpanded ? 'data-table' : 'data-table cdcdataviz-sr-only'} hidden={!tableExpanded} {...getTableProps()}>
|
|
137
|
+
<caption className="visually-hidden">{config.table.label}</caption>
|
|
138
|
+
<thead>
|
|
139
|
+
{headerGroups && headerGroups.map((headerGroup) => (
|
|
140
|
+
<tr {...headerGroup.getHeaderGroupProps()}>
|
|
141
|
+
{headerGroup.headers.map((column) => (
|
|
142
|
+
<th tabIndex="0" {...column.getHeaderProps(column.getSortByToggleProps())} className={column.isSorted ? column.isSortedDesc ? 'sort sort-desc' : 'sort sort-asc' : 'sort'} title={column.Header}>
|
|
143
|
+
{column.render('Header')}
|
|
144
|
+
<div {...column.getResizerProps()} className="resizer" />
|
|
145
|
+
</th>
|
|
146
|
+
))}
|
|
147
|
+
</tr>
|
|
148
|
+
))}
|
|
149
|
+
</thead>
|
|
150
|
+
{rows &&
|
|
151
|
+
<tbody {...getTableBodyProps()}>
|
|
152
|
+
{rows.map((row) => {
|
|
153
|
+
prepareRow(row);
|
|
154
|
+
return (
|
|
155
|
+
<tr {...row.getRowProps()}>
|
|
156
|
+
{row.cells && row.cells.map((cell) => (
|
|
157
|
+
<td tabIndex="0" {...cell.getCellProps()}>
|
|
158
|
+
{cell.render('Cell')}
|
|
159
|
+
</td>
|
|
160
|
+
))}
|
|
161
|
+
</tr>
|
|
162
|
+
);
|
|
163
|
+
})}
|
|
164
|
+
</tbody>
|
|
165
|
+
}
|
|
166
|
+
</table>
|
|
167
|
+
</div>
|
|
168
|
+
{config.table.download && <DownloadButton data={data} />}
|
|
169
|
+
</section>
|
|
170
|
+
</ErrorBoundary>
|
|
171
|
+
);
|
|
172
|
+
}
|