@carto/ps-react-ui 4.3.6 → 4.3.8

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 (117) hide show
  1. package/dist/components.js +123 -123
  2. package/dist/components.js.map +1 -1
  3. package/dist/error-CEkRPccv.js +39 -0
  4. package/dist/error-CEkRPccv.js.map +1 -0
  5. package/dist/{lasso-tool-BctzdzBu.js → lasso-tool-jl4YK02H.js} +19 -19
  6. package/dist/lasso-tool-jl4YK02H.js.map +1 -0
  7. package/dist/no-data-hR3KcJ-_.js +60 -0
  8. package/dist/no-data-hR3KcJ-_.js.map +1 -0
  9. package/dist/{row-D3uVFImu.js → row-BKmVAUN5.js} +2 -2
  10. package/dist/{row-D3uVFImu.js.map → row-BKmVAUN5.js.map} +1 -1
  11. package/dist/{series-BAImrSBo.js → series-D1pynfeh.js} +3 -3
  12. package/dist/{series-BAImrSBo.js.map → series-D1pynfeh.js.map} +1 -1
  13. package/dist/{styles-CCZnY17y.js → styles-DrPyd0y5.js} +28 -22
  14. package/dist/styles-DrPyd0y5.js.map +1 -0
  15. package/dist/types/components/lasso-tool/types.d.ts +1 -1
  16. package/dist/types/widgets/_shared/chart-config/index.d.ts +1 -1
  17. package/dist/types/widgets/_shared/chart-config/option-builders.d.ts +7 -0
  18. package/dist/types/widgets/_shared/chart-config/option-builders.test.d.ts +1 -0
  19. package/dist/types/widgets/actions/change-column/change-column.d.ts +4 -0
  20. package/dist/types/widgets/actions/fullscreen/fullscreen.d.ts +1 -1
  21. package/dist/types/widgets/actions/fullscreen/types.d.ts +2 -1
  22. package/dist/types/widgets/actions/index.d.ts +1 -1
  23. package/dist/types/widgets/actions/lock-selection/types.d.ts +7 -7
  24. package/dist/types/widgets/actions/relative-data/types.d.ts +1 -1
  25. package/dist/types/widgets/echart/types.d.ts +0 -4
  26. package/dist/types/widgets/echart/utils.d.ts +2 -1
  27. package/dist/types/widgets/error/error.d.ts +1 -1
  28. package/dist/types/widgets/error/types.d.ts +8 -0
  29. package/dist/types/widgets/loader/loader.d.ts +1 -1
  30. package/dist/types/widgets/loader/types.d.ts +1 -1
  31. package/dist/types/widgets/stores/types.d.ts +1 -1
  32. package/dist/{use-widget-ref-B8x4sHIj.js → use-widget-ref-P-2i0MJG.js} +2 -2
  33. package/dist/{use-widget-ref-B8x4sHIj.js.map → use-widget-ref-P-2i0MJG.js.map} +1 -1
  34. package/dist/{utils-D3-eQyDR.js → utils-idmvq0Oa.js} +17 -16
  35. package/dist/utils-idmvq0Oa.js.map +1 -0
  36. package/dist/{widget-store-Dn0Bnc4h.js → widget-store-CzDt8oSK.js} +31 -46
  37. package/dist/widget-store-CzDt8oSK.js.map +1 -0
  38. package/dist/widgets/actions.js +714 -697
  39. package/dist/widgets/actions.js.map +1 -1
  40. package/dist/widgets/bar.js +67 -63
  41. package/dist/widgets/bar.js.map +1 -1
  42. package/dist/widgets/category.js +250 -241
  43. package/dist/widgets/category.js.map +1 -1
  44. package/dist/widgets/echart.js +93 -100
  45. package/dist/widgets/echart.js.map +1 -1
  46. package/dist/widgets/error.js +1 -1
  47. package/dist/widgets/formula.js +64 -72
  48. package/dist/widgets/formula.js.map +1 -1
  49. package/dist/widgets/histogram.js +75 -73
  50. package/dist/widgets/histogram.js.map +1 -1
  51. package/dist/widgets/loader.js +58 -49
  52. package/dist/widgets/loader.js.map +1 -1
  53. package/dist/widgets/markdown.js +2 -2
  54. package/dist/widgets/no-data.js +1 -1
  55. package/dist/widgets/pie.js +4 -4
  56. package/dist/widgets/range.js +97 -105
  57. package/dist/widgets/range.js.map +1 -1
  58. package/dist/widgets/scatterplot.js +8 -8
  59. package/dist/widgets/skeleton-loader.js +1 -1
  60. package/dist/widgets/spread.js +84 -100
  61. package/dist/widgets/spread.js.map +1 -1
  62. package/dist/widgets/stores.js +1 -1
  63. package/dist/widgets/table.js +493 -485
  64. package/dist/widgets/table.js.map +1 -1
  65. package/dist/widgets/timeseries.js +4 -4
  66. package/dist/widgets/wrapper.js +156 -156
  67. package/dist/widgets/wrapper.js.map +1 -1
  68. package/dist/widgets.js +4 -4
  69. package/package.json +1 -1
  70. package/src/components/lasso-tool/lasso-tool-inline.tsx +19 -17
  71. package/src/components/lasso-tool/lasso-tool.tsx +22 -20
  72. package/src/components/lasso-tool/types.ts +4 -3
  73. package/src/widgets/_shared/chart-config/index.ts +1 -0
  74. package/src/widgets/_shared/chart-config/option-builders.test.ts +40 -0
  75. package/src/widgets/_shared/chart-config/option-builders.ts +12 -0
  76. package/src/widgets/actions/change-column/change-column.test.tsx +129 -2
  77. package/src/widgets/actions/change-column/change-column.tsx +79 -2
  78. package/src/widgets/actions/fullscreen/fullscreen.tsx +8 -8
  79. package/src/widgets/actions/fullscreen/types.ts +6 -1
  80. package/src/widgets/actions/index.ts +4 -1
  81. package/src/widgets/actions/lock-selection/lock-selection.test.tsx +28 -30
  82. package/src/widgets/actions/lock-selection/types.ts +8 -8
  83. package/src/widgets/actions/relative-data/relative-data.test.tsx +13 -13
  84. package/src/widgets/actions/relative-data/types.ts +1 -1
  85. package/src/widgets/actions/stack-toggle/stack-toggle.test.tsx +19 -9
  86. package/src/widgets/actions/zoom-toggle/zoom-toggle.tsx +113 -95
  87. package/src/widgets/bar/config.ts +37 -28
  88. package/src/widgets/category/category-ui.tsx +25 -22
  89. package/src/widgets/echart/echart-ui.test.tsx +3 -18
  90. package/src/widgets/echart/echart-ui.tsx +4 -22
  91. package/src/widgets/echart/echart.test.tsx +9 -25
  92. package/src/widgets/echart/echart.tsx +36 -29
  93. package/src/widgets/echart/types.ts +0 -4
  94. package/src/widgets/echart/utils.ts +3 -1
  95. package/src/widgets/error/error.tsx +17 -14
  96. package/src/widgets/error/types.ts +10 -0
  97. package/src/widgets/formula/components/value.tsx +13 -13
  98. package/src/widgets/histogram/config.ts +36 -29
  99. package/src/widgets/loader/loader.tsx +28 -25
  100. package/src/widgets/loader/types.ts +3 -1
  101. package/src/widgets/no-data/no-data.tsx +8 -11
  102. package/src/widgets/range/components/range-item.tsx +9 -13
  103. package/src/widgets/spread/components/max-value.tsx +13 -13
  104. package/src/widgets/spread/components/min-value.tsx +13 -13
  105. package/src/widgets/stores/types.ts +1 -4
  106. package/src/widgets/stores/widget-store.ts +1 -27
  107. package/src/widgets/table/hooks/use-pagination.ts +44 -35
  108. package/src/widgets/table/hooks/use-sort.ts +25 -23
  109. package/src/widgets/wrapper/wrapper-ui.tsx +16 -17
  110. package/dist/error-piB8FwYO.js +0 -38
  111. package/dist/error-piB8FwYO.js.map +0 -1
  112. package/dist/lasso-tool-BctzdzBu.js.map +0 -1
  113. package/dist/no-data-jdlbMef0.js +0 -61
  114. package/dist/no-data-jdlbMef0.js.map +0 -1
  115. package/dist/styles-CCZnY17y.js.map +0 -1
  116. package/dist/utils-D3-eQyDR.js.map +0 -1
  117. package/dist/widget-store-Dn0Bnc4h.js.map +0 -1
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { niceNum } from './option-builders'
3
+
4
+ describe('niceNum', () => {
5
+ it('should return 0 for 0', () => {
6
+ expect(niceNum(0)).toBe(0)
7
+ })
8
+
9
+ it('should return 0 for negative values', () => {
10
+ expect(niceNum(-5)).toBe(0)
11
+ })
12
+
13
+ it('should round 547 to 600', () => {
14
+ expect(niceNum(547)).toBe(600)
15
+ })
16
+
17
+ it('should keep 200 as 200', () => {
18
+ expect(niceNum(200)).toBe(200)
19
+ })
20
+
21
+ it('should round 1200 to 2000', () => {
22
+ expect(niceNum(1200)).toBe(2000)
23
+ })
24
+
25
+ it('should round 18 to 20', () => {
26
+ expect(niceNum(18)).toBe(20)
27
+ })
28
+
29
+ it('should keep 5 as 5', () => {
30
+ expect(niceNum(5)).toBe(5)
31
+ })
32
+
33
+ it('should round 350 to 400', () => {
34
+ expect(niceNum(350)).toBe(400)
35
+ })
36
+
37
+ it('should keep 1 as 1', () => {
38
+ expect(niceNum(1)).toBe(1)
39
+ })
40
+ })
@@ -9,6 +9,18 @@ import type {
9
9
  * Shared EChart configuration builders for chart widgets
10
10
  */
11
11
 
12
+ /**
13
+ * Rounds a value up to the nearest "nice" number.
14
+ * A nice number is a multiple of 10^floor(log10(value)).
15
+ *
16
+ * Examples: 547 → 600, 200 → 200, 1200 → 2000, 18 → 20, 5 → 5
17
+ */
18
+ export function niceNum(value: number): number {
19
+ if (value <= 0) return 0
20
+ const base = Math.pow(10, Math.floor(Math.log10(value)))
21
+ return Math.ceil(value / base) * base
22
+ }
23
+
12
24
  /**
13
25
  * Builds standard legend configuration for chart widgets
14
26
  *
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect, beforeEach, vi } from 'vitest'
2
- import { render, screen, fireEvent } from '@testing-library/react'
3
- import { ChangeColumn } from './change-column'
2
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
3
+ import { ChangeColumn, CHANGE_COLUMN_TOOL_ID } from './change-column'
4
4
  import { useWidgetStore } from '../../stores/widget-store'
5
5
  import type { TableColumn } from '../../table/types'
6
6
 
@@ -160,4 +160,131 @@ describe('ChangeColumn', () => {
160
160
  expect(item.getAttribute('tabindex')).toBe('0')
161
161
  })
162
162
  })
163
+
164
+ describe('config tool registration', () => {
165
+ test('registers config tool on mount', () => {
166
+ useWidgetStore.getState().setWidget(widgetId, {
167
+ columns: mockColumns,
168
+ })
169
+
170
+ render(<ChangeColumn id={widgetId} />)
171
+
172
+ const widget = useWidgetStore.getState().getWidget(widgetId)
173
+ const tool = widget?.registeredTools?.find(
174
+ (t) => t.id === CHANGE_COLUMN_TOOL_ID,
175
+ )
176
+
177
+ expect(tool).toBeDefined()
178
+ expect(tool?.type).toBe('config')
179
+ expect(tool?.order).toBe(100)
180
+ expect(tool?.enabled).toBe(true)
181
+ })
182
+
183
+ test('unregisters config tool on unmount', () => {
184
+ useWidgetStore.getState().setWidget(widgetId, {
185
+ columns: mockColumns,
186
+ })
187
+
188
+ render(<ChangeColumn id={widgetId} />)
189
+
190
+ // Verify tool is registered
191
+ let widget = useWidgetStore.getState().getWidget(widgetId)
192
+ expect(
193
+ widget?.registeredTools?.find((t) => t.id === CHANGE_COLUMN_TOOL_ID),
194
+ ).toBeDefined()
195
+
196
+ // Unmount
197
+ cleanup()
198
+
199
+ // Verify tool is unregistered
200
+ widget = useWidgetStore.getState().getWidget(widgetId)
201
+ expect(
202
+ widget?.registeredTools?.find((t) => t.id === CHANGE_COLUMN_TOOL_ID),
203
+ ).toBeUndefined()
204
+ })
205
+
206
+ test('config tool returns currentConfig unchanged when columns match widget state', () => {
207
+ useWidgetStore.getState().setWidget(widgetId, {
208
+ columns: mockColumns,
209
+ })
210
+
211
+ render(<ChangeColumn id={widgetId} />)
212
+
213
+ const widget = useWidgetStore.getState().getWidget(widgetId)
214
+ const tool = widget?.registeredTools?.find(
215
+ (t) => t.id === CHANGE_COLUMN_TOOL_ID,
216
+ )
217
+
218
+ // When config columns match widget columns, fn returns the same reference
219
+ const input = { columns: mockColumns }
220
+ const result = tool?.fn(input, tool.config)
221
+ expect(result).toBe(input)
222
+ })
223
+
224
+ test('column order persists when config pipeline re-runs with original config', async () => {
225
+ useWidgetStore.getState().setWidget(widgetId, {
226
+ columns: mockColumns,
227
+ })
228
+
229
+ render(<ChangeColumn id={widgetId} />)
230
+
231
+ // Simulate a drag that reorders columns via setWidget
232
+ const reorderedColumns: TableColumn[] = [
233
+ { id: 'population', label: 'Population' },
234
+ { id: 'name', label: 'Name' },
235
+ { id: 'country', label: 'Country' },
236
+ ]
237
+ useWidgetStore.getState().setWidget(widgetId, {
238
+ columns: reorderedColumns,
239
+ })
240
+
241
+ // Simulate config pipeline re-running with original column order
242
+ const originalConfig = { columns: mockColumns }
243
+ await useWidgetStore
244
+ .getState()
245
+ .executeConfigPipeline(widgetId, originalConfig)
246
+
247
+ // Verify the columns are in the reordered order
248
+ const widget = useWidgetStore.getState().getWidget(widgetId)
249
+ const resultColumns = (widget as unknown as Record<string, unknown>)
250
+ ?.columns as TableColumn[] | undefined
251
+ expect(resultColumns?.map((c) => c.id)).toEqual([
252
+ 'population',
253
+ 'name',
254
+ 'country',
255
+ ])
256
+ })
257
+
258
+ test('new columns appended at the end when not in widget order', async () => {
259
+ useWidgetStore.getState().setWidget(widgetId, {
260
+ columns: mockColumns,
261
+ })
262
+
263
+ render(<ChangeColumn id={widgetId} />)
264
+
265
+ // Simulate widget columns having only a partial order (e.g., user dragged 2 of 3)
266
+ const partialOrder: TableColumn[] = [
267
+ { id: 'country', label: 'Country' },
268
+ { id: 'name', label: 'Name' },
269
+ ]
270
+ useWidgetStore.getState().setWidget(widgetId, {
271
+ columns: partialOrder,
272
+ })
273
+
274
+ // Config pipeline runs with all columns (includes 'population' not in widget order)
275
+ const originalConfig = { columns: mockColumns }
276
+ await useWidgetStore
277
+ .getState()
278
+ .executeConfigPipeline(widgetId, originalConfig)
279
+
280
+ const widget = useWidgetStore.getState().getWidget(widgetId)
281
+ const resultColumns = (widget as unknown as Record<string, unknown>)
282
+ ?.columns as TableColumn[] | undefined
283
+ expect(resultColumns?.map((c) => c.id)).toEqual([
284
+ 'country',
285
+ 'name',
286
+ 'population',
287
+ ])
288
+ })
289
+ })
163
290
  })
@@ -14,16 +14,24 @@ import {
14
14
  verticalListSortingStrategy,
15
15
  } from '@dnd-kit/sortable'
16
16
  import { IconButton, Menu, SvgIcon } from '@mui/material'
17
- import { useCallback, useMemo, useState, type MouseEvent } from 'react'
17
+ import {
18
+ useCallback,
19
+ useEffect,
20
+ useMemo,
21
+ useState,
22
+ type MouseEvent,
23
+ } from 'react'
18
24
  import { useWidgetStore } from '../../stores/widget-store'
19
25
  import type { ChangeColumnProps } from './types'
20
26
  import { actionButtonStyles } from '../shared/styles'
21
27
  import { Tooltip } from '../../../components'
22
- import type { TableWidgetState } from '../../table/types'
28
+ import type { TableColumn, TableWidgetState } from '../../table/types'
23
29
  import { ChangeColumnIcon } from './change-column-icon'
24
30
  import { SortableColumnItem } from './sortable-column-item'
25
31
  import { useShallow } from 'zustand/shallow'
26
32
 
33
+ export const CHANGE_COLUMN_TOOL_ID = 'change-column'
34
+
27
35
  /**
28
36
  * Widget action to reorder columns in a table widget via drag-and-drop.
29
37
  *
@@ -31,6 +39,9 @@ import { useShallow } from 'zustand/shallow'
31
39
  * drag and drop columns to reorder them. All columns are displayed and
32
40
  * can be reordered.
33
41
  *
42
+ * Registers as a config pipeline tool so that column order is automatically
43
+ * re-applied when the base config is updated (e.g., by WidgetLoader).
44
+ *
34
45
  * Returns null if there are fewer than 2 columns.
35
46
  *
36
47
  * @example
@@ -47,10 +58,64 @@ export function ChangeColumn({
47
58
  }: ChangeColumnProps) {
48
59
  const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
49
60
  const setWidget = useWidgetStore((state) => state.setWidget)
61
+ const registerTool = useWidgetStore((state) => state.registerTool)
62
+ const unregisterTool = useWidgetStore((state) => state.unregisterTool)
50
63
  const columns = useWidgetStore(
51
64
  useShallow((state) => state.getWidget<TableWidgetState>(id)?.columns),
52
65
  )
53
66
 
67
+ /**
68
+ * Config tool function that reorders columns to match the current widget state.
69
+ * Reads desired order from the widget store (set by handleDragEnd via setWidget).
70
+ * Preserves referential identity when the order already matches to prevent
71
+ * re-render loops in the config pipeline.
72
+ */
73
+ const reorderFn = useCallback(
74
+ (currentConfig: unknown): unknown => {
75
+ const widgetState = useWidgetStore
76
+ .getState()
77
+ .getWidget<TableWidgetState>(id)
78
+ const currentColumns = widgetState?.columns
79
+ if (!currentColumns || currentColumns.length === 0) return currentConfig
80
+
81
+ const config = currentConfig as Record<string, unknown>
82
+ const configColumns = config.columns as TableColumn[] | undefined
83
+ if (!configColumns || configColumns.length === 0) return currentConfig
84
+
85
+ // Check if config columns are already in the same order as widget columns
86
+ const alreadyMatches =
87
+ configColumns.length === currentColumns.length &&
88
+ configColumns.every((col, i) => col.id === currentColumns[i]?.id)
89
+ if (alreadyMatches) return currentConfig
90
+
91
+ // Reorder config columns to match widget column order
92
+ const columnMap = new Map(configColumns.map((col) => [col.id, col]))
93
+ const reordered: TableColumn[] = []
94
+
95
+ for (const widgetCol of currentColumns) {
96
+ const col = columnMap.get(widgetCol.id)
97
+ if (col) {
98
+ reordered.push(col)
99
+ columnMap.delete(widgetCol.id)
100
+ }
101
+ }
102
+
103
+ // Append any new columns not in the widget order
104
+ for (const col of columnMap.values()) {
105
+ reordered.push(col)
106
+ }
107
+
108
+ // If result matches current widget columns, reuse the same array reference
109
+ // to prevent downstream subscribers from detecting a change
110
+ const matchesWidget =
111
+ reordered.length === currentColumns.length &&
112
+ reordered.every((col, i) => col.id === currentColumns[i]?.id)
113
+
114
+ return { ...config, columns: matchesWidget ? currentColumns : reordered }
115
+ },
116
+ [id],
117
+ )
118
+
54
119
  const sensors = useSensors(
55
120
  useSensor(PointerSensor),
56
121
  useSensor(KeyboardSensor, {
@@ -63,6 +128,18 @@ export function ChangeColumn({
63
128
  [columns],
64
129
  )
65
130
 
131
+ // Register config tool on mount
132
+ useEffect(() => {
133
+ registerTool(id, {
134
+ id: CHANGE_COLUMN_TOOL_ID,
135
+ type: 'config',
136
+ order: 100,
137
+ enabled: true,
138
+ fn: reorderFn,
139
+ })
140
+ return () => unregisterTool(id, CHANGE_COLUMN_TOOL_ID)
141
+ }, [id, registerTool, unregisterTool, reorderFn])
142
+
66
143
  const handleToggle = useCallback((event: MouseEvent<HTMLElement>) => {
67
144
  event.stopPropagation()
68
145
  setAnchorEl(event.currentTarget)
@@ -22,15 +22,13 @@ export function FullScreen({
22
22
  Icon,
23
23
  IconButtonProps,
24
24
  DialogContentProps: { sx, ...DialogContentProps } = {},
25
+ DialogProps,
25
26
  }: FullScreenProps) {
26
- const { isFullScreen, title } = useWidgetStore(
27
- useShallow((state) => {
28
- const widget = state.getWidget<FullScreenState>(id)
29
- return {
30
- isFullScreen: widget?.isFullScreen,
31
- title: widget?.title,
32
- }
33
- }),
27
+ const isFullScreen = useWidgetStore(
28
+ useShallow((state) => state.getWidget<FullScreenState>(id)?.isFullScreen),
29
+ )
30
+ const title = useWidgetStore(
31
+ useShallow((state) => state.getWidget<FullScreenState>(id)?.title),
34
32
  )
35
33
  const setWidget = useWidgetStore((state) => state.setWidget)
36
34
 
@@ -51,7 +49,9 @@ export function FullScreen({
51
49
  <Dialog
52
50
  maxWidth={false}
53
51
  open={!!isFullScreen}
52
+ keepMounted
54
53
  aria-labelledby={labels?.ariaLabel ?? `fullscreen-dialog-title-${id}`}
54
+ {...DialogProps}
55
55
  onClose={() => updateFullScreenConfig({ isFullScreen: false })}
56
56
  >
57
57
  <DialogTitle
@@ -1,4 +1,8 @@
1
- import type { DialogContentProps, IconButtonProps } from '@mui/material'
1
+ import type {
2
+ DialogContentProps,
3
+ IconButtonProps,
4
+ DialogProps,
5
+ } from '@mui/material'
2
6
  import type { ReactNode } from 'react'
3
7
  import type { BaseWidgetState } from '../../../widgets/stores/types'
4
8
 
@@ -22,6 +26,7 @@ export interface FullScreenProps {
22
26
  }
23
27
  children: ReactNode
24
28
  DialogContentProps?: DialogContentProps
29
+ DialogProps?: DialogProps
25
30
  IconButtonProps?: IconButtonProps
26
31
  Icon?: ReactNode
27
32
  }
@@ -37,7 +37,10 @@ export type {
37
37
  } from './searcher/types'
38
38
 
39
39
  /* Change Column Widget */
40
- export { ChangeColumn } from './change-column/change-column'
40
+ export {
41
+ ChangeColumn,
42
+ CHANGE_COLUMN_TOOL_ID,
43
+ } from './change-column/change-column'
41
44
  export type { ChangeColumnProps } from './change-column/types'
42
45
 
43
46
  /* Lock Selection Widget */
@@ -1,9 +1,8 @@
1
1
  import { describe, test, expect, beforeEach } from 'vitest'
2
2
  import { render, screen, fireEvent } from '@testing-library/react'
3
- import { LockSelection } from './lock-selection'
3
+ import { LockSelection, LOCK_SELECTION_TOOL_ID } from './lock-selection'
4
4
  import { useWidgetStore } from '../../stores/widget-store'
5
5
  import type { EchartWidgetData } from '../../echart/types'
6
- import type { LockSelectionState } from './types'
7
6
  import { WidgetLoader } from '../../loader/loader'
8
7
 
9
8
  // Test data
@@ -45,25 +44,29 @@ describe('LockSelection', () => {
45
44
  })
46
45
 
47
46
  test('shows unlock tooltip when locked', () => {
48
- // Pre-set the store to locked state
49
- useWidgetStore.getState().setWidget(widgetId, { isLocked: true })
50
-
51
47
  render(<LockSelection id={widgetId} selectedItems={['Electronics']} />)
52
48
 
53
- const button = screen.getByRole('button', { name: 'Unlock selection' })
54
- expect(button).toBeTruthy()
49
+ // Lock by clicking the button
50
+ const button = screen.getByRole('button')
51
+ fireEvent.click(button)
52
+
53
+ const unlockedButton = screen.getByRole('button', {
54
+ name: 'Unlock selection',
55
+ })
56
+ expect(unlockedButton).toBeTruthy()
55
57
  })
56
58
 
57
- test('toggles isLocked in store when clicked', () => {
59
+ test('toggles isLocked in tool state when clicked', () => {
58
60
  render(<LockSelection id={widgetId} selectedItems={['Electronics']} />)
59
61
 
60
62
  const button = screen.getByRole('button')
61
63
  fireEvent.click(button)
62
64
 
63
- const widget = useWidgetStore
64
- .getState()
65
- .getWidget<LockSelectionState>(widgetId)
66
- expect(widget?.isLocked).toBe(true)
65
+ const widget = useWidgetStore.getState().getWidget(widgetId)
66
+ const tool = widget?.registeredTools?.find(
67
+ (t) => t.id === LOCK_SELECTION_TOOL_ID,
68
+ )
69
+ expect(tool?.enabled).toBe(true)
67
70
  })
68
71
 
69
72
  test('filters data in store when locking', async () => {
@@ -86,36 +89,33 @@ describe('LockSelection', () => {
86
89
 
87
90
  await useWidgetStore.getState().executeToolPipeline(widgetId, mockData)
88
91
 
89
- const widget = useWidgetStore
90
- .getState()
91
- .getWidget<LockSelectionState>(widgetId)
92
+ const widget = useWidgetStore.getState().getWidget(widgetId)
92
93
  expect(widget?.data).toEqual([[{ name: 'Electronics', value: 440 }]])
93
94
  })
94
95
 
95
96
  test('clears locked items when unlocking', () => {
96
- // Pre-set the store to locked state
97
- useWidgetStore.getState().setWidget(widgetId, {
98
- isLocked: true,
99
- lockedItems: ['Electronics'],
100
- })
101
-
102
97
  render(<LockSelection id={widgetId} selectedItems={['Electronics']} />)
103
98
 
99
+ // Lock first
104
100
  const button = screen.getByRole('button')
105
101
  fireEvent.click(button)
106
102
 
107
- const widget = useWidgetStore
108
- .getState()
109
- .getWidget<LockSelectionState>(widgetId)
110
- expect(widget?.isLocked).toBe(false)
103
+ // Then unlock
104
+ fireEvent.click(button)
105
+
106
+ const widget = useWidgetStore.getState().getWidget(widgetId)
107
+ const tool = widget?.registeredTools?.find(
108
+ (t) => t.id === LOCK_SELECTION_TOOL_ID,
109
+ )
110
+ expect(tool?.enabled).toBe(false)
111
111
  })
112
112
 
113
113
  test('has active state when locked', () => {
114
- useWidgetStore.getState().setWidget(widgetId, { isLocked: true })
115
-
116
114
  render(<LockSelection id={widgetId} selectedItems={['Electronics']} />)
117
115
 
118
116
  const button = screen.getByRole('button')
117
+ fireEvent.click(button)
118
+
119
119
  expect(button.getAttribute('data-active')).toBe('true')
120
120
  })
121
121
 
@@ -175,9 +175,7 @@ describe('LockSelection', () => {
175
175
  .getState()
176
176
  .executeToolPipeline(widgetId, multiSeriesData)
177
177
 
178
- const widget = useWidgetStore
179
- .getState()
180
- .getWidget<LockSelectionState>(widgetId)
178
+ const widget = useWidgetStore.getState().getWidget(widgetId)
181
179
  expect(widget?.data).toEqual([
182
180
  [{ name: 'Electronics', value: 440 }],
183
181
  [{ name: 'Electronics', value: 520 }],
@@ -2,14 +2,6 @@ import type { IconButtonProps } from '@mui/material'
2
2
  import type { ReactNode } from 'react'
3
3
  import type { BaseWidgetState } from '../../stores/types'
4
4
 
5
- /**
6
- * Lock selection specific state properties.
7
- */
8
- export interface LockSelectionStateProps {
9
- /** Whether the selection is currently locked */
10
- isLocked?: boolean
11
- }
12
-
13
5
  /**
14
6
  * Widget state extension for lock selection functionality.
15
7
  * Extends the base widget state with lock-specific properties.
@@ -39,3 +31,11 @@ export interface LockSelectionProps {
39
31
  /** Custom icon to display */
40
32
  Icon?: ReactNode
41
33
  }
34
+
35
+ /**
36
+ * Lock selection specific state properties.
37
+ */
38
+ export interface LockSelectionStateProps {
39
+ /** Whether the selection is currently locked */
40
+ isLocked?: boolean
41
+ }
@@ -1,9 +1,8 @@
1
1
  import { describe, test, expect, beforeEach } from 'vitest'
2
2
  import { render, screen, fireEvent, waitFor } from '@testing-library/react'
3
- import { RelativeData } from './relative-data'
3
+ import { RelativeData, RELATIVE_DATA_TOOL_ID } from './relative-data'
4
4
  import { useWidgetStore } from '../../stores/widget-store'
5
5
  import type { EchartWidgetData } from '../../echart/types'
6
- import type { RelativeDataState } from './types'
7
6
 
8
7
  describe('RelativeData', () => {
9
8
  const widgetId = 'test-relative-widget'
@@ -94,25 +93,27 @@ describe('RelativeData', () => {
94
93
  expect(button.getAttribute('aria-label')).toBe('Show absolute values')
95
94
  })
96
95
 
97
- test('initializes widget store with defaultIsRelative on mount', async () => {
96
+ test('initializes tool state with defaultIsRelative on mount', async () => {
98
97
  render(<RelativeData id={widgetId} defaultIsRelative />)
99
98
 
100
99
  await waitFor(() => {
101
- const widget = useWidgetStore
102
- .getState()
103
- .getWidget<RelativeDataState>(widgetId)
104
- expect(widget?.isRelative).toBe(true)
100
+ const widget = useWidgetStore.getState().getWidget(widgetId)
101
+ const tool = widget?.registeredTools?.find(
102
+ (t) => t.id === RELATIVE_DATA_TOOL_ID,
103
+ )
104
+ expect(tool?.enabled).toBe(true)
105
105
  })
106
106
  })
107
107
 
108
- test('initializes widget store with false when defaultIsRelative is false', async () => {
108
+ test('initializes tool state with false when defaultIsRelative is false', async () => {
109
109
  render(<RelativeData id={widgetId} defaultIsRelative={false} />)
110
110
 
111
111
  await waitFor(() => {
112
- const widget = useWidgetStore
113
- .getState()
114
- .getWidget<RelativeDataState>(widgetId)
115
- expect(widget?.isRelative).toBe(false)
112
+ const widget = useWidgetStore.getState().getWidget(widgetId)
113
+ const tool = widget?.registeredTools?.find(
114
+ (t) => t.id === RELATIVE_DATA_TOOL_ID,
115
+ )
116
+ expect(tool?.enabled).toBe(false)
116
117
  })
117
118
  })
118
119
 
@@ -233,7 +234,6 @@ describe('RelativeData', () => {
233
234
  // Pre-populate the widget in the store with relative data
234
235
  useWidgetStore.getState().setWidget(widgetId, {
235
236
  data: initialData,
236
- isRelative: true,
237
237
  })
238
238
 
239
239
  render(<RelativeData id={widgetId} defaultIsRelative />)
@@ -1,6 +1,6 @@
1
1
  import type { IconButtonProps } from '@mui/material'
2
2
  import type { ReactNode } from 'react'
3
- import type { BaseWidgetState } from '../../stores/types'
3
+ import type { BaseWidgetState } from '../../../widgets/stores'
4
4
 
5
5
  export interface RelativeDataProps {
6
6
  /** Widget ID to update data in the widget store */
@@ -39,14 +39,17 @@ describe('StackToggle', () => {
39
39
  expect(button).toBeTruthy()
40
40
  })
41
41
 
42
- test('toggles to stacked mode and updates widget store', () => {
42
+ test('toggles to stacked mode and updates tool config', () => {
43
43
  render(<StackToggle id={widgetId} />)
44
44
 
45
45
  const button = screen.getByRole('button')
46
46
  fireEvent.click(button)
47
47
 
48
48
  const widget = useWidgetStore.getState().getWidget(widgetId)
49
- expect((widget as { isStacked?: boolean })?.isStacked).toBe(true)
49
+ const tool = widget?.registeredTools?.find(
50
+ (t) => t.id === STACK_TOGGLE_TOOL_ID,
51
+ )
52
+ expect(tool?.config?.stacked).toBe(true)
50
53
  })
51
54
 
52
55
  test('toggles back to unstacked mode', () => {
@@ -61,7 +64,10 @@ describe('StackToggle', () => {
61
64
  fireEvent.click(button)
62
65
 
63
66
  const widget = useWidgetStore.getState().getWidget(widgetId)
64
- expect((widget as { isStacked?: boolean })?.isStacked).toBe(false)
67
+ const tool = widget?.registeredTools?.find(
68
+ (t) => t.id === STACK_TOGGLE_TOOL_ID,
69
+ )
70
+ expect(tool?.config?.stacked).toBe(false)
65
71
  })
66
72
 
67
73
  test('has active state when in stacked mode', () => {
@@ -99,18 +105,24 @@ describe('StackToggle', () => {
99
105
  expect(button).toBeTruthy()
100
106
  })
101
107
 
102
- test('initializes store with default values on mount', () => {
108
+ test('initializes tool config with default values on mount', () => {
103
109
  render(<StackToggle id={widgetId} />)
104
110
 
105
111
  const widget = useWidgetStore.getState().getWidget(widgetId)
106
- expect((widget as { isStacked?: boolean })?.isStacked).toBe(false)
112
+ const tool = widget?.registeredTools?.find(
113
+ (t) => t.id === STACK_TOGGLE_TOOL_ID,
114
+ )
115
+ expect(tool?.config?.stacked).toBe(false)
107
116
  })
108
117
 
109
- test('initializes store with stacked values when defaultIsStacked is true', () => {
118
+ test('initializes tool config with stacked values when defaultIsStacked is true', () => {
110
119
  render(<StackToggle id={widgetId} defaultIsStacked />)
111
120
 
112
121
  const widget = useWidgetStore.getState().getWidget(widgetId)
113
- expect((widget as { isStacked?: boolean })?.isStacked).toBe(true)
122
+ const tool = widget?.registeredTools?.find(
123
+ (t) => t.id === STACK_TOGGLE_TOOL_ID,
124
+ )
125
+ expect(tool?.config?.stacked).toBe(true)
114
126
  })
115
127
 
116
128
  test('applies stack to EChart series via config pipeline when toggling to stacked', async () => {
@@ -279,7 +291,6 @@ describe('StackToggle', () => {
279
291
  test('config tool re-applies stack when config pipeline re-runs', async () => {
280
292
  // Start with stacked widget
281
293
  useWidgetStore.getState().setWidget(widgetId, {
282
- isStacked: true,
283
294
  option: {
284
295
  series: [
285
296
  { name: 'Series 1', type: 'bar', stack: DEFAULT_STACK_GROUP },
@@ -313,7 +324,6 @@ describe('StackToggle', () => {
313
324
  test('config tool does not apply stack when unstacked and pipeline re-runs', async () => {
314
325
  // Start with unstacked widget
315
326
  useWidgetStore.getState().setWidget(widgetId, {
316
- isStacked: false,
317
327
  option: {
318
328
  series: [
319
329
  { name: 'Series 1', type: 'bar' },