@carto/ps-react-ui 4.5.0 → 4.6.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 (99) hide show
  1. package/dist/{download-config-DemuQ3Jm.js → download-config-C3I0jWIL.js} +2 -2
  2. package/dist/{download-config-DemuQ3Jm.js.map → download-config-C3I0jWIL.js.map} +1 -1
  3. package/dist/{row-D4VOhcNI.js → row-DZSP99LW.js} +2 -2
  4. package/dist/{row-D4VOhcNI.js.map → row-DZSP99LW.js.map} +1 -1
  5. package/dist/{series-Bola3CmD.js → series-DLNHDWs0.js} +3 -3
  6. package/dist/{series-Bola3CmD.js.map → series-DLNHDWs0.js.map} +1 -1
  7. package/dist/types/hooks/index.d.ts +0 -1
  8. package/dist/types/widgets/actions/brush-toggle/brush-toggle.d.ts +3 -0
  9. package/dist/types/widgets/actions/index.d.ts +4 -4
  10. package/dist/types/widgets/actions/lock-selection/types.d.ts +2 -0
  11. package/dist/types/widgets/actions/relative-data/relative-data.d.ts +7 -2
  12. package/dist/types/widgets/actions/relative-data/types.d.ts +2 -0
  13. package/dist/types/widgets/actions/zoom-toggle/zoom-toggle.d.ts +4 -0
  14. package/dist/types/widgets/category/index.d.ts +10 -2
  15. package/dist/types/widgets/category/style.d.ts +1 -0
  16. package/dist/types/widgets/no-data/no-data.d.ts +3 -2
  17. package/dist/types/widgets/no-data/types.d.ts +5 -1
  18. package/dist/types/widgets/stores/index.d.ts +1 -1
  19. package/dist/types/widgets/stores/types.d.ts +10 -10
  20. package/dist/types/widgets/stores/widget-store.d.ts +2 -3
  21. package/dist/types/widgets/table/index.d.ts +6 -2
  22. package/dist/{use-widget-ref-BFazQvJK.js → use-widget-ref-Ddr_SlJJ.js} +2 -2
  23. package/dist/{use-widget-ref-BFazQvJK.js.map → use-widget-ref-Ddr_SlJJ.js.map} +1 -1
  24. package/dist/{use-widget-selector-DqRmWQ1K.js → use-widget-selector-DFl2hW0R.js} +2 -2
  25. package/dist/{use-widget-selector-DqRmWQ1K.js.map → use-widget-selector-DFl2hW0R.js.map} +1 -1
  26. package/dist/{widget-store-CIrb9RKP.js → widget-store-Bw5zRUGg.js} +93 -95
  27. package/dist/widget-store-Bw5zRUGg.js.map +1 -0
  28. package/dist/widgets/actions.js +770 -755
  29. package/dist/widgets/actions.js.map +1 -1
  30. package/dist/widgets/bar.js +2 -2
  31. package/dist/widgets/category.js +187 -183
  32. package/dist/widgets/category.js.map +1 -1
  33. package/dist/widgets/echart.js +2 -2
  34. package/dist/widgets/error.js +37 -2
  35. package/dist/widgets/error.js.map +1 -1
  36. package/dist/widgets/formula.js +5 -5
  37. package/dist/widgets/histogram.js +1 -1
  38. package/dist/widgets/loader.js +1 -1
  39. package/dist/widgets/markdown.js +2 -2
  40. package/dist/widgets/no-data.js +58 -2
  41. package/dist/widgets/no-data.js.map +1 -1
  42. package/dist/widgets/note.js +121 -2
  43. package/dist/widgets/note.js.map +1 -1
  44. package/dist/widgets/pie.js +2 -2
  45. package/dist/widgets/range.js +3 -3
  46. package/dist/widgets/scatterplot.js +2 -2
  47. package/dist/widgets/skeleton-loader.js +1 -1
  48. package/dist/widgets/spread.js +5 -5
  49. package/dist/widgets/stores.js +2 -2
  50. package/dist/widgets/subheader.js +29 -29
  51. package/dist/widgets/subheader.js.map +1 -1
  52. package/dist/widgets/table.js +3 -3
  53. package/dist/widgets/timeseries.js +2 -2
  54. package/dist/widgets/utils.js +1 -1
  55. package/dist/widgets/wrapper.js +2 -2
  56. package/package.json +1 -5
  57. package/src/hooks/index.ts +0 -1
  58. package/src/widgets/actions/brush-toggle/brush-toggle.tsx +18 -22
  59. package/src/widgets/actions/change-column/change-column.test.tsx +1 -1
  60. package/src/widgets/actions/download/download.test.tsx +1 -1
  61. package/src/widgets/actions/index.ts +11 -2
  62. package/src/widgets/actions/lock-selection/lock-selection.test.tsx +14 -0
  63. package/src/widgets/actions/lock-selection/lock-selection.tsx +18 -11
  64. package/src/widgets/actions/lock-selection/types.ts +2 -0
  65. package/src/widgets/actions/relative-data/relative-data.test.tsx +211 -20
  66. package/src/widgets/actions/relative-data/relative-data.tsx +65 -34
  67. package/src/widgets/actions/relative-data/types.ts +2 -0
  68. package/src/widgets/actions/searcher/searcher.tsx +28 -30
  69. package/src/widgets/actions/stack-toggle/stack-toggle.tsx +11 -2
  70. package/src/widgets/actions/zoom-toggle/zoom-toggle.tsx +53 -45
  71. package/src/widgets/category/category-ui.tsx +7 -6
  72. package/src/widgets/category/index.ts +13 -14
  73. package/src/widgets/category/style.ts +1 -0
  74. package/src/widgets/no-data/no-data.test.tsx +90 -40
  75. package/src/widgets/no-data/no-data.tsx +7 -5
  76. package/src/widgets/no-data/types.ts +5 -1
  77. package/src/widgets/stores/index.ts +2 -0
  78. package/src/widgets/stores/types.ts +10 -18
  79. package/src/widgets/stores/widget-store.test.ts +132 -13
  80. package/src/widgets/stores/widget-store.ts +29 -35
  81. package/src/widgets/subheader/subheader.tsx +11 -3
  82. package/src/widgets/table/index.ts +6 -4
  83. package/dist/error-Cj8eUMrl.js +0 -40
  84. package/dist/error-Cj8eUMrl.js.map +0 -1
  85. package/dist/no-data-DkIt7Qt1.js +0 -61
  86. package/dist/no-data-DkIt7Qt1.js.map +0 -1
  87. package/dist/note-t51drNe0.js +0 -124
  88. package/dist/note-t51drNe0.js.map +0 -1
  89. package/dist/types/hooks/use-debounce.d.ts +0 -19
  90. package/dist/types/widgets/category/components/index.d.ts +0 -10
  91. package/dist/types/widgets/index.d.ts +0 -9
  92. package/dist/types/widgets/table/hooks/index.d.ts +0 -6
  93. package/dist/widget-store-CIrb9RKP.js.map +0 -1
  94. package/dist/widgets.js +0 -13
  95. package/dist/widgets.js.map +0 -1
  96. package/src/hooks/use-debounce.ts +0 -55
  97. package/src/widgets/category/components/index.ts +0 -14
  98. package/src/widgets/index.ts +0 -25
  99. package/src/widgets/table/hooks/index.ts +0 -7
