@carto/ps-react-ui 4.11.2 → 4.12.0

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 (82) hide show
  1. package/dist/chat.js +962 -733
  2. package/dist/chat.js.map +1 -1
  3. package/dist/csv-item-hH_Gt7ur.js +32 -0
  4. package/dist/csv-item-hH_Gt7ur.js.map +1 -0
  5. package/dist/png-item-9dNbB37T.js +57 -0
  6. package/dist/png-item-9dNbB37T.js.map +1 -0
  7. package/dist/table-B3ZWWhJt.js +383 -0
  8. package/dist/table-B3ZWWhJt.js.map +1 -0
  9. package/dist/types/chat/containers/chat-footer.d.ts +1 -1
  10. package/dist/types/chat/containers/styles.d.ts +79 -12
  11. package/dist/types/chat/index.d.ts +1 -1
  12. package/dist/types/chat/types.d.ts +21 -0
  13. package/dist/types/chat/use-typewriter.d.ts +5 -3
  14. package/dist/types/widgets-v2/actions/download/constants.d.ts +12 -0
  15. package/dist/types/widgets-v2/actions/download/csv-item.d.ts +38 -0
  16. package/dist/types/widgets-v2/actions/download/icons.d.ts +6 -0
  17. package/dist/types/widgets-v2/actions/download/index.d.ts +3 -1
  18. package/dist/types/widgets-v2/actions/index.d.ts +1 -1
  19. package/dist/types/widgets-v2/wrapper/style.d.ts +5 -12
  20. package/dist/widgets-v2/actions.js +40 -36
  21. package/dist/widgets-v2/actions.js.map +1 -1
  22. package/dist/widgets-v2/bar.js +77 -84
  23. package/dist/widgets-v2/bar.js.map +1 -1
  24. package/dist/widgets-v2/category.js +50 -55
  25. package/dist/widgets-v2/category.js.map +1 -1
  26. package/dist/widgets-v2/formula.js +37 -43
  27. package/dist/widgets-v2/formula.js.map +1 -1
  28. package/dist/widgets-v2/histogram.js +138 -144
  29. package/dist/widgets-v2/histogram.js.map +1 -1
  30. package/dist/widgets-v2/markdown.js +18 -17
  31. package/dist/widgets-v2/markdown.js.map +1 -1
  32. package/dist/widgets-v2/pie.js +67 -73
  33. package/dist/widgets-v2/pie.js.map +1 -1
  34. package/dist/widgets-v2/scatterplot.js +75 -81
  35. package/dist/widgets-v2/scatterplot.js.map +1 -1
  36. package/dist/widgets-v2/spread.js +36 -41
  37. package/dist/widgets-v2/spread.js.map +1 -1
  38. package/dist/widgets-v2/table.js +46 -55
  39. package/dist/widgets-v2/table.js.map +1 -1
  40. package/dist/widgets-v2/timeseries.js +81 -87
  41. package/dist/widgets-v2/timeseries.js.map +1 -1
  42. package/dist/widgets-v2.js +247 -243
  43. package/dist/widgets-v2.js.map +1 -1
  44. package/package.json +3 -3
  45. package/src/chat/bubbles/styles.ts +5 -1
  46. package/src/chat/containers/chat-content.tsx +4 -1
  47. package/src/chat/containers/chat-footer.test.tsx +59 -0
  48. package/src/chat/containers/chat-footer.tsx +124 -36
  49. package/src/chat/containers/styles.ts +107 -16
  50. package/src/chat/feedback/styles.ts +11 -4
  51. package/src/chat/index.ts +1 -0
  52. package/src/chat/types.ts +22 -0
  53. package/src/chat/use-typewriter.ts +32 -24
  54. package/src/widgets-v2/actions/download/constants.ts +14 -0
  55. package/src/widgets-v2/actions/download/csv-item.test.tsx +77 -0
  56. package/src/widgets-v2/actions/download/csv-item.tsx +71 -0
  57. package/src/widgets-v2/actions/download/icons.tsx +10 -1
  58. package/src/widgets-v2/actions/download/index.ts +3 -1
  59. package/src/widgets-v2/actions/download/png-item.tsx +2 -1
  60. package/src/widgets-v2/actions/index.ts +5 -0
  61. package/src/widgets-v2/bar/download.tsx +16 -22
  62. package/src/widgets-v2/category/download.test.ts +9 -0
  63. package/src/widgets-v2/category/download.ts +16 -20
  64. package/src/widgets-v2/formula/download.tsx +23 -29
  65. package/src/widgets-v2/histogram/download.ts +22 -26
  66. package/src/widgets-v2/markdown/{download.ts → download.tsx} +5 -2
  67. package/src/widgets-v2/pie/download.ts +16 -20
  68. package/src/widgets-v2/scatterplot/download.ts +16 -20
  69. package/src/widgets-v2/spread/download.ts +23 -27
  70. package/src/widgets-v2/table/download.test.ts +10 -0
  71. package/src/widgets-v2/table/download.ts +11 -15
  72. package/src/widgets-v2/table/helpers.test.ts +19 -0
  73. package/src/widgets-v2/table/helpers.ts +7 -12
  74. package/src/widgets-v2/timeseries/download.ts +36 -40
  75. package/src/widgets-v2/wrapper/style.ts +13 -18
  76. package/src/widgets-v2/wrapper/widget-wrapper.test.tsx +66 -0
  77. package/src/widgets-v2/wrapper/widget-wrapper.tsx +7 -4
  78. package/dist/png-item-BE9uEqlD.js +0 -45
  79. package/dist/png-item-BE9uEqlD.js.map +0 -1
  80. package/dist/table-C9IMbTr0.js +0 -385
  81. package/dist/table-C9IMbTr0.js.map +0 -1
  82. package/dist/types/chat/feedback/styles.d.ts +0 -211
