@carto/ps-react-ui 4.5.1 → 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 (94) 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/no-data/no-data.d.ts +3 -2
  16. package/dist/types/widgets/no-data/types.d.ts +5 -1
  17. package/dist/types/widgets/stores/index.d.ts +1 -1
  18. package/dist/types/widgets/stores/types.d.ts +10 -10
  19. package/dist/types/widgets/stores/widget-store.d.ts +2 -3
  20. package/dist/types/widgets/table/index.d.ts +6 -2
  21. package/dist/{use-widget-ref-BFazQvJK.js → use-widget-ref-Ddr_SlJJ.js} +2 -2
  22. package/dist/{use-widget-ref-BFazQvJK.js.map → use-widget-ref-Ddr_SlJJ.js.map} +1 -1
  23. package/dist/{use-widget-selector-DqRmWQ1K.js → use-widget-selector-DFl2hW0R.js} +2 -2
  24. package/dist/{use-widget-selector-DqRmWQ1K.js.map → use-widget-selector-DFl2hW0R.js.map} +1 -1
  25. package/dist/{widget-store-CIrb9RKP.js → widget-store-Bw5zRUGg.js} +93 -95
  26. package/dist/widget-store-Bw5zRUGg.js.map +1 -0
  27. package/dist/widgets/actions.js +770 -755
  28. package/dist/widgets/actions.js.map +1 -1
  29. package/dist/widgets/bar.js +2 -2
  30. package/dist/widgets/category.js +3 -3
  31. package/dist/widgets/category.js.map +1 -1
  32. package/dist/widgets/echart.js +2 -2
  33. package/dist/widgets/error.js +37 -2
  34. package/dist/widgets/error.js.map +1 -1
  35. package/dist/widgets/formula.js +5 -5
  36. package/dist/widgets/histogram.js +1 -1
  37. package/dist/widgets/loader.js +1 -1
  38. package/dist/widgets/markdown.js +2 -2
  39. package/dist/widgets/no-data.js +58 -2
  40. package/dist/widgets/no-data.js.map +1 -1
  41. package/dist/widgets/note.js +121 -2
  42. package/dist/widgets/note.js.map +1 -1
  43. package/dist/widgets/pie.js +2 -2
  44. package/dist/widgets/range.js +3 -3
  45. package/dist/widgets/scatterplot.js +2 -2
  46. package/dist/widgets/skeleton-loader.js +1 -1
  47. package/dist/widgets/spread.js +5 -5
  48. package/dist/widgets/stores.js +2 -2
  49. package/dist/widgets/table.js +3 -3
  50. package/dist/widgets/timeseries.js +2 -2
  51. package/dist/widgets/utils.js +1 -1
  52. package/dist/widgets/wrapper.js +2 -2
  53. package/package.json +1 -5
  54. package/src/hooks/index.ts +0 -1
  55. package/src/widgets/actions/brush-toggle/brush-toggle.tsx +18 -22
  56. package/src/widgets/actions/change-column/change-column.test.tsx +1 -1
  57. package/src/widgets/actions/download/download.test.tsx +1 -1
  58. package/src/widgets/actions/index.ts +11 -2
  59. package/src/widgets/actions/lock-selection/lock-selection.test.tsx +14 -0
  60. package/src/widgets/actions/lock-selection/lock-selection.tsx +18 -11
  61. package/src/widgets/actions/lock-selection/types.ts +2 -0
  62. package/src/widgets/actions/relative-data/relative-data.test.tsx +211 -20
  63. package/src/widgets/actions/relative-data/relative-data.tsx +65 -34
  64. package/src/widgets/actions/relative-data/types.ts +2 -0
  65. package/src/widgets/actions/searcher/searcher.tsx +28 -30
  66. package/src/widgets/actions/stack-toggle/stack-toggle.tsx +11 -2
  67. package/src/widgets/actions/zoom-toggle/zoom-toggle.tsx +53 -45
  68. package/src/widgets/category/category-ui.tsx +4 -6
  69. package/src/widgets/category/index.ts +13 -14
  70. package/src/widgets/no-data/no-data.test.tsx +90 -40
  71. package/src/widgets/no-data/no-data.tsx +7 -5
  72. package/src/widgets/no-data/types.ts +5 -1
  73. package/src/widgets/stores/index.ts +2 -0
  74. package/src/widgets/stores/types.ts +10 -18
  75. package/src/widgets/stores/widget-store.test.ts +132 -13
  76. package/src/widgets/stores/widget-store.ts +29 -35
  77. package/src/widgets/table/index.ts +6 -4
  78. package/dist/error-Cj8eUMrl.js +0 -40
  79. package/dist/error-Cj8eUMrl.js.map +0 -1
  80. package/dist/no-data-DkIt7Qt1.js +0 -61
  81. package/dist/no-data-DkIt7Qt1.js.map +0 -1
  82. package/dist/note-t51drNe0.js +0 -124
  83. package/dist/note-t51drNe0.js.map +0 -1
  84. package/dist/types/hooks/use-debounce.d.ts +0 -19
  85. package/dist/types/widgets/category/components/index.d.ts +0 -10
  86. package/dist/types/widgets/index.d.ts +0 -9
  87. package/dist/types/widgets/table/hooks/index.d.ts +0 -6
  88. package/dist/widget-store-CIrb9RKP.js.map +0 -1
  89. package/dist/widgets.js +0 -13
  90. package/dist/widgets.js.map +0 -1
  91. package/src/hooks/use-debounce.ts +0 -55
  92. package/src/widgets/category/components/index.ts +0 -14
  93. package/src/widgets/index.ts +0 -25
  94. package/src/widgets/table/hooks/index.ts +0 -7
