@cdc/editor 4.26.3 → 4.26.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/cdceditor-CY9IcPSi.es.js +6 -0
- package/dist/cdceditor-DlpiY3fQ.es.js +4 -0
- package/dist/cdceditor.js +74691 -71864
- package/package.json +9 -9
- package/src/_stories/Editor.stories.tsx +62 -0
- package/src/components/ChooseTab.tsx +5 -3
- package/src/components/DataImport/components/DataImport.tsx +114 -30
- package/src/components/DataImport/helpers/applyAutoDetectedDateParseFormat.ts +35 -0
- package/src/components/DataImport/tests/applyAutoDetectedDateParseFormat.test.ts +128 -0
- package/src/components/PreviewDataTable.test.tsx +184 -0
- package/src/components/PreviewDataTable.tsx +18 -7
- package/src/components/modal/Confirmation.jsx +5 -4
- package/src/scss/main.scss +14 -1
- package/LICENSE +0 -201
- package/dist/cdceditor-vr9HZwRt.es.js +0 -6
- package/example/data-horizontal-filters.json +0 -8
- package/example/data-horizontal-multiseries-filters.json +0 -18
- package/example/data-horizontal-multiseries.json +0 -6
- package/example/data-horizontal.json +0 -4
- package/example/data-vertical-filters.json +0 -10
- package/example/data-vertical-multiseries-filters.json +0 -18
- package/example/data-vertical-multiseries-multirow-filters.json +0 -50
- package/example/data-vertical-multiseries-multirow.json +0 -14
- package/example/data-vertical-multiseries.json +0 -6
- package/example/data-vertical.json +0 -6
- package/example/region-map.json +0 -33
- package/example/test.json +0 -110280
- package/example/valid-county-data.json +0 -3049
- package/example/valid-scatterplot.csv +0 -17
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cdc/editor",
|
|
3
|
-
"version": "4.26.
|
|
3
|
+
"version": "4.26.4",
|
|
4
4
|
"description": "React component for generating a new component entry",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bugs": "https://github.com/CDCgov/cdc-open-viz/issues",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@cdc/chart": "^4.26.
|
|
9
|
-
"@cdc/core": "^4.26.
|
|
10
|
-
"@cdc/dashboard": "^4.26.
|
|
11
|
-
"@cdc/data-bite": "^4.26.
|
|
12
|
-
"@cdc/map": "^4.26.
|
|
13
|
-
"@cdc/markup-include": "^4.26.
|
|
14
|
-
"@cdc/waffle-chart": "^4.26.
|
|
8
|
+
"@cdc/chart": "^4.26.4",
|
|
9
|
+
"@cdc/core": "^4.26.4",
|
|
10
|
+
"@cdc/dashboard": "^4.26.4",
|
|
11
|
+
"@cdc/data-bite": "^4.26.4",
|
|
12
|
+
"@cdc/map": "^4.26.4",
|
|
13
|
+
"@cdc/markup-include": "^4.26.4",
|
|
14
|
+
"@cdc/waffle-chart": "^4.26.4",
|
|
15
15
|
"axios": "^1.13.2",
|
|
16
16
|
"d3": "^7.9.0",
|
|
17
17
|
"react-dropzone": "^14.3.8",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"vite-plugin-css-injected-by-js": "^2.4.0",
|
|
24
24
|
"vite-plugin-svgr": "^4.2.0"
|
|
25
25
|
},
|
|
26
|
-
"gitHead": "
|
|
26
|
+
"gitHead": "6097de1ff814001880d9ac64bd66becdc092d63c",
|
|
27
27
|
"main": "dist/cdceditor",
|
|
28
28
|
"moduleName": "CdcEditor",
|
|
29
29
|
"peerDependencies": {
|
|
@@ -103,6 +103,68 @@ export const LoadDataTableJsonConfig: Story = {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
export const DownloadDashboardDatasetCSV: Story = {
|
|
107
|
+
args: { config: {} },
|
|
108
|
+
play: async ({ canvasElement }) => {
|
|
109
|
+
const canvas = within(canvasElement)
|
|
110
|
+
const user = userEvent.setup()
|
|
111
|
+
|
|
112
|
+
await loadConfigFromTextArea(canvasElement, DashboardConfig)
|
|
113
|
+
await user.click(canvas.getByText('2. Import Data'))
|
|
114
|
+
await expect(canvas.findByText('Data Sources')).resolves.toBeTruthy()
|
|
115
|
+
|
|
116
|
+
const originalCreateObjectURL = URL.createObjectURL
|
|
117
|
+
let capturedBlob: Blob | null = null
|
|
118
|
+
URL.createObjectURL = (blob: Blob) => {
|
|
119
|
+
capturedBlob = blob
|
|
120
|
+
return 'blob:test-mock'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const downloadBtn = await canvas.findByRole('button', { name: 'Download' })
|
|
125
|
+
await user.click(downloadBtn)
|
|
126
|
+
|
|
127
|
+
expect(capturedBlob).toBeTruthy()
|
|
128
|
+
const text = await capturedBlob!.text()
|
|
129
|
+
expect(text).toContain('Location')
|
|
130
|
+
expect(text).toContain('Rate')
|
|
131
|
+
} finally {
|
|
132
|
+
URL.createObjectURL = originalCreateObjectURL
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const DownloadSingleVizCSV: Story = {
|
|
138
|
+
args: { config: {} },
|
|
139
|
+
play: async ({ canvasElement }) => {
|
|
140
|
+
const canvas = within(canvasElement)
|
|
141
|
+
const user = userEvent.setup()
|
|
142
|
+
|
|
143
|
+
await loadConfigFromTextArea(canvasElement, ChartEditorConfig)
|
|
144
|
+
await user.click(canvas.getByText('2. Import Data'))
|
|
145
|
+
await expect(canvas.findByText('Data Preview')).resolves.toBeTruthy()
|
|
146
|
+
|
|
147
|
+
const originalCreateObjectURL = URL.createObjectURL
|
|
148
|
+
let capturedBlob: Blob | null = null
|
|
149
|
+
URL.createObjectURL = (blob: Blob) => {
|
|
150
|
+
capturedBlob = blob
|
|
151
|
+
return 'blob:test-mock'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const downloadBtn = await canvas.findByRole('button', { name: 'Download CSV' })
|
|
156
|
+
await user.click(downloadBtn)
|
|
157
|
+
|
|
158
|
+
expect(capturedBlob).toBeTruthy()
|
|
159
|
+
const text = await capturedBlob!.text()
|
|
160
|
+
expect(text).toContain('Year')
|
|
161
|
+
expect(text).toContain('Category')
|
|
162
|
+
} finally {
|
|
163
|
+
URL.createObjectURL = originalCreateObjectURL
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
106
168
|
export const InvalidJsonShowsValidationAlert: Story = {
|
|
107
169
|
args: {
|
|
108
170
|
config: {}
|
|
@@ -3,6 +3,7 @@ import '../scss/choose-vis-tab.scss'
|
|
|
3
3
|
|
|
4
4
|
import ConfigContext, { EditorDispatchContext } from '@cdc/core/contexts/EditorContext'
|
|
5
5
|
import Tooltip from '@cdc/core/components/ui/Tooltip'
|
|
6
|
+
import Button from '@cdc/core/components/elements/Button'
|
|
6
7
|
|
|
7
8
|
import AlabamaGraphic from '@cdc/core/assets/icon-map-alabama.svg'
|
|
8
9
|
import AreaChartIcon from '@cdc/core/assets/icon-area-chart.svg'
|
|
@@ -274,15 +275,16 @@ const ChooseTab: React.FC = (): JSX.Element => {
|
|
|
274
275
|
placeholder='{ }'
|
|
275
276
|
value={pastedConfig}
|
|
276
277
|
/>
|
|
277
|
-
<
|
|
278
|
-
|
|
278
|
+
<Button
|
|
279
|
+
variant='primary'
|
|
280
|
+
className='px-4 ms-2'
|
|
279
281
|
type='submit'
|
|
280
282
|
id='load-data'
|
|
281
283
|
disabled={!pastedConfig}
|
|
282
284
|
onClick={() => importConfig(pastedConfig)}
|
|
283
285
|
>
|
|
284
286
|
Load
|
|
285
|
-
</
|
|
287
|
+
</Button>
|
|
286
288
|
</div>
|
|
287
289
|
</div>
|
|
288
290
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useState, useContext, useEffect } from 'react'
|
|
2
2
|
import { useDropzone } from 'react-dropzone'
|
|
3
3
|
import axios from 'axios'
|
|
4
|
+
import { csvFormat } from 'd3'
|
|
4
5
|
|
|
5
6
|
import { DataTransform } from '@cdc/core/helpers/DataTransform'
|
|
6
7
|
|
|
@@ -19,6 +20,7 @@ import CloseIcon from '@cdc/core/assets/icon-close.svg'
|
|
|
19
20
|
import DataDesigner from '@cdc/core/components/managers/DataDesigner'
|
|
20
21
|
import Tooltip from '@cdc/core/components/ui/Tooltip'
|
|
21
22
|
import Icon from '@cdc/core/components/ui/Icon'
|
|
23
|
+
import Button from '@cdc/core/components/elements/Button'
|
|
22
24
|
import { isSolrCsv, isSolrJson } from '@cdc/core/helpers/isSolr'
|
|
23
25
|
import { type Visualization } from '@cdc/core/types/Visualization'
|
|
24
26
|
import { type DataSet } from '@cdc/core/types/DataSet'
|
|
@@ -32,6 +34,7 @@ import { supportedDataTypes } from '../helpers/supportedDataTypes'
|
|
|
32
34
|
import { getFileExtension } from '../helpers/getFileExtension'
|
|
33
35
|
import { parseTextByMimeType } from '../helpers/parseTextByMimeType'
|
|
34
36
|
import { getMimeType } from '../helpers/getMimeType'
|
|
37
|
+
import { applyAutoDetectedDateParseFormat } from '../helpers/applyAutoDetectedDateParseFormat'
|
|
35
38
|
import {
|
|
36
39
|
extractCoveData,
|
|
37
40
|
getSampleVegaJson,
|
|
@@ -208,14 +211,21 @@ const DataImport = () => {
|
|
|
208
211
|
payload: { datasetKey: newDatasetName || fileSource, dataset, oldDatasetKey }
|
|
209
212
|
})
|
|
210
213
|
} else {
|
|
214
|
+
const configWithAutoDetectedDateFormat = applyAutoDetectedDateParseFormat(
|
|
215
|
+
{
|
|
216
|
+
...config,
|
|
217
|
+
...tempConfig
|
|
218
|
+
},
|
|
219
|
+
newData as Record<string, unknown>[]
|
|
220
|
+
)
|
|
221
|
+
|
|
211
222
|
let newConfig = {
|
|
212
|
-
...
|
|
213
|
-
...tempConfig,
|
|
223
|
+
...configWithAutoDetectedDateFormat,
|
|
214
224
|
data: newData,
|
|
215
225
|
dataMetadata,
|
|
216
226
|
dataFileName: fileSource, // new file source
|
|
217
227
|
dataFileSourceType: fileSourceType, // new file source type
|
|
218
|
-
formattedData: transform.developerStandardize(newData,
|
|
228
|
+
formattedData: transform.developerStandardize(newData, configWithAutoDetectedDateFormat.dataDescription)
|
|
219
229
|
}
|
|
220
230
|
if (setDataURL) {
|
|
221
231
|
newConfig.dataUrl = fileSource
|
|
@@ -359,7 +369,7 @@ const DataImport = () => {
|
|
|
359
369
|
Always load from URL (normally will only pull once)
|
|
360
370
|
</label>
|
|
361
371
|
<div className='d-flex justify-content-end mt-2 mb-3'>
|
|
362
|
-
<
|
|
372
|
+
<Button
|
|
363
373
|
className='btn btn-primary px-4'
|
|
364
374
|
type='submit'
|
|
365
375
|
id='load-data'
|
|
@@ -367,7 +377,7 @@ const DataImport = () => {
|
|
|
367
377
|
onClick={() => loadData(null, externalURL, editingDataset)}
|
|
368
378
|
>
|
|
369
379
|
Save & Load
|
|
370
|
-
</
|
|
380
|
+
</Button>
|
|
371
381
|
</div>
|
|
372
382
|
</>
|
|
373
383
|
)
|
|
@@ -388,7 +398,7 @@ const DataImport = () => {
|
|
|
388
398
|
return (
|
|
389
399
|
//todo convert to modal
|
|
390
400
|
<>
|
|
391
|
-
<
|
|
401
|
+
<Button
|
|
392
402
|
className='btn danger'
|
|
393
403
|
onClick={() =>
|
|
394
404
|
resetEditor(
|
|
@@ -399,7 +409,7 @@ const DataImport = () => {
|
|
|
399
409
|
>
|
|
400
410
|
Clear
|
|
401
411
|
<CloseIcon />
|
|
402
|
-
</
|
|
412
|
+
</Button>
|
|
403
413
|
{/* DEV-851 link to replace file should pop file dialog */}
|
|
404
414
|
{config.dataFileSourceType === 'file' && (
|
|
405
415
|
<div className='link link-replace' {...getRootProps2()}>
|
|
@@ -435,6 +445,49 @@ const DataImport = () => {
|
|
|
435
445
|
dispatch({ type: 'DELETE_DASHBOARD_DATASET', payload: { datasetKey } })
|
|
436
446
|
}
|
|
437
447
|
|
|
448
|
+
const downloadCSV = (data: Array<Record<string, unknown>>, filename: string) => {
|
|
449
|
+
// Normalize filename: strip query/hash, remove path, replace extension with .csv
|
|
450
|
+
let baseName = filename || ''
|
|
451
|
+
|
|
452
|
+
const queryIndex = baseName.indexOf('?')
|
|
453
|
+
const hashIndex = baseName.indexOf('#')
|
|
454
|
+
const cutoffIndex = queryIndex === -1 ? hashIndex : hashIndex === -1 ? queryIndex : Math.min(queryIndex, hashIndex)
|
|
455
|
+
if (cutoffIndex !== -1) {
|
|
456
|
+
baseName = baseName.substring(0, cutoffIndex)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const lastSlash = Math.max(baseName.lastIndexOf('/'), baseName.lastIndexOf('\\'))
|
|
460
|
+
if (lastSlash !== -1) {
|
|
461
|
+
baseName = baseName.substring(lastSlash + 1)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const lastDot = baseName.lastIndexOf('.')
|
|
465
|
+
if (lastDot > 0) {
|
|
466
|
+
baseName = baseName.substring(0, lastDot)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!baseName) {
|
|
470
|
+
baseName = 'data'
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const name = `${baseName}.csv`
|
|
474
|
+
const blob = new Blob(['\uFEFF' + csvFormat(data)], { type: 'text/csv;charset=utf-8;' })
|
|
475
|
+
// @ts-ignore
|
|
476
|
+
if (typeof window.navigator.msSaveBlob === 'function') {
|
|
477
|
+
// @ts-ignore
|
|
478
|
+
navigator.msSaveBlob(blob, name)
|
|
479
|
+
} else {
|
|
480
|
+
const url = URL.createObjectURL(blob)
|
|
481
|
+
const link = document.createElement('a')
|
|
482
|
+
link.href = url
|
|
483
|
+
link.download = name
|
|
484
|
+
document.body.appendChild(link)
|
|
485
|
+
link.click()
|
|
486
|
+
document.body.removeChild(link)
|
|
487
|
+
URL.revokeObjectURL(url)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
438
491
|
const updateDataFromVegaConfig = pastedConfig => {
|
|
439
492
|
const vegaConfig = parseVegaConfig(JSON.parse(pastedConfig))
|
|
440
493
|
const newData = extractCoveData(vegaConfig)
|
|
@@ -609,7 +662,7 @@ const DataImport = () => {
|
|
|
609
662
|
</fieldset>
|
|
610
663
|
)
|
|
611
664
|
)}
|
|
612
|
-
<
|
|
665
|
+
<Button
|
|
613
666
|
className='btn full-width btn-primary'
|
|
614
667
|
onClick={() => {
|
|
615
668
|
setConfig({
|
|
@@ -621,7 +674,7 @@ const DataImport = () => {
|
|
|
621
674
|
}}
|
|
622
675
|
>
|
|
623
676
|
Add New URL Filter
|
|
624
|
-
</
|
|
677
|
+
</Button>
|
|
625
678
|
</>
|
|
626
679
|
)
|
|
627
680
|
|
|
@@ -639,7 +692,7 @@ const DataImport = () => {
|
|
|
639
692
|
<th>Name</th>
|
|
640
693
|
<th>Size</th>
|
|
641
694
|
<th>Type</th>
|
|
642
|
-
<th colSpan={
|
|
695
|
+
<th colSpan={4}>Actions</th>
|
|
643
696
|
</tr>
|
|
644
697
|
</thead>
|
|
645
698
|
<tbody>
|
|
@@ -651,17 +704,33 @@ const DataImport = () => {
|
|
|
651
704
|
<td className='p-1'>{displaySize(config.datasets[datasetKey].dataFileSize)}</td>
|
|
652
705
|
<td className='p-1'>{config.datasets[datasetKey].dataFileFormat}</td>
|
|
653
706
|
<td>
|
|
654
|
-
<
|
|
655
|
-
|
|
707
|
+
<Button
|
|
708
|
+
variant='link'
|
|
709
|
+
className='p-1'
|
|
656
710
|
onClick={() => setGlobalDatasetProp(datasetKey, 'preview', true)}
|
|
657
711
|
>
|
|
658
712
|
Preview
|
|
713
|
+
</Button>
|
|
714
|
+
</td>
|
|
715
|
+
<td>
|
|
716
|
+
<button
|
|
717
|
+
className='btn btn-link p-1'
|
|
718
|
+
disabled={!config.datasets[datasetKey].data?.length}
|
|
719
|
+
onClick={() =>
|
|
720
|
+
downloadCSV(
|
|
721
|
+
config.datasets[datasetKey].data,
|
|
722
|
+
config.datasets[datasetKey].dataFileName || datasetKey
|
|
723
|
+
)
|
|
724
|
+
}
|
|
725
|
+
>
|
|
726
|
+
Download
|
|
659
727
|
</button>
|
|
660
728
|
</td>
|
|
661
729
|
<td>
|
|
662
730
|
{config.datasets[datasetKey].dataFileSourceType === 'url' && (
|
|
663
|
-
<
|
|
664
|
-
|
|
731
|
+
<Button
|
|
732
|
+
variant='link'
|
|
733
|
+
className='p-1'
|
|
665
734
|
onClick={() => {
|
|
666
735
|
if (editingDataset === datasetKey) {
|
|
667
736
|
setEditingDataset(undefined)
|
|
@@ -677,13 +746,13 @@ const DataImport = () => {
|
|
|
677
746
|
}}
|
|
678
747
|
>
|
|
679
748
|
Edit
|
|
680
|
-
</
|
|
749
|
+
</Button>
|
|
681
750
|
)}
|
|
682
751
|
</td>
|
|
683
752
|
<td>
|
|
684
|
-
<
|
|
753
|
+
<Button variant='danger' onClick={() => removeDataset(datasetKey)}>
|
|
685
754
|
X
|
|
686
|
-
</
|
|
755
|
+
</Button>
|
|
687
756
|
</td>
|
|
688
757
|
</tr>
|
|
689
758
|
)
|
|
@@ -719,6 +788,14 @@ const DataImport = () => {
|
|
|
719
788
|
)}
|
|
720
789
|
</div>
|
|
721
790
|
<div>{resetButton()}</div>
|
|
791
|
+
{config.data?.length > 0 && (
|
|
792
|
+
<button
|
|
793
|
+
className='btn btn-link p-1'
|
|
794
|
+
onClick={() => downloadCSV(config.data, config.dataFileName || 'data')}
|
|
795
|
+
>
|
|
796
|
+
Download CSV
|
|
797
|
+
</button>
|
|
798
|
+
)}
|
|
722
799
|
</div>
|
|
723
800
|
)}
|
|
724
801
|
|
|
@@ -727,6 +804,14 @@ const DataImport = () => {
|
|
|
727
804
|
<div className='url-source-options'>
|
|
728
805
|
<div>{loadDataFromUrl()}</div>
|
|
729
806
|
<div>{resetButton()}</div>
|
|
807
|
+
{config.data?.length > 0 && (
|
|
808
|
+
<button
|
|
809
|
+
className='btn btn-link p-1'
|
|
810
|
+
onClick={() => downloadCSV(config.data, config.dataFileName || 'data')}
|
|
811
|
+
>
|
|
812
|
+
Download CSV
|
|
813
|
+
</button>
|
|
814
|
+
)}
|
|
730
815
|
</div>
|
|
731
816
|
{config.dataUrl && (config.type === 'chart' || config.type === 'map') && urlFilters}
|
|
732
817
|
</>
|
|
@@ -750,15 +835,16 @@ const DataImport = () => {
|
|
|
750
835
|
placeholder='{ }'
|
|
751
836
|
/>
|
|
752
837
|
<div className='mb-3 d-flex justify-content-end'>
|
|
753
|
-
<
|
|
754
|
-
|
|
838
|
+
<Button
|
|
839
|
+
variant='primary'
|
|
840
|
+
className='px-4'
|
|
755
841
|
type='submit'
|
|
756
842
|
id='load-data'
|
|
757
843
|
disabled={!pastedConfig}
|
|
758
844
|
onClick={() => updateDataFromVegaConfig(pastedConfig)}
|
|
759
845
|
>
|
|
760
846
|
Save & Load
|
|
761
|
-
</
|
|
847
|
+
</Button>
|
|
762
848
|
</div>
|
|
763
849
|
</div>
|
|
764
850
|
</TabPane>
|
|
@@ -812,15 +898,16 @@ const DataImport = () => {
|
|
|
812
898
|
/>
|
|
813
899
|
</label>
|
|
814
900
|
<div className='d-flex justify-content-end mt-2 mb-3'>
|
|
815
|
-
<
|
|
816
|
-
|
|
901
|
+
<Button
|
|
902
|
+
variant='primary'
|
|
903
|
+
className='px-4'
|
|
817
904
|
type='submit'
|
|
818
905
|
id='load-data'
|
|
819
906
|
disabled={!newDatasetName || !externalURL}
|
|
820
907
|
onClick={() => loadData(null, externalURL, editingDataset)}
|
|
821
908
|
>
|
|
822
909
|
Save & Load
|
|
823
|
-
</
|
|
910
|
+
</Button>
|
|
824
911
|
</div>
|
|
825
912
|
</TabPane>
|
|
826
913
|
</Tabs>
|
|
@@ -895,20 +982,17 @@ const DataImport = () => {
|
|
|
895
982
|
|
|
896
983
|
{config.type === 'dashboard' && !addingDataset && (
|
|
897
984
|
<div className='mt-2'>
|
|
898
|
-
<
|
|
985
|
+
<Button variant='primary' onClick={() => setAddingDataset(true)}>
|
|
899
986
|
+ Add More Files
|
|
900
|
-
</
|
|
987
|
+
</Button>
|
|
901
988
|
</div>
|
|
902
989
|
)}
|
|
903
990
|
|
|
904
991
|
{readyToConfigure && (
|
|
905
992
|
<div className='mt-2'>
|
|
906
|
-
<
|
|
907
|
-
className='btn btn-primary'
|
|
908
|
-
onClick={() => dispatch({ type: 'EDITOR_SET_GLOBALACTIVE', payload: 2 })}
|
|
909
|
-
>
|
|
993
|
+
<Button variant='primary' onClick={() => dispatch({ type: 'EDITOR_SET_GLOBALACTIVE', payload: 2 })}>
|
|
910
994
|
Configure your visualization
|
|
911
|
-
</
|
|
995
|
+
</Button>
|
|
912
996
|
</div>
|
|
913
997
|
)}
|
|
914
998
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getAutoDetectedDateParseFormat, isDateScale } from '@cdc/core/helpers/cove/date'
|
|
2
|
+
import { type Visualization } from '@cdc/core/types/Visualization'
|
|
3
|
+
|
|
4
|
+
const isChartWithDateAxis = (config: Visualization) =>
|
|
5
|
+
config?.type === 'chart' && !!config?.xAxis && isDateScale(config.xAxis)
|
|
6
|
+
|
|
7
|
+
export const applyAutoDetectedDateParseFormat = (
|
|
8
|
+
config: Visualization,
|
|
9
|
+
importedData: Record<string, unknown>[]
|
|
10
|
+
): Visualization => {
|
|
11
|
+
if (!isChartWithDateAxis(config) || !config?.xAxis?.dataKey || !importedData?.length) {
|
|
12
|
+
return config
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const existingDateParseFormat = config.xAxis.dateParseFormat
|
|
16
|
+
|
|
17
|
+
if (typeof existingDateParseFormat === 'string' && existingDateParseFormat.trim() !== '') {
|
|
18
|
+
return config
|
|
19
|
+
}
|
|
20
|
+
const xAxisKey = config.xAxis.dataKey
|
|
21
|
+
|
|
22
|
+
const autoDetectedDateParseFormat = getAutoDetectedDateParseFormat(importedData, xAxisKey)
|
|
23
|
+
|
|
24
|
+
if (!autoDetectedDateParseFormat) {
|
|
25
|
+
return config
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
...config,
|
|
30
|
+
xAxis: {
|
|
31
|
+
...config.xAxis,
|
|
32
|
+
dateParseFormat: autoDetectedDateParseFormat
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { applyAutoDetectedDateParseFormat } from '../helpers/applyAutoDetectedDateParseFormat'
|
|
3
|
+
|
|
4
|
+
describe('applyAutoDetectedDateParseFormat', () => {
|
|
5
|
+
const baseChartConfig: any = {
|
|
6
|
+
type: 'chart',
|
|
7
|
+
xAxis: {
|
|
8
|
+
type: 'date',
|
|
9
|
+
dataKey: 'date',
|
|
10
|
+
dateParseFormat: ''
|
|
11
|
+
},
|
|
12
|
+
tooltips: {
|
|
13
|
+
dateDisplayFormat: '%B %-d, %Y'
|
|
14
|
+
},
|
|
15
|
+
table: {
|
|
16
|
+
dateDisplayFormat: '%b %-d %Y'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const reliableSlashDateRows = [
|
|
21
|
+
{ date: '2024/03/15', value: 10 },
|
|
22
|
+
{ date: '2025/11/09', value: 12 }
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
const ambiguousUsOrEuDateRows = [
|
|
26
|
+
{ date: '01/02/2024', value: 10 },
|
|
27
|
+
{ date: '02/03/2024', value: 12 }
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const missingXAxisDateRows = [{ otherDate: '2024/03/15', value: 10 }]
|
|
31
|
+
const sparseFirstRowReliableDateRows = [
|
|
32
|
+
{ otherDate: 'ignore-me', value: 8 },
|
|
33
|
+
{ date: '2024/03/15', value: 10 },
|
|
34
|
+
{ date: '2025/11/09', value: 12 }
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
it('auto-fills the chart xAxis date parse format when detection is reliable', () => {
|
|
38
|
+
const result: any = applyAutoDetectedDateParseFormat(baseChartConfig, reliableSlashDateRows)
|
|
39
|
+
|
|
40
|
+
expect(result.xAxis.dateParseFormat).toBe('%Y/%m/%d')
|
|
41
|
+
expect(result.tooltips.dateDisplayFormat).toBe('%B %-d, %Y')
|
|
42
|
+
expect(result.table.dateDisplayFormat).toBe('%b %-d %Y')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('auto-fills the chart xAxis date parse format for date-time axes when detection is reliable', () => {
|
|
46
|
+
const result: any = applyAutoDetectedDateParseFormat(
|
|
47
|
+
{
|
|
48
|
+
...baseChartConfig,
|
|
49
|
+
xAxis: {
|
|
50
|
+
...baseChartConfig.xAxis,
|
|
51
|
+
type: 'date-time'
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
reliableSlashDateRows
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
expect(result.xAxis.dateParseFormat).toBe('%Y/%m/%d')
|
|
58
|
+
expect(result.tooltips.dateDisplayFormat).toBe('%B %-d, %Y')
|
|
59
|
+
expect(result.table.dateDisplayFormat).toBe('%b %-d %Y')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('leaves the config unchanged when the sample is ambiguous', () => {
|
|
63
|
+
const result: any = applyAutoDetectedDateParseFormat(baseChartConfig, ambiguousUsOrEuDateRows)
|
|
64
|
+
|
|
65
|
+
expect(result).toEqual(baseChartConfig)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('leaves the config unchanged when xAxis.dateParseFormat is already configured', () => {
|
|
69
|
+
const preconfiguredChartConfig: any = {
|
|
70
|
+
...baseChartConfig,
|
|
71
|
+
xAxis: {
|
|
72
|
+
...baseChartConfig.xAxis,
|
|
73
|
+
dateParseFormat: '%d/%m/%Y'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const result: any = applyAutoDetectedDateParseFormat(preconfiguredChartConfig, reliableSlashDateRows)
|
|
78
|
+
|
|
79
|
+
expect(result).toEqual(preconfiguredChartConfig)
|
|
80
|
+
})
|
|
81
|
+
it('does nothing for non-date chart axes', () => {
|
|
82
|
+
const result: any = applyAutoDetectedDateParseFormat(
|
|
83
|
+
{
|
|
84
|
+
...baseChartConfig,
|
|
85
|
+
xAxis: {
|
|
86
|
+
...baseChartConfig.xAxis,
|
|
87
|
+
type: 'categorical'
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
[{ date: '2024/03/15', value: 10 }]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual({
|
|
94
|
+
...baseChartConfig,
|
|
95
|
+
xAxis: {
|
|
96
|
+
...baseChartConfig.xAxis,
|
|
97
|
+
type: 'categorical'
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('does nothing for non-chart visualizations', () => {
|
|
103
|
+
const result: any = applyAutoDetectedDateParseFormat(
|
|
104
|
+
{
|
|
105
|
+
...baseChartConfig,
|
|
106
|
+
type: 'map'
|
|
107
|
+
},
|
|
108
|
+
[{ date: '2024/03/15', value: 10 }]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
expect(result).toEqual({
|
|
112
|
+
...baseChartConfig,
|
|
113
|
+
type: 'map'
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('does nothing when the configured x-axis column is missing', () => {
|
|
118
|
+
const result: any = applyAutoDetectedDateParseFormat(baseChartConfig, missingXAxisDateRows)
|
|
119
|
+
|
|
120
|
+
expect(result).toEqual(baseChartConfig)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('still detects the date parse format when the first row is missing the x-axis key', () => {
|
|
124
|
+
const result: any = applyAutoDetectedDateParseFormat(baseChartConfig, sparseFirstRowReliableDateRows)
|
|
125
|
+
|
|
126
|
+
expect(result.xAxis.dateParseFormat).toBe('%Y/%m/%d')
|
|
127
|
+
})
|
|
128
|
+
})
|