@carto/ps-react-ui 4.3.5 → 4.3.6

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 (59) hide show
  1. package/dist/components.js +2 -2
  2. package/dist/{error-B2IJ9d2h.js → error-piB8FwYO.js} +2 -2
  3. package/dist/{error-B2IJ9d2h.js.map → error-piB8FwYO.js.map} +1 -1
  4. package/dist/{lasso-tool-wFqOD6wk.js → lasso-tool-BctzdzBu.js} +185 -160
  5. package/dist/lasso-tool-BctzdzBu.js.map +1 -0
  6. package/dist/{no-data-C54XJt13.js → no-data-jdlbMef0.js} +2 -2
  7. package/dist/{no-data-C54XJt13.js.map → no-data-jdlbMef0.js.map} +1 -1
  8. package/dist/{row-DrHwXNvF.js → row-D3uVFImu.js} +2 -2
  9. package/dist/{row-DrHwXNvF.js.map → row-D3uVFImu.js.map} +1 -1
  10. package/dist/{series-D3Pc-kYX.js → series-BAImrSBo.js} +3 -3
  11. package/dist/{series-D3Pc-kYX.js.map → series-BAImrSBo.js.map} +1 -1
  12. package/dist/types/widgets/actions/index.d.ts +2 -2
  13. package/dist/types/widgets/actions/stack-toggle/stack-toggle.d.ts +3 -2
  14. package/dist/types/widgets/actions/zoom-toggle/zoom-toggle.d.ts +4 -0
  15. package/dist/types/widgets/loader/loader.d.ts +1 -1
  16. package/dist/types/widgets/loader/types.d.ts +1 -1
  17. package/dist/types/widgets/stores/index.d.ts +1 -1
  18. package/dist/types/widgets/stores/types.d.ts +15 -0
  19. package/dist/{use-widget-ref-B0aNCANx.js → use-widget-ref-B8x4sHIj.js} +2 -2
  20. package/dist/{use-widget-ref-B0aNCANx.js.map → use-widget-ref-B8x4sHIj.js.map} +1 -1
  21. package/dist/widget-store-Dn0Bnc4h.js +178 -0
  22. package/dist/widget-store-Dn0Bnc4h.js.map +1 -0
  23. package/dist/widgets/actions.js +698 -617
  24. package/dist/widgets/actions.js.map +1 -1
  25. package/dist/widgets/bar.js +2 -2
  26. package/dist/widgets/category.js +2 -2
  27. package/dist/widgets/echart.js +2 -2
  28. package/dist/widgets/error.js +1 -1
  29. package/dist/widgets/formula.js +5 -5
  30. package/dist/widgets/histogram.js +2 -2
  31. package/dist/widgets/loader.js +41 -40
  32. package/dist/widgets/loader.js.map +1 -1
  33. package/dist/widgets/markdown.js +2 -2
  34. package/dist/widgets/no-data.js +1 -1
  35. package/dist/widgets/pie.js +2 -2
  36. package/dist/widgets/range.js +2 -2
  37. package/dist/widgets/scatterplot.js +2 -2
  38. package/dist/widgets/skeleton-loader.js +1 -1
  39. package/dist/widgets/spread.js +5 -5
  40. package/dist/widgets/stores.js +1 -1
  41. package/dist/widgets/table.js +3 -3
  42. package/dist/widgets/timeseries.js +2 -2
  43. package/dist/widgets/wrapper.js +2 -2
  44. package/dist/widgets.js +4 -4
  45. package/package.json +1 -1
  46. package/src/components/lasso-tool/lasso-tool.tsx +5 -2
  47. package/src/widgets/actions/index.ts +2 -2
  48. package/src/widgets/actions/stack-toggle/stack-toggle.test.tsx +143 -9
  49. package/src/widgets/actions/stack-toggle/stack-toggle.tsx +61 -70
  50. package/src/widgets/actions/zoom-toggle/zoom-toggle.tsx +85 -53
  51. package/src/widgets/loader/loader.tsx +18 -8
  52. package/src/widgets/loader/types.ts +1 -1
  53. package/src/widgets/stores/index.ts +1 -0
  54. package/src/widgets/stores/types.ts +20 -0
  55. package/src/widgets/stores/widget-store.test.ts +141 -0
  56. package/src/widgets/stores/widget-store.ts +99 -2
  57. package/dist/lasso-tool-wFqOD6wk.js.map +0 -1
  58. package/dist/widget-store-CB6Trp_0.js +0 -131
  59. package/dist/widget-store-CB6Trp_0.js.map +0 -1
