@carto/ps-react-ui 4.11.3 → 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 (77) 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/widgets-v2/actions.js +40 -36
  20. package/dist/widgets-v2/actions.js.map +1 -1
  21. package/dist/widgets-v2/bar.js +77 -84
  22. package/dist/widgets-v2/bar.js.map +1 -1
  23. package/dist/widgets-v2/category.js +50 -55
  24. package/dist/widgets-v2/category.js.map +1 -1
  25. package/dist/widgets-v2/formula.js +37 -43
  26. package/dist/widgets-v2/formula.js.map +1 -1
  27. package/dist/widgets-v2/histogram.js +138 -144
  28. package/dist/widgets-v2/histogram.js.map +1 -1
  29. package/dist/widgets-v2/markdown.js +18 -17
  30. package/dist/widgets-v2/markdown.js.map +1 -1
  31. package/dist/widgets-v2/pie.js +67 -73
  32. package/dist/widgets-v2/pie.js.map +1 -1
  33. package/dist/widgets-v2/scatterplot.js +75 -81
  34. package/dist/widgets-v2/scatterplot.js.map +1 -1
  35. package/dist/widgets-v2/spread.js +36 -41
  36. package/dist/widgets-v2/spread.js.map +1 -1
  37. package/dist/widgets-v2/table.js +46 -55
  38. package/dist/widgets-v2/table.js.map +1 -1
  39. package/dist/widgets-v2/timeseries.js +81 -87
  40. package/dist/widgets-v2/timeseries.js.map +1 -1
  41. package/dist/widgets-v2.js +1 -1
  42. package/package.json +1 -1
  43. package/src/chat/bubbles/styles.ts +5 -1
  44. package/src/chat/containers/chat-content.tsx +4 -1
  45. package/src/chat/containers/chat-footer.test.tsx +59 -0
  46. package/src/chat/containers/chat-footer.tsx +124 -36
  47. package/src/chat/containers/styles.ts +107 -16
  48. package/src/chat/feedback/styles.ts +11 -4
  49. package/src/chat/index.ts +1 -0
  50. package/src/chat/types.ts +22 -0
  51. package/src/chat/use-typewriter.ts +32 -24
  52. package/src/widgets-v2/actions/download/constants.ts +14 -0
  53. package/src/widgets-v2/actions/download/csv-item.test.tsx +77 -0
  54. package/src/widgets-v2/actions/download/csv-item.tsx +71 -0
  55. package/src/widgets-v2/actions/download/icons.tsx +10 -1
  56. package/src/widgets-v2/actions/download/index.ts +3 -1
  57. package/src/widgets-v2/actions/download/png-item.tsx +2 -1
  58. package/src/widgets-v2/actions/index.ts +5 -0
  59. package/src/widgets-v2/bar/download.tsx +16 -22
  60. package/src/widgets-v2/category/download.test.ts +9 -0
  61. package/src/widgets-v2/category/download.ts +16 -20
  62. package/src/widgets-v2/formula/download.tsx +23 -29
  63. package/src/widgets-v2/histogram/download.ts +22 -26
  64. package/src/widgets-v2/markdown/{download.ts → download.tsx} +5 -2
  65. package/src/widgets-v2/pie/download.ts +16 -20
  66. package/src/widgets-v2/scatterplot/download.ts +16 -20
  67. package/src/widgets-v2/spread/download.ts +23 -27
  68. package/src/widgets-v2/table/download.test.ts +10 -0
  69. package/src/widgets-v2/table/download.ts +11 -15
  70. package/src/widgets-v2/table/helpers.test.ts +19 -0
  71. package/src/widgets-v2/table/helpers.ts +7 -12
  72. package/src/widgets-v2/timeseries/download.ts +36 -40
  73. package/dist/png-item-BE9uEqlD.js +0 -45
  74. package/dist/png-item-BE9uEqlD.js.map +0 -1
  75. package/dist/table-C9IMbTr0.js +0 -385
  76. package/dist/table-C9IMbTr0.js.map +0 -1
  77. package/dist/types/chat/feedback/styles.d.ts +0 -211