@@ -7,6 +7,7 @@ import {
7
7
  } from './relative-data'
8
8
  import { useWidgetStore } from '../../stores/widget-store'
9
9
  import type { EchartWidgetData } from '../../echart/types'
10
+ import type { RelativeDataState } from './types'
10
11
 
11
12
  describe('RelativeData', () => {
12
13
  const widgetId = 'test-relative-widget'
@@ -227,23 +228,28 @@ describe('RelativeData', () => {
227
228
  expect(button.hasAttribute('disabled')).toBeTruthy()
228
229
  })
229
230
 
230
- test('registers config tool on mount', async () => {
231
+ test('registers both data and config tools on mount', async () => {
231
232
  render(<RelativeData id={widgetId} />)
232
233
 
233
234
  await waitFor(() => {
234
235
  const widget = useWidgetStore.getState().getWidget(widgetId)
235
- const tool = widget?.registeredTools?.find(
236
+ const dataTool = widget?.registeredTools?.find(
237
+ (t) => t.id === RELATIVE_DATA_TOOL_ID,
238
+ )
239
+ const configTool = widget?.registeredTools?.find(
236
240
  (t) => t.id === RELATIVE_DATA_CONFIG_TOOL_ID,
237
241
  )
238
- expect(tool).toBeTruthy()
239
- expect(tool?.type).toBe('config')
240
- expect(tool?.enabled).toBe(true)
242
+ expect(dataTool).toBeTruthy()
243
+ expect(configTool).toBeTruthy()
244
+ expect(configTool?.type).toBe('config')
245
+ // Data tool disabled by default (isRelative=false)
246
+ expect(dataTool?.enabled).toBe(false)
247
+ // Config tool always enabled — it reads isRelative from store internally
248
+ expect(configTool?.enabled).toBe(true)
241
249
  })
242
250
  })
243
251
 
244
- test('sets formatter via config pipeline when toggling to relative mode', async () => {
245
- useWidgetStore.getState().setWidget(widgetId, { max: 500 })
246
-
252
+ test('data tool enabled when toggled to relative mode', async () => {
247
253
  render(<RelativeData id={widgetId} />)
248
254
 
249
255
  const button = screen.getByRole('button')
@@ -251,16 +257,77 @@ describe('RelativeData', () => {
251
257
 
252
258
  await waitFor(() => {
253
259
  const widget = useWidgetStore.getState().getWidget(widgetId)
254
- const tool = widget?.registeredTools?.find(
255
- (t) => t.id === RELATIVE_DATA_CONFIG_TOOL_ID,
260
+ const dataTool = widget?.registeredTools?.find(
261
+ (t) => t.id === RELATIVE_DATA_TOOL_ID,
256
262
  )
257
- expect(tool?.config?.isRelative).toBe(true)
258
- expect(tool?.config?.originalFormatter).toBeUndefined()
259
- expect(tool?.config?.originalMax).toBe(500)
263
+ expect(dataTool?.enabled).toBe(true)
260
264
  })
261
265
  })
262
266
 
263
- test('restores original formatter via config pipeline when toggling back', () => {
267
+ test('stores isRelative in widget root state', () => {
268
+ render(<RelativeData id={widgetId} />)
269
+
270
+ // Default: isRelative=false in store
271
+ let widget = useWidgetStore.getState().getWidget(widgetId)
272
+ expect((widget as RelativeDataState | undefined)?.isRelative).toBe(false)
273
+
274
+ // Toggle to relative
275
+ const button = screen.getByRole('button')
276
+ fireEvent.click(button)
277
+
278
+ widget = useWidgetStore.getState().getWidget(widgetId)
279
+ expect((widget as RelativeDataState | undefined)?.isRelative).toBe(true)
280
+
281
+ // Toggle back
282
+ fireEvent.click(button)
283
+
284
+ widget = useWidgetStore.getState().getWidget(widgetId)
285
+ expect((widget as RelativeDataState | undefined)?.isRelative).toBe(false)
286
+ })
287
+
288
+ test('config tool does not apply formatter when sourceData is empty', async () => {
289
+ const customFormatter = (value: number) => `$${value}`
290
+ useWidgetStore
291
+ .getState()
292
+ .setWidget(widgetId, { formatter: customFormatter, sourceData: [] })
293
+
294
+ render(<RelativeData id={widgetId} />)
295
+
296
+ const button = screen.getByRole('button')
297
+ fireEvent.click(button)
298
+
299
+ // Execute config pipeline — sourceData is empty, so formatter should pass through
300
+ await useWidgetStore
301
+ .getState()
302
+ .executeConfigPipeline(widgetId, { formatter: customFormatter, max: 200 })
303
+
304
+ const widget = useWidgetStore.getState().getWidget(widgetId)
305
+ expect(widget?.formatter).toBe(customFormatter)
306
+ expect((widget as unknown as Record<string, unknown>)?.max).toBe(200)
307
+ })
308
+
309
+ test('config pipeline applies percentage formatter when in relative mode', async () => {
310
+ useWidgetStore
311
+ .getState()
312
+ .setWidget(widgetId, { max: 500, sourceData: mockData })
313
+
314
+ render(<RelativeData id={widgetId} />)
315
+
316
+ const button = screen.getByRole('button')
317
+ fireEvent.click(button)
318
+
319
+ // Execute config pipeline
320
+ await useWidgetStore
321
+ .getState()
322
+ .executeConfigPipeline(widgetId, { max: 500 })
323
+
324
+ const widget = useWidgetStore.getState().getWidget(widgetId)
325
+ expect((widget as unknown as Record<string, unknown>)?.max).toBe(100)
326
+ expect(widget?.formatter).toBeDefined()
327
+ expect(typeof widget?.formatter).toBe('function')
328
+ })
329
+
330
+ test('config tool passes config through unchanged when not in relative mode', async () => {
264
331
  const customFormatter = (value: number) => `$${value}`
265
332
  useWidgetStore
266
333
  .getState()
@@ -268,20 +335,144 @@ describe('RelativeData', () => {
268
335
 
269
336
  render(<RelativeData id={widgetId} />)
270
337
 
338
+ // Execute config pipeline — config tool is enabled but isRelative=false in store,
339
+ // so it passes through the config unchanged
340
+ await useWidgetStore
341
+ .getState()
342
+ .executeConfigPipeline(widgetId, { formatter: customFormatter, max: 200 })
343
+
344
+ const widget = useWidgetStore.getState().getWidget(widgetId)
345
+ expect(widget?.formatter).toBe(customFormatter)
346
+ expect((widget as unknown as Record<string, unknown>)?.max).toBe(200)
347
+ })
348
+
349
+ test('restores original formatter/max when toggling back to absolute', async () => {
350
+ const customFormatter = (value: number) => `$${value}`
351
+ useWidgetStore.getState().setWidget(widgetId, {
352
+ formatter: customFormatter,
353
+ max: 200,
354
+ sourceData: mockData,
355
+ })
356
+
357
+ render(<RelativeData id={widgetId} />)
358
+
271
359
  const button = screen.getByRole('button')
272
360
 
273
- // Toggle to relative
361
+ // Toggle to relative — captures originalFormatter/originalMax in store
274
362
  fireEvent.click(button)
275
- // Toggle back to absolute
363
+
364
+ await useWidgetStore
365
+ .getState()
366
+ .executeConfigPipeline(widgetId, { max: 200 })
367
+
368
+ let widget = useWidgetStore.getState().getWidget(widgetId)
369
+ expect((widget as unknown as Record<string, unknown>)?.max).toBe(100)
370
+
371
+ // Toggle back to absolute — config tool restores originals from store
372
+ fireEvent.click(button)
373
+
374
+ await useWidgetStore
375
+ .getState()
376
+ .executeConfigPipeline(widgetId, { max: 200 })
377
+
378
+ widget = useWidgetStore.getState().getWidget(widgetId)
379
+ expect(widget?.formatter).toBe(customFormatter)
380
+ expect((widget as unknown as Record<string, unknown>)?.max).toBe(200)
381
+ })
382
+
383
+ test('removes percentage formatter when toggling back with no original formatter', async () => {
384
+ // Widget has no formatter — originalFormatter will be captured as undefined
385
+ useWidgetStore.getState().setWidget(widgetId, { sourceData: mockData })
386
+
387
+ render(<RelativeData id={widgetId} />)
388
+
389
+ const button = screen.getByRole('button')
390
+
391
+ // Toggle to relative — captures originalFormatter=undefined
392
+ fireEvent.click(button)
393
+
394
+ await useWidgetStore.getState().executeConfigPipeline(widgetId, {})
395
+
396
+ let widget = useWidgetStore.getState().getWidget(widgetId)
397
+ expect(widget?.formatter).toBeDefined()
398
+ expect(typeof widget?.formatter).toBe('function')
399
+
400
+ // Toggle back to absolute — should remove percentage formatter
401
+ fireEvent.click(button)
402
+
403
+ await useWidgetStore.getState().executeConfigPipeline(widgetId, {})
404
+
405
+ widget = useWidgetStore.getState().getWidget(widgetId)
406
+ expect(widget?.formatter).toBeUndefined()
407
+ })
408
+
409
+ test('stores originalFormatter/originalMax in widget root on toggle', () => {
410
+ const customFormatter = (value: number) => `$${value}`
411
+ useWidgetStore
412
+ .getState()
413
+ .setWidget(widgetId, { formatter: customFormatter, max: 200 })
414
+
415
+ render(<RelativeData id={widgetId} />)
416
+
417
+ const button = screen.getByRole('button')
418
+ fireEvent.click(button)
419
+
420
+ const widget = useWidgetStore
421
+ .getState()
422
+ .getWidget<RelativeDataState>(widgetId)
423
+ expect(widget?.originalFormatter).toBe(customFormatter)
424
+ expect(widget?.originalMax).toBe(200)
425
+ })
426
+
427
+ test('restores original formatter/max when unmounting while in relative mode', async () => {
428
+ const customFormatter = (value: number) => `$${value}`
429
+ useWidgetStore
430
+ .getState()
431
+ .setWidget(widgetId, { formatter: customFormatter, max: 200 })
432
+
433
+ const { unmount } = render(<RelativeData id={widgetId} />)
434
+
435
+ const button = screen.getByRole('button')
436
+
437
+ // Toggle to relative — captures originalFormatter/originalMax in store
276
438
  fireEvent.click(button)
277
439
 
440
+ await waitFor(() => {
441
+ const widget = useWidgetStore
442
+ .getState()
443
+ .getWidget<RelativeDataState>(widgetId)
444
+ expect(widget?.isRelative).toBe(true)
445
+ expect(widget?.originalFormatter).toBe(customFormatter)
446
+ expect(widget?.originalMax).toBe(200)
447
+ })
448
+
449
+ // Unmount while still in relative mode
450
+ unmount()
451
+
452
+ const widget = useWidgetStore.getState().getWidget(widgetId)
453
+ expect(widget?.formatter).toBe(customFormatter)
454
+ expect((widget as unknown as Record<string, unknown>)?.max).toBe(200)
455
+ })
456
+
457
+ test('unregisters tools on unmount', async () => {
458
+ const { unmount } = render(<RelativeData id={widgetId} />)
459
+
460
+ await waitFor(() => {
461
+ const widget = useWidgetStore.getState().getWidget(widgetId)
462
+ expect(widget?.registeredTools?.length).toBeGreaterThan(0)
463
+ })
464
+
465
+ unmount()
466
+
278
467
  const widget = useWidgetStore.getState().getWidget(widgetId)
279
- const tool = widget?.registeredTools?.find(
468
+ const dataTool = widget?.registeredTools?.find(
469
+ (t) => t.id === RELATIVE_DATA_TOOL_ID,
470
+ )
471
+ const configTool = widget?.registeredTools?.find(
280
472
  (t) => t.id === RELATIVE_DATA_CONFIG_TOOL_ID,
281
473
  )
282
- expect(tool?.config?.isRelative).toBe(false)
283
- expect(tool?.config?.originalFormatter).toBe(customFormatter)
284
- expect(tool?.config?.originalMax).toBe(200)
474
+ expect(dataTool).toBeUndefined()
475
+ expect(configTool).toBeUndefined()
285
476
  })
286
477
 
287
478
  test('recalculates relative values when store data changes externally while in relative mode', async () => {
@@ -3,7 +3,7 @@ import { PercentOutlined } from '@mui/icons-material'
3
3
  import { useCallback, useEffect, useRef } from 'react'
4
4
  import { widgetStoreActions } from '../../stores/widget-store'
5
5
  import { useWidgetSelector } from '../../stores/use-widget-selector'
6
- import type { RelativeDataProps } from './types'
6
+ import type { RelativeDataProps, RelativeDataState } from './types'
7
7
  import { actionButtonStyles } from '../shared/styles'
8
8
  import { Tooltip } from '../../../components'
9
9
  import { calculateTotal, toRelativeData } from './utils'
@@ -15,8 +15,13 @@ export const RELATIVE_DATA_CONFIG_TOOL_ID = 'relative-data-config'
15
15
  /**
16
16
  * Widget action to toggle between relative (percentage) and absolute data display.
17
17
  *
18
- * Registers a transformation tool in the widget pipeline when mounted.
19
- * When relative mode is active, transforms data to percentages via the pipeline.
18
+ * Registers two transformation tools in the widget pipeline when mounted:
19
+ * - A data tool that converts values to percentages (enabled/disabled via store)
20
+ * - A config tool that is **always enabled** and reads `isRelative` from the store
21
+ * to decide whether to apply the percentage formatter or restore the original one.
22
+ * The config tool must always participate because the original formatter may have
23
+ * been set via `setWidget` (not in the base config), so disabling the tool would
24
+ * leave the percentage formatter stuck on the widget.
20
25
  *
21
26
  * @example
22
27
  * ```tsx
@@ -39,15 +44,21 @@ export function RelativeData({
39
44
  undefined,
40
45
  )
41
46
 
42
- const { storeIsRelative } = useWidgetSelector(id, (w) => ({
43
- storeIsRelative: w?.registeredTools?.find(
44
- (t) => t.id === RELATIVE_DATA_CONFIG_TOOL_ID,
45
- )?.config?.isRelative as boolean | undefined,
47
+ // Read isRelative from widget store root single source of truth
48
+ const { isRelative } = useWidgetSelector(id, (w) => ({
49
+ isRelative:
50
+ (w as RelativeDataState | undefined)?.isRelative ?? defaultIsRelative,
46
51
  }))
47
52
 
48
- const isRelative = storeIsRelative ?? defaultIsRelative
53
+ // Initialize store with default value on mount
54
+ useEffect(() => {
55
+ const current = widgetStoreActions.getWidget<RelativeDataState>(id)
56
+ if (current?.isRelative === undefined) {
57
+ widgetStoreActions.setWidget(id, { isRelative: defaultIsRelative })
58
+ }
59
+ }, [id, defaultIsRelative])
49
60
 
50
- // Register data tool with all reactive deps — store's no-op detection handles performance
61
+ // Register data tool once fn has no closure deps
51
62
  useEffect(() => {
52
63
  widgetStoreActions.registerTool(id, {
53
64
  id: RELATIVE_DATA_TOOL_ID,
@@ -63,18 +74,35 @@ export function RelativeData({
63
74
  return () => widgetStoreActions.unregisterTool(id, RELATIVE_DATA_TOOL_ID)
64
75
  }, [id, order, defaultIsRelative])
65
76
 
66
- // Register config tool with all reactive deps store's no-op detection handles performance
77
+ // Sync data tool enabledlightweight, no re-registration
78
+ useEffect(() => {
79
+ widgetStoreActions.setToolEnabled(id, RELATIVE_DATA_TOOL_ID, isRelative)
80
+ }, [id, isRelative])
81
+
82
+ // Register config tool — ALWAYS enabled.
83
+ // Reads isRelative, originalFormatter, originalMax from the store at execution time:
84
+ // - isRelative=true → applies percentage formatter and max=100
85
+ // - isRelative=false → restores original formatter/max from store
67
86
  useEffect(() => {
68
87
  widgetStoreActions.registerTool(id, {
69
88
  id: RELATIVE_DATA_CONFIG_TOOL_ID,
70
89
  type: 'config',
71
90
  order,
72
91
  enabled: true,
73
- fn: (currentConfig: unknown, toolConfig?: Record<string, unknown>) => {
92
+ fn: (currentConfig: unknown) => {
74
93
  const config = currentConfig as Record<string, unknown>
75
- if (toolConfig?.isRelative) {
94
+ const widget = widgetStoreActions.getWidget<RelativeDataState>(id)
95
+
96
+ const hasSourceData =
97
+ widget?.sourceData != null &&
98
+ !(Array.isArray(widget.sourceData) && widget.sourceData.length === 0)
99
+
100
+ if (widget?.isRelative) {
101
+ // Don't apply percentage formatter when there's no source data
102
+ if (!hasSourceData) return config
103
+
76
104
  if (!percentFormatterRef.current) {
77
- const locale = toolConfig?.locale as string | undefined
105
+ const locale = widget?.locale
78
106
  percentFormatterRef.current = (value: number) =>
79
107
  new Intl.NumberFormat(locale, {
80
108
  style: 'percent',
@@ -84,46 +112,49 @@ export function RelativeData({
84
112
  }
85
113
  return { ...config, formatter: percentFormatterRef.current, max: 100 }
86
114
  }
87
- // Switching back from relative mode
115
+
116
+ // Not in relative mode — restore originals from store if captured.
117
+ // Use `in` because originalFormatter may have been captured as undefined
118
+ // (widget had no formatter before toggling relative on).
88
119
  percentFormatterRef.current = undefined
89
- if (toolConfig && 'originalFormatter' in toolConfig) {
120
+ if (widget != null && 'originalFormatter' in widget) {
90
121
  return {
91
122
  ...config,
92
- formatter: toolConfig.originalFormatter,
93
- max: toolConfig.originalMax,
123
+ formatter: widget.originalFormatter,
124
+ max: widget.originalMax,
94
125
  }
95
126
  }
96
127
  return config
97
128
  },
98
- config: {
99
- isRelative: defaultIsRelative,
100
- },
101
129
  })
102
- return () =>
130
+ return () => {
131
+ // Restore original formatter/max if unmounting while in relative mode
132
+ const widget = widgetStoreActions.getWidget<RelativeDataState>(id)
133
+ if (widget?.isRelative && 'originalFormatter' in widget) {
134
+ widgetStoreActions.setWidget(id, {
135
+ formatter: widget.originalFormatter,
136
+ max: widget.originalMax,
137
+ })
138
+ }
139
+ percentFormatterRef.current = undefined
103
140
  widgetStoreActions.unregisterTool(id, RELATIVE_DATA_CONFIG_TOOL_ID)
141
+ }
104
142
  }, [id, order, defaultIsRelative])
105
143
 
106
144
  const handleToggle = useCallback(() => {
107
145
  const newIsRelative = !isRelative
108
- widgetStoreActions.setToolEnabled(id, RELATIVE_DATA_TOOL_ID, newIsRelative)
146
+ percentFormatterRef.current = undefined
109
147
 
110
148
  if (newIsRelative) {
111
- const widget = widgetStoreActions.getWidget(id) as {
112
- formatter?: (value: number) => string
113
- locale?: string
114
- max?: number
115
- }
116
- widgetStoreActions.updateToolConfig(id, RELATIVE_DATA_CONFIG_TOOL_ID, {
149
+ // Capture current formatter/max in store before switching to relative
150
+ const widget = widgetStoreActions.getWidget(id)
151
+ widgetStoreActions.setWidget(id, {
117
152
  isRelative: true,
118
153
  originalFormatter: widget?.formatter,
119
- originalMax: widget?.max,
120
- locale: widget?.locale,
154
+ originalMax: (widget as unknown as Record<string, unknown>)?.max,
121
155
  })
122
156
  } else {
123
- percentFormatterRef.current = undefined
124
- widgetStoreActions.updateToolConfig(id, RELATIVE_DATA_CONFIG_TOOL_ID, {
125
- isRelative: false,
126
- })
157
+ widgetStoreActions.setWidget(id, { isRelative: false })
127
158
  }
128
159
  }, [isRelative, id])
129
160
 
@@ -27,5 +27,7 @@ export interface RelativeDataProps {
27
27
  export type RelativeDataState<T = unknown> = BaseWidgetState<
28
28
  T & {
29
29
  isRelative?: boolean
30
+ originalFormatter?: (value: number) => string
31
+ originalMax?: number
30
32
  }
31
33
  >
@@ -57,42 +57,44 @@ export function Searcher({
57
57
  [id],
58
58
  )
59
59
 
60
- // Register tool with all reactive deps store's no-op detection handles performance
60
+ // Register tool once fn reads searchText from the store at execution time.
61
+ // Enabled is synced separately to avoid full re-registration on toggle.
61
62
  useEffect(() => {
62
63
  widgetStoreActions.registerTool(id, {
63
64
  id: SEARCHER_TOOL_ID,
64
65
  order,
65
- enabled,
66
- fn: async (data, config) => {
67
- const searchTextFromConfig = (config?.searchText as string) || ''
66
+ enabled: false,
67
+ fn: async (data) => {
68
+ const widget = widgetStoreActions.getWidget<SearcherState>(id)
69
+ const currentSearchText = widget?.searchText ?? ''
68
70
 
69
71
  // Execute filter (can be sync or async)
70
- const result = filter(data as EchartWidgetData, searchTextFromConfig)
72
+ const result = filter(data as EchartWidgetData, currentSearchText)
71
73
 
72
74
  // Return result directly (pipeline will handle Promise)
73
75
  return result
74
76
  },
75
- config: { searchText },
76
77
  disables: [LOCK_SELECTION_TOOL_ID],
77
78
  })
78
79
 
79
80
  return () => widgetStoreActions.unregisterTool(id, SEARCHER_TOOL_ID)
80
- }, [id, order, enabled, searchText, filter])
81
+ }, [id, order, filter])
81
82
 
82
- // Update config when search text changes (debounced)
83
- const debouncedUpdateConfig = useCallback(
84
- (text: string) => {
85
- if (debounceTimeoutRef.current) {
86
- clearTimeout(debounceTimeoutRef.current)
87
- }
88
- debounceTimeoutRef.current = setTimeout(() => {
89
- widgetStoreActions.updateToolConfig(id, SEARCHER_TOOL_ID, {
90
- searchText: text,
91
- })
92
- }, debounceDelay)
93
- },
94
- [id, debounceDelay],
95
- )
83
+ // Sync enabled from store lightweight, no re-registration
84
+ useEffect(() => {
85
+ widgetStoreActions.setToolEnabled(id, SEARCHER_TOOL_ID, enabled)
86
+ }, [id, enabled])
87
+
88
+ // Trigger pipeline re-execution when search text changes (debounced).
89
+ // The fn reads searchText from the store, so we just need to trigger the pipeline.
90
+ const debouncedTriggerPipeline = useCallback(() => {
91
+ if (debounceTimeoutRef.current) {
92
+ clearTimeout(debounceTimeoutRef.current)
93
+ }
94
+ debounceTimeoutRef.current = setTimeout(() => {
95
+ widgetStoreActions.triggerToolPipeline(id)
96
+ }, debounceDelay)
97
+ }, [id, debounceDelay])
96
98
 
97
99
  // Auto-focus when enabled becomes true
98
100
  useEffect(() => {
@@ -117,16 +119,14 @@ export function Searcher({
117
119
  (event: React.ChangeEvent<HTMLInputElement>) => {
118
120
  const newValue = event.target.value
119
121
  setSearchText(newValue)
120
- debouncedUpdateConfig(newValue)
122
+ debouncedTriggerPipeline()
121
123
  },
122
- [debouncedUpdateConfig, setSearchText],
124
+ [debouncedTriggerPipeline, setSearchText],
123
125
  )
124
126
 
125
127
  const handleClear = useCallback(() => {
126
128
  setSearchText('')
127
- widgetStoreActions.updateToolConfig(id, SEARCHER_TOOL_ID, {
128
- searchText: '',
129
- })
129
+ widgetStoreActions.triggerToolPipeline(id)
130
130
  if (inputRef.current) {
131
131
  inputRef.current.focus()
132
132
  }
@@ -187,10 +187,8 @@ const defaultFilterFn: SearcherFilterFn = (
187
187
  return Promise.resolve(
188
188
  data.map((series) =>
189
189
  series.filter((item) =>
190
- Object.values(item).some(
191
- (value) =>
192
- typeof value === 'string' &&
193
- value.toLowerCase().includes(lowerSearch),
190
+ Object.values(item).some((value) =>
191
+ String(value).toLowerCase().includes(lowerSearch),
194
192
  ),
195
193
  ),
196
194
  ),
@@ -54,13 +54,13 @@ export function StackToggle({
54
54
  const effectiveDefaultIsStacked = hasStackInSeries || defaultIsStacked
55
55
  const isStacked = storeIsStacked ?? effectiveDefaultIsStacked
56
56
 
57
- // Register config tool with all reactive deps — store's no-op detection handles performance
57
+ // Register config tool once fn has no closure deps
58
58
  useEffect(() => {
59
59
  widgetStoreActions.registerTool(id, {
60
60
  id: STACK_TOGGLE_TOOL_ID,
61
61
  type: 'config',
62
62
  order: 10,
63
- enabled: isStacked && hasMultiSeries,
63
+ enabled: false,
64
64
  fn: (currentConfig: unknown) => {
65
65
  const config = currentConfig as Record<string, unknown>
66
66
  const option = config.option as EchartOptionsProps | undefined
@@ -82,6 +82,15 @@ export function StackToggle({
82
82
  },
83
83
  })
84
84
  return () => widgetStoreActions.unregisterTool(id, STACK_TOGGLE_TOOL_ID)
85
+ }, [id])
86
+
87
+ // Sync enabled from store — lightweight, no re-registration
88
+ useEffect(() => {
89
+ widgetStoreActions.setToolEnabled(
90
+ id,
91
+ STACK_TOGGLE_TOOL_ID,
92
+ isStacked && hasMultiSeries,
93
+ )
85
94
  }, [id, isStacked, hasMultiSeries])
86
95
 
87
96
  // Initialize store with default value only if not already configured