@@ -8,13 +8,16 @@ import { GroupedBarChartIcon } from './grouped-bar-chart-icon'
8
8
  import { getEChartStackConfig } from '../../echart/utils'
9
9
  import { DEFAULT_STACK_GROUP } from '../../echart/const'
10
10
  import type { EchartWidgetState } from '../../echart/types'
11
+ import type { EchartOptionsProps } from '../../echart/types'
11
12
  import { useShallow } from 'zustand/shallow'
12
13
 
14
+ export const STACK_TOGGLE_TOOL_ID = 'stack-toggle'
15
+
13
16
  /**
14
17
  * Widget action to toggle stacking behavior in ECharts bar and histogram widgets.
15
18
  *
16
- * Stores the stack state in the widget store and updates the ECharts option
17
- * using getEChartStackConfig to apply stacking to all series.
19
+ * Registers as a config pipeline tool so that stack configuration is automatically
20
+ * re-applied when the base config is updated (e.g., by WidgetLoader).
18
21
  *
19
22
  * @example
20
23
  * ```tsx
@@ -32,93 +35,81 @@ export function StackToggle({
32
35
  IconButtonProps,
33
36
  }: StackToggleProps) {
34
37
  const setWidget = useWidgetStore((state) => state.setWidget)
38
+ const registerTool = useWidgetStore((state) => state.registerTool)
39
+ const unregisterTool = useWidgetStore((state) => state.unregisterTool)
40
+ const setToolEnabled = useWidgetStore((state) => state.setToolEnabled)
41
+ const updateToolConfig = useWidgetStore((state) => state.updateToolConfig)
42
+
35
43
  const storeIsStacked = useWidgetStore(
36
44
  useShallow((state) => state.getWidget<StackToggleState>(id)?.isStacked),
37
45
  )
38
46
 
39
- const getWidget = useWidgetStore((state) => state.getWidget)
40
-
41
- /**
42
- * Checks if there are multiple series in the chart
43
- */
44
- const hasMultiSeries = useMemo(() => {
45
- const option = getWidget<EchartWidgetState>(id)?.option
46
- if (!option) return false
47
-
48
- const series = Array.isArray(option.series)
49
- ? option.series
50
- : [option.series]
51
-
52
- return series.length > 1
53
- }, [getWidget, id])
54
-
55
- /**
56
- * Checks if any series in the option has a stack property defined
57
- */
58
- const hasStackInSeries = useMemo(() => {
59
- const option = getWidget<EchartWidgetState>(id)?.option
60
- if (!option) return false
47
+ const option = useWidgetStore(
48
+ useShallow((state) => state.getWidget<EchartWidgetState>(id)?.option),
49
+ )
61
50
 
51
+ const { hasMultiSeries, hasStackInSeries } = useMemo(() => {
52
+ if (!option) return { hasMultiSeries: false, hasStackInSeries: false }
62
53
  const series = Array.isArray(option.series)
63
54
  ? option.series
64
55
  : [option.series]
65
-
66
- return series.some((s) => (s as { stack?: string })?.stack)
67
- }, [getWidget, id])
56
+ return {
57
+ hasMultiSeries: series.length > 1,
58
+ hasStackInSeries: series.some((s) => (s as { stack?: string })?.stack),
59
+ }
60
+ }, [option])
68
61
 
69
62
  // If series already has stack defined, default to stacked=true
70
63
  const effectiveDefaultIsStacked = hasStackInSeries || defaultIsStacked
71
64
  const isStacked = storeIsStacked ?? effectiveDefaultIsStacked
72
65
 
