@cdc/editor 4.26.2 → 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.
Files changed (30) hide show
  1. package/dist/cdceditor-CY9IcPSi.es.js +6 -0
  2. package/dist/cdceditor-DlpiY3fQ.es.js +4 -0
  3. package/dist/cdceditor.js +84436 -77912
  4. package/package.json +9 -9
  5. package/src/CdcEditor.tsx +2 -3
  6. package/src/_stories/Editor.stories.tsx +173 -6
  7. package/src/components/ChooseTab.test.tsx +36 -0
  8. package/src/components/ChooseTab.tsx +39 -26
  9. package/src/components/DataImport/components/DataImport.tsx +124 -35
  10. package/src/components/DataImport/helpers/applyAutoDetectedDateParseFormat.ts +35 -0
  11. package/src/components/DataImport/tests/applyAutoDetectedDateParseFormat.test.ts +128 -0
  12. package/src/components/PreviewDataTable.test.tsx +184 -0
  13. package/src/components/PreviewDataTable.tsx +55 -49
  14. package/src/components/modal/Confirmation.jsx +5 -4
  15. package/src/scss/main.scss +15 -2
  16. package/dist/cdceditor-Cf9_fbQf.es.js +0 -6
  17. package/example/data-horizontal-filters.json +0 -8
  18. package/example/data-horizontal-multiseries-filters.json +0 -18
  19. package/example/data-horizontal-multiseries.json +0 -6
  20. package/example/data-horizontal.json +0 -4
  21. package/example/data-vertical-filters.json +0 -10
  22. package/example/data-vertical-multiseries-filters.json +0 -18
  23. package/example/data-vertical-multiseries-multirow-filters.json +0 -50
  24. package/example/data-vertical-multiseries-multirow.json +0 -14
  25. package/example/data-vertical-multiseries.json +0 -6
  26. package/example/data-vertical.json +0 -6
  27. package/example/region-map.json +0 -33
  28. package/example/test.json +0 -110280
  29. package/example/valid-county-data.json +0 -3049
  30. package/example/valid-scatterplot.csv +0 -17
@@ -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,12 +20,13 @@ 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'
25
27
 
26
28
  import './data-import.scss'
27
- import '@cdc/core/styles/v2/components/data-designer.scss'
29
+ import '@cdc/core/components/managers/data-designer.scss'
28
30
 
29
31
  import { errorMessages, maxFileSize } from '../../../helpers/errorMessages'
30
32
  import { displaySize } from '../helpers/displaySize'
@@ -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,
@@ -39,6 +42,7 @@ import {
39
42
  parseVegaConfig,
40
43
  updateVegaData
41
44
  } from '@cdc/core/helpers/vegaConfig'
45
+ import { extractDataAndMetadata } from '@cdc/core/helpers/extractDataAndMetadata'
42
46
 