@@ -1,5 +1,5 @@
1
1
  import { SvgIcon, type SvgIconProps } from '@mui/material'
2
- import { ImageOutlined } from '@mui/icons-material'
2
+ import { ArticleOutlined, ImageOutlined } from '@mui/icons-material'
3
3
 
4
4
  /**
5
5
  * Generic "image" glyph used for the PNG download item. Wraps MUI's
@@ -10,6 +10,15 @@ export function PNGIcon(props: SvgIconProps) {
10
10
  return <ImageOutlined {...props} />
11
11
  }
12
12
 
13
+ /**
14
+ * Generic "document" glyph used for the Markdown download item. Wraps MUI's
15
+ * `ArticleOutlined`, mirroring {@link PNGIcon}, so the Markdown menu item
16
+ * carries an icon consistent with the other download formats.
17
+ */
18
+ export function MarkdownIcon(props: SvgIconProps) {
19
+ return <ArticleOutlined {...props} />
20
+ }
21
+
13
22
  /**
14
23
  * "CSV" rectangle with the letters spelled inside — matches v1 visual and is
15
24
  * easier to recognise in a download menu than a generic table glyph.
@@ -7,7 +7,9 @@ export {
7
7
  type DownloadHandle,
8
8
  type DownloadDOMToPNGOptions,
9
9
  } from './exports'
10
- export { CSVIcon, PNGIcon } from './icons'
10
+ export { CSVIcon, PNGIcon, MarkdownIcon } from './icons'
11
11
  export { buildPngDownloadItem, type BuildPngDownloadItemArgs } from './png-item'
12
+ export { buildCsvDownloadItem, type BuildCsvDownloadItemArgs } from './csv-item'
13
+ export { DOWNLOAD_ITEM_IDS, type DownloadItemId } from './constants'
12
14
  export type { DownloadItem } from './types'
13
15
  export { DEFAULT_DOWNLOAD_LABELS, type DownloadLabels } from './labels'
@@ -1,3 +1,4 @@
1
+ import { DOWNLOAD_ITEM_IDS } from './constants'
1
2
  import { downloadDOMToPNG } from './exports'
2
3
  import { PNGIcon } from './icons'
3
4
  import type { DownloadItem } from './types'
@@ -29,7 +30,7 @@ export function buildPngDownloadItem(
29
30
  args: BuildPngDownloadItemArgs,
30
31
  ): DownloadItem {
31
32
  return {
32
- id: 'png',
33
+ id: DOWNLOAD_ITEM_IDS.png,
33
34
  label: args.label ?? 'PNG',
34
35
  icon: <PNGIcon fontSize='small' />,
35
36
  resolve: async () => {
@@ -54,14 +54,19 @@ export {
54
54
  toCsvString,
55
55
  triggerLinkDownload,
56
56
  buildPngDownloadItem,
57
+ buildCsvDownloadItem,
57
58
  CSVIcon,
58
59
  PNGIcon,
60
+ MarkdownIcon,
59
61
  DEFAULT_DOWNLOAD_LABELS,
62
+ DOWNLOAD_ITEM_IDS,
60
63
  type DownloadProps,
61
64
  type DownloadItem,
65
+ type DownloadItemId,
62
66
  type DownloadHandle,
63
67
  type DownloadDOMToPNGOptions,
64
68
  type BuildPngDownloadItemArgs,
69
+ type BuildCsvDownloadItemArgs,
65
70
  type DownloadLabels,
66
71
  } from './download'
67
72
  export {
@@ -1,7 +1,6 @@
1
1
  import {
2
- CSVIcon,
2
+ buildCsvDownloadItem,
3
3
  buildPngDownloadItem,
4
- downloadToCSV,
5
4
  type DownloadItem,
6
5
  } from '../actions/download'
7
6
  import type { BarWidgetData } from './types'
@@ -42,25 +41,20 @@ export function createBarDownloadConfig(
42
41
  }),
43
42
  )
44
43
  }
45
- items.push({
46
- id: 'csv',
47
- label: 'CSV',
48
- icon: <CSVIcon fontSize='small' />,
49
- resolve: () => {
50
- const data = args.getData()
51
- const rows: unknown[][] = []
52
- for (const [i, series] of data.entries()) {
53
- if (i > 0) rows.push([])
54
- rows.push(['name', 'value'])
55
- for (const d of series) rows.push([d.name, d.value])
56
- }
57
- const handle = downloadToCSV(rows)
58
- return Promise.resolve({
59
- url: handle.url,
60
- filename: `${args.filename}.csv`,
61
- revoke: handle.revoke,
62
- })
63
- },
64
- })
44
+ items.push(
45
+ buildCsvDownloadItem({
46
+ filename: args.filename,
47
+ getRows: () => {
48
+ const data = args.getData()
49
+ const rows: unknown[][] = []
50
+ for (const [i, series] of data.entries()) {
51
+ if (i > 0) rows.push([])
52
+ rows.push(['name', 'value'])
53
+ for (const d of series) rows.push([d.name, d.value])
54
+ }
55
+ return rows
56
+ },
57
+ }),
58
+ )
65
59
  return items
66
60
  }
@@ -37,6 +37,15 @@ describe('createCategoryDownloadConfig', () => {
37
37
  ).toEqual(['csv'])
38
38
  })
39
39
 
40
+ it('CSV item carries the canonical icon and short label', () => {
41
+ const item = createCategoryDownloadConfig({
42
+ filename: 'c',
43
+ getData: () => data,
44
+ }).find((i) => i.id === 'csv')!
45
+ expect(item.label).toBe('CSV')
46
+ expect(item.icon).toBeTruthy()
47
+ })
48
+
40
49
  it('prepends PNG when getCaptureEl is provided', () => {
41
50
  const items = createCategoryDownloadConfig({
42
51
  filename: 'c',
@@ -1,6 +1,6 @@
1
1
  import {
2
+ buildCsvDownloadItem,
2
3
  buildPngDownloadItem,
3
- downloadToCSV,
4
4
  type DownloadItem,
5
5
  } from '../actions/download'
6
6
  import type { CategoryWidgetData } from './types'
@@ -30,25 +30,21 @@ export function createCategoryDownloadConfig(args: {
30
30
  }),
31
31
  )
32
32
  }
33
- items.push({
34
- id: 'csv',
35
- label: 'Download as CSV',
36
- resolve: () => {
37
- const data = args.getData()
38
- const rows: unknown[][] = [['series', 'name', 'value']]
39
- for (const [i, series] of data.entries()) {
40
- const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
41
- for (const item of series) {
42
- rows.push([seriesName, item.name, item.value])
33
+ items.push(
34
+ buildCsvDownloadItem({
35
+ filename: args.filename,
36
+ getRows: () => {
37
+ const data = args.getData()
38
+ const rows: unknown[][] = [['series', 'name', 'value']]
39
+ for (const [i, series] of data.entries()) {
40
+ const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
41
+ for (const item of series) {
42
+ rows.push([seriesName, item.name, item.value])
43
+ }
43
44
  }
44
- }
45
- const handle = downloadToCSV(rows)
46
- return Promise.resolve({
47
- url: handle.url,
48
- filename: `${args.filename}.csv`,
49
- revoke: handle.revoke,
50
- })
51
- },
52
- })
45
+ return rows
46
+ },
47
+ }),
48
+ )
53
49
  return items
54
50
  }
@@ -1,7 +1,6 @@
1
1
  import {
2
- CSVIcon,
2
+ buildCsvDownloadItem,
3
3
  buildPngDownloadItem,
4
- downloadToCSV,
5
4
  type DownloadItem,
6
5
  } from '../actions/download'
7
6
  import type { FormulaWidgetData } from './types'
@@ -38,32 +37,27 @@ export function createFormulaDownloadConfig(
38
37
  }),
39
38
  )
40
39
  }
41
- items.push({
42
- id: 'csv',
43
- label: 'CSV',
44
- icon: <CSVIcon fontSize='small' />,
45
- resolve: () => {
46
- const data = args.getData()
47
- const rows: unknown[][] = [
48
- ['series', 'prefix', 'value', 'suffix', 'note', 'delta'],
49
- ]
50
- for (const d of data) {
51
- rows.push([
52
- d.series?.name ?? '',
53
- d.prefix ?? '',
54
- d.value,
55
- d.suffix ?? '',
56
- d.note ?? '',
57
- d.delta?.value ?? '',
58
- ])
59
- }
60
- const handle = downloadToCSV(rows)
61
- return Promise.resolve({
62
- url: handle.url,
63
- filename: `${args.filename}.csv`,
64
- revoke: handle.revoke,
65
- })
66
- },
67
- })
40
+ items.push(
41
+ buildCsvDownloadItem({
42
+ filename: args.filename,
43
+ getRows: () => {
44
+ const data = args.getData()
45
+ const rows: unknown[][] = [
46
+ ['series', 'prefix', 'value', 'suffix', 'note', 'delta'],
47
+ ]
48
+ for (const d of data) {
49
+ rows.push([
50
+ d.series?.name ?? '',
51
+ d.prefix ?? '',
52
+ d.value,
53
+ d.suffix ?? '',
54
+ d.note ?? '',
55
+ d.delta?.value ?? '',
56
+ ])
57
+ }
58
+ return rows
59
+ },
60
+ }),
61
+ )
68
62
  return items
69
63
  }
@@ -1,6 +1,6 @@
1
1
  import {
2
+ buildCsvDownloadItem,
2
3
  buildPngDownloadItem,
3
- downloadToCSV,
4
4
  type DownloadItem,
5
5
  } from '../actions/download'
6
6
  import type { HistogramWidgetData } from './types'
@@ -31,30 +31,26 @@ export function createHistogramDownloadConfig(args: {
31
31
  }),
32
32
  )
33
33
  }
34
- items.push({
35
- id: 'csv',
36
- label: 'Download as CSV',
37
- resolve: () => {
38
- const data = args.getData()
39
- const ticks = args.getTicks()
40
- const seriesCount = data.length
41
- const header: unknown[] = ['bin_low', 'bin_high']
42
- for (let i = 0; i < seriesCount; i++) {
43
- header.push(args.seriesNames?.[i] ?? `series_${i + 1}`)
44
- }
45
- const rows: unknown[][] = [header]
46
- for (let bin = 0; bin < Math.max(0, ticks.length - 1); bin++) {
47
- const row: unknown[] = [ticks[bin], ticks[bin + 1]]
48
- for (let s = 0; s < seriesCount; s++) row.push(data[s]?.[bin] ?? 0)
49
- rows.push(row)
50
- }
51
- const handle = downloadToCSV(rows)
52
- return Promise.resolve({
53
- url: handle.url,
54
- filename: `${args.filename}.csv`,
55
- revoke: handle.revoke,
56
- })
57
- },
58
- })
34
+ items.push(
35
+ buildCsvDownloadItem({
36
+ filename: args.filename,
37
+ getRows: () => {
38
+ const data = args.getData()
39
+ const ticks = args.getTicks()
40
+ const seriesCount = data.length
41
+ const header: unknown[] = ['bin_low', 'bin_high']
42
+ for (let i = 0; i < seriesCount; i++) {
43
+ header.push(args.seriesNames?.[i] ?? `series_${i + 1}`)
44
+ }
45
+ const rows: unknown[][] = [header]
46
+ for (let bin = 0; bin < Math.max(0, ticks.length - 1); bin++) {
47
+ const row: unknown[] = [ticks[bin], ticks[bin + 1]]
48
+ for (let s = 0; s < seriesCount; s++) row.push(data[s]?.[bin] ?? 0)
49
+ rows.push(row)
50
+ }
51
+ return rows
52
+ },
53
+ }),
54
+ )
59
55
  return items
60
56
  }
@@ -1,4 +1,6 @@
1
1
  import {
2
+ DOWNLOAD_ITEM_IDS,
3
+ MarkdownIcon,
2
4
  buildPngDownloadItem,
3
5
  toCsvString,
4
6
  triggerLinkDownload,
@@ -31,8 +33,9 @@ export function createMarkdownDownloadConfig(args: {
31
33
  )
32
34
  }
33
35
  items.push({
34
- id: 'md',
35
- label: 'Download as Markdown',
36
+ id: DOWNLOAD_ITEM_IDS.markdown,
37
+ label: 'Markdown',
38
+ icon: <MarkdownIcon fontSize='small' />,
36
39
  resolve: () => {
37
40
  const data = args.getData()
38
41
  const blob = new Blob([data.content ?? ''], {
@@ -1,6 +1,6 @@
1
1
  import {
2
+ buildCsvDownloadItem,
2
3
  buildPngDownloadItem,
3
- downloadToCSV,
4
4
  type DownloadItem,
5
5
  } from '../actions/download'
6
6
  import type { PieWidgetData } from './types'
@@ -31,25 +31,21 @@ export function createPieDownloadConfig(args: {
31
31
  }),
32
32
  )
33
33
  }
34
- items.push({
35
- id: 'csv',
36
- label: 'Download as CSV',
37
- resolve: () => {
38
- const data = args.getData()
39
- const rows: unknown[][] = [['series', 'name', 'value']]
40
- for (const [i, series] of data.entries()) {
41
- const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
42
- for (const slice of series) {
43
- rows.push([seriesName, slice.name, slice.value])
34
+ items.push(
35
+ buildCsvDownloadItem({
36
+ filename: args.filename,
37
+ getRows: () => {
38
+ const data = args.getData()
39
+ const rows: unknown[][] = [['series', 'name', 'value']]
40
+ for (const [i, series] of data.entries()) {
41
+ const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
42
+ for (const slice of series) {
43
+ rows.push([seriesName, slice.name, slice.value])
44
+ }
44
45
  }
45
- }
46
- const handle = downloadToCSV(rows)
47
- return Promise.resolve({
48
- url: handle.url,
49
- filename: `${args.filename}.csv`,
50
- revoke: handle.revoke,
51
- })
52
- },
53
- })
46
+ return rows
47
+ },
48
+ }),
49
+ )
54
50
  return items
55
51
  }
@@ -1,6 +1,6 @@
1
1
  import {
2
+ buildCsvDownloadItem,
2
3
  buildPngDownloadItem,
3
- downloadToCSV,
4
4
  type DownloadItem,
5
5
  } from '../actions/download'
6
6
  import type { ScatterplotWidgetData } from './types'
@@ -30,25 +30,21 @@ export function createScatterplotDownloadConfig(args: {
30
30
  }),
31
31
  )
32
32
  }
33
- items.push({
34
- id: 'csv',
35
- label: 'Download as CSV',
36
- resolve: () => {
37
- const data = args.getData()
38
- const rows: unknown[][] = [['series', 'x', 'y']]
39
- for (const [i, series] of data.entries()) {
40
- const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
41
- for (const [x, y] of series) {
42
- rows.push([seriesName, x, y])
33
+ items.push(
34
+ buildCsvDownloadItem({
35
+ filename: args.filename,
36
+ getRows: () => {
37
+ const data = args.getData()
38
+ const rows: unknown[][] = [['series', 'x', 'y']]
39
+ for (const [i, series] of data.entries()) {
40
+ const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
41
+ for (const [x, y] of series) {
42
+ rows.push([seriesName, x, y])
43
+ }
43
44
  }
44
- }
45
- const handle = downloadToCSV(rows)
46
- return Promise.resolve({
47
- url: handle.url,
48
- filename: `${args.filename}.csv`,
49
- revoke: handle.revoke,
50
- })
51
- },
52
- })
45
+ return rows
46
+ },
47
+ }),
48
+ )
53
49
  return items
54
50
  }
@@ -1,6 +1,6 @@
1
1
  import {
2
+ buildCsvDownloadItem,
2
3
  buildPngDownloadItem,
3
- downloadToCSV,
4
4
  type DownloadItem,
5
5
  } from '../actions/download'
6
6
  import type { SpreadWidgetData } from './types'
@@ -29,31 +29,27 @@ export function createSpreadDownloadConfig(args: {
29
29
  }),
30
30
  )
31
31
  }
32
- items.push({
33
- id: 'csv',
34
- label: 'Download as CSV',
35
- resolve: () => {
36
- const data = args.getData()
37
- const rows: unknown[][] = [
38
- ['series', 'prefix', 'min', 'max', 'suffix', 'note'],
39
- ]
40
- for (const item of data) {
41
- rows.push([
42
- item.series?.name ?? '',
43
- item.prefix ?? '',
44
- item.min,
45
- item.max,
46
- item.suffix ?? '',
47
- item.note ?? '',
48
- ])
49
- }
50
- const handle = downloadToCSV(rows)
51
- return Promise.resolve({
52
- url: handle.url,
53
- filename: `${args.filename}.csv`,
54
- revoke: handle.revoke,
55
- })
56
- },
57
- })
32
+ items.push(
33
+ buildCsvDownloadItem({
34
+ filename: args.filename,
35
+ getRows: () => {
36
+ const data = args.getData()
37
+ const rows: unknown[][] = [
38
+ ['series', 'prefix', 'min', 'max', 'suffix', 'note'],
39
+ ]
40
+ for (const item of data) {
41
+ rows.push([
42
+ item.series?.name ?? '',
43
+ item.prefix ?? '',
44
+ item.min,
45
+ item.max,
46
+ item.suffix ?? '',
47
+ item.note ?? '',
48
+ ])
49
+ }
50
+ return rows
51
+ },
52
+ }),
53
+ )
58
54
  return items
59
55
  }
@@ -61,6 +61,16 @@ describe('createTableDownloadConfig', () => {
61
61
  expect(csvText).toBe('Name,Score\nAlpha,10\nBeta,20')
62
62
  })
63
63
 
64
+ it('CSV guards formula-injection cells end-to-end', async () => {
65
+ const items = createTableDownloadConfig({
66
+ filename: 't',
67
+ getData: () => [{ id: 1, name: '=HYPERLINK("x")', score: 1 }],
68
+ columns,
69
+ })
70
+ await items.find((i) => i.id === 'csv')!.resolve()
71
+ expect(csvText).toBe('Name,Score\n"\'=HYPERLINK(""x"")",1')
72
+ })
73
+
64
74
  it('CSV calls getData() at click time (not at config creation)', async () => {
65
75
  let snapshot: TableWidgetData = data
66
76
  const items = createTableDownloadConfig({
@@ -1,4 +1,8 @@
1
- import { buildPngDownloadItem, type DownloadItem } from '../actions/download'
1
+ import {
2
+ buildCsvDownloadItem,
3
+ buildPngDownloadItem,
4
+ type DownloadItem,
5
+ } from '../actions/download'
2
6
  import { tableDataToCsv } from './helpers'
3
7
  import type { TableColumn, TableWidgetData } from './types'
4
8
 
@@ -29,19 +33,11 @@ export function createTableDownloadConfig(opts: {
29
33
  }),
30
34
  )
31
35
  }
32
- items.push({
33
- id: 'csv',
34
- label: 'Download as CSV',
35
- resolve: () => {
36
- const csv = tableDataToCsv(opts.getData(), opts.columns)
37
- const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
38
- const url = URL.createObjectURL(blob)
39
- return Promise.resolve({
40
- url,
41
- filename: `${opts.filename}.csv`,
42
- revoke: () => URL.revokeObjectURL(url),
43
- })
44
- },
45
- })
36
+ items.push(
37
+ buildCsvDownloadItem({
38
+ filename: opts.filename,
39
+ getCsv: () => tableDataToCsv(opts.getData(), opts.columns),
40
+ }),
41
+ )
46
42
  return items
47
43
  }
@@ -211,4 +211,23 @@ describe('tableDataToCsv', () => {
211
211
  expect(csv).toContain('"[""x"",""y""]"')
212
212
  expect(csv).toContain('"{""z"":1}"')
213
213
  })
214
+
215
+ it('guards against formula injection by prefixing leading =,+,-,@', () => {
216
+ const csv = tableDataToCsv(
217
+ [
218
+ { id: 1, name: '=SUM(A1)', score: '+1' },
219
+ { id: 2, name: '-2', score: '@cmd' },
220
+ ],
221
+ cols,
222
+ )
223
+ const lines = csv.split('\n')
224
+ expect(lines[1]).toBe("'=SUM(A1),'+1")
225
+ expect(lines[2]).toBe("'-2,'@cmd")
226
+ })
227
+
228
+ it('still wraps a formula cell that also contains a comma', () => {
229
+ const csv = tableDataToCsv([{ id: 1, name: '=A1,B2', score: 1 }], cols)
230
+ // Prefixed against injection, then quoted for the embedded comma.
231
+ expect(csv.split('\n')[1]).toBe('"\'=A1,B2",1')
232
+ })
214
233
  })
@@ -1,3 +1,4 @@
1
+ import { toCsvString } from '../actions/download'
1
2
  import type {
2
3
  TableColumn,
3
4
  TableRow,
@@ -102,20 +103,14 @@ export function tableDataToCsv(
102
103
  data: TableWidgetData,
103
104
  columns: readonly TableColumn[],
104
105
  ): string {
105
- const head = columns.map((c) => csvEscape(stringifyHeader(c.label))).join(',')
106
- const lines = [head]
106
+ // Pre-stringify with the table's own header/cell rules, then delegate the
107
+ // CSV assembly (quoting + spreadsheet formula-injection guard) to the shared
108
+ // `toCsvString` so escaping stays identical to every other widget's export.
109
+ const rows: string[][] = [columns.map((c) => stringifyHeader(c.label))]
107
110
  for (const row of data) {
108
- const cells = columns.map((c) => csvEscape(stringifyCell(row[c.id])))
109
- lines.push(cells.join(','))
111
+ rows.push(columns.map((c) => stringifyCell(row[c.id])))
110
112
  }
111
- return lines.join('\n')
112
- }
113
-
114
- function csvEscape(value: string): string {
115
- if (/[",\n\r]/.test(value)) {
116
- return `"${value.replace(/"/g, '""')}"`
117
- }
118
- return value
113
+ return toCsvString(rows)
119
114
  }
120
115
 
121
116
  function stringifyHeader(label: unknown): string {