@carto/ps-react-ui 4.4.3 → 4.5.1

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 (116) hide show
  1. package/dist/{download-config-Dqu78h2a.js → download-config-DemuQ3Jm.js} +9 -10
  2. package/dist/{download-config-Dqu78h2a.js.map → download-config-DemuQ3Jm.js.map} +1 -1
  3. package/dist/error-Cj8eUMrl.js +40 -0
  4. package/dist/error-Cj8eUMrl.js.map +1 -0
  5. package/dist/no-data-DkIt7Qt1.js +61 -0
  6. package/dist/no-data-DkIt7Qt1.js.map +1 -0
  7. package/dist/row-D4VOhcNI.js +34 -0
  8. package/dist/row-D4VOhcNI.js.map +1 -0
  9. package/dist/series-Bola3CmD.js +90 -0
  10. package/dist/series-Bola3CmD.js.map +1 -0
  11. package/dist/types/widgets/category/style.d.ts +1 -0
  12. package/dist/types/widgets/echart/shared-resize-observer.d.ts +12 -0
  13. package/dist/types/widgets/stores/index.d.ts +2 -1
  14. package/dist/types/widgets/stores/use-widget-selector.d.ts +35 -0
  15. package/dist/types/widgets/stores/widget-store-performance.test.d.ts +1 -0
  16. package/dist/types/widgets/stores/widget-store.d.ts +49 -27
  17. package/dist/types/widgets/table/types.d.ts +1 -1
  18. package/dist/use-widget-ref-BFazQvJK.js +22 -0
  19. package/dist/use-widget-ref-BFazQvJK.js.map +1 -0
  20. package/dist/use-widget-selector-DqRmWQ1K.js +12 -0
  21. package/dist/use-widget-selector-DqRmWQ1K.js.map +1 -0
  22. package/dist/widget-store-CIrb9RKP.js +263 -0
  23. package/dist/widget-store-CIrb9RKP.js.map +1 -0
  24. package/dist/widgets/actions.js +783 -817
  25. package/dist/widgets/actions.js.map +1 -1
  26. package/dist/widgets/bar.js +2 -2
  27. package/dist/widgets/category.js +259 -258
  28. package/dist/widgets/category.js.map +1 -1
  29. package/dist/widgets/echart.js +109 -99
  30. package/dist/widgets/echart.js.map +1 -1
  31. package/dist/widgets/error.js +1 -1
  32. package/dist/widgets/formula.js +71 -63
  33. package/dist/widgets/formula.js.map +1 -1
  34. package/dist/widgets/histogram.js +7 -8
  35. package/dist/widgets/histogram.js.map +1 -1
  36. package/dist/widgets/loader.js +53 -60
  37. package/dist/widgets/loader.js.map +1 -1
  38. package/dist/widgets/markdown.js +51 -50
  39. package/dist/widgets/markdown.js.map +1 -1
  40. package/dist/widgets/no-data.js +1 -1
  41. package/dist/widgets/pie.js +2 -2
  42. package/dist/widgets/range.js +146 -144
  43. package/dist/widgets/range.js.map +1 -1
  44. package/dist/widgets/scatterplot.js +2 -2
  45. package/dist/widgets/skeleton-loader.js +18 -17
  46. package/dist/widgets/skeleton-loader.js.map +1 -1
  47. package/dist/widgets/spread.js +110 -94
  48. package/dist/widgets/spread.js.map +1 -1
  49. package/dist/widgets/stores.js +5 -2
  50. package/dist/widgets/stores.js.map +1 -1
  51. package/dist/widgets/subheader.js +29 -29
  52. package/dist/widgets/subheader.js.map +1 -1
  53. package/dist/widgets/table.js +422 -436
  54. package/dist/widgets/table.js.map +1 -1
  55. package/dist/widgets/timeseries.js +2 -2
  56. package/dist/widgets/utils.js +1 -1
  57. package/dist/widgets/wrapper.js +156 -158
  58. package/dist/widgets/wrapper.js.map +1 -1
  59. package/dist/widgets.js +4 -4
  60. package/package.json +1 -1
  61. package/src/hooks/use-widget-ref.ts +3 -4
  62. package/src/widgets/actions/brush-toggle/brush-toggle.tsx +18 -32
  63. package/src/widgets/actions/change-column/change-column.tsx +15 -15
  64. package/src/widgets/actions/change-column/sortable-column-item.tsx +3 -1
  65. package/src/widgets/actions/download/download.tsx +4 -3
  66. package/src/widgets/actions/fullscreen/fullscreen.tsx +7 -11
  67. package/src/widgets/actions/lock-selection/lock-selection.tsx +12 -15
  68. package/src/widgets/actions/relative-data/relative-data.tsx +22 -26
  69. package/src/widgets/actions/searcher/searcher-toggle.tsx +11 -12
  70. package/src/widgets/actions/searcher/searcher.tsx +20 -21
  71. package/src/widgets/actions/stack-toggle/stack-toggle.tsx +15 -21
  72. package/src/widgets/actions/zoom-toggle/zoom-toggle.tsx +27 -43
  73. package/src/widgets/category/category-ui.tsx +30 -31
  74. package/src/widgets/category/style.ts +1 -0
  75. package/src/widgets/echart/echart-ui.test.tsx +20 -16
  76. package/src/widgets/echart/echart-ui.tsx +6 -12
  77. package/src/widgets/echart/echart.tsx +13 -27
  78. package/src/widgets/echart/shared-resize-observer.ts +45 -0
  79. package/src/widgets/error/error.tsx +7 -9
  80. package/src/widgets/formula/components/prefix.tsx +4 -6
  81. package/src/widgets/formula/components/row.tsx +4 -4
  82. package/src/widgets/formula/components/series.tsx +4 -6
  83. package/src/widgets/formula/components/suffix.tsx +4 -6
  84. package/src/widgets/formula/components/value.tsx +9 -16
  85. package/src/widgets/loader/loader.tsx +31 -44
  86. package/src/widgets/markdown/markdown.tsx +4 -7
  87. package/src/widgets/no-data/no-data.tsx +7 -10
  88. package/src/widgets/range/components/range-item.tsx +20 -18
  89. package/src/widgets/skeleton-loader/skeleton-loader.tsx +2 -5
  90. package/src/widgets/spread/components/max-value.tsx +14 -16
  91. package/src/widgets/spread/components/min-value.tsx +14 -16
  92. package/src/widgets/stores/index.ts +2 -1
  93. package/src/widgets/stores/use-widget-selector.ts +47 -0
  94. package/src/widgets/stores/widget-store-performance.test.ts +750 -0
  95. package/src/widgets/stores/widget-store.test.ts +81 -0
  96. package/src/widgets/stores/widget-store.ts +225 -44
  97. package/src/widgets/subheader/subheader.tsx +11 -3
  98. package/src/widgets/table/config.ts +0 -1
  99. package/src/widgets/table/hooks/use-pagination.ts +28 -52
  100. package/src/widgets/table/hooks/use-selection.ts +20 -24
  101. package/src/widgets/table/hooks/use-sort.ts +22 -39
  102. package/src/widgets/table/types.ts +1 -1
  103. package/src/widgets/wrapper/wrapper-ui.tsx +12 -13
  104. package/src/widgets/wrapper/wrapper.tsx +4 -6
  105. package/dist/error-CEkRPccv.js +0 -39
  106. package/dist/error-CEkRPccv.js.map +0 -1
  107. package/dist/no-data-hR3KcJ-_.js +0 -60
  108. package/dist/no-data-hR3KcJ-_.js.map +0 -1
  109. package/dist/row-DTCV0Ocm.js +0 -35
  110. package/dist/row-DTCV0Ocm.js.map +0 -1
  111. package/dist/series-CYNOu2Ju.js +0 -91
  112. package/dist/series-CYNOu2Ju.js.map +0 -1
  113. package/dist/use-widget-ref-wtFLDFCD.js +0 -25
  114. package/dist/use-widget-ref-wtFLDFCD.js.map +0 -1
  115. package/dist/widget-store-CzDt8oSK.js +0 -163
  116. package/dist/widget-store-CzDt8oSK.js.map +0 -1