73
- /**
74
- * Updates the ECharts option with the stack configuration
75
- * Preserves existing stack group names from series, falling back to DEFAULT_STACK_GROUP
76
- */
77
- const updateOptions = useCallback(
78
- (stacked: boolean) => {
79
- const option = getWidget<EchartWidgetState>(id)?.option
80
-
81
- if (!option) return
82
-
83
- const series = Array.isArray(option.series)
84
- ? option.series
85
- : [option.series]
86
-
87
- const updatedSeries = series.map((s) => {
88
- // Extract existing stack group from series, fallback to default
89
- const existingStack = (s as { stack?: string })?.stack
90
- const stackGroup =
91
- typeof existingStack === 'string'
92
- ? existingStack
93
- : DEFAULT_STACK_GROUP
94
-
95
- return {
96
- ...s,
97
- ...getEChartStackConfig(stacked, stackGroup),
98
- }
99
- })
100
-
101
- setWidget(id, {
102
- option: {
103
- ...option,
104
- series: updatedSeries,
105
- },
106
- })
107
- },
108
- [getWidget, id, setWidget],
109
- )
66
+ // Register config tool on mount
67
+ useEffect(() => {
68
+ registerTool(id, {
69
+ id: STACK_TOGGLE_TOOL_ID,
70
+ type: 'config',
71
+ order: 10,
72
+ enabled: isStacked && hasMultiSeries,
73
+ fn: (currentConfig, toolConfig) => {
74
+ const config = currentConfig as Record<string, unknown>
75
+ const option = config.option as EchartOptionsProps | undefined
76
+ if (!option) return currentConfig
77
+
78
+ const stacked = (toolConfig?.stacked as boolean) ?? false
79
+ const series = Array.isArray(option.series)
80
+ ? option.series
81
+ : [option.series]
82
+ const updatedSeries = series.map((s) => {
83
+ const existingStack = (s as { stack?: string })?.stack
84
+ const stackGroup =
85
+ typeof existingStack === 'string'
86
+ ? existingStack
87
+ : DEFAULT_STACK_GROUP
88
+ return { ...s, ...getEChartStackConfig(stacked, stackGroup) }
89
+ })
90
+
91
+ return { ...config, option: { ...option, series: updatedSeries } }
92
+ },
93
+ config: { stacked: isStacked },
94
+ })
95
+ return () => unregisterTool(id, STACK_TOGGLE_TOOL_ID)
96
+ }, [id, registerTool, unregisterTool, isStacked, hasMultiSeries])
97
+
98
+ // Sync tool enabled/config when state changes
99
+ useEffect(() => {
100
+ setToolEnabled(id, STACK_TOGGLE_TOOL_ID, isStacked && hasMultiSeries)
101
+ updateToolConfig(id, STACK_TOGGLE_TOOL_ID, { stacked: isStacked })
102
+ }, [id, isStacked, hasMultiSeries, setToolEnabled, updateToolConfig])
110
103
 
111
104
  // Initialize store with default value only if not already configured
112
105
  useEffect(() => {
106
+ if (storeIsStacked !== undefined) return
113
107
  setWidget(id, { isStacked: effectiveDefaultIsStacked })
114
- }, [effectiveDefaultIsStacked, id, setWidget])
108
+ }, [effectiveDefaultIsStacked, id, setWidget, storeIsStacked])
115
109
 
116
110
  const handleToggle = useCallback(() => {
117
- const newIsStacked = !isStacked
118
-
119
- setWidget(id, { isStacked: newIsStacked })
120
- updateOptions(newIsStacked)
121
- }, [isStacked, id, setWidget, updateOptions])
111
+ setWidget(id, { isStacked: !isStacked })
112
+ }, [isStacked, id, setWidget])
122
113
 
123
114
  const tooltipLabel = isStacked
124
115
  ? (labels?.unstacked ?? 'Disable stacking')
@@ -10,11 +10,17 @@ import { styles } from './style'
10
10
  import { Tooltip } from '../../../components'
11
11
  import { getEChartZoomConfig } from '../../echart/utils'
12
12
  import type { EchartWidgetState } from '../../echart/types'
13
+ import type { EchartOptionsProps } from '../../echart/types'
13
14
  import { useShallow } from 'zustand/shallow'
14
15
 