@@ -1,5 +1,5 @@
1
- import { u as o, w as r } from "../widget-store-CIrb9RKP.js";
2
- import { u as i } from "../use-widget-selector-DqRmWQ1K.js";
1
+ import { u as o, w as r } from "../widget-store-Bw5zRUGg.js";
2
+ import { u as i } from "../use-widget-selector-DFl2hW0R.js";
3
3
  export {
4
4
  i as useWidgetSelector,
5
5
  o as useWidgetStore,
@@ -6,10 +6,10 @@ import se from "@mui/icons-material/FirstPage";
6
6
  import ce from "@mui/icons-material/KeyboardArrowLeft";
7
7
  import ae from "@mui/icons-material/KeyboardArrowRight";
8
8
  import fe from "@mui/icons-material/LastPage";
9
+ import { u as de } from "../use-widget-ref-Ddr_SlJJ.js";
9
10
  import "react";
10
- import { u as de } from "../use-widget-ref-BFazQvJK.js";
11
- import { w as x } from "../widget-store-CIrb9RKP.js";
12
- import { u as B } from "../use-widget-selector-DqRmWQ1K.js";
11
+ import { w as x } from "../widget-store-Bw5zRUGg.js";
12
+ import { u as B } from "../use-widget-selector-DFl2hW0R.js";
13
13
  import "zustand/shallow";
14
14
  import "@mui/icons-material";
15
15
  import { d as J, a as F } from "../exports-Cr43OCul.js";
@@ -2,11 +2,11 @@ import { jsxs as m, jsx as r } from "react/jsx-runtime";
2
2
  import { c as y } from "react/compiler-runtime";
3
3
  import "react";
4
4
  import "echarts";
5
- import "../widget-store-CIrb9RKP.js";
5
+ import "../widget-store-Bw5zRUGg.js";
6
6
  import "zustand/shallow";
7
7
  import { g as b } from "../options-D9wflre6.js";
8
8
  import { m as k } from "../utils-BOhInag6.js";
9
- import { c as v, f as w } from "../download-config-DemuQ3Jm.js";
9
+ import { c as v, f as w } from "../download-config-C3I0jWIL.js";
10
10
  import { g as L, b as C, a as I, d as S, e as _, c as A } from "../styles-Y8q7Jff3.js";
11
11
  import { Box as l, Skeleton as s } from "@mui/material";
12
12
  const Y = v(w);
@@ -1,5 +1,5 @@
1
1
  import { d as i, a as n } from "../formatter-B9Bxn1k7.js";
2
- import { c as f, f as p, s as c } from "../download-config-DemuQ3Jm.js";
2
+ import { c as f, f as p, s as c } from "../download-config-C3I0jWIL.js";
3
3
  import { a as m, b as u, c as C, d as b, e as x, f as g, g as F, h as y, n as A } from "../styles-Y8q7Jff3.js";
4
4
  function r({
5
5
  type: a,
@@ -6,8 +6,8 @@ import { useState as H, useLayoutEffect as G } from "react";
6
6
  import "../lasso-tool-BYbxrJ-7.js";
7
7
  import "../cjs-D4KH3azB.js";
8
8
  import { S as U } from "../smart-tooltip-D4vwQpFf.js";
9
- import { u as V } from "../use-widget-selector-DqRmWQ1K.js";
10
- import { w as M } from "../widget-store-CIrb9RKP.js";
9
+ import { u as V } from "../use-widget-selector-DFl2hW0R.js";
10
+ import { w as M } from "../widget-store-Bw5zRUGg.js";
11
11
  const k = {
12
12
  root: {
13
13
  ".Mui-disabled .MuiAccordionSummary-expandIconWrapper": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carto/ps-react-ui",
3
- "version": "4.5.1",
3
+ "version": "4.6.0",
4
4
  "description": "CARTO's Professional Service React Material library",
5
5
  "type": "module",
6
6
  "devDependencies": {
@@ -38,10 +38,6 @@
38
38
  "import": "./dist/components.js",
39
39
  "types": "./dist/types/components/index.d.ts"
40
40
  },
41
- "./widgets": {
42
- "import": "./dist/widgets.js",
43
- "types": "./dist/types/widgets/index.d.ts"
44
- },
45
41
  "./widgets/actions": {
46
42
  "import": "./dist/widgets/actions.js",
47
43
  "types": "./dist/types/widgets/actions/index.d.ts"
@@ -1,2 +1 @@
1
- export { useDebounce } from './use-debounce'
2
1
  export { useWidgetRef } from './use-widget-ref'
@@ -2,7 +2,7 @@ import { Box, IconButton } from '@mui/material'
2
2
  import { HighlightAltOutlined } from '@mui/icons-material'
3
3
  import { useEffect, useCallback, useRef } from 'react'
4
4
  import { widgetStoreActions } from '../../stores/widget-store'
5
- import type { BrushSelectedItems, BrushToggleProps } from './types'
5
+ import type { BrushSelectedItems, BrushToggleProps, BrushState } from './types'
6
6
  import { styles } from './style'
7
7
  import { Tooltip } from '../../../components'
8
8
  import { getEChartBrushConfig } from '../../echart/utils'
@@ -17,6 +17,9 @@ export const BRUSH_TOGGLE_TOOL_ID = 'brush-toggle'
17
17
  * Registers as a config pipeline tool so that brush configuration is automatically
18
18
  * re-applied when the base config is updated (e.g., by WidgetLoader).
19
19
  *
20
+ * Brush state is stored in the widget store root, and the tool derives its
21
+ * `enabled` flag from the store. This keeps state accessible across component instances.
22
+ *
20
23
  * When brush is active, users can drag across bars to select a range.
21
24
  * Selection clearing is handled via the chart's brush interactions/toolbox configuration.
22
25
  * Only intended for use in fullscreen ToolbarActions for bar and histogram widgets.
@@ -38,26 +41,19 @@ export function BrushToggle({
38
41
  }: BrushToggleProps) {
39
42
  const selected = useRef<BrushSelectedItems>({ dataIndex: [], seriesIndex: 0 })
40
43
 
44
+ // Read brush state from widget store root — single source of truth
41
45
  const { brush } = useWidgetSelector(id, (w) => ({
42
- brush: w?.registeredTools?.find((tool) => tool.id === BRUSH_TOGGLE_TOOL_ID)
43
- ?.enabled,
46
+ brush: (w as BrushState | undefined)?.brush ?? false,
44
47
  }))
45
48
 
46
- const toggleTool = useCallback(
47
- (value: boolean) => {
48
- widgetStoreActions.setToolEnabled(id, BRUSH_TOGGLE_TOOL_ID, value)
49
- },
50
- [id],
51
- )
52
-
53
49
  const handleToggle = useCallback(() => {
54
50
  const newBrush = !brush
55
- toggleTool(newBrush)
51
+ widgetStoreActions.setWidget(id, { brush: newBrush })
56
52
 
57
53
  if (newBrush) {
58
54
  onBrushSelected?.({ dataIndex: [], seriesIndex: 0 }) // Clear selection when enabling brush
59
55
  }
60
- }, [brush, onBrushSelected, toggleTool])
56
+ }, [brush, id, onBrushSelected])
61
57
 
62
58
  // Re-dispatch brush action after every ECharts render while brush is active
63
59
  useEffect(() => {
@@ -135,22 +131,17 @@ export function BrushToggle({
135
131
 
136
132
  const handleBrushEnd = useCallback(() => {
137
133
  onBrushSelected?.(selected.current)
138
- toggleTool(false) // Disable brush after selection is made
139
- }, [onBrushSelected, toggleTool])
134
+ widgetStoreActions.setWidget(id, { brush: false }) // Disable brush after selection is made
135
+ }, [onBrushSelected, id])
140
136
 
141
- // Register config tool with all reactive deps store's no-op detection handles performance
137
+ // Register config tool once fn closure depends on event handlers.
138
+ // Enabled is synced separately to avoid full re-registration on toggle.
142
139
  useEffect(() => {
143
- const existingTool = widgetStoreActions
144
- .getWidget(id)
145
- ?.registeredTools?.find((tool) => tool.id === BRUSH_TOGGLE_TOOL_ID)
146
-
147
- const initialEnabled = existingTool?.enabled ?? false
148
-
149
140
  widgetStoreActions.registerTool(id, {
150
141
  id: BRUSH_TOGGLE_TOOL_ID,
151
142
  type: 'config',
152
143
  order: 25,
153
- enabled: initialEnabled,
144
+ enabled: false,
154
145
  fn: (currentConfig) => {
155
146
  const config = currentConfig as Record<string, unknown>
156
147
  const option = config.option as EchartOptionsProps | undefined
@@ -178,6 +169,11 @@ export function BrushToggle({
178
169
  return () => widgetStoreActions.unregisterTool(id, BRUSH_TOGGLE_TOOL_ID)
179
170
  }, [id, handleBrushSelected, handleBrushEnd])
180
171
 
172
+ // Sync enabled from store — lightweight, no re-registration
173
+ useEffect(() => {
174
+ widgetStoreActions.setToolEnabled(id, BRUSH_TOGGLE_TOOL_ID, brush)
175
+ }, [id, brush])
176
+
181
177
  const enableLabel = labels?.enable ?? 'Enable brush selection'
182
178
  const disableLabel = labels?.disable ?? 'Disable brush selection'
183
179
  const tooltipLabel = brush ? disableLabel : enableLabel
@@ -217,7 +217,7 @@ describe('ChangeColumn', () => {
217
217
 
218
218
  // When config columns match widget columns, fn returns the same reference
219
219
  const input = { columns: mockColumns }
220
- const result = tool?.fn(input, tool.config)
220
+ const result = tool?.fn(input)
221
221
  expect(result).toBe(input)
222
222
  })
223
223
 
@@ -1,7 +1,7 @@
1
1
  import { describe, test, expect, beforeEach, vi } from 'vitest'
2
2
  import { render, screen, fireEvent, waitFor } from '@testing-library/react'
3
3
  import { Download } from './download'
4
- import { useWidgetStore } from '../../../widgets'
4
+ import { useWidgetStore } from '../../../widgets/stores'
5
5
  import type { DownloadItem } from './types'
6
6
 
7
7
  describe('Download', () => {
@@ -1,6 +1,10 @@
1
1
  /* Fullscreen Widget */
2
2
  export { FullScreen } from './fullscreen/fullscreen'
3
- export type { FullScreenState, FullScreenConfig } from './fullscreen/types'
3
+ export type {
4
+ FullScreenState,
5
+ FullScreenConfig,
6
+ FullScreenProps,
7
+ } from './fullscreen/types'
4
8
 
5
9
  /* Download Widget */
6
10
  export { Download } from './download/download'
@@ -13,7 +17,10 @@ export {
13
17
  RELATIVE_DATA_TOOL_ID,
14
18
  RELATIVE_DATA_CONFIG_TOOL_ID,
15
19
  } from './relative-data/relative-data'
16
- export type { RelativeDataProps } from './relative-data/types'
20
+ export type {
21
+ RelativeDataProps,
22
+ RelativeDataState,
23
+ } from './relative-data/types'
17
24
 
18
25
  /* Zoom Toggle Widget */
19
26
  export { ZoomToggle, ZOOM_TOGGLE_TOOL_ID } from './zoom-toggle/zoom-toggle'
@@ -35,6 +42,7 @@ export type {
35
42
  SearcherProps,
36
43
  SearcherFilterFn,
37
44
  SearcherState,
45
+ SearcherStateProps,
38
46
  } from './searcher/types'
39
47
 
40
48
  /* Change Column Widget */
@@ -52,6 +60,7 @@ export {
52
60
  export type {
53
61
  LockSelectionProps,
54
62
  LockSelectionState,
63
+ LockSelectionStateProps,
55
64
  } from './lock-selection/types'
56
65
 
57
66
  /* Brush Toggle Widget */
@@ -3,6 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react'
3
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'
6
7
  import { WidgetLoader } from '../../loader/loader'
7
8
 
8
9
  // Test data
@@ -110,6 +111,19 @@ describe('LockSelection', () => {
110
111
  expect(tool?.enabled).toBe(false)
111
112
  })
112
113
 
114
+ test('clears lock state when selectedItems becomes empty while locked', () => {
115
+ // Simulate remount: isLocked persists in store but selectedItems is now empty
116
+ useWidgetStore
117
+ .getState()
118
+ .setWidget(widgetId, { isLocked: true, lockedItems: ['Electronics'] })
119
+
120
+ render(<LockSelection id={widgetId} selectedItems={[]} />)
121
+
122
+ const widget = useWidgetStore.getState().getWidget(widgetId)
123
+ expect((widget as LockSelectionState | undefined)?.isLocked).toBe(false)
124
+ expect((widget as LockSelectionState | undefined)?.lockedItems).toEqual([])
125
+ })
126
+
113
127
  test('has active state when locked', () => {
114
128
  render(<LockSelection id={widgetId} selectedItems={['Electronics']} />)
115
129
 
@@ -1,6 +1,6 @@
1
1
  import { IconButton } from '@mui/material'
2
2
  import { CheckBoxOutlined } from '@mui/icons-material'
3
- import { useCallback, useEffect, useMemo } from 'react'
3
+ import { useCallback, useEffect } from 'react'
4
4
  import type { LockSelectionProps, LockSelectionState } from './types'
5
5
  import { actionButtonStyles } from '../shared/styles'
6
6
  import { Tooltip } from '../../../components'
@@ -39,28 +39,35 @@ export function LockSelection({
39
39
  }))
40
40
 
41
41
  const isLocked = storeIsLocked ?? false
42
- const lockedItems = useMemo(
43
- () => (isLocked ? selectedItems : []),
44
- [isLocked, selectedItems],
45
- )
46
42
 
47
- // Register tool with all reactive deps store's no-op detection handles performance
43
+ // Register tool once fn reads lockedItems from the store at execution time
48
44
  useEffect(() => {
49
45
  widgetStoreActions.registerTool(id, {
50
46
  id: LOCK_SELECTION_TOOL_ID,
51
47
  order,
52
- enabled: isLocked,
53
- fn: (data, config) => {
54
- const items = (config?.lockedItems as string[]) || []
48
+ enabled: false,
49
+ fn: (data) => {
50
+ const widget = widgetStoreActions.getWidget<LockSelectionState>(id)
51
+ const items = widget?.lockedItems ?? []
55
52
  if (items.length === 0) return data
56
53
 
57
54
  return filterDataByLockedItems(data as EchartWidgetData, items)
58
55
  },
59
- config: { lockedItems },
60
56
  })
61
57
 
62
58
  return () => widgetStoreActions.unregisterTool(id, LOCK_SELECTION_TOOL_ID)
63
- }, [id, order, isLocked, lockedItems])
59
+ }, [id, order])
60
+
61
+ // Sync enabled from store — lightweight, no re-registration.
62
+ // When selectedItems is empty (e.g., remount with no active selection),
63
+ // disable the tool and clear stale lock state.
64
+ useEffect(() => {
65
+ if (isLocked && selectedItems.length === 0) {
66
+ widgetStoreActions.setWidget(id, { isLocked: false, lockedItems: [] })
67
+ return
68
+ }
69
+ widgetStoreActions.setToolEnabled(id, LOCK_SELECTION_TOOL_ID, isLocked)
70
+ }, [id, isLocked, selectedItems.length])
64
71
 
65
72
  const handleToggle = useCallback(() => {
66
73
  if (isLocked) {
@@ -38,4 +38,6 @@ export interface LockSelectionProps {
38
38
  export interface LockSelectionStateProps {
39
39
  /** Whether the selection is currently locked */
40
40
  isLocked?: boolean
41
+ /** Items locked for filtering */
42
+ lockedItems?: string[]
41
43
  }
@@ -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 () => {