@@ -604,6 +604,87 @@ describe('WidgetStore', () => {
604
604
 
605
605
  expect(executionOrder).not.toContain('config-tool')
606
606
  })
607
+
608
+ it('should not overwrite user-modified state when config lacks that property', async () => {
609
+ useWidgetStore.getState().setWidget(widgetId, {
610
+ type: 'table',
611
+ isLoading: false,
612
+ })
613
+
614
+ // Apply initial config (without selected — clean config pattern)
615
+ await useWidgetStore.getState().executeConfigPipeline(widgetId, {
616
+ columns: [],
617
+ selectable: true,
618
+ mode: 'local',
619
+ })
620
+
621
+ // Simulate user selecting rows via UI action
622
+ useWidgetStore.getState().setWidget(widgetId, {
623
+ selected: [1, 2, 3],
624
+ })
625
+
626
+ // Re-run config pipeline (e.g., triggered by tool registration or data change)
627
+ await useWidgetStore.getState().executeConfigPipeline(widgetId, {
628
+ columns: [],
629
+ selectable: true,
630
+ mode: 'local',
631
+ })
632
+
633
+ // Selection should be preserved — config doesn't include 'selected'
634
+ const widget = useWidgetStore.getState().getWidget(widgetId)
635
+ expect((widget as { selected?: (string | number)[] }).selected).toEqual([
636
+ 1, 2, 3,
637
+ ])
638
+ })
639
+
640
+ it('should not overwrite user-modified state when a tool passes base config through unchanged', async () => {
641
+ const baseConfig = {
642
+ option: { series: [{ name: 'A' }] },
643
+ selected: [] as number[],
644
+ }
645
+
646
+ useWidgetStore.getState().setWidget(widgetId, {
647
+ type: 'bar',
648
+ isLoading: false,
649
+ })
650
+
651
+ // Register a config tool that only changes `option` but spreads
652
+ // the entire base config (the typical { ...config, option: modified } pattern)
653
+ useWidgetStore.getState().registerTool(widgetId, {
654
+ id: 'stack-tool',
655
+ type: 'config',
656
+ order: 10,
657
+ enabled: true,
658
+ fn: (config: unknown) => {
659
+ const c = config as typeof baseConfig
660
+ return { ...c, option: { series: [{ name: 'A', stack: 'group' }] } }
661
+ },
662
+ })
663
+
664
+ // Initial pipeline sets option and selected
665
+ await useWidgetStore
666
+ .getState()
667
+ .executeConfigPipeline(widgetId, baseConfig)
668
+
669
+ // Simulate user modifying selected via UI
670
+ useWidgetStore.getState().setWidget(widgetId, { selected: [1, 2] })
671
+
672
+ // Re-run pipeline — tool still only changes `option`,
673
+ // but spreads { ...config, option: modified } which includes selected: []
674
+ await useWidgetStore
675
+ .getState()
676
+ .executeConfigPipeline(widgetId, baseConfig)
677
+
678
+ const widget = useWidgetStore.getState().getWidget(widgetId) as {
679
+ selected?: number[]
680
+ option?: { series?: { stack?: string }[] }
681
+ }
682
+
683
+ // Tool's change should be applied
684
+ expect(widget.option?.series?.[0]?.stack).toBe('group')
685
+ // User's selection should be preserved — the tool didn't change `selected`
686
+ expect(widget.selected).toEqual([1, 2])
687
+ })
607
688
  })
