@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.
- package/dist/chat.js +962 -733
- package/dist/chat.js.map +1 -1
- package/dist/csv-item-hH_Gt7ur.js +32 -0
- package/dist/csv-item-hH_Gt7ur.js.map +1 -0
- package/dist/png-item-9dNbB37T.js +57 -0
- package/dist/png-item-9dNbB37T.js.map +1 -0
- package/dist/table-B3ZWWhJt.js +383 -0
- package/dist/table-B3ZWWhJt.js.map +1 -0
- package/dist/types/chat/containers/chat-footer.d.ts +1 -1
- package/dist/types/chat/containers/styles.d.ts +79 -12
- package/dist/types/chat/index.d.ts +1 -1
- package/dist/types/chat/types.d.ts +21 -0
- package/dist/types/chat/use-typewriter.d.ts +5 -3
- package/dist/types/widgets-v2/actions/download/constants.d.ts +12 -0
- package/dist/types/widgets-v2/actions/download/csv-item.d.ts +38 -0
- package/dist/types/widgets-v2/actions/download/icons.d.ts +6 -0
- package/dist/types/widgets-v2/actions/download/index.d.ts +3 -1
- package/dist/types/widgets-v2/actions/index.d.ts +1 -1
- package/dist/types/widgets-v2/wrapper/style.d.ts +5 -12
- package/dist/widgets-v2/actions.js +40 -36
- package/dist/widgets-v2/actions.js.map +1 -1
- package/dist/widgets-v2/bar.js +77 -84
- package/dist/widgets-v2/bar.js.map +1 -1
- package/dist/widgets-v2/category.js +50 -55
- package/dist/widgets-v2/category.js.map +1 -1
- package/dist/widgets-v2/formula.js +37 -43
- package/dist/widgets-v2/formula.js.map +1 -1
- package/dist/widgets-v2/histogram.js +138 -144
- package/dist/widgets-v2/histogram.js.map +1 -1
- package/dist/widgets-v2/markdown.js +18 -17
- package/dist/widgets-v2/markdown.js.map +1 -1
- package/dist/widgets-v2/pie.js +67 -73
- package/dist/widgets-v2/pie.js.map +1 -1
- package/dist/widgets-v2/scatterplot.js +75 -81
- package/dist/widgets-v2/scatterplot.js.map +1 -1
- package/dist/widgets-v2/spread.js +36 -41
- package/dist/widgets-v2/spread.js.map +1 -1
- package/dist/widgets-v2/table.js +46 -55
- package/dist/widgets-v2/table.js.map +1 -1
- package/dist/widgets-v2/timeseries.js +81 -87
- package/dist/widgets-v2/timeseries.js.map +1 -1
- package/dist/widgets-v2.js +247 -243
- package/dist/widgets-v2.js.map +1 -1
- package/package.json +3 -3
- package/src/chat/bubbles/styles.ts +5 -1
- package/src/chat/containers/chat-content.tsx +4 -1
- package/src/chat/containers/chat-footer.test.tsx +59 -0
- package/src/chat/containers/chat-footer.tsx +124 -36
- package/src/chat/containers/styles.ts +107 -16
- package/src/chat/feedback/styles.ts +11 -4
- package/src/chat/index.ts +1 -0
- package/src/chat/types.ts +22 -0
- package/src/chat/use-typewriter.ts +32 -24
- package/src/widgets-v2/actions/download/constants.ts +14 -0
- package/src/widgets-v2/actions/download/csv-item.test.tsx +77 -0
- package/src/widgets-v2/actions/download/csv-item.tsx +71 -0
- package/src/widgets-v2/actions/download/icons.tsx +10 -1
- package/src/widgets-v2/actions/download/index.ts +3 -1
- package/src/widgets-v2/actions/download/png-item.tsx +2 -1
- package/src/widgets-v2/actions/index.ts +5 -0
- package/src/widgets-v2/bar/download.tsx +16 -22
- package/src/widgets-v2/category/download.test.ts +9 -0
- package/src/widgets-v2/category/download.ts +16 -20
- package/src/widgets-v2/formula/download.tsx +23 -29
- package/src/widgets-v2/histogram/download.ts +22 -26
- package/src/widgets-v2/markdown/{download.ts → download.tsx} +5 -2
- package/src/widgets-v2/pie/download.ts +16 -20
- package/src/widgets-v2/scatterplot/download.ts +16 -20
- package/src/widgets-v2/spread/download.ts +23 -27
- package/src/widgets-v2/table/download.test.ts +10 -0
- package/src/widgets-v2/table/download.ts +11 -15
- package/src/widgets-v2/table/helpers.test.ts +19 -0
- package/src/widgets-v2/table/helpers.ts +7 -12
- package/src/widgets-v2/timeseries/download.ts +36 -40
- package/src/widgets-v2/wrapper/style.ts +13 -18
- package/src/widgets-v2/wrapper/widget-wrapper.test.tsx +66 -0
- package/src/widgets-v2/wrapper/widget-wrapper.tsx +7 -4
- package/dist/png-item-BE9uEqlD.js +0 -45
- package/dist/png-item-BE9uEqlD.js.map +0 -1
- package/dist/table-C9IMbTr0.js +0 -385
- package/dist/table-C9IMbTr0.js.map +0 -1
- 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:
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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:
|
|
35
|
-
label: '
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
lines.push(cells.join(','))
|
|
111
|
+
rows.push(columns.map((c) => stringifyCell(row[c.id])))
|
|
110
112
|
}
|
|
111
|
-
return
|
|
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 {
|