@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.
@@ -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
+ }