608
689
 
609
690
  describe('Tool Dependency Management', () => {
@@ -1,5 +1,10 @@
1
1
  import { create } from 'zustand'
2
- import type { WidgetState, WidgetStore, ToolRegistration } from './types'
2
+ import type {
3
+ WidgetState,
4
+ WidgetStore,
5
+ ToolRegistration,
6
+ WidgetStoreActions,
7
+ } from './types'
3
8
 
4
9
  // Track active pipeline executions for cancellation
5
10
  const activePipelines = new Map<string, number>()
@@ -8,38 +13,40 @@ const activeConfigPipelines = new Map<string, number>()
8
13
  /**
9
14
  * Zustand store for managing widget state across the application.
10
15
  *
11
- * This store provides centralized state management for all widget UI components
16
+ * Provides centralized state management for all widget UI components, including
17
+ * data/config transformation pipelines via registered tools.
12
18
  *
13
- * @example
14
- * ```tsx
15
- * // Import the store
16
- * import { useWidgetStore } from '@carto/ps-react-ui/widgets'
19
+ * **Performance optimizations:**
20
+ * - `registerTool` skips the store update when structural properties (order, enabled,
21
+ * type, disables) haven't changed — only `fn` and `config` are updated via direct
22
+ * mutation, avoiding a new `registeredTools` array reference and WidgetLoader
23
+ * pipeline cascades.
24
+ * - `setToolEnabled` skips the store update when the enabled state is already the
25
+ * requested value.
26
+ * - `executeToolPipeline` / `executeConfigPipeline` skip the final `set()` when
27
+ * the transformed data/config is referentially identical to what's already in the store.
28
+ * - Both pipelines support cancellation — newer executions for the same widget
29
+ * automatically cancel in-progress ones.
17
30
  *
18
- * // Use in a component
19
- * function MyWidget() {
20
- * const setWidget = useWidgetStore((state) => state.setWidget)
21
- * const widget = useWidgetStore((state) => state.widgets['my-widget'])
22
- *
23
- * useEffect(() => {
24
- * setWidget({
25
- * id: 'my-widget',
26
- * type: 'formula',
27
- * title: 'Total Sales',
28
- * isLoading: false,
29
- * visible: true,
30
- * data: { value: 1000, prefix: '$' }
31
- * })
32
- * }, [setWidget])
31
+ * @example Reading widget state (prefer useWidgetSelector for performance)
32
+ * ```tsx
33
+ * import { useWidgetSelector } from '@carto/ps-react-ui/widgets'
33
34
  *
34
- * return <div>{widget?.data?.value}</div>
35
+ * function MyWidget({ id }: { id: string }) {
36
+ * const { title, data } = useWidgetSelector(id, (w) => ({
37
+ * title: w?.title,
38
+ * data: w?.data,
39
+ * }))
40
+ * return <div>{title}: {JSON.stringify(data)}</div>
35
41
  * }
36
42
  * ```
37
43
  *
38
- * @example
44
+ * @example Writing widget state
39
45
  * ```tsx
40
- * // Get widgets by type
41
- * const getWidgetsByType = useWidgetStore((state) => state.getWidgetsByType)
42
- * const formulaWidgets = getWidgetsByType('formula')
46
+ * import { widgetStoreActions } from '@carto/ps-react-ui/widgets'
47
+ *
48
+ * const { setWidget } = widgetStoreActions
49
+ * setWidget('my-widget', { type: 'formula', isLoading: false, data: { value: 1000 } })
43
50
  * ```
44
51
  */
45
52
  export const useWidgetStore = create<WidgetStore>()((set, get) => ({
@@ -47,21 +54,23 @@ export const useWidgetStore = create<WidgetStore>()((set, get) => ({
47
54
  widgets: {},
48
55
 
49
56
  // Actions
50
- setWidget: (id, widget) =>
57
+
58
+ /** Merges partial state into the widget entry. Creates the widget if it doesn't exist. */
59
+ setWidget: (id, widget) => {
51
60
  set((state) => {
52
- const current =
53
- state.widgets[id] ?? ({} as WidgetStore['widgets'][string])
61
+ const prev = state.widgets[id] ?? ({} as WidgetStore['widgets'][string])
54
62
  return {
55
63
  widgets: {
56
64
  ...state.widgets,
57
65
  [id]: {
58
- ...current,
66
+ ...prev,
59
67
  ...widget,
60
68
  id,
61
69
  },
62
70
  },
63
71
  }
64
- }),
72
+ })
73
+ },
65
74
 
66
75
  removeWidget: (id) =>
67
76
  set((state) => {
@@ -81,10 +90,37 @@ export const useWidgetStore = create<WidgetStore>()((set, get) => ({
81
90
  return get().widgets[id] as T | undefined
82
91
  },
83
92
 
84
- registerTool: (widgetId: string, tool: ToolRegistration) =>
93
+ /**
94
+ * Registers a transformation tool for a widget's data or config pipeline.
95
+ *
96
+ * **No-op optimization:** When a tool with the same `id` already exists and its
97
+ * structural properties (`order`, `enabled`, `type`, `disables`) are unchanged,
98
+ * only `fn` and `config` are updated via direct mutation — no store update is
99
+ * triggered. This allows action components to include all reactive dependencies
100
+ * in their `useEffect` arrays without causing WidgetLoader pipeline cascades.
101
+ */
102
+ registerTool: (widgetId: string, tool: ToolRegistration) => {
103
+ const current = get().widgets[widgetId]
104
+ const existingTool = current?.registeredTools?.find(
105
+ (t: ToolRegistration) => t.id === tool.id,
106
+ )
107
+
108
+ // No-op: structural properties unchanged — update fn/config via direct mutation.
109
+ // Safe because fn and config are only consumed imperatively during pipeline execution.
110
+ if (
111
+ existingTool?.order === tool.order &&
112
+ existingTool.enabled === tool.enabled &&
113
+ existingTool.type === tool.type &&
114
+ existingTool.disables === tool.disables
115
+ ) {
116
+ existingTool.fn = tool.fn
117
+ if (tool.config) existingTool.config = tool.config
118
+ return
119
+ }
120
+
85
121
  set((state) => {
86
- const current = state.widgets[widgetId] ?? ({} as WidgetState)
87
- const registeredTools = current.registeredTools ?? []
122
+ const widget = state.widgets[widgetId] ?? ({} as WidgetState)
123
+ const registeredTools = widget.registeredTools ?? []
88
124
 
89
125
  // Remove existing tool with same id if present
90
126
  const filteredTools = registeredTools.filter(
@@ -95,13 +131,14 @@ export const useWidgetStore = create<WidgetStore>()((set, get) => ({
95
131
  widgets: {
96
132
  ...state.widgets,
97
133
  [widgetId]: {
98
- ...current,
134
+ ...widget,
99
135
  id: widgetId,
100
136
  registeredTools: [...filteredTools, tool],
101
137
  },
102
138
  },
103
139
  }
104
- }),
140
+ })
141
+ },
105
142
 
106
143
  unregisterTool: (widgetId: string, toolId: string) =>
107
144
  set((state) => {
@@ -154,12 +191,26 @@ export const useWidgetStore = create<WidgetStore>()((set, get) => ({
154
191
  }
155
192
  }),
156
193
 
157
- setToolEnabled: (widgetId: string, toolId: string, enabled: boolean) =>
194
+ /**
195
+ * Updates a tool's enabled state.
196
+ *
197
+ * **No-op optimization:** Skips the store update if the tool already has the
198
+ * requested enabled value.
199
+ */
200
+ setToolEnabled: (widgetId: string, toolId: string, enabled: boolean) => {
201
+ const current = get().widgets[widgetId]
202
+ if (current) {
203
+ const tool = current.registeredTools?.find(
204
+ (t: ToolRegistration) => t.id === toolId,
205
+ )
206
+ if (tool?.enabled === enabled) return
207
+ }
208
+
158
209
  set((state) => {
159
- const current = state.widgets[widgetId]
160
- if (!current) return state
210
+ const widget = state.widgets[widgetId]
211
+ if (!widget) return state
161
212
 
162
- const registeredTools = current.registeredTools ?? []
213
+ const registeredTools = widget.registeredTools ?? []
163
214
  const updatedTools = registeredTools.map((tool: ToolRegistration) =>
164
215
  tool.id === toolId ? { ...tool, enabled } : tool,
165
216
  )
@@ -168,13 +219,26 @@ export const useWidgetStore = create<WidgetStore>()((set, get) => ({
168
219
  widgets: {
169
220
  ...state.widgets,
170
221
  [widgetId]: {
171
- ...current,
222
+ ...widget,
172
223
  registeredTools: updatedTools,
173
224
  },
174
225
  },
175
226
  }
176
- }),
227
+ })
228
+ },
177
229
 
230
+ /**
231
+ * Executes the data transformation pipeline for a widget.
232
+ *
233
+ * Filters to enabled data-type tools (respecting `disables`), sorts by `order`,
234
+ * and chains their `fn` calls. Supports async tools.
235
+ *
236
+ * **Cancellation:** Newer executions for the same widget automatically cancel
237
+ * in-progress ones via a version counter.
238
+ *
239
+ * **No-op optimization:** Skips the final `set()` if the transformed data is
240
+ * referentially identical (`Object.is`) to what's already in the store.
241
+ */
178
242
  executeToolPipeline: async (widgetId: string, sourceData: unknown) => {
179
243
  const widget = get().widgets[widgetId]
180
244
  if (!widget) return
@@ -221,6 +285,15 @@ export const useWidgetStore = create<WidgetStore>()((set, get) => ({
221
285
  }
222
286
  }
223
287
 
288
+ // Skip store update if data hasn't changed (e.g., passthrough pipeline with no tools)
289
+ const widgetAfter = get().widgets[widgetId]
290
+ if (widgetAfter && Object.is(widgetAfter.data, transformedData)) {
291
+ if (activePipelines.get(widgetId) === currentExecution) {
292
+ activePipelines.delete(widgetId)
293
+ }
294
+ return
295
+ }
296
+
224
297
  // Single store update with final transformed data
225
298
  set((state) => {
226
299
  const currentWidget = state.widgets[widgetId]
@@ -243,6 +316,18 @@ export const useWidgetStore = create<WidgetStore>()((set, get) => ({
243
316
  }
244
317
  },
245
318
 
319
+ /**
320
+ * Executes the config transformation pipeline for a widget.
321
+ *
322
+ * Filters to enabled config-type tools (respecting `disables`), sorts by `order`,
323
+ * and chains their `fn` calls. The transformed config is spread into the widget state.
324
+ *
325
+ * **Cancellation:** Newer executions for the same widget automatically cancel
326
+ * in-progress ones via a version counter.
327
+ *
328
+ * **No-op optimization:** Skips the final `set()` when the config object is unchanged
329
+ * and all its properties already match what's in the store.
330
+ */
246
331
  executeConfigPipeline: async (widgetId: string, baseConfig: object) => {
247
332
  const widget = get().widgets[widgetId]
248
333
  if (!widget) return
@@ -287,7 +372,50 @@ export const useWidgetStore = create<WidgetStore>()((set, get) => ({
287
372
  }
288
373
  }
289
374
 
290
- // Set the transformed config into the widget
375
+ // Build a patch that only includes properties the pipeline should set.
376
+ // A property is applied when:
377
+ // 1. A config tool explicitly changed it (transformedConfig[k] !== baseConfig[k])
378
+ // 2. First pipeline run for this widget (no _lastConfig yet)
379
+ // 3. The widget value still matches what the pipeline last set — meaning the
380
+ // user hasn't modified it via setWidget, so it's safe to overwrite.
381
+ // A property is SKIPPED when the widget value differs from _lastConfig,
382
+ // meaning the user (or a hook) changed it since the last pipeline run.
383
+ const base = baseConfig as Record<string, unknown>
384
+ const result = transformedConfig as Record<string, unknown>
385
+ const widgetNow = get().widgets[widgetId] as unknown as Record<
386
+ string,
387
+ unknown
388
+ >
389
+ const lastConfig = widgetNow?._lastConfig as
390
+ | Record<string, unknown>
391
+ | undefined
392
+
393
+ const patch: Record<string, unknown> = {}
394
+ for (const key of Object.keys(result)) {
395
+ const toolChanged = !Object.is(result[key], base[key])
396
+ const isFirstRun = !lastConfig || !(key in lastConfig)
397
+ const userUnmodified =
398
+ !isFirstRun && Object.is(widgetNow[key], lastConfig[key])
399
+
400
+ if (toolChanged || isFirstRun || userUnmodified) {
401
+ patch[key] = result[key]
402
+ }
403
+ }
404
+
405
+ // Skip store update if every patch value already matches the widget
406
+ const hasChanges = Object.keys(patch).some(
407
+ (k) => !Object.is(widgetNow?.[k], patch[k]),
408
+ )
409
+ const configRefChanged = !Object.is(transformedConfig, lastConfig)
410
+
411
+ if (!hasChanges && !configRefChanged) {
412
+ if (activeConfigPipelines.get(widgetId) === currentExecution) {
413
+ activeConfigPipelines.delete(widgetId)
414
+ }
415
+ return
416
+ }
417
+
418
+ // Apply the patch and store the pipeline output for next run's comparison
291
419
  set((state) => {
292
420
  const currentWidget = state.widgets[widgetId]
293
421
  if (!currentWidget) return state
@@ -297,7 +425,8 @@ export const useWidgetStore = create<WidgetStore>()((set, get) => ({
297
425
  ...state.widgets,
298
426
  [widgetId]: {
299
427
  ...currentWidget,
300
- ...(transformedConfig as Record<string, unknown>),
428
+ ...patch,
429
+ _lastConfig: transformedConfig,
301
430
  },
302
431
  },
303
432
  }
@@ -308,3 +437,55 @@ export const useWidgetStore = create<WidgetStore>()((set, get) => ({
308
437
  }
309
438
  },
310
439
  }))
440
+
441
+ /**
442
+ * Stable references to store actions, accessible without creating a subscription.
443
+ *
444
+ * Use this instead of `useWidgetStore((state) => state.setWidget)` to avoid
445
+ * unnecessary subscriber evaluations. Actions are stable functions that never
446
+ * change, so subscribing to them wastes cycles on every store update.
447
+ *
448
+ * @example
449
+ * ```tsx
450
+ * import { widgetStoreActions } from '@carto/ps-react-ui/widgets'
451
+ *
452
+ * const { setWidget, registerTool } = widgetStoreActions
453
+ *
454
+ * useEffect(() => {
455
+ * registerTool(id, { id: 'my-tool', order: 10, enabled: true, fn: (d) => d })
456
+ * return () => widgetStoreActions.unregisterTool(id, 'my-tool')
457
+ * }, [id])
458
+ * ```
459
+ */
460
+ export const widgetStoreActions: WidgetStoreActions = {
461
+ get setWidget() {
462
+ return useWidgetStore.getState().setWidget
463
+ },
464
+ get removeWidget() {
465
+ return useWidgetStore.getState().removeWidget
466
+ },
467
+ get clearWidgets() {
468
+ return useWidgetStore.getState().clearWidgets
469
+ },
470
+ get getWidget() {
471
+ return useWidgetStore.getState().getWidget
472
+ },
473
+ get registerTool() {
474
+ return useWidgetStore.getState().registerTool
475
+ },
476
+ get unregisterTool() {
477
+ return useWidgetStore.getState().unregisterTool
478
+ },
479
+ get updateToolConfig() {
480
+ return useWidgetStore.getState().updateToolConfig
481
+ },
482
+ get setToolEnabled() {
483
+ return useWidgetStore.getState().setToolEnabled
484
+ },
485
+ get executeToolPipeline() {
486
+ return useWidgetStore.getState().executeToolPipeline
487
+ },
488
+ get executeConfigPipeline() {
489
+ return useWidgetStore.getState().executeConfigPipeline
490
+ },
491
+ } as WidgetStoreActions
@@ -19,9 +19,17 @@ export function WidgetSubHeader({
19
19
  sx,
20
20
  }: WidgetSubHeaderProps) {
21
21
  return (
22
- <Box sx={{ ...styles.root, ...sx }}>
23
- {slotLeft && <Box sx={styles.slotLeft}>{slotLeft}</Box>}
24
- <Box sx={styles.slotRight}>{slotRight}</Box>
22
+ <Box sx={{ ...styles.root, ...sx }} className='widget-subheader'>
23
+ {slotLeft && (
24
+ <Box sx={styles.slotLeft} className='widget-subheader-slot-left'>
25
+ {slotLeft}
26
+ </Box>
27
+ )}
28
+ {slotRight && (
29
+ <Box sx={styles.slotRight} className='widget-subheader-slot-right'>
30
+ {slotRight}
31
+ </Box>
32
+ )}
25
33
  </Box>
26
34
  )
27
35
  }
@@ -70,7 +70,6 @@ export function tableConfig(columns: TableColumn[] = []): TableWidgetConfig {
70
70
  return {
71
71
  columns,
72
72
  selectable: false,
73
- selected: [],
74
73
  mode: DEFAULT_MODE,
75
74
  }
76
75
  }
@@ -1,8 +1,8 @@
1
1
  import { useCallback, useMemo } from 'react'
2
2
  import type { TableRow, TableWidgetState } from '../types'
3
3
  import { paginateData } from '../helpers'
4
- import { useWidgetStore } from '../../stores/widget-store'
5
- import { useShallow } from 'zustand/shallow'
4
+ import { widgetStoreActions } from '../../stores/widget-store'
5
+ import { useWidgetSelector } from '../../stores/use-widget-selector'
6
6
  import {
7
7
  DEFAULT_PAGE,
8
8
  DEFAULT_ROWS_PER_PAGE,
@@ -53,50 +53,26 @@ export function usePagination<T extends TableRow>(
53
53
  widgetId: string,
54
54
  data: T[],
55
55
  ): UsePaginationResult<T> {
56
- // Get store actions and state
57
- const setWidget = useWidgetStore((state) => state.setWidget)
58
- const getWidget = useWidgetStore((state) => state.getWidget)
59
- const paginationEnabled = useWidgetStore(
60
- useShallow(
61
- (state) =>
62
- !!(state.widgets[widgetId] as TableWidgetState | undefined)?.pagination,
63
- ),
64
- )
65
- const page = useWidgetStore(
66
- useShallow(
67
- (state) =>
68
- (state.widgets[widgetId] as TableWidgetState | undefined)?.pagination
69
- ?.page ?? DEFAULT_PAGE,
70
- ),
71
- )
72
- const rowsPerPage = useWidgetStore(
73
- useShallow(
74
- (state) =>
75
- (state.widgets[widgetId] as TableWidgetState | undefined)?.pagination
76
- ?.rowsPerPage ?? DEFAULT_ROWS_PER_PAGE,
77
- ),
78
- )
79
- const rowsPerPageOptions = useWidgetStore(
80
- useShallow(
81
- (state) =>
82
- (state.widgets[widgetId] as TableWidgetState | undefined)?.pagination
83
- ?.rowsPerPageOptions ?? DEFAULT_ROWS_PER_PAGE_OPTIONS,
84
- ),
85
- )
86
- const total = useWidgetStore(
87
- useShallow(
88
- (state) =>
89
- (state.widgets[widgetId] as TableWidgetState | undefined)?.pagination
90
- ?.total ?? data.length,
91
- ),
92
- )
93
-
94
- const mode = useWidgetStore(
95
- useShallow((state) => {
96
- const widget = state.widgets[widgetId] as TableWidgetState | undefined
97
- return widget?.mode
98
- }),
99
- )
56
+ // Get store state
57
+ const {
58
+ paginationEnabled,
59
+ page,
60
+ rowsPerPage,
61
+ rowsPerPageOptions,
62
+ total,
63
+ mode,
64
+ } = useWidgetSelector(widgetId, (w) => {
65
+ const widget = w as TableWidgetState | undefined
66
+ return {
67
+ paginationEnabled: !!widget?.pagination,
68
+ page: widget?.pagination?.page ?? DEFAULT_PAGE,
69
+ rowsPerPage: widget?.pagination?.rowsPerPage ?? DEFAULT_ROWS_PER_PAGE,
70
+ rowsPerPageOptions:
71
+ widget?.pagination?.rowsPerPageOptions ?? DEFAULT_ROWS_PER_PAGE_OPTIONS,
72
+ total: widget?.pagination?.total ?? data.length,
73
+ mode: widget?.mode,
74
+ }
75
+ })
100
76
 
101
77
  // Calculate max page
102
78
  const maxPage = Math.max(0, Math.ceil(total / (rowsPerPage ?? 1)) - 1)
@@ -105,7 +81,7 @@ export function usePagination<T extends TableRow>(
105
81
  const setPage = useCallback(
106
82
  (newPage: number) => {
107
83
  // Read current state directly from store to avoid stale closures
108
- const widget = getWidget<TableWidgetState>(widgetId)
84
+ const widget = widgetStoreActions.getWidget<TableWidgetState>(widgetId)
109
85
  const currentTotal = widget?.pagination?.total ?? data.length
110
86
  const currentRowsPerPage =
111
87
  widget?.pagination?.rowsPerPage ?? DEFAULT_ROWS_PER_PAGE
@@ -115,21 +91,21 @@ export function usePagination<T extends TableRow>(
115
91
  )
116
92
  const boundedPage = Math.max(0, Math.min(newPage, currentMaxPage))
117
93
 
118
- setWidget<TableWidgetState>(widgetId, {
94
+ widgetStoreActions.setWidget<TableWidgetState>(widgetId, {
119
95
  pagination: {
120
96
  ...widget?.pagination,
121
97
  page: boundedPage,
122
98
  },
123
99
  })
124
100
  },
125
- [data.length, getWidget, setWidget, widgetId],
101
+ [data.length, widgetId],
126
102
  )
127
103
 
128
104
  // Set rows per page and reset to first page
129
105
  const setRowsPerPage = useCallback(
130
106
  (newRowsPerPage: number) => {
131
- const widget = getWidget<TableWidgetState>(widgetId)
132
- setWidget<TableWidgetState>(widgetId, {
107
+ const widget = widgetStoreActions.getWidget<TableWidgetState>(widgetId)
108
+ widgetStoreActions.setWidget<TableWidgetState>(widgetId, {
133
109
  pagination: {
134
110
  ...widget?.pagination,
135
111
  rowsPerPage: newRowsPerPage,
@@ -137,7 +113,7 @@ export function usePagination<T extends TableRow>(
137
113
  },
138
114
  })
139
115
  },
140
- [getWidget, setWidget, widgetId],
116
+ [widgetId],
141
117
  )
142
118
 
143
119
  // Navigation helpers