package/src/chat/types.ts CHANGED
@@ -110,9 +110,31 @@ export interface ChatFooterProps extends ChatSxProps {
110
110
  send?: string
111
111
  /** Defaults to `'Stop'`. */
112
112
  stop?: string
113
+ /** `aria-label` for the model-selector trigger. Defaults to `'Select model'`. */
114
+ model?: string
113
115
  }
114
116
  /** Helper text rendered under the input. Defaults to an AI disclaimer; pass `null` to hide. */
115
117
  caption?: ReactNode
118
+ /**
119
+ * Selectable models for the in-toolbar model selector. The selector is only
120
+ * rendered when this is a non-empty array; the component is otherwise
121
+ * model-agnostic (the host owns the list, selection, and default).
122
+ */
123
+ models?: ChatModelOption[]
124
+ /** Currently selected model `value`. Controlled — the host owns the state. */
125
+ selectedModel?: string
126
+ /** Called with the picked model `value` when the user selects one. */
127
+ onModelChange?: (value: string) => void
128
+ /** Extra controls rendered at the start (left) of the toolbar, before the model selector. */
129
+ startToolbarSlot?: ReactNode
130
+ /** Extra controls rendered at the end (right) of the toolbar, before the send/stop button. */
131
+ endToolbarSlot?: ReactNode
132
+ }
133
+
134
+ /** An option for the `ChatFooter` model selector. */
135
+ export interface ChatModelOption {
136
+ value: string
137
+ label: string
116
138
  }
117
139
 
118
140
  // === Extras props ===
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
3
3
  interface UseTypewriterOptions {
4
4
  /** Characters revealed per second (default: `500`). */
5
5
  speed?: number
6
- /** When true on mount, skip the animation and reveal the full text immediately. */
6
+ /** When true, reveal the full text immediately (and snap to it if it grows). */
7
7
  skipAnimation?: boolean
8
8
  }
9
9
 