16
+ export const ZOOM_TOGGLE_TOOL_ID = 'zoom-toggle'
17
+
15
18
  /**
16
19
  * Widget action to toggle EChart zoom functionality.
17
20
  *
21
+ * Registers as a config pipeline tool so that zoom configuration is automatically
22
+ * re-applied when the base config is updated (e.g., by WidgetLoader).
23
+ *
18
24
  * When zoom is active, displays an inline reset button to disable zoom.
19
25
  * Only intended for use in fullscreen ToolbarActions for bar and histogram widgets.
20
26
  *
@@ -41,6 +47,10 @@ export function ZoomToggle({
41
47
  const theme = useTheme()
42
48
  const setWidget = useWidgetStore((state) => state.setWidget)
43
49
  const getWidget = useWidgetStore((state) => state.getWidget)
50
+ const registerTool = useWidgetStore((state) => state.registerTool)
51
+ const unregisterTool = useWidgetStore((state) => state.unregisterTool)
52
+ const setToolEnabled = useWidgetStore((state) => state.setToolEnabled)
53
+ const updateToolConfig = useWidgetStore((state) => state.updateToolConfig)
44
54
 
45
55
  const zoom = useWidgetStore(
46
56
  useShallow((state) => {
@@ -49,76 +59,94 @@ export function ZoomToggle({
49
59
  }),
50
60
  )
51
61
 
52
- const updateZoomOption = useCallback(
53
- (enabled: boolean, start: number, end: number) => {
54
- const zoomConfig = getEChartZoomConfig(
55
- enabled,
56
- { start, end },
57
- { inside: true, xSlider: true, ySlider: false },
58
- theme,
59
- )
60
- const currentOption = getWidget<EchartWidgetState>(id)?.option
61
-
62
- setWidget<EchartWidgetState & ZoomState>(id, {
63
- zoom: enabled,
64
- zoomStart: start,
65
- zoomEnd: end,
66
- option: {
67
- ...currentOption,
68
- ...zoomConfig,
69
- },
70
- })
71
- },
72
- [getWidget, id, setWidget, theme],
62
+ const zoomStart = useWidgetStore(
63
+ useShallow((state) => {
64
+ const widget = state.getWidget<ZoomState>(id)
65
+ return widget?.zoomStart ?? defaultZoomStart
66
+ }),
67
+ )
68
+
69
+ const zoomEnd = useWidgetStore(
70
+ useShallow((state) => {
71
+ const widget = state.getWidget<ZoomState>(id)
72
+ return widget?.zoomEnd ?? defaultZoomEnd
73
+ }),
73
74
  )
74
75
 
76
+ // Register config tool on mount
77
+ useEffect(() => {
78
+ registerTool(id, {
79
+ id: ZOOM_TOGGLE_TOOL_ID,
80
+ type: 'config',
81
+ order: 20,
82
+ enabled: zoom,
83
+ fn: (currentConfig, toolConfig) => {
84
+ const config = currentConfig as Record<string, unknown>
85
+ const option = config.option as EchartOptionsProps | undefined
86
+ const enabled = (toolConfig?.enabled as boolean) ?? false
87
+ const start = (toolConfig?.start as number) ?? 0
88
+ const end = (toolConfig?.end as number) ?? 100
89
+
90
+ const zoomConfig = getEChartZoomConfig(
91
+ enabled,
92
+ { start, end },
93
+ { inside: true, xSlider: true, ySlider: false },
94
+ theme,
95
+ )
96
+ return { ...config, option: { ...option, ...zoomConfig } }
97
+ },
98
+ config: { enabled: zoom, start: zoomStart, end: zoomEnd },
99
+ })
100
+ return () => unregisterTool(id, ZOOM_TOGGLE_TOOL_ID)
101
+ }, [id, registerTool, unregisterTool, zoom, theme, zoomStart, zoomEnd])
102
+
103
+ // Sync tool enabled/config when state changes
104
+ useEffect(() => {
105
+ setToolEnabled(id, ZOOM_TOGGLE_TOOL_ID, zoom)
106
+ updateToolConfig(id, ZOOM_TOGGLE_TOOL_ID, {
107
+ enabled: zoom,
108
+ start: zoomStart,
109
+ end: zoomEnd,
110
+ })
111
+ }, [id, zoom, zoomStart, zoomEnd, setToolEnabled, updateToolConfig])
112
+
75
113
  // Initialize zoom state from defaults only if not already set
76
114
  useEffect(() => {
77
115
  const existingState = getWidget<ZoomState>(id)
78
116
 
79
117
  if (existingState?.zoom) return
80
118
 
81
- // Apply initial zoom config
82
- updateZoomOption(
83
- existingState?.zoom ?? defaultZoom,
84
- existingState?.zoomStart ?? defaultZoomStart,
85
- existingState?.zoomEnd ?? defaultZoomEnd,
86
- )
87
- }, [
88
- defaultZoom,
89
- defaultZoomEnd,
90
- defaultZoomStart,
91
- getWidget,
92
- id,
93
- updateZoomOption,
94
- ])
95
-
96
- // Cleanup: reset zoom when component unmounts
119
+ setWidget<ZoomState>(id, {
120
+ zoom: existingState?.zoom ?? defaultZoom,
121
+ zoomStart: existingState?.zoomStart ?? defaultZoomStart,
122
+ zoomEnd: existingState?.zoomEnd ?? defaultZoomEnd,
123
+ })
124
+ }, [defaultZoom, defaultZoomEnd, defaultZoomStart, getWidget, id, setWidget])
125
+
126
+ // Cleanup: disable zoom when component unmounts
97
127
  useEffect(() => {
98
128
  return () => {
99
- const existingState = getWidget<ZoomState>(id)
100
- updateZoomOption(
101
- false,
102
- existingState?.zoomStart ?? 0,
103
- existingState?.zoomEnd ?? 100,
104
- )
129
+ setWidget<ZoomState>(id, { zoom: false })
105
130
  }
106
- }, [getWidget, id, updateZoomOption])
131
+ }, [id, setWidget])
107
132
 
108
133
  const handleToggle = () => {
109
134
  const newZoom = !zoom
110
-
111
135
  const existingState = getWidget<ZoomState>(id)
112
136
 
113
- updateZoomOption(
114
- newZoom,
115
- newZoom ? (existingState?.zoomStart ?? defaultZoomStart) : 0,
116
- newZoom ? (existingState?.zoomEnd ?? defaultZoomEnd) : 100,
117
- )
137
+ setWidget<ZoomState>(id, {
138
+ zoom: newZoom,
139
+ zoomStart: newZoom ? (existingState?.zoomStart ?? defaultZoomStart) : 0,
140
+ zoomEnd: newZoom ? (existingState?.zoomEnd ?? defaultZoomEnd) : 100,
141
+ })
118
142
  }
119
143
 
120
144
  const handleReset = () => {
121
- updateZoomOption(true, defaultZoomStart, defaultZoomEnd)
145
+ setWidget<ZoomState>(id, {
146
+ zoom: true,
147
+ zoomStart: defaultZoomStart,
148
+ zoomEnd: defaultZoomEnd,
149
+ })
122
150
  }
123
151
 
124
152
  // Handle dataZoom event to update zoom range in store
@@ -133,10 +161,14 @@ export function ZoomToggle({
133
161
  const end = zoomEvent.end
134
162
 
135
163
  if (start !== undefined && end !== undefined) {
136
- updateZoomOption(true, start, end)
164
+ setWidget<ZoomState>(id, {
165
+ zoom: true,
166
+ zoomStart: start,
167
+ zoomEnd: end,
168
+ })
137
169
  }
138
170
  },
139
- [updateZoomOption],
171
+ [id, setWidget],
140
172
  )
141
173
 
142
174
  // Register dataZoom event handler when zoom is enabled
@@ -3,11 +3,14 @@ import type { WidgetLoaderProps } from './types'
3
3
  import { useWidgetStore } from '../stores/widget-store'
4
4
  import type { WrapperState } from '../wrapper'
5
5
 
6
- export function WidgetLoader<T>(props: WidgetLoaderProps<T>) {
6
+ export function WidgetLoader<T extends Record<string, unknown> = Record<string, unknown>>(props: WidgetLoaderProps<T>) {
7
7
  const setWidget = useWidgetStore((state) => state.setWidget)
8
8
  const executeToolPipeline = useWidgetStore(
9
9
  (state) => state.executeToolPipeline,
10
10
  )
11
+ const executeConfigPipeline = useWidgetStore(
12
+ (state) => state.executeConfigPipeline,
13
+ )
11
14
 
12
15
  // Split into 3 effects for metadata and 1 for data pipeline:
13
16
  // Each property that can be modified independently gets its own effect to avoid
@@ -35,21 +38,19 @@ export function WidgetLoader<T>(props: WidgetLoaderProps<T>) {
35
38
  })
36
39
  }, [props.id, props.isLoading, props.isFetching, props.error, setWidget])
37
40
 
38
- // Effect 3: Config updates
41
+ // Effect 3: Config updates — run through config pipeline
39
42
  useEffect(() => {
40
43
  if (props.config) {
41
- setWidget<WrapperState>(props.id, {
42
- ...props.config,
43
- })
44
+ void executeConfigPipeline(props.id, props.config)
44
45
  }
45
- }, [props.id, props.config, setWidget])
46
+ }, [props.id, props.config, executeConfigPipeline])
46
47
 
47
48
  // Effect 4: Execute tool pipeline when props.data changes
48
49
  useEffect(() => {
49
50
  void executeToolPipeline(props.id, props.data)
50
51
  }, [props.id, props.data, executeToolPipeline])
51
52
 
52
- // Effect 5: Re-execute pipeline when tool state changes (enabled/config)
53
+ // Effect 5: Re-execute pipelines when tool state changes (enabled/config)
53
54
  useEffect(() => {
54
55
  let prevTools = useWidgetStore.getState().widgets[props.id]?.registeredTools
55
56
 
@@ -60,11 +61,20 @@ export function WidgetLoader<T>(props: WidgetLoaderProps<T>) {
60
61
  if (currentTools !== prevTools) {
61
62
  prevTools = currentTools
62
63
  void executeToolPipeline(props.id, props.data)
64
+ if (props.config) {
65
+ void executeConfigPipeline(props.id, props.config)
66
+ }
63
67
  }
64
68
  })
65
69
 
66
70
  return unsubscribe
67
- }, [props.id, props.data, executeToolPipeline])
71
+ }, [
72
+ props.id,
73
+ props.data,
74
+ props.config,
75
+ executeToolPipeline,
76
+ executeConfigPipeline,
77
+ ])
68
78
 
69
79
  return props.children
70
80
  }
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import type { WidgetsStoreProps, WidgetState } from '../stores/types'
3
3
 
4
- export interface WidgetLoaderProps<T> extends WidgetsStoreProps {
4
+ export interface WidgetLoaderProps<T extends Record<string, unknown> = Record<string, unknown>> extends WidgetsStoreProps {
5
5
  children: ReactNode
6
6
  config?: T
7
7
  }
@@ -1,6 +1,7 @@
1
1
  export { useWidgetStore } from './widget-store'
2
2
  export type {
3
3
  BaseWidgetState,
4
+ ToolType,
4
5
  WidgetsStoreProps,
5
6
  WidgetState,
6
7
  WidgetStore,
@@ -1,5 +1,12 @@
1
1
  import type { RefObject } from 'react'
2
2
 
3
+ /**
4
+ * Tool type determines which pipeline a tool participates in.
5
+ * - 'data': transforms widget data (default)
6
+ * - 'config': transforms widget config/option
7
+ */
8
+ export type ToolType = 'data' | 'config'
9
+
3
10
  export interface WidgetsStoreProps {
4
11
  /** Unique identifier for the widget */
5
12
  id: string
@@ -77,6 +84,8 @@ export interface ToolRegistration {
77
84
  fn: ToolTransformFunction
78
85
  /** Whether tool is currently enabled */
79
86
  enabled: boolean
87
+ /** 'data' (default) transforms data, 'config' transforms widget config/option */
88
+ type?: ToolType
80
89
  /** Tool-specific configuration */
81
90
  config?: Record<string, unknown>
82
91
  /**
@@ -184,6 +193,17 @@ export interface WidgetStoreActions {
184
193
  * @param sourceData - Original data to transform
185
194
  */
186
195
  executeToolPipeline: (widgetId: string, sourceData: unknown) => Promise<void>
196
+
197
+ /**
198
+ * Execute the config transformation pipeline
199
+ * Applies config-type tools to the base config, then sets the result on the widget
200
+ * @param widgetId - Widget ID
201
+ * @param baseConfig - Base config to transform
202
+ */
203
+ executeConfigPipeline: (
204
+ widgetId: string,
205
+ baseConfig: Record<string, unknown>,
206
+ ) => Promise<void>
187
207
  }
188
208
 
189
209
  /**
@@ -465,6 +465,147 @@ describe('WidgetStore', () => {
465
465
  })
466
466
  })
467
467
 
468
+ describe('Config Tool Pipeline', () => {
469
+ const widgetId = 'test-widget-config'
470
+
471
+ beforeEach(() => {
472
+ useWidgetStore.getState().clearWidgets()
473
+ })
474
+
475
+ it('executes config tools and sets transformed config', async () => {
476
+ useWidgetStore.getState().setWidget(widgetId, {
477
+ type: 'bar',
478
+ isLoading: false,
479
+ })
480
+
481
+ const configTool: import('./types').ToolRegistration = {
482
+ id: 'stack-tool',
483
+ type: 'config',
484
+ order: 10,
485
+ enabled: true,
486
+ fn: (config) => {
487
+ const c = config as Record<string, unknown>
488
+ const option = c.option as { series?: { name: string }[] }
489
+ const series = option?.series ?? []
490
+ return {
491
+ ...c,
492
+ option: {
493
+ ...option,
494
+ series: series.map((s) => ({ ...s, stack: 'group' })),
495
+ },
496
+ }
497
+ },
498
+ }
499
+
500
+ useWidgetStore.getState().registerTool(widgetId, configTool)
501
+
502
+ await useWidgetStore.getState().executeConfigPipeline(widgetId, {
503
+ option: {
504
+ series: [{ name: 'Series 1' }, { name: 'Series 2' }],
505
+ },
506
+ })
507
+
508
+ const widget = useWidgetStore.getState().getWidget(widgetId)
509
+ const option = (widget as { option?: { series?: { stack?: string }[] } })
510
+ ?.option
511
+ expect(option?.series?.[0]?.stack).toBe('group')
512
+ expect(option?.series?.[1]?.stack).toBe('group')
513
+ })
514
+
515
+ it('passes base config through when no config tools registered', async () => {
516
+ useWidgetStore.getState().setWidget(widgetId, {
517
+ type: 'bar',
518
+ isLoading: false,
519
+ })
520
+
521
+ await useWidgetStore.getState().executeConfigPipeline(widgetId, {
522
+ option: { series: [{ name: 'Series 1' }] },
523
+ })
524
+
525
+ const widget = useWidgetStore.getState().getWidget(widgetId)
526
+ const option = (widget as { option?: { series?: { name: string }[] } })
527
+ ?.option
528
+ expect(option?.series?.[0]?.name).toBe('Series 1')
529
+ })
530
+
531
+ it('does not include config tools in data pipeline', async () => {
532
+ const executionOrder: string[] = []
533
+
534
+ useWidgetStore.getState().setWidget(widgetId, {
535
+ type: 'bar',
536
+ isLoading: false,
537
+ })
538
+
539
+ const dataTool: import('./types').ToolRegistration = {
540
+ id: 'data-tool',
541
+ type: 'data',
542
+ order: 10,
543
+ enabled: true,
544
+ fn: (data) => {
545
+ executionOrder.push('data-tool')
546
+ return data
547
+ },
548
+ }
549
+
550
+ const configTool: import('./types').ToolRegistration = {
551
+ id: 'config-tool',
552
+ type: 'config',
553
+ order: 10,
554
+ enabled: true,
555
+ fn: (config) => {
556
+ executionOrder.push('config-tool')
557
+ return config
558
+ },
559
+ }
560
+
561
+ useWidgetStore.getState().registerTool(widgetId, dataTool)
562
+ useWidgetStore.getState().registerTool(widgetId, configTool)
563
+
564
+ await useWidgetStore.getState().executeToolPipeline(widgetId, {})
565
+
566
+ expect(executionOrder).toEqual(['data-tool'])
567
+ expect(executionOrder).not.toContain('config-tool')
568
+ })
569
+
570
+ it('respects disables across tool types', async () => {
571
+ const executionOrder: string[] = []
572
+
573
+ useWidgetStore.getState().setWidget(widgetId, {
574
+ type: 'bar',
575
+ isLoading: false,
576
+ })
577
+
578
+ const configTool: import('./types').ToolRegistration = {
579
+ id: 'config-tool',
580
+ type: 'config',
581
+ order: 10,
582
+ enabled: true,
583
+ fn: (config) => {
584
+ executionOrder.push('config-tool')
585
+ return config
586
+ },
587
+ }
588
+
589
+ const disablerTool: import('./types').ToolRegistration = {
590
+ id: 'disabler',
591
+ type: 'data',
592
+ order: 10,
593
+ enabled: true,
594
+ fn: (data) => data,
595
+ disables: ['config-tool'],
596
+ }
597
+
598
+ useWidgetStore.getState().registerTool(widgetId, configTool)
599
+ useWidgetStore.getState().registerTool(widgetId, disablerTool)
600
+
601
+ await useWidgetStore.getState().executeConfigPipeline(widgetId, {
602
+ option: {},
603
+ })
604
+
605
+ expect(executionOrder).not.toContain('config-tool')
606
+ })
607
+ })
608
+
468
609
  describe('Tool Dependency Management', () => {
469
610
  const widgetId = 'test-widget-deps'
470
611