43
47
  const DataImport = () => {
44
48
  const { config, errors, tempConfig, sharepath } = useContext(ConfigContext)
@@ -179,12 +183,13 @@ const DataImport = () => {
179
183
  const filereader = new FileReader()
180
184
 
181
185
  filereader.onload = function () {
182
- const handleSetConfig = (newData: Object[], useTempConfig = false) => {
186
+ const handleSetConfig = (newData: Object[], useTempConfig = false, dataMetadata = {}) => {
183
187
  const setDataURL = keepURL && fileSourceType === 'url'
184
188
  if (config.type === 'dashboard') {
185
189
  const dataFileFormat = mimeType.split('/')[1].toUpperCase()
186
190
  const dataset = {
187
191
  data: newData,
192
+ dataMetadata,
188
193
  dataFileSize: fileSize,
189
194
  dataFileName: fileSource, // new file source
190
195
  dataFileSourceType: fileSourceType, // new file source type
@@ -206,13 +211,21 @@ const DataImport = () => {
206
211
  payload: { datasetKey: newDatasetName || fileSource, dataset, oldDatasetKey }
207
212
  })
208
213
  } else {
214
+ const configWithAutoDetectedDateFormat = applyAutoDetectedDateParseFormat(
215
+ {
216
+ ...config,
217
+ ...tempConfig
218
+ },
219
+ newData as Record<string, unknown>[]
220
+ )
221
+
209
222
  let newConfig = {
210
- ...config,
211
- ...tempConfig,
223
+ ...configWithAutoDetectedDateFormat,
212
224
  data: newData,
225
+ dataMetadata,
213
226
  dataFileName: fileSource, // new file source
214
227
  dataFileSourceType: fileSourceType, // new file source type
215
- formattedData: transform.developerStandardize(newData, config.dataDescription)
228
+ formattedData: transform.developerStandardize(newData, configWithAutoDetectedDateFormat.dataDescription)
216
229
  }
217
230
  if (setDataURL) {
218
231
  newConfig.dataUrl = fileSource
@@ -227,14 +240,16 @@ const DataImport = () => {
227
240
  if (config.vegaConfig) {
228
241
  return updateDataFromVegaData(result, fileSource, fileSourceType)
229
242
  }
230
- const text = transform.autoStandardize(result)
243
+ const { data: extractedData, dataMetadata } = extractDataAndMetadata(result)
244
+ const text = transform.autoStandardize(extractedData)
231
245
  if (config.data && config.series) {
232
246
  if (dataExists(text, config.series, config?.xAxis.dataKey)) {
233
- handleSetConfig(text, true)
247
+ handleSetConfig(text, true, dataMetadata)
234
248
  } else {
235
249
  resetEditor(
236
250
  {
237
251
  data: text,
252
+ dataMetadata,
238
253
  dataFileName: fileSource,
239
254
  dataFileSourceType: fileSourceType
240
255
  } as Visualization,
@@ -242,7 +257,7 @@ const DataImport = () => {
242
257
  )
243
258
  }
244
259
  } else {
245
- handleSetConfig(text)
260
+ handleSetConfig(text, false, dataMetadata)
246
261
  }
247
262
 
248
263
  if (editingDataset) {
@@ -354,7 +369,7 @@ const DataImport = () => {
354
369
  Always load from URL (normally will only pull once)
355
370
  </label>
356
371
  <div className='d-flex justify-content-end mt-2 mb-3'>
357
- <button
372
+ <Button
358
373
  className='btn btn-primary px-4'
359
374
  type='submit'
360
375
  id='load-data'
@@ -362,7 +377,7 @@ const DataImport = () => {
362
377
  onClick={() => loadData(null, externalURL, editingDataset)}
363
378
  >
364
379
  Save & Load
365
- </button>
380
+ </Button>
366
381
  </div>
367
382
  </>
368
383
  )
@@ -383,7 +398,7 @@ const DataImport = () => {
383
398
  return (
384
399
  //todo convert to modal
385
400
  <>
386
- <button
401
+ <Button
387
402
  className='btn danger'
388
403
  onClick={() =>
389
404
  resetEditor(
@@ -394,7 +409,7 @@ const DataImport = () => {
394
409
  >
395
410
  Clear
396
411
  <CloseIcon />
397
- </button>
412
+ </Button>
398
413
  {/* DEV-851 link to replace file should pop file dialog */}
399
414
  {config.dataFileSourceType === 'file' && (
400
415
  <div className='link link-replace' {...getRootProps2()}>
@@ -430,6 +445,49 @@ const DataImport = () => {
430
445
  dispatch({ type: 'DELETE_DASHBOARD_DATASET', payload: { datasetKey } })
431
446
  }
432
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
+
433
491
  const updateDataFromVegaConfig = pastedConfig => {
434
492
  const vegaConfig = parseVegaConfig(JSON.parse(pastedConfig))
435
493
  const newData = extractCoveData(vegaConfig)
@@ -604,7 +662,7 @@ const DataImport = () => {
604
662
  </fieldset>
605
663
  )
606
664
  )}
607
- <button
665
+ <Button
608
666
  className='btn full-width btn-primary'
609
667
  onClick={() => {
610
668
  setConfig({
@@ -616,7 +674,7 @@ const DataImport = () => {
616
674
  }}
617
675
  >
618
676
  Add New URL Filter
619
- </button>
677
+ </Button>
620
678
  </>
621
679
  )
622
680
 
@@ -634,7 +692,7 @@ const DataImport = () => {
634
692
  <th>Name</th>
635
693
  <th>Size</th>
636
694
  <th>Type</th>
637
- <th colSpan={3}>Actions</th>
695
+ <th colSpan={4}>Actions</th>
638
696
  </tr>
639
697
  </thead>
640
698
  <tbody>
@@ -646,17 +704,33 @@ const DataImport = () => {
646
704
  <td className='p-1'>{displaySize(config.datasets[datasetKey].dataFileSize)}</td>
647
705
  <td className='p-1'>{config.datasets[datasetKey].dataFileFormat}</td>
648
706
  <td>
649
- <button
650
- className='btn btn-link p-1'
707
+ <Button
708
+ variant='link'
709
+ className='p-1'
651
710
  onClick={() => setGlobalDatasetProp(datasetKey, 'preview', true)}
652
711
  >
653
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
654
727
  </button>
655
728
  </td>
656
729
  <td>
657
730
  {config.datasets[datasetKey].dataFileSourceType === 'url' && (
658
- <button
659
- className='btn btn-link p-1'
731
+ <Button
732
+ variant='link'
733
+ className='p-1'
660
734
  onClick={() => {
661
735
  if (editingDataset === datasetKey) {
662
736
  setEditingDataset(undefined)
@@ -672,13 +746,13 @@ const DataImport = () => {
672
746
  }}
673
747
  >
674
748
  Edit
675
- </button>
749
+ </Button>
676
750
  )}
677
751
  </td>
678
752
  <td>
679
- <button className='btn btn-danger' onClick={() => removeDataset(datasetKey)}>
753
+ <Button variant='danger' onClick={() => removeDataset(datasetKey)}>
680
754
  X
681
- </button>
755
+ </Button>
682
756
  </td>
683
757
  </tr>
684
758
  )
@@ -714,6 +788,14 @@ const DataImport = () => {
714
788
  )}
715
789
  </div>
716
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
+ )}
717
799
  </div>
718
800
  )}
719
801
 
@@ -722,6 +804,14 @@ const DataImport = () => {
722
804
  <div className='url-source-options'>
723
805
  <div>{loadDataFromUrl()}</div>
724
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
+ )}
725
815
  </div>
726
816
  {config.dataUrl && (config.type === 'chart' || config.type === 'map') && urlFilters}
727
817
  </>
@@ -745,15 +835,16 @@ const DataImport = () => {
745
835
  placeholder='{ }'
746
836
  />
747
837
  <div className='mb-3 d-flex justify-content-end'>
748
- <button
749
- className='btn btn-primary px-4'
838
+ <Button
839
+ variant='primary'
840
+ className='px-4'
750
841
  type='submit'
751
842
  id='load-data'
752
843
  disabled={!pastedConfig}
753
844
  onClick={() => updateDataFromVegaConfig(pastedConfig)}
754
845
  >
755
846
  Save & Load
756
- </button>
847
+ </Button>
757
848
  </div>
758
849
  </div>
759
850
  </TabPane>
@@ -807,15 +898,16 @@ const DataImport = () => {
807
898
  />
808
899
  </label>
809
900
  <div className='d-flex justify-content-end mt-2 mb-3'>
810
- <button
811
- className='btn btn-primary px-4'
901
+ <Button
902
+ variant='primary'
903
+ className='px-4'
812
904
  type='submit'
813
905
  id='load-data'
814
906
  disabled={!newDatasetName || !externalURL}
815
907
  onClick={() => loadData(null, externalURL, editingDataset)}
816
908
  >
817
909
  Save & Load
818
- </button>
910
+ </Button>
819
911
  </div>
820
912
  </TabPane>
821
913
  </Tabs>
@@ -890,20 +982,17 @@ const DataImport = () => {
890
982
 
891
983
  {config.type === 'dashboard' && !addingDataset && (
892
984
  <div className='mt-2'>
893
- <button className='btn btn-primary' onClick={() => setAddingDataset(true)}>
985
+ <Button variant='primary' onClick={() => setAddingDataset(true)}>
894
986
  + Add More Files
895
- </button>
987
+ </Button>
896
988
  </div>
897
989
  )}
898
990
 
899
991
  {readyToConfigure && (
900
992
  <div className='mt-2'>
901
- <button
902
- className='btn btn-primary'
903
- onClick={() => dispatch({ type: 'EDITOR_SET_GLOBALACTIVE', payload: 2 })}
904
- >
993
+ <Button variant='primary' onClick={() => dispatch({ type: 'EDITOR_SET_GLOBALACTIVE', payload: 2 })}>
905
994
  Configure your visualization
906
- </button>
995
+ </Button>
907
996
  </div>
908
997
  )}
909
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
+ })