@@ -16,8 +16,10 @@ interface UseTypewriterResult {
16
16
 
17
17
  /**
18
18
  * Reveals a string character-by-character at a steady rate via
19
- * `requestAnimationFrame`. Useful for smoothing out bursty WebSocket-streamed
20
- * agent message text pair it with `ChatAgentMessage`.
19
+ * `requestAnimationFrame`. Designed for bursty, streamed agent text: as
20
+ * `fullText` grows the reveal keeps chasing the new end, and flipping
21
+ * `skipAnimation` to `true` (e.g. once generation finishes) snaps to the full
22
+ * text. Pair it with `ChatAgentMessage`.
21
23
  *
22
24
  * @example
23
25
  * ```tsx
@@ -36,47 +38,53 @@ export function useTypewriter(
36
38
  ): UseTypewriterResult {
37
39
  const { speed = 500, skipAnimation = false } = options
38
40
 
39
- const skipRef = useRef(skipAnimation)
40
-
41
41
  const [charIndex, setCharIndex] = useState(() =>
42
- skipRef.current ? fullText.length : 0,
42
+ skipAnimation ? fullText.length : 0,
43
43
  )
44
44
 
45
+ // Mirror of the revealed count, read/written ONLY inside the rAF loop (never
46
+ // during render). This lets the animation effect omit `charIndex` from its
47
+ // deps: were it a dep, the loop would tear down and restart on every revealed
48
+ // frame — resetting its frame timer — and never accumulate the elapsed time
49
+ // needed to advance, which stalls completely while `fullText` is also growing
50
+ // (the streaming case).
51
+ const charRef = useRef(charIndex)
52
+
45
53
  useEffect(() => {
46
- if (skipRef.current) {
47
- setCharIndex(fullText.length)
48
- return
49
- }
54
+ // When `skipAnimation` is set there's nothing to animate — the full text is
55
+ // shown directly via the derived `displayedText` below (no setState here, so
56
+ // a streamed message snaps to its full text the moment generation finishes,
57
+ // without an extra render or a `set-state-in-effect` cascade).
58
+ if (skipAnimation) return
50
59
 
51
- if (charIndex >= fullText.length) {
52
- return
53
- }
60
+ // Already fully revealed (e.g. text hasn't grown since the last frame).
61
+ if (charRef.current >= fullText.length) return
54
62
 
55
63
  const msPerChar = 1000 / speed
56
64
  let rafId: number
57
65
  let lastTime: number | null = null
58
66
 
59
67
  function tick(timestamp: number) {
60
- lastTime ??= timestamp
61
-
62
- const elapsed = timestamp - lastTime
63
- const charsToAdd = Math.floor(elapsed / msPerChar)
64
-
68
+ // Plain `?? =` not `??=`: the React Compiler can't lower logical-assignment
69
+ // operators and would bail this hook out of optimization.
70
+ lastTime = lastTime ?? timestamp
71
+ const charsToAdd = Math.floor((timestamp - lastTime) / msPerChar)
65
72
  if (charsToAdd > 0) {
66
73
  lastTime = timestamp
67
- setCharIndex((prev) => Math.min(prev + charsToAdd, fullText.length))
74
+ const next = Math.min(charRef.current + charsToAdd, fullText.length)
75
+ charRef.current = next
76
+ setCharIndex(next)
77
+ if (next >= fullText.length) return // fully revealed — stop the loop
68
78
  }
69
-
70
79
  rafId = requestAnimationFrame(tick)
71
80
  }
72
81
 
73
82
  rafId = requestAnimationFrame(tick)
74
-
75
83
  return () => cancelAnimationFrame(rafId)
76
- }, [fullText, charIndex, speed])
84
+ }, [fullText, speed, skipAnimation])
77
85
 
78
86
  return {
79
- displayedText: fullText.slice(0, charIndex),
80
- isTyping: charIndex < fullText.length,
87
+ displayedText: skipAnimation ? fullText : fullText.slice(0, charIndex),
88
+ isTyping: !skipAnimation && charIndex < fullText.length,
81
89
  }
82
90
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Canonical `DownloadItem.id`s for the built-in download formats. Centralised
3
+ * so the per-format builders — and anything that keys off the id (menu logic,
4
+ * tests) — reference one source of truth instead of re-typing string literals.
5
+ * The Markdown format keeps the short `'md'` id to match its `.md` extension.
6
+ */
7
+ export const DOWNLOAD_ITEM_IDS = {
8
+ png: 'png',
9
+ csv: 'csv',
10
+ markdown: 'md',
11
+ } as const
12
+
13
+ export type DownloadItemId =
14
+ (typeof DOWNLOAD_ITEM_IDS)[keyof typeof DOWNLOAD_ITEM_IDS]
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { buildCsvDownloadItem } from './csv-item'
3
+
4
+ let csvText = ''
5
+ let revokeSpy: ReturnType<typeof vi.spyOn>
6
+
7
+ beforeEach(() => {
8
+ csvText = ''
9
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:csv')
10
+ revokeSpy = vi
11
+ .spyOn(URL, 'revokeObjectURL')
12
+ .mockImplementation(() => undefined)
13
+ const RealBlob = global.Blob
14
+ vi.stubGlobal(
15
+ 'Blob',
16
+ class extends RealBlob {
17
+ constructor(parts: BlobPart[], opts?: BlobPropertyBag) {
18
+ csvText = typeof parts[0] === 'string' ? parts[0] : ''
19
+ super(parts, opts)
20
+ }
21
+ },
22
+ )
23
+ })
24
+
25
+ describe('buildCsvDownloadItem', () => {
26
+ it('returns an item with the canonical CSV shape (id, label, icon)', () => {
27
+ const item = buildCsvDownloadItem({ filename: 'demo', getRows: () => [] })
28
+ expect(item.id).toBe('csv')
29
+ expect(item.label).toBe('CSV')
30
+ expect(item.icon).toBeTruthy()
31
+ })
32
+
33
+ it('honours a label override', () => {
34
+ const item = buildCsvDownloadItem({
35
+ filename: 'demo',
36
+ getRows: () => [],
37
+ label: 'Export data',
38
+ })
39
+ expect(item.label).toBe('Export data')
40
+ })
41
+
42
+ it('getRows path serialises rows via toCsvString and returns a .csv handle', async () => {
43
+ const item = buildCsvDownloadItem({
44
+ filename: 'sales',
45
+ getRows: () => [
46
+ ['name', 'value'],
47
+ ['a', 1],
48
+ ],
49
+ })
50
+ const handle = await item.resolve()
51
+ expect(handle.url).toBe('blob:csv')
52
+ expect(handle.filename).toBe('sales.csv')
53
+ expect(csvText).toBe('name,value\na,1')
54
+ handle.revoke?.()
55
+ expect(revokeSpy).toHaveBeenCalledWith('blob:csv')
56
+ })
57
+
58
+ it('getRows path applies the formula-injection guard', async () => {
59
+ const item = buildCsvDownloadItem({
60
+ filename: 'sales',
61
+ getRows: () => [['=SUM(A1)']],
62
+ })
63
+ await item.resolve()
64
+ expect(csvText).toBe("'=SUM(A1)")
65
+ })
66
+
67
+ it('getCsv path passes the pre-built CSV through verbatim', async () => {
68
+ const item = buildCsvDownloadItem({
69
+ filename: 'table',
70
+ getCsv: () => 'col\n=raw',
71
+ })
72
+ const handle = await item.resolve()
73
+ expect(handle.filename).toBe('table.csv')
74
+ // getCsv is an escape hatch — content is used as-is, no extra escaping.
75
+ expect(csvText).toBe('col\n=raw')
76
+ })
77
+ })
@@ -0,0 +1,71 @@
1
+ import { DOWNLOAD_ITEM_IDS } from './constants'
2
+ import { downloadToCSV } from './exports'
3
+ import { CSVIcon } from './icons'
4
+ import type { DownloadItem } from './types'
5
+
6
+ interface BuildCsvDownloadItemBase {
7
+ /** Base filename (without extension). The item appends `.csv`. */
8
+ filename: string
9
+ /** Override the menu label. Default `'CSV'`. */
10
+ label?: string
11
+ }
12
+
13
+ /**
14
+ * Args for {@link buildCsvDownloadItem}. A discriminated union: a caller must
15
+ * supply exactly one content source — `getRows` (rows serialised through the
16
+ * shared `toCsvString`) or `getCsv` (a pre-built CSV string). The `?: never`
17
+ * arms make passing both — or neither — a compile-time error.
18
+ */
19
+ export type BuildCsvDownloadItemArgs =
20
+ | (BuildCsvDownloadItemBase & {
21
+ /**
22
+ * Builds the CSV rows at click time. Used by most widgets — rows are run
23
+ * through `toCsvString` so escaping (incl. the spreadsheet
24
+ * formula-injection guard) stays consistent across widgets.
25
+ */
26
+ getRows: () => readonly (readonly unknown[])[]
27
+ getCsv?: never
28
+ })
29
+ | (BuildCsvDownloadItemBase & {
30
+ /**
31
+ * Returns a pre-built CSV string at click time. Escape hatch for widgets
32
+ * (e.g. Table) that already serialise their own CSV with bespoke
33
+ * header/cell handling.
34
+ */
35
+ getCsv: () => string
36
+ getRows?: never
37
+ })
38
+
39
+ /**
40
+ * Builds the standard CSV `DownloadItem` used by every per-widget download
41
+ * config. Centralised so the menu label, icon, and `.csv` filename suffix stay
42
+ * consistent across widgets — mirrors {@link buildPngDownloadItem} so neither
43
+ * format can drift again.
44
+ */
45
+ export function buildCsvDownloadItem(
46
+ args: BuildCsvDownloadItemArgs,
47
+ ): DownloadItem {
48
+ return {
49
+ id: DOWNLOAD_ITEM_IDS.csv,
50
+ label: args.label ?? 'CSV',
51
+ icon: <CSVIcon fontSize='small' />,
52
+ resolve: () => {
53
+ if (args.getCsv) {
54
+ const csv = args.getCsv()
55
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
56
+ const url = URL.createObjectURL(blob)
57
+ return Promise.resolve({
58
+ url,
59
+ filename: `${args.filename}.csv`,
60
+ revoke: () => URL.revokeObjectURL(url),
61
+ })
62
+ }
63
+ const handle = downloadToCSV(args.getRows())
64
+ return Promise.resolve({
65
+ url: handle.url,
66
+ filename: `${args.filename}.csv`,
67
+ revoke: handle.revoke,
68
+ })
69
+ },
70
+ }
71
+ }
@@ -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 ?? ''], {