From f6e8c35adcd6d5a53265f7f089702c7e2d306120 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 20 May 2026 16:48:51 -0600 Subject: [PATCH 01/18] feat: Add table-options-example plugin demonstrating customizable Table Options sidebar --- plugins/manifest.json | 5 ++ .../table-options-example/src/js/.gitignore | 3 + .../table-options-example/src/js/README.md | 31 ++++++++ .../table-options-example/src/js/package.json | 46 +++++++++++ .../src/js/src/ColumnInspectorPage.tsx | 30 +++++++ .../js/src/TableOptionsExampleMiddleware.tsx | 29 +++++++ .../TableOptionsExamplePanelMiddleware.tsx | 29 +++++++ .../src/js/src/TableOptionsExamplePlugin.ts | 18 +++++ .../src/js/src/columnInspectorItemType.ts | 9 +++ .../table-options-example/src/js/src/index.ts | 10 +++ .../src/useComposedSidebarExtension.test.tsx | 78 +++++++++++++++++++ .../src/js/src/useComposedSidebarExtension.ts | 44 +++++++++++ .../src/js/vite.config.ts | 29 +++++++ 13 files changed, 361 insertions(+) create mode 100644 plugins/table-options-example/src/js/.gitignore create mode 100644 plugins/table-options-example/src/js/README.md create mode 100644 plugins/table-options-example/src/js/package.json create mode 100644 plugins/table-options-example/src/js/src/ColumnInspectorPage.tsx create mode 100644 plugins/table-options-example/src/js/src/TableOptionsExampleMiddleware.tsx create mode 100644 plugins/table-options-example/src/js/src/TableOptionsExamplePanelMiddleware.tsx create mode 100644 plugins/table-options-example/src/js/src/TableOptionsExamplePlugin.ts create mode 100644 plugins/table-options-example/src/js/src/columnInspectorItemType.ts create mode 100644 plugins/table-options-example/src/js/src/index.ts create mode 100644 plugins/table-options-example/src/js/src/useComposedSidebarExtension.test.tsx create mode 100644 plugins/table-options-example/src/js/src/useComposedSidebarExtension.ts create mode 100644 plugins/table-options-example/src/js/vite.config.ts diff --git a/plugins/manifest.json b/plugins/manifest.json index 77d968765..50554e7c1 100644 --- a/plugins/manifest.json +++ b/plugins/manifest.json @@ -35,6 +35,11 @@ "name": "pivot", "version": "0.0.0", "main": "src/js/dist/index.js" + }, + { + "name": "table-options-example", + "version": "0.0.0", + "main": "src/js/dist/bundle/index.js" } ] } diff --git a/plugins/table-options-example/src/js/.gitignore b/plugins/table-options-example/src/js/.gitignore new file mode 100644 index 000000000..84f3869a7 --- /dev/null +++ b/plugins/table-options-example/src/js/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +*.tsbuildinfo diff --git a/plugins/table-options-example/src/js/README.md b/plugins/table-options-example/src/js/README.md new file mode 100644 index 000000000..ac751f4f1 --- /dev/null +++ b/plugins/table-options-example/src/js/README.md @@ -0,0 +1,31 @@ +# Deephaven JS Plugin: Table Options Example + +Demo `WidgetMiddlewarePlugin` for [DH-21476](https://deephaven.atlassian.net/browse/DH-21476). +It wraps every table-like widget in an `IrisGridSidebarContext.Provider` +that: + +1. Hides the built-in **Select Distinct Values** sidebar item. +2. Adds a plugin-contributed item titled "Column Inspector" whose + page lists the model's column count. + +Both contributions compose with any parent `IrisGridSidebarContext` — +this plugin reads the parent value and merges its own `transformItems` +on top, so multiple middleware plugins can stack without clobbering +one another. + +## Build + +``` +npm install +npm run build +``` + +Bundle is emitted at `dist/bundle/index.js`. + +## How it plugs in + +`plugins/manifest.json` registers `table-options-example` so the +deephaven-plugins dev proxy serves the bundle. At runtime +`@deephaven/iris-grid` resolves `IrisGridSidebarContext` from React +context inside `IrisGridPanel` and `GridWidgetPlugin`, and forwards +the merged `transformItems` to `IrisGrid#sidebarItems`. diff --git a/plugins/table-options-example/src/js/package.json b/plugins/table-options-example/src/js/package.json new file mode 100644 index 000000000..3112674df --- /dev/null +++ b/plugins/table-options-example/src/js/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deephaven/js-plugin-table-options-example", + "version": "0.0.0", + "description": "Example WidgetMiddlewarePlugin that customizes the IrisGrid Table Options sidebar via IrisGridSidebarContext.", + "keywords": [ + "Deephaven", + "plugin", + "deephaven-js-plugin", + "table-options", + "middleware" + ], + "author": "Deephaven Data Labs LLC", + "license": "Apache-2.0", + "main": "dist/bundle/index.js", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/bundle/index.js", + "default": "./dist/bundle/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "start": "vite build --watch", + "build": "vite build" + }, + "dependencies": { + "@deephaven/components": "^1.17.0", + "@deephaven/iris-grid": "^1.17.0", + "@deephaven/log": "^1.17.0", + "@deephaven/plugin": "^1.17.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "react": "^18.0.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/plugins/table-options-example/src/js/src/ColumnInspectorPage.tsx b/plugins/table-options-example/src/js/src/ColumnInspectorPage.tsx new file mode 100644 index 000000000..40400bf8e --- /dev/null +++ b/plugins/table-options-example/src/js/src/ColumnInspectorPage.tsx @@ -0,0 +1,30 @@ +import { Button } from '@deephaven/components'; +import { type IrisGridSidebarPageProps } from '@deephaven/iris-grid'; + +/** + * Minimal `configPage` demo. Receives `{ model, onBack }` from + * `IrisGrid`'s page switch (see `IrisGridSidebarPageProps`). + */ +export function ColumnInspectorPage({ + model, + onBack, +}: IrisGridSidebarPageProps): JSX.Element { + return ( +
+
Column Inspector
+

+ This page is contributed by + `@deephaven/js-plugin-table-options-example`. +

+

+ The current model exposes {model.columns.length} column + {model.columns.length === 1 ? '' : 's'}. +

+ +
+ ); +} + +export default ColumnInspectorPage; diff --git a/plugins/table-options-example/src/js/src/TableOptionsExampleMiddleware.tsx b/plugins/table-options-example/src/js/src/TableOptionsExampleMiddleware.tsx new file mode 100644 index 000000000..95f4cc362 --- /dev/null +++ b/plugins/table-options-example/src/js/src/TableOptionsExampleMiddleware.tsx @@ -0,0 +1,29 @@ +import { IrisGridSidebarContext } from '@deephaven/iris-grid'; +import Log from '@deephaven/log'; +import type { WidgetMiddlewareComponentProps } from '@deephaven/plugin'; +import { useComposedSidebarExtension } from './useComposedSidebarExtension'; + +const log = Log.module( + '@deephaven/js-plugin-table-options-example/TableOptionsExampleMiddleware' +); + +/** + * Middleware that wraps the base widget component (the non-panel + * `WidgetComponentProps` path, e.g. dashboard widgets via + * `GridWidgetPlugin`) in an `IrisGridSidebarContext.Provider`. + */ +export function TableOptionsExampleMiddleware({ + Component, + ...props +}: WidgetMiddlewareComponentProps): JSX.Element { + const extension = useComposedSidebarExtension(); + log.info('Wrapping widget component', { Component, props }); + return ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + ); +} + +export default TableOptionsExampleMiddleware; diff --git a/plugins/table-options-example/src/js/src/TableOptionsExamplePanelMiddleware.tsx b/plugins/table-options-example/src/js/src/TableOptionsExamplePanelMiddleware.tsx new file mode 100644 index 000000000..351acf189 --- /dev/null +++ b/plugins/table-options-example/src/js/src/TableOptionsExamplePanelMiddleware.tsx @@ -0,0 +1,29 @@ +import { IrisGridSidebarContext } from '@deephaven/iris-grid'; +import Log from '@deephaven/log'; +import type { WidgetMiddlewarePanelProps } from '@deephaven/plugin'; +import { useComposedSidebarExtension } from './useComposedSidebarExtension'; + +const log = Log.module( + '@deephaven/js-plugin-table-options-example/TableOptionsExamplePanelMiddleware' +); + +/** + * Middleware that wraps the panel widget (`IrisGridPanel` host) in an + * `IrisGridSidebarContext.Provider`. Same composition rules as the + * non-panel `TableOptionsExampleMiddleware`. + */ +export function TableOptionsExamplePanelMiddleware({ + Component, + ...props +}: WidgetMiddlewarePanelProps): JSX.Element { + const extension = useComposedSidebarExtension(); + log.info('Wrapping panel component', { Component, props }); + return ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + ); +} + +export default TableOptionsExamplePanelMiddleware; diff --git a/plugins/table-options-example/src/js/src/TableOptionsExamplePlugin.ts b/plugins/table-options-example/src/js/src/TableOptionsExamplePlugin.ts new file mode 100644 index 000000000..e2f2c804e --- /dev/null +++ b/plugins/table-options-example/src/js/src/TableOptionsExamplePlugin.ts @@ -0,0 +1,18 @@ +import { PluginType, type WidgetMiddlewarePlugin } from '@deephaven/plugin'; +import { TableOptionsExampleMiddleware } from './TableOptionsExampleMiddleware'; +import { TableOptionsExamplePanelMiddleware } from './TableOptionsExamplePanelMiddleware'; + +export const TableOptionsExamplePlugin: WidgetMiddlewarePlugin = { + name: '@deephaven/js-plugin-table-options-example', + type: PluginType.MIDDLEWARE_PLUGIN, + supportedTypes: [ + 'Table', + 'TreeTable', + 'HierarchicalTable', + 'PartitionedTable', + ], + component: TableOptionsExampleMiddleware, + panelComponent: TableOptionsExamplePanelMiddleware, +}; + +export default TableOptionsExamplePlugin; diff --git a/plugins/table-options-example/src/js/src/columnInspectorItemType.ts b/plugins/table-options-example/src/js/src/columnInspectorItemType.ts new file mode 100644 index 000000000..01bd7279a --- /dev/null +++ b/plugins/table-options-example/src/js/src/columnInspectorItemType.ts @@ -0,0 +1,9 @@ +/** + * Stable type key for this plugin's sidebar item. The + * `plugin::` convention keeps plugin contributions + * from colliding with built-in `OptionType` values or with other + * plugins. + */ +// eslint-disable-next-line import/prefer-default-export +export const COLUMN_INSPECTOR_ITEM_TYPE = + 'plugin:table-options-example:column-inspector'; diff --git a/plugins/table-options-example/src/js/src/index.ts b/plugins/table-options-example/src/js/src/index.ts new file mode 100644 index 000000000..854532416 --- /dev/null +++ b/plugins/table-options-example/src/js/src/index.ts @@ -0,0 +1,10 @@ +import { TableOptionsExamplePlugin } from './TableOptionsExamplePlugin'; + +export { COLUMN_INSPECTOR_ITEM_TYPE } from './columnInspectorItemType'; +export { ColumnInspectorPage } from './ColumnInspectorPage'; +export { TableOptionsExampleMiddleware } from './TableOptionsExampleMiddleware'; +export { TableOptionsExamplePanelMiddleware } from './TableOptionsExamplePanelMiddleware'; +export { TableOptionsExamplePlugin } from './TableOptionsExamplePlugin'; +export { useComposedSidebarExtension } from './useComposedSidebarExtension'; + +export default TableOptionsExamplePlugin; diff --git a/plugins/table-options-example/src/js/src/useComposedSidebarExtension.test.tsx b/plugins/table-options-example/src/js/src/useComposedSidebarExtension.test.tsx new file mode 100644 index 000000000..e25fbed28 --- /dev/null +++ b/plugins/table-options-example/src/js/src/useComposedSidebarExtension.test.tsx @@ -0,0 +1,78 @@ +import { + IrisGridSidebarContext, + type IrisGridSidebarExtension, + type OptionItem, + OptionType, +} from '@deephaven/iris-grid'; +import { renderHook } from '@testing-library/react'; +import React, { type ReactNode } from 'react'; +import { useComposedSidebarExtension } from './useComposedSidebarExtension'; +import { COLUMN_INSPECTOR_ITEM_TYPE } from './columnInspectorItemType'; + +function makeDefaults(): readonly OptionItem[] { + return Object.freeze([ + { type: OptionType.QUICK_FILTERS, title: 'Quick Filters' }, + { type: OptionType.SELECT_DISTINCT, title: 'Select Distinct Values' }, + { type: OptionType.AGGREGATIONS, title: 'Aggregate Columns' }, + ]); +} + +function wrap(parent?: IrisGridSidebarExtension | null) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +describe('useComposedSidebarExtension', () => { + it('hides SELECT_DISTINCT and appends the column inspector item when no parent is present', () => { + const { result } = renderHook(() => useComposedSidebarExtension(), { + wrapper: wrap(null), + }); + const out = result.current.transformItems!(makeDefaults()); + + expect(out.map(o => o.type)).toEqual([ + OptionType.QUICK_FILTERS, + OptionType.AGGREGATIONS, + COLUMN_INSPECTOR_ITEM_TYPE, + ]); + const item = out.find(o => o.type === COLUMN_INSPECTOR_ITEM_TYPE)!; + expect(item.title).toBe('Column Inspector'); + expect(item.configPage).toBeDefined(); + }); + + it('composes on top of a parent transform (parent runs first)', () => { + const parentTransform = jest.fn( + (defaults: readonly OptionItem[]) => + defaults.filter( + o => o.type !== OptionType.QUICK_FILTERS + ) as readonly OptionItem[] + ); + const { result } = renderHook(() => useComposedSidebarExtension(), { + wrapper: wrap({ transformItems: parentTransform }), + }); + const out = result.current.transformItems!(makeDefaults()); + + expect(parentTransform).toHaveBeenCalledTimes(1); + expect(out.map(o => o.type)).toEqual([ + OptionType.AGGREGATIONS, + COLUMN_INSPECTOR_ITEM_TYPE, + ]); + }); + + it('returns a stable transform across renders when parent is unchanged', () => { + const parent = { + transformItems: (defaults: readonly OptionItem[]) => defaults, + }; + const { result, rerender } = renderHook( + () => useComposedSidebarExtension(), + { wrapper: wrap(parent) } + ); + const first = result.current; + rerender(); + expect(result.current).toBe(first); + }); +}); diff --git a/plugins/table-options-example/src/js/src/useComposedSidebarExtension.ts b/plugins/table-options-example/src/js/src/useComposedSidebarExtension.ts new file mode 100644 index 000000000..1f2b6914b --- /dev/null +++ b/plugins/table-options-example/src/js/src/useComposedSidebarExtension.ts @@ -0,0 +1,44 @@ +import { useContext, useMemo } from 'react'; +import { + IrisGridSidebarContext, + type IrisGridSidebarExtension, + type OptionItem, + OptionType, +} from '@deephaven/iris-grid'; +import { ColumnInspectorPage } from './ColumnInspectorPage'; +import { COLUMN_INSPECTOR_ITEM_TYPE } from './columnInspectorItemType'; + +const COLUMN_INSPECTOR_ITEM: OptionItem = { + type: COLUMN_INSPECTOR_ITEM_TYPE, + title: 'Column Inspector', + subtitle: 'Plugin-contributed sidebar page', + configPage: ColumnInspectorPage, +}; + +/** + * Composes this plugin's contribution on top of any parent + * `IrisGridSidebarContext` value already present in the tree: + * + * parent.transformItems → drop SELECT_DISTINCT → append COLUMN_INSPECTOR_ITEM + * + * Returning a memoized value keeps `IrisGrid`'s + * `applySidebarItemsTransform` dependency stable between renders. + */ +export function useComposedSidebarExtension(): IrisGridSidebarExtension { + const parent = useContext(IrisGridSidebarContext); + return useMemo(() => { + const parentTransform = parent?.transformItems; + return { + transformItems: defaults => { + const base = + parentTransform != null ? parentTransform(defaults) : defaults; + const filtered = base.filter( + o => o.type !== OptionType.SELECT_DISTINCT + ); + return [...filtered, COLUMN_INSPECTOR_ITEM]; + }, + }; + }, [parent]); +} + +export default useComposedSidebarExtension; diff --git a/plugins/table-options-example/src/js/vite.config.ts b/plugins/table-options-example/src/js/vite.config.ts new file mode 100644 index 000000000..03586165d --- /dev/null +++ b/plugins/table-options-example/src/js/vite.config.ts @@ -0,0 +1,29 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; + +// https://vitejs.dev/config/ +export default defineConfig(({ mode }) => ({ + build: { + minify: false, + outDir: 'dist/bundle', + lib: { + entry: './src/index.ts', + fileName: () => 'index.js', + formats: ['cjs'], + }, + rollupOptions: { + external: [ + 'react', + 'react-dom', + '@deephaven/components', + '@deephaven/iris-grid', + '@deephaven/log', + '@deephaven/plugin', + ], + }, + }, + define: + mode === 'production' ? { 'process.env.NODE_ENV': '"production"' } : {}, + plugins: [react()], +})); From a7892f7937fb327cb3fd7a368e308003f28e7c9b Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Thu, 21 May 2026 10:52:42 -0600 Subject: [PATCH 02/18] Cleanup --- plugins/table-options-example/src/js/README.md | 10 +++++----- plugins/table-options-example/src/js/package.json | 12 ++---------- .../src/js/src/TableOptionsExampleMiddleware.tsx | 2 +- .../js/src/TableOptionsExamplePanelMiddleware.tsx | 2 +- .../src/js/src/useComposedSidebarExtension.test.tsx | 8 ++++---- .../src/js/src/useComposedSidebarExtension.ts | 10 +++++----- plugins/table-options-example/src/js/vite.config.ts | 1 - 7 files changed, 18 insertions(+), 27 deletions(-) diff --git a/plugins/table-options-example/src/js/README.md b/plugins/table-options-example/src/js/README.md index ac751f4f1..c223afd6a 100644 --- a/plugins/table-options-example/src/js/README.md +++ b/plugins/table-options-example/src/js/README.md @@ -9,9 +9,9 @@ that: page lists the model's column count. Both contributions compose with any parent `IrisGridSidebarContext` — -this plugin reads the parent value and merges its own `transformItems` -on top, so multiple middleware plugins can stack without clobbering -one another. +this plugin reads the parent value and merges its own +`transformTableOptions` on top, so multiple middleware plugins can +stack without clobbering one another. ## Build @@ -20,7 +20,7 @@ npm install npm run build ``` -Bundle is emitted at `dist/bundle/index.js`. +Bundle is emitted at `dist/index.js`. ## How it plugs in @@ -28,4 +28,4 @@ Bundle is emitted at `dist/bundle/index.js`. deephaven-plugins dev proxy serves the bundle. At runtime `@deephaven/iris-grid` resolves `IrisGridSidebarContext` from React context inside `IrisGridPanel` and `GridWidgetPlugin`, and forwards -the merged `transformItems` to `IrisGrid#sidebarItems`. +the merged `transformTableOptions` to `IrisGrid#transformTableOptions`. diff --git a/plugins/table-options-example/src/js/package.json b/plugins/table-options-example/src/js/package.json index 3112674df..0b9774a2e 100644 --- a/plugins/table-options-example/src/js/package.json +++ b/plugins/table-options-example/src/js/package.json @@ -11,17 +11,9 @@ ], "author": "Deephaven Data Labs LLC", "license": "Apache-2.0", - "main": "dist/bundle/index.js", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "require": "./dist/bundle/index.js", - "default": "./dist/bundle/index.js" - } - }, + "main": "dist/index.js", "files": [ - "dist" + "dist/index.js" ], "scripts": { "start": "vite build --watch", diff --git a/plugins/table-options-example/src/js/src/TableOptionsExampleMiddleware.tsx b/plugins/table-options-example/src/js/src/TableOptionsExampleMiddleware.tsx index 95f4cc362..b77108514 100644 --- a/plugins/table-options-example/src/js/src/TableOptionsExampleMiddleware.tsx +++ b/plugins/table-options-example/src/js/src/TableOptionsExampleMiddleware.tsx @@ -17,7 +17,7 @@ export function TableOptionsExampleMiddleware({ ...props }: WidgetMiddlewareComponentProps): JSX.Element { const extension = useComposedSidebarExtension(); - log.info('Wrapping widget component', { Component, props }); + log.debug('Wrapping widget component', { Component, props }); return ( {/* eslint-disable-next-line react/jsx-props-no-spreading */} diff --git a/plugins/table-options-example/src/js/src/TableOptionsExamplePanelMiddleware.tsx b/plugins/table-options-example/src/js/src/TableOptionsExamplePanelMiddleware.tsx index 351acf189..4a9f4c26c 100644 --- a/plugins/table-options-example/src/js/src/TableOptionsExamplePanelMiddleware.tsx +++ b/plugins/table-options-example/src/js/src/TableOptionsExamplePanelMiddleware.tsx @@ -17,7 +17,7 @@ export function TableOptionsExamplePanelMiddleware({ ...props }: WidgetMiddlewarePanelProps): JSX.Element { const extension = useComposedSidebarExtension(); - log.info('Wrapping panel component', { Component, props }); + log.debug('Wrapping panel component', { Component, props }); return ( {/* eslint-disable-next-line react/jsx-props-no-spreading */} diff --git a/plugins/table-options-example/src/js/src/useComposedSidebarExtension.test.tsx b/plugins/table-options-example/src/js/src/useComposedSidebarExtension.test.tsx index e25fbed28..368bb6909 100644 --- a/plugins/table-options-example/src/js/src/useComposedSidebarExtension.test.tsx +++ b/plugins/table-options-example/src/js/src/useComposedSidebarExtension.test.tsx @@ -32,7 +32,7 @@ describe('useComposedSidebarExtension', () => { const { result } = renderHook(() => useComposedSidebarExtension(), { wrapper: wrap(null), }); - const out = result.current.transformItems!(makeDefaults()); + const out = result.current.transformTableOptions!(makeDefaults()); expect(out.map(o => o.type)).toEqual([ OptionType.QUICK_FILTERS, @@ -52,9 +52,9 @@ describe('useComposedSidebarExtension', () => { ) as readonly OptionItem[] ); const { result } = renderHook(() => useComposedSidebarExtension(), { - wrapper: wrap({ transformItems: parentTransform }), + wrapper: wrap({ transformTableOptions: parentTransform }), }); - const out = result.current.transformItems!(makeDefaults()); + const out = result.current.transformTableOptions!(makeDefaults()); expect(parentTransform).toHaveBeenCalledTimes(1); expect(out.map(o => o.type)).toEqual([ @@ -65,7 +65,7 @@ describe('useComposedSidebarExtension', () => { it('returns a stable transform across renders when parent is unchanged', () => { const parent = { - transformItems: (defaults: readonly OptionItem[]) => defaults, + transformTableOptions: (defaults: readonly OptionItem[]) => defaults, }; const { result, rerender } = renderHook( () => useComposedSidebarExtension(), diff --git a/plugins/table-options-example/src/js/src/useComposedSidebarExtension.ts b/plugins/table-options-example/src/js/src/useComposedSidebarExtension.ts index 1f2b6914b..426ffea89 100644 --- a/plugins/table-options-example/src/js/src/useComposedSidebarExtension.ts +++ b/plugins/table-options-example/src/js/src/useComposedSidebarExtension.ts @@ -19,17 +19,17 @@ const COLUMN_INSPECTOR_ITEM: OptionItem = { * Composes this plugin's contribution on top of any parent * `IrisGridSidebarContext` value already present in the tree: * - * parent.transformItems → drop SELECT_DISTINCT → append COLUMN_INSPECTOR_ITEM + * parent.transformTableOptions → drop SELECT_DISTINCT → append COLUMN_INSPECTOR_ITEM * - * Returning a memoized value keeps `IrisGrid`'s - * `applySidebarItemsTransform` dependency stable between renders. + * Returning a memoized value keeps `IrisGrid`'s transform input + * referentially stable between renders. */ export function useComposedSidebarExtension(): IrisGridSidebarExtension { const parent = useContext(IrisGridSidebarContext); return useMemo(() => { - const parentTransform = parent?.transformItems; + const parentTransform = parent?.transformTableOptions; return { - transformItems: defaults => { + transformTableOptions: defaults => { const base = parentTransform != null ? parentTransform(defaults) : defaults; const filtered = base.filter( diff --git a/plugins/table-options-example/src/js/vite.config.ts b/plugins/table-options-example/src/js/vite.config.ts index 03586165d..029d8b405 100644 --- a/plugins/table-options-example/src/js/vite.config.ts +++ b/plugins/table-options-example/src/js/vite.config.ts @@ -6,7 +6,6 @@ import react from '@vitejs/plugin-react-swc'; export default defineConfig(({ mode }) => ({ build: { minify: false, - outDir: 'dist/bundle', lib: { entry: './src/index.ts', fileName: () => 'index.js', From 12e543785359d0ba151ba56264b3a366aa28b4e1 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Thu, 21 May 2026 12:04:17 -0600 Subject: [PATCH 03/18] Naming cleanup --- .../table-options-example/src/js/README.md | 6 +- .../table-options-example/src/js/package.json | 2 +- .../src/js/src/ColumnInspectorPage.tsx | 6 +- .../js/src/TableOptionsExampleMiddleware.tsx | 12 +-- .../TableOptionsExamplePanelMiddleware.tsx | 12 +-- .../table-options-example/src/js/src/index.ts | 2 +- .../src/useComposedSidebarExtension.test.tsx | 20 ++--- .../src/js/src/useComposedSidebarExtension.ts | 14 ++-- .../useComposedTableOptionsExtension.test.tsx | 78 +++++++++++++++++++ .../src/useComposedTableOptionsExtension.ts | 44 +++++++++++ 10 files changed, 159 insertions(+), 37 deletions(-) create mode 100644 plugins/table-options-example/src/js/src/useComposedTableOptionsExtension.test.tsx create mode 100644 plugins/table-options-example/src/js/src/useComposedTableOptionsExtension.ts diff --git a/plugins/table-options-example/src/js/README.md b/plugins/table-options-example/src/js/README.md index c223afd6a..5e7520600 100644 --- a/plugins/table-options-example/src/js/README.md +++ b/plugins/table-options-example/src/js/README.md @@ -1,14 +1,14 @@ # Deephaven JS Plugin: Table Options Example Demo `WidgetMiddlewarePlugin` for [DH-21476](https://deephaven.atlassian.net/browse/DH-21476). -It wraps every table-like widget in an `IrisGridSidebarContext.Provider` +It wraps every table-like widget in an `IrisGridTableOptionsContext.Provider` that: 1. Hides the built-in **Select Distinct Values** sidebar item. 2. Adds a plugin-contributed item titled "Column Inspector" whose page lists the model's column count. -Both contributions compose with any parent `IrisGridSidebarContext` — +Both contributions compose with any parent `IrisGridTableOptionsContext` — this plugin reads the parent value and merges its own `transformTableOptions` on top, so multiple middleware plugins can stack without clobbering one another. @@ -26,6 +26,6 @@ Bundle is emitted at `dist/index.js`. `plugins/manifest.json` registers `table-options-example` so the deephaven-plugins dev proxy serves the bundle. At runtime -`@deephaven/iris-grid` resolves `IrisGridSidebarContext` from React +`@deephaven/iris-grid` resolves `IrisGridTableOptionsContext` from React context inside `IrisGridPanel` and `GridWidgetPlugin`, and forwards the merged `transformTableOptions` to `IrisGrid#transformTableOptions`. diff --git a/plugins/table-options-example/src/js/package.json b/plugins/table-options-example/src/js/package.json index 0b9774a2e..be6e4792d 100644 --- a/plugins/table-options-example/src/js/package.json +++ b/plugins/table-options-example/src/js/package.json @@ -1,7 +1,7 @@ { "name": "@deephaven/js-plugin-table-options-example", "version": "0.0.0", - "description": "Example WidgetMiddlewarePlugin that customizes the IrisGrid Table Options sidebar via IrisGridSidebarContext.", + "description": "Example WidgetMiddlewarePlugin that customizes the IrisGrid Table Options sidebar via IrisGridTableOptionsContext.", "keywords": [ "Deephaven", "plugin", diff --git a/plugins/table-options-example/src/js/src/ColumnInspectorPage.tsx b/plugins/table-options-example/src/js/src/ColumnInspectorPage.tsx index 40400bf8e..1ccda702e 100644 --- a/plugins/table-options-example/src/js/src/ColumnInspectorPage.tsx +++ b/plugins/table-options-example/src/js/src/ColumnInspectorPage.tsx @@ -1,14 +1,14 @@ import { Button } from '@deephaven/components'; -import { type IrisGridSidebarPageProps } from '@deephaven/iris-grid'; +import { type IrisGridTableOptionsPageProps } from '@deephaven/iris-grid'; /** * Minimal `configPage` demo. Receives `{ model, onBack }` from - * `IrisGrid`'s page switch (see `IrisGridSidebarPageProps`). + * `IrisGrid`'s page switch (see `IrisGridTableOptionsPageProps`). */ export function ColumnInspectorPage({ model, onBack, -}: IrisGridSidebarPageProps): JSX.Element { +}: IrisGridTableOptionsPageProps): JSX.Element { return (
Column Inspector
diff --git a/plugins/table-options-example/src/js/src/TableOptionsExampleMiddleware.tsx b/plugins/table-options-example/src/js/src/TableOptionsExampleMiddleware.tsx index b77108514..91c3755ab 100644 --- a/plugins/table-options-example/src/js/src/TableOptionsExampleMiddleware.tsx +++ b/plugins/table-options-example/src/js/src/TableOptionsExampleMiddleware.tsx @@ -1,7 +1,7 @@ -import { IrisGridSidebarContext } from '@deephaven/iris-grid'; +import { IrisGridTableOptionsContext } from '@deephaven/iris-grid'; import Log from '@deephaven/log'; import type { WidgetMiddlewareComponentProps } from '@deephaven/plugin'; -import { useComposedSidebarExtension } from './useComposedSidebarExtension'; +import { useComposedTableOptionsExtension } from './useComposedTableOptionsExtension'; const log = Log.module( '@deephaven/js-plugin-table-options-example/TableOptionsExampleMiddleware' @@ -10,19 +10,19 @@ const log = Log.module( /** * Middleware that wraps the base widget component (the non-panel * `WidgetComponentProps` path, e.g. dashboard widgets via - * `GridWidgetPlugin`) in an `IrisGridSidebarContext.Provider`. + * `GridWidgetPlugin`) in an `IrisGridTableOptionsContext.Provider`. */ export function TableOptionsExampleMiddleware({ Component, ...props }: WidgetMiddlewareComponentProps): JSX.Element { - const extension = useComposedSidebarExtension(); + const extension = useComposedTableOptionsExtension(); log.debug('Wrapping widget component', { Component, props }); return ( - + {/* eslint-disable-next-line react/jsx-props-no-spreading */} - + ); } diff --git a/plugins/table-options-example/src/js/src/TableOptionsExamplePanelMiddleware.tsx b/plugins/table-options-example/src/js/src/TableOptionsExamplePanelMiddleware.tsx index 4a9f4c26c..f22a7275d 100644 --- a/plugins/table-options-example/src/js/src/TableOptionsExamplePanelMiddleware.tsx +++ b/plugins/table-options-example/src/js/src/TableOptionsExamplePanelMiddleware.tsx @@ -1,7 +1,7 @@ -import { IrisGridSidebarContext } from '@deephaven/iris-grid'; +import { IrisGridTableOptionsContext } from '@deephaven/iris-grid'; import Log from '@deephaven/log'; import type { WidgetMiddlewarePanelProps } from '@deephaven/plugin'; -import { useComposedSidebarExtension } from './useComposedSidebarExtension'; +import { useComposedTableOptionsExtension } from './useComposedTableOptionsExtension'; const log = Log.module( '@deephaven/js-plugin-table-options-example/TableOptionsExamplePanelMiddleware' @@ -9,20 +9,20 @@ const log = Log.module( /** * Middleware that wraps the panel widget (`IrisGridPanel` host) in an - * `IrisGridSidebarContext.Provider`. Same composition rules as the + * `IrisGridTableOptionsContext.Provider`. Same composition rules as the * non-panel `TableOptionsExampleMiddleware`. */ export function TableOptionsExamplePanelMiddleware({ Component, ...props }: WidgetMiddlewarePanelProps): JSX.Element { - const extension = useComposedSidebarExtension(); + const extension = useComposedTableOptionsExtension(); log.debug('Wrapping panel component', { Component, props }); return ( - + {/* eslint-disable-next-line react/jsx-props-no-spreading */} - + ); } diff --git a/plugins/table-options-example/src/js/src/index.ts b/plugins/table-options-example/src/js/src/index.ts index 854532416..557d62590 100644 --- a/plugins/table-options-example/src/js/src/index.ts +++ b/plugins/table-options-example/src/js/src/index.ts @@ -5,6 +5,6 @@ export { ColumnInspectorPage } from './ColumnInspectorPage'; export { TableOptionsExampleMiddleware } from './TableOptionsExampleMiddleware'; export { TableOptionsExamplePanelMiddleware } from './TableOptionsExamplePanelMiddleware'; export { TableOptionsExamplePlugin } from './TableOptionsExamplePlugin'; -export { useComposedSidebarExtension } from './useComposedSidebarExtension'; +export { useComposedTableOptionsExtension } from './useComposedTableOptionsExtension'; export default TableOptionsExamplePlugin; diff --git a/plugins/table-options-example/src/js/src/useComposedSidebarExtension.test.tsx b/plugins/table-options-example/src/js/src/useComposedSidebarExtension.test.tsx index 368bb6909..b770c8cae 100644 --- a/plugins/table-options-example/src/js/src/useComposedSidebarExtension.test.tsx +++ b/plugins/table-options-example/src/js/src/useComposedSidebarExtension.test.tsx @@ -1,12 +1,12 @@ import { - IrisGridSidebarContext, - type IrisGridSidebarExtension, + IrisGridTableOptionsContext, + type IrisGridTableOptionsExtension, type OptionItem, OptionType, } from '@deephaven/iris-grid'; import { renderHook } from '@testing-library/react'; import React, { type ReactNode } from 'react'; -import { useComposedSidebarExtension } from './useComposedSidebarExtension'; +import { useComposedTableOptionsExtension } from './useComposedSidebarExtension'; import { COLUMN_INSPECTOR_ITEM_TYPE } from './columnInspectorItemType'; function makeDefaults(): readonly OptionItem[] { @@ -17,19 +17,19 @@ function makeDefaults(): readonly OptionItem[] { ]); } -function wrap(parent?: IrisGridSidebarExtension | null) { +function wrap(parent?: IrisGridTableOptionsExtension | null) { return function Wrapper({ children }: { children: ReactNode }) { return ( - + {children} - + ); }; } -describe('useComposedSidebarExtension', () => { +describe('useComposedTableOptionsExtension', () => { it('hides SELECT_DISTINCT and appends the column inspector item when no parent is present', () => { - const { result } = renderHook(() => useComposedSidebarExtension(), { + const { result } = renderHook(() => useComposedTableOptionsExtension(), { wrapper: wrap(null), }); const out = result.current.transformTableOptions!(makeDefaults()); @@ -51,7 +51,7 @@ describe('useComposedSidebarExtension', () => { o => o.type !== OptionType.QUICK_FILTERS ) as readonly OptionItem[] ); - const { result } = renderHook(() => useComposedSidebarExtension(), { + const { result } = renderHook(() => useComposedTableOptionsExtension(), { wrapper: wrap({ transformTableOptions: parentTransform }), }); const out = result.current.transformTableOptions!(makeDefaults()); @@ -68,7 +68,7 @@ describe('useComposedSidebarExtension', () => { transformTableOptions: (defaults: readonly OptionItem[]) => defaults, }; const { result, rerender } = renderHook( - () => useComposedSidebarExtension(), + () => useComposedTableOptionsExtension(), { wrapper: wrap(parent) } ); const first = result.current; diff --git a/plugins/table-options-example/src/js/src/useComposedSidebarExtension.ts b/plugins/table-options-example/src/js/src/useComposedSidebarExtension.ts index 426ffea89..86a2c5c76 100644 --- a/plugins/table-options-example/src/js/src/useComposedSidebarExtension.ts +++ b/plugins/table-options-example/src/js/src/useComposedSidebarExtension.ts @@ -1,7 +1,7 @@ import { useContext, useMemo } from 'react'; import { - IrisGridSidebarContext, - type IrisGridSidebarExtension, + IrisGridTableOptionsContext, + type IrisGridTableOptionsExtension, type OptionItem, OptionType, } from '@deephaven/iris-grid'; @@ -17,16 +17,16 @@ const COLUMN_INSPECTOR_ITEM: OptionItem = { /** * Composes this plugin's contribution on top of any parent - * `IrisGridSidebarContext` value already present in the tree: + * `IrisGridTableOptionsContext` value already present in the tree: * * parent.transformTableOptions → drop SELECT_DISTINCT → append COLUMN_INSPECTOR_ITEM * * Returning a memoized value keeps `IrisGrid`'s transform input * referentially stable between renders. */ -export function useComposedSidebarExtension(): IrisGridSidebarExtension { - const parent = useContext(IrisGridSidebarContext); - return useMemo(() => { +export function useComposedTableOptionsExtension(): IrisGridTableOptionsExtension { + const parent = useContext(IrisGridTableOptionsContext); + return useMemo(() => { const parentTransform = parent?.transformTableOptions; return { transformTableOptions: defaults => { @@ -41,4 +41,4 @@ export function useComposedSidebarExtension(): IrisGridSidebarExtension { }, [parent]); } -export default useComposedSidebarExtension; +export default useComposedTableOptionsExtension; diff --git a/plugins/table-options-example/src/js/src/useComposedTableOptionsExtension.test.tsx b/plugins/table-options-example/src/js/src/useComposedTableOptionsExtension.test.tsx new file mode 100644 index 000000000..18a7ab006 --- /dev/null +++ b/plugins/table-options-example/src/js/src/useComposedTableOptionsExtension.test.tsx @@ -0,0 +1,78 @@ +import { + IrisGridTableOptionsContext, + type IrisGridTableOptionsExtension, + type OptionItem, + OptionType, +} from '@deephaven/iris-grid'; +import { renderHook } from '@testing-library/react'; +import React, { type ReactNode } from 'react'; +import { useComposedTableOptionsExtension } from './useComposedTableOptionsExtension'; +import { COLUMN_INSPECTOR_ITEM_TYPE } from './columnInspectorItemType'; + +function makeDefaults(): readonly OptionItem[] { + return Object.freeze([ + { type: OptionType.QUICK_FILTERS, title: 'Quick Filters' }, + { type: OptionType.SELECT_DISTINCT, title: 'Select Distinct Values' }, + { type: OptionType.AGGREGATIONS, title: 'Aggregate Columns' }, + ]); +} + +function wrap(parent?: IrisGridTableOptionsExtension | null) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +describe('useComposedTableOptionsExtension', () => { + it('hides SELECT_DISTINCT and appends the column inspector item when no parent is present', () => { + const { result } = renderHook(() => useComposedTableOptionsExtension(), { + wrapper: wrap(null), + }); + const out = result.current.transformTableOptions!(makeDefaults()); + + expect(out.map(o => o.type)).toEqual([ + OptionType.QUICK_FILTERS, + OptionType.AGGREGATIONS, + COLUMN_INSPECTOR_ITEM_TYPE, + ]); + const item = out.find(o => o.type === COLUMN_INSPECTOR_ITEM_TYPE)!; + expect(item.title).toBe('Column Inspector'); + expect(item.configPage).toBeDefined(); + }); + + it('composes on top of a parent transform (parent runs first)', () => { + const parentTransform = jest.fn( + (defaults: readonly OptionItem[]) => + defaults.filter( + o => o.type !== OptionType.QUICK_FILTERS + ) as readonly OptionItem[] + ); + const { result } = renderHook(() => useComposedTableOptionsExtension(), { + wrapper: wrap({ transformTableOptions: parentTransform }), + }); + const out = result.current.transformTableOptions!(makeDefaults()); + + expect(parentTransform).toHaveBeenCalledTimes(1); + expect(out.map(o => o.type)).toEqual([ + OptionType.AGGREGATIONS, + COLUMN_INSPECTOR_ITEM_TYPE, + ]); + }); + + it('returns a stable transform across renders when parent is unchanged', () => { + const parent = { + transformTableOptions: (defaults: readonly OptionItem[]) => defaults, + }; + const { result, rerender } = renderHook( + () => useComposedTableOptionsExtension(), + { wrapper: wrap(parent) } + ); + const first = result.current; + rerender(); + expect(result.current).toBe(first); + }); +}); diff --git a/plugins/table-options-example/src/js/src/useComposedTableOptionsExtension.ts b/plugins/table-options-example/src/js/src/useComposedTableOptionsExtension.ts new file mode 100644 index 000000000..86a2c5c76 --- /dev/null +++ b/plugins/table-options-example/src/js/src/useComposedTableOptionsExtension.ts @@ -0,0 +1,44 @@ +import { useContext, useMemo } from 'react'; +import { + IrisGridTableOptionsContext, + type IrisGridTableOptionsExtension, + type OptionItem, + OptionType, +} from '@deephaven/iris-grid'; +import { ColumnInspectorPage } from './ColumnInspectorPage'; +import { COLUMN_INSPECTOR_ITEM_TYPE } from './columnInspectorItemType'; + +const COLUMN_INSPECTOR_ITEM: OptionItem = { + type: COLUMN_INSPECTOR_ITEM_TYPE, + title: 'Column Inspector', + subtitle: 'Plugin-contributed sidebar page', + configPage: ColumnInspectorPage, +}; + +/** + * Composes this plugin's contribution on top of any parent + * `IrisGridTableOptionsContext` value already present in the tree: + * + * parent.transformTableOptions → drop SELECT_DISTINCT → append COLUMN_INSPECTOR_ITEM + * + * Returning a memoized value keeps `IrisGrid`'s transform input + * referentially stable between renders. + */ +export function useComposedTableOptionsExtension(): IrisGridTableOptionsExtension { + const parent = useContext(IrisGridTableOptionsContext); + return useMemo(() => { + const parentTransform = parent?.transformTableOptions; + return { + transformTableOptions: defaults => { + const base = + parentTransform != null ? parentTransform(defaults) : defaults; + const filtered = base.filter( + o => o.type !== OptionType.SELECT_DISTINCT + ); + return [...filtered, COLUMN_INSPECTOR_ITEM]; + }, + }; + }, [parent]); +} + +export default useComposedTableOptionsExtension; From 33131f2f86311d551acfb2ffa9d05453a842b250 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Fri, 22 May 2026 12:24:53 -0600 Subject: [PATCH 04/18] Pivot Builder wip --- package-lock.json | 51 +++ plans/DH-21476-pivot-builder-plugin.md | 199 ++++++++++ plugins/manifest.json | 9 +- plugins/pivot-builder/README.md | 59 +++ plugins/pivot-builder/src/js/.gitignore | 3 + plugins/pivot-builder/src/js/package.json | 46 +++ .../src/js/src/CreatePivotPage.tsx | 184 +++++++++ .../src/js/src/PivotBuilderIrisGridModel.ts | 355 ++++++++++++++++++ .../src/js/src/PivotBuilderMiddleware.tsx | 29 ++ .../src/js/src/PivotBuilderPanelContext.ts | 16 + .../js/src/PivotBuilderPanelMiddleware.tsx | 46 +++ .../src/js/src/PivotBuilderPlugin.ts | 15 + .../src/js/src/PivotBuilderWidget.tsx | 115 ++++++ .../src/js/src/createPivotItemType.ts | 6 + plugins/pivot-builder/src/js/src/index.ts | 16 + .../src/useComposedTableOptionsExtension.ts | 36 ++ plugins/pivot-builder/src/js/vite.config.ts | 41 ++ plugins/pivot/src/js/src/index.ts | 5 + .../table-options-example/src/js/README.md | 25 ++ .../table-options-example/src/js/package.json | 2 +- 20 files changed, 1256 insertions(+), 2 deletions(-) create mode 100644 plans/DH-21476-pivot-builder-plugin.md create mode 100644 plugins/pivot-builder/README.md create mode 100644 plugins/pivot-builder/src/js/.gitignore create mode 100644 plugins/pivot-builder/src/js/package.json create mode 100644 plugins/pivot-builder/src/js/src/CreatePivotPage.tsx create mode 100644 plugins/pivot-builder/src/js/src/PivotBuilderIrisGridModel.ts create mode 100644 plugins/pivot-builder/src/js/src/PivotBuilderMiddleware.tsx create mode 100644 plugins/pivot-builder/src/js/src/PivotBuilderPanelContext.ts create mode 100644 plugins/pivot-builder/src/js/src/PivotBuilderPanelMiddleware.tsx create mode 100644 plugins/pivot-builder/src/js/src/PivotBuilderPlugin.ts create mode 100644 plugins/pivot-builder/src/js/src/PivotBuilderWidget.tsx create mode 100644 plugins/pivot-builder/src/js/src/createPivotItemType.ts create mode 100644 plugins/pivot-builder/src/js/src/index.ts create mode 100644 plugins/pivot-builder/src/js/src/useComposedTableOptionsExtension.ts create mode 100644 plugins/pivot-builder/src/js/vite.config.ts diff --git a/package-lock.json b/package-lock.json index 27fa22c83..fb57e0992 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3146,6 +3146,10 @@ "resolved": "plugins/pivot/src/js", "link": true }, + "node_modules/@deephaven/js-plugin-pivot-builder": { + "resolved": "plugins/pivot-builder/src/js", + "link": true + }, "node_modules/@deephaven/js-plugin-plotly-express": { "resolved": "plugins/plotly-express/src/js", "link": true @@ -3158,6 +3162,10 @@ "resolved": "plugins/table-example/src/js", "link": true }, + "node_modules/@deephaven/js-plugin-table-options-example": { + "resolved": "plugins/table-options-example/src/js", + "link": true + }, "node_modules/@deephaven/js-plugin-theme-pack": { "resolved": "plugins/theme-pack/src/js", "link": true @@ -31665,6 +31673,31 @@ "node": "^18 || >=20" } }, + "plugins/pivot-builder/src/js": { + "name": "@deephaven/js-plugin-pivot-builder", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@deephaven/components": "^1.17.0", + "@deephaven/iris-grid": "^1.18.0", + "@deephaven/js-plugin-pivot": "*", + "@deephaven/jsapi-bootstrap": "^1.17.0", + "@deephaven/jsapi-types": "^1.0.0-dev0.39.6", + "@deephaven/jsapi-utils": "^1.16.0", + "@deephaven/log": "^1.8.0", + "@deephaven/plugin": "^1.18.0", + "@deephaven/utils": "^1.10.0", + "fast-deep-equal": "^3.1.3" + }, + "devDependencies": { + "@deephaven-enterprise/jsapi-coreplus-types": "^1.20240517.518", + "@types/react": "^18.0.0", + "react": "^18.0.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "plugins/pivot/src/js": { "name": "@deephaven/js-plugin-pivot", "version": "0.4.0", @@ -31939,6 +31972,24 @@ "react": "^18.0.0 || ^19.0.0" } }, + "plugins/table-options-example/src/js": { + "name": "@deephaven/js-plugin-table-options-example", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@deephaven/components": "^1.17.0", + "@deephaven/iris-grid": "^1.17.0", + "@deephaven/log": "^1.8.0", + "@deephaven/plugin": "^1.17.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "react": "^18.0.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "plugins/theme-pack/src/js": { "name": "@deephaven/js-plugin-theme-pack", "version": "0.2.0", diff --git a/plans/DH-21476-pivot-builder-plugin.md b/plans/DH-21476-pivot-builder-plugin.md new file mode 100644 index 000000000..d68e6decb --- /dev/null +++ b/plans/DH-21476-pivot-builder-plugin.md @@ -0,0 +1,199 @@ +# DH-21476: pivot-builder plugin (spike) + +## TL;DR + +New JS-only `WidgetMiddlewarePlugin` at `plugins/pivot-builder/`, modeled on +[`table-options-example`](../plugins/table-options-example/). It owns a new +proxy model `PivotBuilderIrisGridModel` (JS-`Proxy` pattern from +[`IrisGridProxyModel`](https://github.com/deephaven/web-client-ui/blob/main/packages/iris-grid/src/IrisGridProxyModel.ts)) +whose inner model is either the original `IrisGridTableModel` or an +`IrisGridPivotModel`. A `pivotConfig` getter/setter mirrors `rollupConfig`; +the setter calls `coreplus.pivot.PivotService.createPivotTable(...)` (API +copied from the local `grid-toolbar` plugin +[`usePivotToggle.ts`](https://github.com/deephaven/deephaven-plugins/blob/pivot-builder-plugin/plugins/grid-toolbar/src/js/src/usePivotToggle.ts)) +and `setNextModel(promise)` to swap. + +The middleware replaces the default `Component` rendering: it fetches the +source `Table`, constructs `PivotBuilderIrisGridModel`, and renders +`` wrapped in an +`IrisGridTableOptionsContext.Provider`. A custom "Create Pivot" Table Options +menu item (`configPage: CreatePivotPage`) receives that model via +`IrisGridTableOptionsPageProps` and calls `model.pivotConfig = defaultConfig` +on click; `pivotConfig = null` reverts. + +Defaults derived inside the setter from `originalTable.columns`: + +- `rowKeys`: first non-numeric column (or first column if all numeric) +- `columnKeys`: second non-numeric column if available, else `[]` +- `aggregations`: `{ Sum: [] }` if any numeric, else + `{ Count: [] }` + +Scope: `supportedTypes: ['Table']` only. Spike-quality — no styling polish, +no Python package, no tests. + +## Phases & Steps + +### Phase 1 — Scaffold the plugin package (no deps on others) + +1. Create `plugins/pivot-builder/` with `LICENSE` and a minimal `README.md`. +2. Create `plugins/pivot-builder/src/js/package.json` cloned from + [`table-options-example/src/js/package.json`](../plugins/table-options-example/src/js/package.json). + Rename to `@deephaven/js-plugin-pivot-builder`. Add deps: + - `@deephaven/components`, `@deephaven/iris-grid`, + `@deephaven/jsapi-bootstrap`, `@deephaven/jsapi-types`, + `@deephaven/log`, `@deephaven/plugin` + - peerDeps: `react`, `@deephaven-enterprise/jsapi-coreplus-types`, + `@deephaven/js-plugin-pivot` (for `IrisGridPivotModel`, `isCorePlusDh`) +3. Create `plugins/pivot-builder/src/js/vite.config.ts` and `tsconfig.json` + cloned from `table-options-example`, with externals updated for the new + deps. +4. Create `plugins/pivot-builder/src/js/.gitignore` for `dist/` and + `node_modules/`. + +### Phase 2 — Core model class (parallel with Phase 1 once scaffold exists) + +5. Create `plugins/pivot-builder/src/js/src/PivotBuilderIrisGridModel.ts`: + - `class PivotBuilderIrisGridModel extends IrisGridModel` using the same + JS `Proxy` constructor trick as `IrisGridProxyModel` (lines 80–120) to + forward unimplemented props to the current inner model. + - State: `originalModel`, `model` (current), `originalTable`, + `pspWidget`, `dh`, `pivot: PivotConfig | null`, `modelPromise`. + - Constructor `(dh, originalTable, pspWidget, formatter?)`. Build inner + via `new IrisGridTableModel(dh, originalTable, formatter)`; call + `startListeningInner(inner)`. + - `get/set pivotConfig`: mirror `IrisGridProxyModel.set rollupConfig` + (lines 400–420). Deep-equal short-circuit, `setNextModel(promise)`. On + `null` swap back to `originalModel`. On non-null: fetch + `PivotService.getInstance(pspWidget)`, call + `service.createPivotTable({ source: originalTable, rowKeys, + columnKeys, aggregations })`, then + `new IrisGridPivotModel(dh, pivotTable)`. + - `setNextModel(promise)`: cancel prior, await, swap listeners + (`stopListeningInner(old)` / `startListeningInner(new)`), dispatch + `EVENT.COLUMNS_CHANGED` and `EVENT.UPDATED`. + - `startListeningInner`/`stopListeningInner`: forward all + `IrisGridModel.EVENT.*` from inner to self via `handleModelEvent` (copy + pattern from `IrisGridPivotModel.removeListeners` and + `IrisGridProxyModel` addListeners). + - `close()`: close current inner model + pivot table if active. + - Static helper `makeDefaultPivotConfig(columns)` per TL;DR. + +### Phase 3 — Middleware & sidebar item (depends on Phase 2) + +6. `createPivotItemType.ts`: `export const CREATE_PIVOT_ITEM_TYPE = + 'plugin:pivot-builder:create-pivot'`. +7. `CreatePivotPage.tsx`: + - Imports `IrisGridTableOptionsPageProps` from `@deephaven/iris-grid`. + - Narrows `model` to `PivotBuilderIrisGridModel`; renders Back + a + primary "Create Pivot" button. Click: + `model.pivotConfig = PivotBuilderIrisGridModel.makeDefaultPivotConfig(model.columns)`. + Show "Reset" when `model.pivotConfig != null` that sets + `pivotConfig = null`. + - Defensive `instanceof` check; render note if model isn't the expected + type. +8. `useComposedTableOptionsExtension.ts`: mirror + `table-options-example/.../useComposedTableOptionsExtension.ts`. Append + the `CREATE_PIVOT_ITEM`; do not filter built-ins. +9. `PivotBuilderWidget.tsx`: + - `useApi()` for `dh`, `useObjectFetch` keyed off + `props.metadata` with `{ ...metadata, type: 'PivotService', name: + 'psp' }` (same probe pattern as `usePivotToggle.ts` lines 70–112). + - `props.fetch()` resolves the source `Table`. Use + `useEffect`+state to build `PivotBuilderIrisGridModel(dh, table, + pspWidget)` once. + - Render + ` + + ` + with `` while loading. +10. `PivotBuilderMiddleware.tsx`: `WidgetMiddlewareComponentProps` → + ignore `Component`, render ``. +11. `PivotBuilderPanelMiddleware.tsx`: spike stub — forward to default + `Component` wrapped only in `IrisGridTableOptionsContext.Provider` + (model in panel path is not `PivotBuilderIrisGridModel`, so the menu + item is non-functional there). Documented in README. +12. `PivotBuilderPlugin.ts`: `WidgetMiddlewarePlugin`, `name: + '@deephaven/js-plugin-pivot-builder'`, `type: + PluginType.MIDDLEWARE_PLUGIN`, `supportedTypes: ['Table']`, + `component: PivotBuilderMiddleware`, `panelComponent: + PivotBuilderPanelMiddleware`. +13. `index.ts`: default export `PivotBuilderPlugin`; named exports for + `PivotBuilderIrisGridModel`, `PivotConfig`, item type. + +### Phase 4 — Wire into monorepo + +14. Confirm `plugins/pivot-builder/src/js` is picked up by root lerna + workspaces (existing glob is `plugins/*/src/js`). +15. `npm install` at repo root. +16. `cd plugins/pivot-builder/src/js && npx vite build`. + +## Relevant files + +- [`grid-toolbar/src/js/src/usePivotToggle.ts`](https://github.com/deephaven/deephaven-plugins/blob/pivot-builder-plugin/plugins/grid-toolbar/src/js/src/usePivotToggle.ts) + — source-of-truth for `PivotService.getInstance(pspWidget)` + + `createPivotTable({source, rowKeys, columnKeys, aggregations})`. Also for + the `psp` widget probe pattern. +- [`grid-toolbar/src/js/src/PivotBuilderDialog.tsx`](https://github.com/deephaven/deephaven-plugins/blob/pivot-builder-plugin/plugins/grid-toolbar/src/js/src/PivotBuilderDialog.tsx) + — `PivotConfig` shape (`rowKeys`, `columnKeys`, `aggregations: + Record`) and `NUMERIC_TYPES` set. +- [`plugins/table-options-example/src/js/`](../plugins/table-options-example/src/js/) + — full scaffold template (package.json, vite.config.ts, + plugin/middleware/extension/page files). +- [`plugins/pivot/src/js/src/IrisGridPivotModel.ts`](../plugins/pivot/src/js/src/IrisGridPivotModel.ts) + — exported via `@deephaven/js-plugin-pivot`; constructor `(dh, + pivotTable)`. +- [`plugins/pivot/src/js/src/PivotUtils.ts`](../plugins/pivot/src/js/src/PivotUtils.ts) + — `isCorePlusDh(dh)` helper. +- [`IrisGridProxyModel.ts`](https://github.com/deephaven/web-client-ui/blob/main/packages/iris-grid/src/IrisGridProxyModel.ts) + lines 80–120 (Proxy constructor) and 400–420 (`set rollupConfig` + + `setNextModel`). +- [`IrisGridModel.ts`](https://github.com/deephaven/web-client-ui/blob/main/packages/iris-grid/src/IrisGridModel.ts) + — base class + `EVENT` enum. +- [`IrisGridTableOptionsContext.tsx`](https://github.com/deephaven/web-client-ui/blob/vlad-DH-21476-table-options/packages/iris-grid/src/sidebar/IrisGridTableOptionsContext.tsx) + and `CommonTypes.tsx` lines 50–80 (`IrisGridTableOptionsPageProps`, + `OptionItem.configPage`). + +## Verification + +1. `cd plugins/pivot-builder/src/js && npx vite build` succeeds with no TS + errors. +2. With the deephaven-plugins dev proxy + web-client-ui + a DHE Core+ worker + running, open a flat table widget on a query that also exports a + `PivotService` named `psp`: + - Grid renders normally (via `PivotBuilderIrisGridModel` wrapping + `IrisGridTableModel`). + - Table Options sidebar shows "Create Pivot" at the bottom of the menu. + - Clicking "Create Pivot" opens `CreatePivotPage` with a "Create Pivot" + button. + - Clicking the button: grid swaps to pivot view; browser console shows + `Creating pivot with config:`. + - "Reset" restores the original flat table. +3. Negative: same widget on a worker with no `psp` variable. Page still + renders; clicking surfaces the error in console; flat table still works. +4. Lint clean: `npm run test:lint -- --selectProjects eslint + --testPathPattern plugins/pivot-builder`. + +## Decisions + +- JS-only plugin (matches `table-options-example`); no Python package, no + `register.py`, no tox. +- Panel path (`panelComponent`) keeps default `IrisGridPanel`; menu item is + non-functional there. Documented in README. Spike acceptable. +- Defaults baked into a static helper on the model — no UI for + configuration in this spike. +- `pivotConfig = null` reverts to the original model. +- Re-export `IrisGridPivotModel` + `isCorePlusDh` via + `@deephaven/js-plugin-pivot` peer dep (same as `grid-toolbar`). + +## Further considerations + +1. **`psp` widget discovery**: hardcoded name `'psp'` (matches + `usePivotToggle`). If the worker exports `PivotService` under a different + name, the spike fails. Option A: keep hardcoded. Option B: scan all + variables for `type === 'PivotService'`. **Recommend A**. +2. **Panel path support**: A — keep stub (current). B — replicate widget + rendering inside an `IrisGridPanel` clone. C — swap model post-hoc by + replacing `IrisGridPanel.makeModel`. **Recommend A**. +3. **`originalTable` lifecycle**: keep the original `IrisGridTableModel` + alive while pivot is active so revert is instant; close both on + `PivotBuilderIrisGridModel.close()`. **Recommend** this. diff --git a/plugins/manifest.json b/plugins/manifest.json index 50554e7c1..4f8b67a99 100644 --- a/plugins/manifest.json +++ b/plugins/manifest.json @@ -34,12 +34,19 @@ { "name": "pivot", "version": "0.0.0", - "main": "src/js/dist/index.js" + "main": "src/js/dist/index.js", + "package": "@deephaven/js-plugin-pivot" }, { "name": "table-options-example", "version": "0.0.0", "main": "src/js/dist/bundle/index.js" + }, + { + "name": "pivot-builder", + "version": "0.0.0", + "main": "src/js/dist/index.js", + "dependencies": ["@deephaven/js-plugin-pivot"] } ] } diff --git a/plugins/pivot-builder/README.md b/plugins/pivot-builder/README.md new file mode 100644 index 000000000..eaa62e739 --- /dev/null +++ b/plugins/pivot-builder/README.md @@ -0,0 +1,59 @@ +# Deephaven JS Plugin: Pivot Builder (spike) + +Spike `WidgetMiddlewarePlugin` for [DH-21476](https://deephaven.atlassian.net/browse/DH-21476). +Adds a **Create Pivot** item to the IrisGrid Table Options sidebar for flat +`Table` widgets. Clicking the button calls the Core+ Pivot API to build a +pivot from the underlying table with sensible defaults and swaps the grid +view in place. + +Reference plan: [`plans/DH-21476-pivot-builder-plugin.md`](../../plans/DH-21476-pivot-builder-plugin.md). + +## How it works + +- The middleware **replaces** the default widget renderer for `Table` + widgets. It fetches the source `Table`, builds a + `PivotBuilderIrisGridModel` that wraps an inner `IrisGridTableModel`, + and renders `` directly inside an + `IrisGridTableOptionsContext.Provider`. +- `PivotBuilderIrisGridModel` mirrors the proxy pattern from + `IrisGridProxyModel` (JS `Proxy` constructor that forwards unimplemented + props to the current inner model). +- It exposes a `pivotConfig` getter/setter that mirrors `rollupConfig`: + assigning a non-null config triggers + `coreplus.pivot.PivotService.createPivotTable(...)` and swaps the inner + model to an `IrisGridPivotModel`. Assigning `null` reverts to the + original `IrisGridTableModel`. + +## Defaults + +`PivotBuilderIrisGridModel.makeDefaultPivotConfig(columns)`: + +- `rowKeys`: first non-numeric column (or first column if all numeric) +- `columnKeys`: second non-numeric column if available, else `[]` +- `aggregations`: `{ Sum: [] }`, or `{ Count: [] }` + when there are no numeric columns + +## Requirements + +- DHE Core+ worker (Pivot API lives in `@deephaven-enterprise/jsapi-coreplus-types`). +- The query must export a `PivotService` variable named `psp` (same + convention used by the reference `grid-toolbar` plugin). + +## Known limitations (spike) + +- `supportedTypes` is `['Table']` only. +- The `panelComponent` path is a stub — it only injects the menu item but + does not wrap the model with `PivotBuilderIrisGridModel`, so the + "Create Pivot" button is non-functional inside `IrisGridPanel`. Use the + non-panel widget path (e.g. `GridWidgetPlugin`) to exercise the spike. +- No persistence, no UI for picking row/column/value columns — the defaults + helper picks them automatically. + +## Build + +``` +npm install +npm run build +``` + +Bundle is emitted at `dist/index.js`. diff --git a/plugins/pivot-builder/src/js/.gitignore b/plugins/pivot-builder/src/js/.gitignore new file mode 100644 index 000000000..84f3869a7 --- /dev/null +++ b/plugins/pivot-builder/src/js/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +*.tsbuildinfo diff --git a/plugins/pivot-builder/src/js/package.json b/plugins/pivot-builder/src/js/package.json new file mode 100644 index 000000000..732b9b560 --- /dev/null +++ b/plugins/pivot-builder/src/js/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deephaven/js-plugin-pivot-builder", + "version": "0.0.0", + "description": "Spike WidgetMiddlewarePlugin that adds a Create Pivot item to the IrisGrid Table Options sidebar (DH-21476).", + "keywords": [ + "Deephaven", + "plugin", + "deephaven-js-plugin", + "pivot", + "table-options", + "middleware" + ], + "author": "Deephaven Data Labs LLC", + "license": "Apache-2.0", + "main": "dist/index.js", + "files": [ + "dist/index.js" + ], + "scripts": { + "start": "vite build --watch", + "build": "vite build" + }, + "dependencies": { + "@deephaven/components": "^1.17.0", + "@deephaven/iris-grid": "^1.18.0", + "@deephaven/jsapi-bootstrap": "^1.17.0", + "@deephaven/jsapi-types": "^1.0.0-dev0.39.6", + "@deephaven/jsapi-utils": "^1.16.0", + "@deephaven/js-plugin-pivot": "*", + "@deephaven/log": "^1.8.0", + "@deephaven/plugin": "^1.18.0", + "@deephaven/utils": "^1.10.0", + "fast-deep-equal": "^3.1.3" + }, + "devDependencies": { + "@deephaven-enterprise/jsapi-coreplus-types": "^1.20240517.518", + "@types/react": "^18.0.0", + "react": "^18.0.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx new file mode 100644 index 000000000..326732c07 --- /dev/null +++ b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx @@ -0,0 +1,184 @@ +import { useCallback, useContext, useMemo, useState } from 'react'; +import { Button } from '@deephaven/components'; +import { type IrisGridTableOptionsPageProps } from '@deephaven/iris-grid'; +import { useApi, useObjectFetch } from '@deephaven/jsapi-bootstrap'; +import { IrisGridPivotModel, isCorePlusDh } from '@deephaven/js-plugin-pivot'; +import Log from '@deephaven/log'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import type { dh as CorePlusDhType } from '@deephaven-enterprise/jsapi-coreplus-types'; +import PivotBuilderIrisGridModel, { + isPivotBuilderIrisGridModel, +} from './PivotBuilderIrisGridModel'; +import { PivotBuilderPanelContext } from './PivotBuilderPanelContext'; + +const log = Log.module('@deephaven/js-plugin-pivot-builder/CreatePivotPage'); + +/** + * Returns `true` when `model` quacks like the host + * `IrisGridProxyModel` (it owns a swappable inner model via `setNextModel` + * and exposes the current `table`). We avoid an `instanceof` check so the + * plugin doesn't pin its build to a specific iris-grid copy. + */ +function isSwappableProxy(model: unknown): model is { + setNextModel(promise: Promise): void; + table?: DhType.Table; + model?: { table?: DhType.Table }; +} { + return ( + typeof model === 'object' && + model !== null && + typeof (model as { setNextModel?: unknown }).setNextModel === 'function' + ); +} + +function getProxyTable(model: unknown): DhType.Table | null { + if (!isSwappableProxy(model)) return null; + // IrisGridProxyModel forwards `table` to the inner IrisGridTableModel. + const t = model.table ?? model.model?.table; + return t ?? null; +} + +/** + * Sidebar `configPage` for the Create Pivot menu item. + * + * Two paths: + * - **Non-panel widget path**: the active model is a + * `PivotBuilderIrisGridModel`; we just write a default `pivotConfig`. + * - **Panel path**: the active model is the host `IrisGridProxyModel`; + * we build the pivot ourselves and hand it to `setNextModel`. + */ +export function CreatePivotPage({ + model, + onBack, +}: IrisGridTableOptionsPageProps): JSX.Element { + const dh = useApi(); + const panelContext = useContext(PivotBuilderPanelContext); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const isProxy = isPivotBuilderIrisGridModel(model); + const hasPivot = isProxy && model.pivotConfig != null; + const swappableProxy = isSwappableProxy(model) ? model : null; + const panelPathReady = + !isProxy && + swappableProxy != null && + isCorePlusDh(dh) && + panelContext?.metadata != null; + + // Subscribe to the well-known `psp` PivotService when in panel mode. + const pspDescriptor = useMemo(() => { + if (!panelPathReady || panelContext?.metadata == null) { + return { type: 'PivotService', name: '__unavailable__' }; + } + return { ...panelContext.metadata, type: 'PivotService', name: 'psp' }; + }, [panelPathReady, panelContext]); + const pspFetch = useObjectFetch(pspDescriptor); + + const canCreate = + isProxy || + (panelPathReady && + (pspFetch.status === 'ready' || pspFetch.status === 'loading')); + + const handleCreate = useCallback(async () => { + setError(null); + if (isPivotBuilderIrisGridModel(model)) { + try { + const defaults = PivotBuilderIrisGridModel.makeDefaultPivotConfig( + model.columns + ); + log.info('Applying default pivot config (widget path)', defaults); + model.pivotConfig = defaults; + onBack(); + } catch (e) { + log.error('Failed to apply pivot config', e); + setError(e instanceof Error ? e.message : String(e)); + } + return; + } + + if (swappableProxy == null || !isCorePlusDh(dh)) { + setError('Create Pivot requires the CorePlus JS API.'); + return; + } + const table = getProxyTable(model); + if (table == null) { + setError('Active model has no underlying table.'); + return; + } + if (pspFetch.status !== 'ready') { + setError('PivotService is not ready yet — try again in a moment.'); + return; + } + + setBusy(true); + try { + const pspWidget = (await pspFetch.fetch()) as CorePlusDhType.Widget; + const config = PivotBuilderIrisGridModel.makeDefaultPivotConfig( + model.columns + ); + log.info('Building pivot via panel path', config); + const pivotService = await ( + dh as unknown as typeof CorePlusDhType + ).coreplus.pivot.PivotService.getInstance(pspWidget); + const pivotTable = await pivotService.createPivotTable({ + source: table as unknown as CorePlusDhType.Table, + rowKeys: config.rowKeys, + columnKeys: config.columnKeys, + aggregations: config.aggregations, + }); + const pivotModel = new IrisGridPivotModel( + dh as unknown as typeof CorePlusDhType, + pivotTable + ); + swappableProxy.setNextModel(Promise.resolve(pivotModel)); + onBack(); + } catch (e) { + log.error('Failed to build pivot (panel path)', e); + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }, [model, swappableProxy, dh, pspFetch, onBack]); + + const handleReset = useCallback(() => { + if (!isPivotBuilderIrisGridModel(model)) return; + log.info('Reverting to flat table model'); + model.pivotConfig = null; + setError(null); + }, [model]); + + return ( +
+
Create Pivot
+

+ Build a pivot view of this table using sensible defaults. The first + non-numeric column becomes the row key, the second non-numeric column + (if any) becomes the column key, and all numeric columns are summed. +

+ {error != null && ( +

+ {error} +

+ )} +
+ + + {hasPivot && ( + + )} +
+
+ ); +} + +export default CreatePivotPage; diff --git a/plugins/pivot-builder/src/js/src/PivotBuilderIrisGridModel.ts b/plugins/pivot-builder/src/js/src/PivotBuilderIrisGridModel.ts new file mode 100644 index 000000000..d12d66c55 --- /dev/null +++ b/plugins/pivot-builder/src/js/src/PivotBuilderIrisGridModel.ts @@ -0,0 +1,355 @@ +import deepEqual from 'fast-deep-equal'; +import { IrisGridModel, IrisGridTableModel } from '@deephaven/iris-grid'; +import { Formatter } from '@deephaven/jsapi-utils'; +import { IrisGridPivotModel, isCorePlusDh } from '@deephaven/js-plugin-pivot'; +import Log from '@deephaven/log'; +import { + type CancelablePromise, + EventShimCustomEvent, + PromiseUtils, +} from '@deephaven/utils'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import type { dh as CorePlusDhType } from '@deephaven-enterprise/jsapi-coreplus-types'; + +const log = Log.module( + '@deephaven/js-plugin-pivot-builder/PivotBuilderIrisGridModel' +); + +/** + * Numeric column type check, copied from the reference `grid-toolbar` plugin + * (`usePivotToggle.ts`). + */ +const NUMERIC_TYPES = new Set([ + 'int', + 'long', + 'short', + 'byte', + 'double', + 'float', + 'java.lang.Integer', + 'java.lang.Long', + 'java.lang.Short', + 'java.lang.Byte', + 'java.lang.Double', + 'java.lang.Float', + 'java.math.BigDecimal', + 'java.math.BigInteger', +]); + +/** + * User-configured pivot settings. Shape mirrors the request payload accepted + * by `coreplus.pivot.PivotService#createPivotTable`. + */ +export interface PivotConfig { + rowKeys: string[]; + columnKeys: string[]; + /** e.g. `{ Sum: ['price', 'qty'] }`. */ + aggregations: Record; +} + +/** + * Proxy `IrisGridModel` that can swap its inner model between the original + * `IrisGridTableModel` (flat) and an `IrisGridPivotModel` (pivot view). + * + * Mirrors `IrisGridProxyModel` from web-client-ui — uses the JS `Proxy` + * constructor trick so that any property/method this class does not + * implement is forwarded to the current inner model. + */ +class PivotBuilderIrisGridModel extends IrisGridModel { + /** Source flat table; needed to build the pivot. */ + private originalTable: DhType.Table; + + /** PivotService widget fetched from the same query (commonly `psp`). */ + private pspWidget: DhType.Widget; + + /** Inner model for the flat view. Kept alive across pivot swaps. */ + private originalModel: IrisGridModel; + + /** Currently active inner model (either `originalModel` or a pivot model). */ + model: IrisGridModel; + + private pivot: PivotConfig | null; + + private irisFormatter: Formatter; + + private modelPromise: CancelablePromise | null; + + // Re-typed to the CorePlus API for pivot calls. The base class field is + // `typeof DhType` — narrowing here keeps consumers happy. + declare dh: typeof CorePlusDhType; + + constructor( + dh: typeof DhType | typeof CorePlusDhType, + originalTable: DhType.Table, + pspWidget: DhType.Widget, + formatter = new Formatter(dh) + ) { + if (!isCorePlusDh(dh)) { + throw new Error('CorePlus is not available; pivot builder requires DHE'); + } + + super(dh); + + // EventTarget methods must be bound; the Proxy below would otherwise + // throw on `this` binding for these. + this.addEventListener = this.addEventListener.bind(this); + this.removeEventListener = this.removeEventListener.bind(this); + this.dispatchEvent = this.dispatchEvent.bind(this); + this.handleModelEvent = this.handleModelEvent.bind(this); + + this.dh = dh; + this.originalTable = originalTable; + this.pspWidget = pspWidget; + this.irisFormatter = formatter; + this.pivot = null; + this.modelPromise = null; + + const inner = new IrisGridTableModel(dh, originalTable, formatter); + this.originalModel = inner; + this.model = inner; + + // eslint-disable-next-line no-constructor-return + return new Proxy(this, { + get(target, prop, receiver) { + const proto = Object.getPrototypeOf(target); + const proxyHasGetter = + Object.getOwnPropertyDescriptor(proto, prop)?.get != null; + if (proxyHasGetter) { + return Reflect.get(target, prop, receiver); + } + const proxyHasProp = Object.prototype.hasOwnProperty.call(target, prop); + const proxyHasFn = Object.prototype.hasOwnProperty.call(proto, prop); + if (proxyHasProp || proxyHasFn) { + return Reflect.get(target, prop, receiver); + } + // Delegate everything else to the current inner model. + const inner = target.model as unknown as Record; + const value = inner[prop]; + if (typeof value === 'function') { + return (value as (...args: unknown[]) => unknown).bind(target.model); + } + return value; + }, + set(target, prop, value, receiver) { + const proto = Object.getPrototypeOf(target); + const proxyHasSetter = + Object.getOwnPropertyDescriptor(proto, prop)?.set != null; + if (proxyHasSetter) { + return Reflect.set(target, prop, value, receiver); + } + const proxyHasProp = Object.prototype.hasOwnProperty.call(target, prop); + if (proxyHasProp) { + return Reflect.set(target, prop, value, receiver); + } + // Delegate to the current inner model. + const inner = target.model as unknown as Record; + inner[prop] = value; + return true; + }, + }); + } + + // --- pivotConfig ---------------------------------------------------- + + get pivotConfig(): PivotConfig | null { + return this.pivot; + } + + set pivotConfig(config: PivotConfig | null) { + log.debug('set pivotConfig', config); + + if (deepEqual(config, this.pivot)) { + return; + } + this.pivot = config; + + if (config == null) { + // Revert to the original flat-table model. + this.setNextModel(Promise.resolve(this.originalModel)); + return; + } + + const modelPromise = this.buildPivotModel(config); + this.setNextModel(modelPromise); + } + + /** + * Build a pivot via `PivotService.createPivotTable` and wrap the resulting + * pivot table in an `IrisGridPivotModel`. + */ + private async buildPivotModel(config: PivotConfig): Promise { + log.info('Creating pivot with config:', config); + + const pivotService = await this.dh.coreplus.pivot.PivotService.getInstance( + this.pspWidget + ); + const pivotTable = await pivotService.createPivotTable({ + source: this.originalTable, + rowKeys: config.rowKeys, + columnKeys: config.columnKeys, + aggregations: config.aggregations, + }); + return new IrisGridPivotModel(this.dh, pivotTable); + } + + // --- model swap (mirrors IrisGridProxyModel.setNextModel/setModel) -- + + private setModel(model: IrisGridModel): void { + log.debug('setModel', model); + const oldModel = this.model; + if (oldModel !== this.originalModel && oldModel !== model) { + oldModel.close(); + } + this.model = model; + if (this.listenerCount > 0) { + this.addListeners(model); + } + this.dispatchEvent( + new EventShimCustomEvent(IrisGridModel.EVENT.COLUMNS_CHANGED, { + detail: model.columns, + }) + ); + this.dispatchEvent( + new EventShimCustomEvent(IrisGridModel.EVENT.UPDATED, { detail: null }) + ); + } + + private setNextModel(modelPromise: Promise): void { + log.debug2('setNextModel'); + if (this.modelPromise) { + this.modelPromise.cancel(); + } + if (this.listenerCount > 0) { + this.removeListeners(this.model); + } + this.modelPromise = PromiseUtils.makeCancelable( + modelPromise, + (model: IrisGridModel) => { + if (model !== this.originalModel) { + model.close(); + } + } + ); + this.modelPromise + .then(model => { + this.modelPromise = null; + this.setModel(model); + }) + .catch((err: unknown) => { + if (PromiseUtils.isCanceled(err)) { + log.debug2('setNextModel cancelled'); + return; + } + log.error('Unable to build pivot model', err); + this.modelPromise = null; + // Drop back to the original model so the UI stays usable. + this.pivot = null; + this.setModel(this.originalModel); + this.dispatchEvent( + new EventShimCustomEvent(IrisGridModel.EVENT.REQUEST_FAILED, { + detail: err, + }) + ); + }); + } + + // --- event forwarding ---------------------------------------------- + + private handleModelEvent(event: CustomEvent): void { + const { detail, type } = event; + this.dispatchEvent(new EventShimCustomEvent(type, { detail })); + } + + startListening(): void { + super.startListening(); + this.addListeners(this.model); + } + + stopListening(): void { + super.stopListening(); + this.removeListeners(this.model); + } + + private addListeners(model: IrisGridModel): void { + const events = Object.keys(IrisGridModel.EVENT); + for (let i = 0; i < events.length; i += 1) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore -- iterating the readonly EVENT map + model.addEventListener(events[i], this.handleModelEvent); + } + } + + private removeListeners(model: IrisGridModel): void { + const events = Object.keys(IrisGridModel.EVENT); + for (let i = 0; i < events.length; i += 1) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore -- iterating the readonly EVENT map + model.removeEventListener(events[i], this.handleModelEvent); + } + } + + // --- formatter / cleanup ------------------------------------------- + + get formatter(): Formatter { + return this.irisFormatter; + } + + set formatter(formatter: Formatter) { + this.irisFormatter = formatter; + // Forward to inner model so cell formatting stays consistent. + (this.model as unknown as { formatter: Formatter }).formatter = formatter; + } + + close(): void { + log.debug('close'); + if (this.modelPromise) { + this.modelPromise.cancel(); + this.modelPromise = null; + } + // Close the current model if it's a pivot we created. + if (this.model !== this.originalModel) { + this.model.close(); + } + this.originalModel.close(); + } + + // --- defaults helper ----------------------------------------------- + + /** + * Spike-quality default config derived from the columns of the source + * table. Picks the first non-numeric column as the row key, the second + * non-numeric as the column key (if any), and aggregates all numeric + * columns as `Sum`. Falls back to `Count` when no numeric columns exist. + */ + static makeDefaultPivotConfig( + columns: readonly DhType.Column[] + ): PivotConfig { + const numeric: string[] = []; + const nonNumeric: string[] = []; + for (const col of columns) { + if (NUMERIC_TYPES.has(col.type)) { + numeric.push(col.name); + } else { + nonNumeric.push(col.name); + } + } + const rowKeys = + nonNumeric.length > 0 + ? nonNumeric.slice(0, 1) + : columns.length > 0 + ? [columns[0].name] + : []; + const columnKeys = nonNumeric.length > 1 ? nonNumeric.slice(1, 2) : []; + const aggregations: Record = + numeric.length > 0 ? { Sum: numeric } : { Count: [] }; + return { rowKeys, columnKeys, aggregations }; + } +} + +export function isPivotBuilderIrisGridModel( + model: unknown +): model is PivotBuilderIrisGridModel { + return model instanceof PivotBuilderIrisGridModel; +} + +export default PivotBuilderIrisGridModel; diff --git a/plugins/pivot-builder/src/js/src/PivotBuilderMiddleware.tsx b/plugins/pivot-builder/src/js/src/PivotBuilderMiddleware.tsx new file mode 100644 index 000000000..b7d208151 --- /dev/null +++ b/plugins/pivot-builder/src/js/src/PivotBuilderMiddleware.tsx @@ -0,0 +1,29 @@ +import Log from '@deephaven/log'; +import type { WidgetMiddlewareComponentProps } from '@deephaven/plugin'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import { PivotBuilderWidget } from './PivotBuilderWidget'; + +const log = Log.module( + '@deephaven/js-plugin-pivot-builder/PivotBuilderMiddleware' +); + +/** + * Middleware for the non-panel widget path (e.g. `GridWidgetPlugin`). + * + * Note: this middleware **replaces** the downstream `Component`. It owns + * the `IrisGrid` mount so the proxy model can intercept Table Options + * actions. Anything contributed further down the middleware chain by way + * of `Component` is intentionally dropped for this spike. + */ +export function PivotBuilderMiddleware( + props: WidgetMiddlewareComponentProps +): JSX.Element { + log.debug('Replacing default Table widget with pivot builder'); + // Strip the wrapped `Component` — we render IrisGrid ourselves. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { Component: _Component, ...rest } = props; + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + +export default PivotBuilderMiddleware; diff --git a/plugins/pivot-builder/src/js/src/PivotBuilderPanelContext.ts b/plugins/pivot-builder/src/js/src/PivotBuilderPanelContext.ts new file mode 100644 index 000000000..4e6d64612 --- /dev/null +++ b/plugins/pivot-builder/src/js/src/PivotBuilderPanelContext.ts @@ -0,0 +1,16 @@ +import { createContext } from 'react'; +import type { dh as DhType } from '@deephaven/jsapi-types'; + +/** + * Surfaces panel-host info that the sidebar `Create Pivot` page needs in + * order to build a pivot in-place against an `IrisGridProxyModel` it does + * not own (panel path). Provided by `PivotBuilderPanelMiddleware`. + */ +export interface PivotBuilderPanelContextValue { + metadata: DhType.ide.VariableDescriptor | undefined; +} + +export const PivotBuilderPanelContext = + createContext(null); + +export default PivotBuilderPanelContext; diff --git a/plugins/pivot-builder/src/js/src/PivotBuilderPanelMiddleware.tsx b/plugins/pivot-builder/src/js/src/PivotBuilderPanelMiddleware.tsx new file mode 100644 index 000000000..6966e9d2d --- /dev/null +++ b/plugins/pivot-builder/src/js/src/PivotBuilderPanelMiddleware.tsx @@ -0,0 +1,46 @@ +import { useMemo } from 'react'; +import { IrisGridTableOptionsContext } from '@deephaven/iris-grid'; +import Log from '@deephaven/log'; +import type { WidgetMiddlewarePanelProps } from '@deephaven/plugin'; +import { useComposedTableOptionsExtension } from './useComposedTableOptionsExtension'; +import { + PivotBuilderPanelContext, + type PivotBuilderPanelContextValue, +} from './PivotBuilderPanelContext'; + +const log = Log.module( + '@deephaven/js-plugin-pivot-builder/PivotBuilderPanelMiddleware' +); + +/** + * Panel-path middleware. + * + * The host `IrisGridPanel` constructs its own `IrisGridProxyModel`, so we + * don't swap the model here. Instead we expose the panel's `metadata` + * through `PivotBuilderPanelContext` so the sidebar `Create Pivot` page + * can build a pivot in place against the host proxy via `setNextModel`. + */ +export function PivotBuilderPanelMiddleware({ + Component, + ...props +}: WidgetMiddlewarePanelProps): JSX.Element { + const extension = useComposedTableOptionsExtension(); + const panelContext = useMemo( + () => ({ metadata: props.metadata }), + [props.metadata] + ); + log.debug('Wrapping panel component', { + Component, + metadata: props.metadata, + }); + return ( + + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + + ); +} + +export default PivotBuilderPanelMiddleware; diff --git a/plugins/pivot-builder/src/js/src/PivotBuilderPlugin.ts b/plugins/pivot-builder/src/js/src/PivotBuilderPlugin.ts new file mode 100644 index 000000000..91a1ae058 --- /dev/null +++ b/plugins/pivot-builder/src/js/src/PivotBuilderPlugin.ts @@ -0,0 +1,15 @@ +import { PluginType, type WidgetMiddlewarePlugin } from '@deephaven/plugin'; +import { PivotBuilderMiddleware } from './PivotBuilderMiddleware'; +import { PivotBuilderPanelMiddleware } from './PivotBuilderPanelMiddleware'; + +export const PivotBuilderPlugin: WidgetMiddlewarePlugin = { + name: '@deephaven/js-plugin-pivot-builder', + type: PluginType.MIDDLEWARE_PLUGIN, + // Spike: flat `Table` widgets only. Tree/hierarchical/partitioned tables + // cannot be pivoted by `PivotService.createPivotTable` today. + supportedTypes: ['Table'], + component: PivotBuilderMiddleware, + panelComponent: PivotBuilderPanelMiddleware, +}; + +export default PivotBuilderPlugin; diff --git a/plugins/pivot-builder/src/js/src/PivotBuilderWidget.tsx b/plugins/pivot-builder/src/js/src/PivotBuilderWidget.tsx new file mode 100644 index 000000000..58e092024 --- /dev/null +++ b/plugins/pivot-builder/src/js/src/PivotBuilderWidget.tsx @@ -0,0 +1,115 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + IrisGrid, + IrisGridTableOptionsContext, + type IrisGridModel, +} from '@deephaven/iris-grid'; +import { LoadingOverlay } from '@deephaven/components'; +import { useApi, useObjectFetch } from '@deephaven/jsapi-bootstrap'; +import { isCorePlusDh } from '@deephaven/js-plugin-pivot'; +import Log from '@deephaven/log'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import type { WidgetMiddlewareComponentProps } from '@deephaven/plugin'; +import PivotBuilderIrisGridModel from './PivotBuilderIrisGridModel'; +import { useComposedTableOptionsExtension } from './useComposedTableOptionsExtension'; + +const log = Log.module('@deephaven/js-plugin-pivot-builder/PivotBuilderWidget'); + +/** + * Replaces the default Table widget renderer with an `IrisGrid` driven by + * a `PivotBuilderIrisGridModel`. The proxy model swaps its inner model + * when `pivotConfig` is written from the sidebar. + */ +export function PivotBuilderWidget({ + fetch, + metadata, +}: WidgetMiddlewareComponentProps): JSX.Element { + const dh = useApi(); + const extension = useComposedTableOptionsExtension(); + const [model, setModel] = useState(null); + const [error, setError] = useState(null); + const builtModelRef = useRef(null); + + // Subscribe to the well-known `psp` PivotService on the same query. + const pspDescriptor = useMemo(() => { + if (!isCorePlusDh(dh) || metadata == null) { + // Use a sentinel name so ObjectFetchManager stays inert when unavailable. + return { type: 'PivotService', name: '__unavailable__' }; + } + return { ...metadata, type: 'PivotService', name: 'psp' }; + }, [dh, metadata]); + const pspFetch = useObjectFetch(pspDescriptor); + + useEffect(() => { + let cancelled = false; + setModel(null); + setError(null); + + if (!isCorePlusDh(dh)) { + setError( + new Error('CorePlus API not available; pivot builder requires DHE') + ); + return undefined; + } + if (pspFetch.status !== 'ready') { + // Wait for psp to resolve before fetching the table. + return undefined; + } + + (async () => { + try { + const [table, pspWidget] = await Promise.all([ + fetch(), + pspFetch.fetch(), + ]); + if (cancelled) { + table?.close?.(); + return; + } + const built = new PivotBuilderIrisGridModel( + dh, + table, + pspWidget as DhType.Widget + ); + builtModelRef.current = built; + setModel(built); + } catch (e) { + if (cancelled) return; + log.error('Failed to build pivot builder model', e); + setError(e); + } + })(); + + return () => { + cancelled = true; + }; + }, [dh, fetch, pspFetch]); + + // Close the model when the component unmounts or is replaced. + useEffect( + () => () => { + builtModelRef.current?.close(); + builtModelRef.current = null; + }, + [] + ); + + if (error != null) { + return ( + + ); + } + if (model == null) { + return ; + } + + return ( + + + + ); +} + +export default PivotBuilderWidget; diff --git a/plugins/pivot-builder/src/js/src/createPivotItemType.ts b/plugins/pivot-builder/src/js/src/createPivotItemType.ts new file mode 100644 index 000000000..3a836aab5 --- /dev/null +++ b/plugins/pivot-builder/src/js/src/createPivotItemType.ts @@ -0,0 +1,6 @@ +/** + * Stable type key for the Create Pivot sidebar item. The + * `plugin::` convention keeps plugin contributions from + * colliding with built-in `OptionType` values or with other plugins. + */ +export const CREATE_PIVOT_ITEM_TYPE = 'plugin:pivot-builder:create-pivot'; diff --git a/plugins/pivot-builder/src/js/src/index.ts b/plugins/pivot-builder/src/js/src/index.ts new file mode 100644 index 000000000..27a193ee5 --- /dev/null +++ b/plugins/pivot-builder/src/js/src/index.ts @@ -0,0 +1,16 @@ +import { PivotBuilderPlugin } from './PivotBuilderPlugin'; + +export { CREATE_PIVOT_ITEM_TYPE } from './createPivotItemType'; +export { CreatePivotPage } from './CreatePivotPage'; +export { + default as PivotBuilderIrisGridModel, + isPivotBuilderIrisGridModel, + type PivotConfig, +} from './PivotBuilderIrisGridModel'; +export { PivotBuilderMiddleware } from './PivotBuilderMiddleware'; +export { PivotBuilderPanelMiddleware } from './PivotBuilderPanelMiddleware'; +export { PivotBuilderPlugin } from './PivotBuilderPlugin'; +export { PivotBuilderWidget } from './PivotBuilderWidget'; +export { useComposedTableOptionsExtension } from './useComposedTableOptionsExtension'; + +export default PivotBuilderPlugin; diff --git a/plugins/pivot-builder/src/js/src/useComposedTableOptionsExtension.ts b/plugins/pivot-builder/src/js/src/useComposedTableOptionsExtension.ts new file mode 100644 index 000000000..df454f912 --- /dev/null +++ b/plugins/pivot-builder/src/js/src/useComposedTableOptionsExtension.ts @@ -0,0 +1,36 @@ +import { useContext, useMemo } from 'react'; +import { + IrisGridTableOptionsContext, + type IrisGridTableOptionsExtension, + type OptionItem, +} from '@deephaven/iris-grid'; +import { CreatePivotPage } from './CreatePivotPage'; +import { CREATE_PIVOT_ITEM_TYPE } from './createPivotItemType'; + +const CREATE_PIVOT_ITEM: OptionItem = { + type: CREATE_PIVOT_ITEM_TYPE, + title: 'Create Pivot', + subtitle: 'Build a pivot from this table', + configPage: CreatePivotPage, +}; + +/** + * Composes this plugin's sidebar contribution on top of any parent + * `IrisGridTableOptionsContext` already in scope. Pattern lifted from + * `table-options-example/useComposedTableOptionsExtension`. + */ +export function useComposedTableOptionsExtension(): IrisGridTableOptionsExtension { + const parent = useContext(IrisGridTableOptionsContext); + return useMemo(() => { + const parentTransform = parent?.transformTableOptions; + return { + transformTableOptions: defaults => { + const base = + parentTransform != null ? parentTransform(defaults) : defaults; + return [...base, CREATE_PIVOT_ITEM]; + }, + }; + }, [parent]); +} + +export default useComposedTableOptionsExtension; diff --git a/plugins/pivot-builder/src/js/vite.config.ts b/plugins/pivot-builder/src/js/vite.config.ts new file mode 100644 index 000000000..e665e4d6e --- /dev/null +++ b/plugins/pivot-builder/src/js/vite.config.ts @@ -0,0 +1,41 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; + +// https://vitejs.dev/config/ +export default defineConfig(({ mode }) => ({ + build: { + minify: false, + lib: { + entry: './src/index.ts', + fileName: () => 'index.js', + formats: ['cjs'], + }, + rollupOptions: { + output: { + exports: 'named', + }, + // Externalize peer deps following the grid-toolbar pattern. + // These are provided at runtime by DHE's remote-component.config.ts + // resolve map (or by the loaded js-plugin-pivot bundle in DHE's + // plugin loader). `fast-deep-equal` is small and not in DHE's resolve, + // so we let it bundle. + external: [ + 'react', + 'react-dom', + '@deephaven/components', + '@deephaven/iris-grid', + '@deephaven/jsapi-bootstrap', + '@deephaven/jsapi-types', + '@deephaven/jsapi-utils', + '@deephaven/js-plugin-pivot', + '@deephaven/log', + '@deephaven/plugin', + '@deephaven/utils', + ], + }, + }, + define: + mode === 'production' ? { 'process.env.NODE_ENV': '"production"' } : {}, + plugins: [react()], +})); diff --git a/plugins/pivot/src/js/src/index.ts b/plugins/pivot/src/js/src/index.ts index 878b1f931..e550cfbe5 100644 --- a/plugins/pivot/src/js/src/index.ts +++ b/plugins/pivot/src/js/src/index.ts @@ -3,4 +3,9 @@ import { PivotPlugin } from './PivotPlugin'; // Export legacy dashboard plugin as named export for compatibility with Grizzly export * from './DashboardPlugin'; +// Re-exports consumed by downstream plugins (e.g. pivot-builder) that need +// to construct pivot models directly. +export { default as IrisGridPivotModel } from './IrisGridPivotModel'; +export { isCorePlusDh } from './PivotUtils'; + export default PivotPlugin; diff --git a/plugins/table-options-example/src/js/README.md b/plugins/table-options-example/src/js/README.md index 5e7520600..f12579322 100644 --- a/plugins/table-options-example/src/js/README.md +++ b/plugins/table-options-example/src/js/README.md @@ -29,3 +29,28 @@ deephaven-plugins dev proxy serves the bundle. At runtime `@deephaven/iris-grid` resolves `IrisGridTableOptionsContext` from React context inside `IrisGridPanel` and `GridWidgetPlugin`, and forwards the merged `transformTableOptions` to `IrisGrid#transformTableOptions`. + +## Future work + +`transformTableOptions` is intentionally pure — it receives only the +default item list, not the `IrisGridModel` or grid state. State-aware +menus (e.g. "only show *Reset filters* when filters exist") belong in +the middleware: subscribe to model events in the `Provider`, recompute +the extension, and let the transform stay a plain projection of +`defaults`. See `useComposedTableOptionsExtension` for the composition +shape. + +The constraint is partly about keeping the surface small, but mostly +about memoization: `IrisGrid` caches the menu on the identity of the +transform and `defaults`. The model is a mutable handle whose +identity doesn't change when its fields do, so reading it inside the +transform would silently produce stale menus. Driving recomputes from +the middleware (which knows which events matter) keeps the cache key +honest. + +If real plugins start needing model awareness inside the transform +itself, the planned evolution is to add a second argument carrying a +curated **snapshot of values** — something like +`(defaults, { isRollup, hasFilters, columnCount }) => items` — so memo +invalidation tracks actual dependencies. The `IrisGridModel` itself, +or the full `IrisGrid` instance, will not be exposed. diff --git a/plugins/table-options-example/src/js/package.json b/plugins/table-options-example/src/js/package.json index be6e4792d..551ea42b8 100644 --- a/plugins/table-options-example/src/js/package.json +++ b/plugins/table-options-example/src/js/package.json @@ -22,7 +22,7 @@ "dependencies": { "@deephaven/components": "^1.17.0", "@deephaven/iris-grid": "^1.17.0", - "@deephaven/log": "^1.17.0", + "@deephaven/log": "^1.8.0", "@deephaven/plugin": "^1.17.0" }, "devDependencies": { From 9f6177c004f8348851ab83c97c026cde9580bfec Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Fri, 29 May 2026 10:40:12 -0600 Subject: [PATCH 05/18] Pivot builder wip --- package-lock.json | 1002 +++-------------- plugins/pivot-builder/src/js/package.json | 10 +- .../src/js/src/CreatePivotPage.tsx | 339 ++++-- .../src/js/src/PivotBuilderIrisGridModel.ts | 355 ------ .../src/js/src/PivotBuilderPanelContext.ts | 16 - .../js/src/PivotBuilderPanelMiddleware.tsx | 266 ++++- .../src/js/src/PivotBuilderWidget.tsx | 64 +- .../src/js/src/PivotServiceContext.ts | 15 + plugins/pivot-builder/src/js/src/index.ts | 6 +- .../src/js/src/pivotBuilderModel.ts | 239 ++++ .../src/useComposedTableOptionsExtension.ts | 18 +- plugins/pivot-builder/src/js/vite.config.ts | 3 +- plugins/pivot/src/js/src/index.ts | 9 +- 13 files changed, 950 insertions(+), 1392 deletions(-) delete mode 100644 plugins/pivot-builder/src/js/src/PivotBuilderIrisGridModel.ts delete mode 100644 plugins/pivot-builder/src/js/src/PivotBuilderPanelContext.ts create mode 100644 plugins/pivot-builder/src/js/src/PivotServiceContext.ts create mode 100644 plugins/pivot-builder/src/js/src/pivotBuilderModel.ts diff --git a/package-lock.json b/package-lock.json index fb57e0992..d1d8cc485 100644 --- a/package-lock.json +++ b/package-lock.json @@ -105,6 +105,7 @@ "resolved": "https://registry.npmjs.org/@adobe/react-spectrum/-/react-spectrum-3.38.0.tgz", "integrity": "sha512-0/zFmTz/sKf8rvB8EHMuWIE5miY1gSAvTr5q4fPIiQJQwMAlQyXfH3oy++/MsiC30HyT3Mp93scxX2F1ErKL4g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@internationalized/string": "^3.2.5", "@react-aria/i18n": "^3.12.4", @@ -2710,9 +2711,9 @@ } }, "node_modules/@deephaven/components": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-1.17.0.tgz", - "integrity": "sha512-0H/W0q3iH07rKvS/Ev3OLLfeAQtZi/sujuZL+MnQInPJTX3rNHb8XcwAKI5bI/u5a/+PvGkNswZcS3njvKxJ0Q==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-1.21.0.tgz", + "integrity": "sha512-mJGZybJAggwRtxlCGbpV6gGqTBrwuZfAMKkn1wI+7qi+0DyoBjFah7FV4pTdhysINknnzOT0aAIsZcxQcj327g==", "license": "Apache-2.0", "dependencies": { "@adobe/react-spectrum": "3.38.0", @@ -2726,13 +2727,20 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@hello-pangea/dnd": "^18.0.1", "@internationalized/date": "^3.5.5", + "@react-aria/focus": "^3.21.0", + "@react-aria/i18n": "^3.12.11", + "@react-spectrum/label": "^3.16.17", + "@react-spectrum/overlays": "^5.8.0", "@react-spectrum/theme-default": "^3.5.1", "@react-spectrum/toast": "^3.0.0-beta.16", "@react-spectrum/utils": "^3.11.5", + "@react-stately/overlays": "^3.6.18", + "@react-stately/utils": "^3.10.8", "@react-types/combobox": "3.13.1", "@react-types/radio": "^3.8.1", "@react-types/shared": "^3.22.1", "@react-types/textfield": "^3.9.1", + "@spectrum-icons/ui": "^3.6.18", "bootstrap": "4.6.2", "classnames": "^2.3.1", "event-target-shim": "^6.0.2", @@ -2831,16 +2839,16 @@ } }, "node_modules/@deephaven/dashboard": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/@deephaven/dashboard/-/dashboard-1.17.1.tgz", - "integrity": "sha512-ToEAhv9Im/kumvv+OLJgQO27rc+jRJGoNbej4rbRRU2PQGjKh9+eJlYMA4kHM4jSQrM9EJUtmF5MAoU9fwwyPA==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@deephaven/dashboard/-/dashboard-1.21.0.tgz", + "integrity": "sha512-OUwmEJ+k5Tnmg+QkGHRT2cRv+9oXxLfT1/zgT1ploKUYWU1J8KPY9CpnLLuIaXu9qu9C28aEpGnLeI/9D7zN5Q==", "license": "Apache-2.0", "dependencies": { - "@deephaven/components": "^1.17.0", - "@deephaven/golden-layout": "^1.17.1", + "@deephaven/components": "^1.21.0", + "@deephaven/golden-layout": "^1.21.0", "@deephaven/log": "^1.8.0", "@deephaven/react-hooks": "^1.14.0", - "@deephaven/redux": "^1.17.0", + "@deephaven/redux": "^1.19.0", "@deephaven/utils": "^1.10.0", "classnames": "^2.3.1", "fast-deep-equal": "^3.1.3", @@ -3001,12 +3009,12 @@ } }, "node_modules/@deephaven/golden-layout": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/@deephaven/golden-layout/-/golden-layout-1.17.1.tgz", - "integrity": "sha512-lnA87WSFcFoceK7DtsxNqKjEYCF7L427VxtMdMR7xU/tsTJUQnlT5MNR9BV/2Ybz8AR8Kh1qQv/3EmY1vlHGmA==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@deephaven/golden-layout/-/golden-layout-1.21.0.tgz", + "integrity": "sha512-PW1mmUytVjcCIsNa0gErf1GIq0GHsUSFJXZAkrVuZSvCQ3UDWN9sQ6GjRXI3lkaue+o+QRMq0WYX34IwbZGV6g==", "license": "Apache-2.0", "dependencies": { - "@deephaven/components": "^1.17.0", + "@deephaven/components": "^1.21.0", "jquery": "^3.6.0", "nanoid": "^5.0.7" }, @@ -3366,9 +3374,9 @@ } }, "node_modules/@deephaven/redux": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@deephaven/redux/-/redux-1.17.0.tgz", - "integrity": "sha512-pbq1Npd0JHkZDiK7gt5Oj4EVJuikQ76Jd0qoo20P5Ouan5M2iZg3HZNHVmicAJHA9p7+EmYAeXpHS52rUmQszg==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@deephaven/redux/-/redux-1.19.0.tgz", + "integrity": "sha512-ChzeUsaaoTMhM9Qrw9t0yCmEjdNNBoVf9RDfBQhTb2ifxNh2ZVxAgN4/yWB7kIlynfEmUwqYNQ7f2gcRNeEIHw==", "license": "Apache-2.0", "dependencies": { "@deephaven/jsapi-types": "^1.0.0-dev0.40.4", @@ -7006,6 +7014,20 @@ "integrity": "sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==", "license": "MIT" }, + "node_modules/@react-aria/focus": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.0.tgz", + "integrity": "sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0", + "react-aria": "3.48.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@react-aria/i18n": { "version": "3.13.0", "resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.13.0.tgz", @@ -7137,22 +7159,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/accordion/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/accordion/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -7208,22 +7214,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/actionbar/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/actionbar/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -7279,22 +7269,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/actiongroup/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/actiongroup/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -7349,22 +7323,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/avatar/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/avatar/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -7419,22 +7377,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/badge/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/badge/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -7490,22 +7432,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/breadcrumbs/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/breadcrumbs/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -7560,22 +7486,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/button/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/button/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -7630,22 +7540,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/buttongroup/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/buttongroup/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -7700,22 +7594,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/calendar/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/calendar/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -7770,22 +7648,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/checkbox/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/checkbox/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -7841,22 +7703,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/color/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/color/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -7912,22 +7758,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/combobox/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/combobox/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -7982,22 +7812,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/contextualhelp/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/contextualhelp/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -8052,22 +7866,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/datepicker/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/datepicker/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -8122,22 +7920,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/dialog/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/dialog/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -8192,29 +7974,13 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/divider/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", + "node_modules/@react-spectrum/divider/node_modules/@spectrum-icons/workflow": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", + "integrity": "sha512-ILuhgWh9jMXaEVMRuOYgTAjMc22cKyvCtUDyZmc8OEMfOYuejj+Gcp5t6DhaCfE0M9rORtVxCrRgsO2WyEgfUw==", "license": "Apache-2.0", "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-spectrum/divider/node_modules/@spectrum-icons/workflow": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", - "integrity": "sha512-ILuhgWh9jMXaEVMRuOYgTAjMc22cKyvCtUDyZmc8OEMfOYuejj+Gcp5t6DhaCfE0M9rORtVxCrRgsO2WyEgfUw==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-workflow": "2.3.5", + "@adobe/react-spectrum-workflow": "2.3.5", "@swc/helpers": "^0.5.0" }, "peerDependencies": { @@ -8264,22 +8030,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/dnd/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/dnd/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -8334,22 +8084,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/dropzone/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/dropzone/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -8418,22 +8152,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/form/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/form/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -8488,22 +8206,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/icon/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/icon/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -8558,22 +8260,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/illustratedmessage/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/illustratedmessage/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -8628,22 +8314,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/image/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/image/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -8698,14 +8368,13 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/inlinealert/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", + "node_modules/@react-spectrum/inlinealert/node_modules/@spectrum-icons/workflow": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", + "integrity": "sha512-ILuhgWh9jMXaEVMRuOYgTAjMc22cKyvCtUDyZmc8OEMfOYuejj+Gcp5t6DhaCfE0M9rORtVxCrRgsO2WyEgfUw==", "license": "Apache-2.0", "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", + "@adobe/react-spectrum-workflow": "2.3.5", "@swc/helpers": "^0.5.0" }, "peerDependencies": { @@ -8714,7 +8383,46 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/inlinealert/node_modules/@spectrum-icons/workflow": { + "node_modules/@react-spectrum/label": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@react-spectrum/label/-/label-3.17.0.tgz", + "integrity": "sha512-cv3cHYSOvKfDvjyYSZylyhxZHnWDEm6k0RvqxAv9DKu3KMPgNxiUHoQAWHhJ9pzz4Jqch7DF9ZiL9t6TNDfb3Q==", + "license": "Apache-2.0", + "dependencies": { + "@adobe/react-spectrum": "3.47.0", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-spectrum/label/node_modules/@adobe/react-spectrum": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/@adobe/react-spectrum/-/react-spectrum-3.47.0.tgz", + "integrity": "sha512-EDQuMzz0kUeiMUUlxoeLFQyyxOXaAC7qlBw2PYOUfFLYd87xcV7VVV0JxiYx8zGk1IIY3UgQHgXrS1fv7CgezQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@internationalized/date": "^3.12.1", + "@react-types/shared": "^3.34.0", + "@spectrum-icons/ui": "^3.7.0", + "@spectrum-icons/workflow": "^4.3.0", + "@swc/helpers": "^0.5.0", + "client-only": "^0.0.1", + "clsx": "^2.0.0", + "react-aria": "3.48.0", + "react-aria-components": "1.17.0", + "react-stately": "3.46.0", + "react-transition-group": "^4.4.5", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-spectrum/label/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", "integrity": "sha512-ILuhgWh9jMXaEVMRuOYgTAjMc22cKyvCtUDyZmc8OEMfOYuejj+Gcp5t6DhaCfE0M9rORtVxCrRgsO2WyEgfUw==", @@ -8768,22 +8476,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/labeledvalue/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/labeledvalue/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -8839,22 +8531,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/layout/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/layout/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -8909,22 +8585,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/link/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/link/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -8980,22 +8640,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/list/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/list/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9051,22 +8695,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/listbox/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/listbox/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9122,22 +8750,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/menu/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/menu/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9192,22 +8804,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/meter/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/meter/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9262,22 +8858,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/numberfield/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/numberfield/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9332,22 +8912,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/overlays/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/overlays/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9403,22 +8967,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/picker/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/picker/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9473,22 +9021,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/progress/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/progress/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9544,22 +9076,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/provider/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/provider/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9614,22 +9130,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/radio/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/radio/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9684,22 +9184,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/searchfield/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/searchfield/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9754,22 +9238,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/slider/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/slider/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9824,22 +9292,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/statuslight/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/statuslight/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9894,22 +9346,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/switch/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/switch/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -9965,22 +9401,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/table/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/table/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -10036,22 +9456,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/tabs/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/tabs/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -10107,22 +9511,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/tag/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/tag/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -10177,22 +9565,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/text/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/text/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -10247,22 +9619,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/textfield/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/textfield/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -10317,22 +9673,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/theme-dark/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/theme-dark/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -10387,22 +9727,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/theme-default/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/theme-default/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -10457,22 +9781,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/theme-light/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/theme-light/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -10527,22 +9835,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/toast/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/toast/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -10597,22 +9889,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/tooltip/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/tooltip/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -10668,22 +9944,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/utils/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/utils/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -10738,22 +9998,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/view/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/view/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -10808,22 +10052,6 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-spectrum/well/node_modules/@spectrum-icons/ui": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", - "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-spectrum/well/node_modules/@spectrum-icons/workflow": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.0.tgz", @@ -10867,6 +10095,20 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-stately/overlays": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@react-stately/overlays/-/overlays-3.7.0.tgz", + "integrity": "sha512-VyFlju6JqEUTyr+igrEjTeUi2MXw7IBOxWYzLoq26UJxf+45okqUWfyKRdXTvNjGJqQol9fqIg5Nv8fU4H/CvQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0", + "react-stately": "3.46.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@react-stately/radio": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/@react-stately/radio/-/radio-3.12.0.tgz", @@ -10881,6 +10123,20 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-stately/utils": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.12.0.tgz", + "integrity": "sha512-7q+iHz9cENvro1dVKgdTxNh1i1mtWuLUI6UHp10TAgpxM9DyRDvmuN35zLXYCmMDgx3WLY2xkwqoez8xd+CdxQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0", + "react-stately": "3.46.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@react-types/combobox": { "version": "3.13.1", "resolved": "https://registry.npmjs.org/@react-types/combobox/-/combobox-3.13.1.tgz", @@ -11499,6 +10755,22 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@spectrum-icons/ui": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.0.tgz", + "integrity": "sha512-86iQSDfJb3Ama1WSJ/mEiFy4DJT7e/v4pSmEuX4aKKMzbNYft+O40N18S2POUnmblrb7MQneLC/pgIp1SDBwEQ==", + "license": "Apache-2.0", + "dependencies": { + "@adobe/react-spectrum-ui": "1.2.1", + "@babel/runtime": "^7.24.4", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "@adobe/react-spectrum": "^3.47.0", + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@swc/core": { "version": "1.15.30", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.30.tgz", @@ -31679,6 +30951,9 @@ "license": "Apache-2.0", "dependencies": { "@deephaven/components": "^1.17.0", + "@deephaven/dashboard": "^1.18.0", + "@deephaven/dashboard-core-plugins": "^1.18.0", + "@deephaven/icons": "^1.2.0", "@deephaven/iris-grid": "^1.18.0", "@deephaven/js-plugin-pivot": "*", "@deephaven/jsapi-bootstrap": "^1.17.0", @@ -31692,10 +30967,13 @@ "devDependencies": { "@deephaven-enterprise/jsapi-coreplus-types": "^1.20240517.518", "@types/react": "^18.0.0", - "react": "^18.0.0" + "@types/react-dom": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" } }, "plugins/pivot/src/js": { diff --git a/plugins/pivot-builder/src/js/package.json b/plugins/pivot-builder/src/js/package.json index 732b9b560..b2462bfd0 100644 --- a/plugins/pivot-builder/src/js/package.json +++ b/plugins/pivot-builder/src/js/package.json @@ -22,6 +22,9 @@ }, "dependencies": { "@deephaven/components": "^1.17.0", + "@deephaven/dashboard": "^1.18.0", + "@deephaven/dashboard-core-plugins": "^1.18.0", + "@deephaven/icons": "^1.2.0", "@deephaven/iris-grid": "^1.18.0", "@deephaven/jsapi-bootstrap": "^1.17.0", "@deephaven/jsapi-types": "^1.0.0-dev0.39.6", @@ -35,10 +38,13 @@ "devDependencies": { "@deephaven-enterprise/jsapi-coreplus-types": "^1.20240517.518", "@types/react": "^18.0.0", - "react": "^18.0.0" + "@types/react-dom": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "publishConfig": { "access": "public" diff --git a/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx index 326732c07..74da664e9 100644 --- a/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx +++ b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx @@ -1,144 +1,177 @@ -import { useCallback, useContext, useMemo, useState } from 'react'; -import { Button } from '@deephaven/components'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button, Checkbox, Select } from '@deephaven/components'; import { type IrisGridTableOptionsPageProps } from '@deephaven/iris-grid'; -import { useApi, useObjectFetch } from '@deephaven/jsapi-bootstrap'; -import { IrisGridPivotModel, isCorePlusDh } from '@deephaven/js-plugin-pivot'; import Log from '@deephaven/log'; -import type { dh as DhType } from '@deephaven/jsapi-types'; -import type { dh as CorePlusDhType } from '@deephaven-enterprise/jsapi-coreplus-types'; -import PivotBuilderIrisGridModel, { +import { + isNumericColumn, isPivotBuilderIrisGridModel, -} from './PivotBuilderIrisGridModel'; -import { PivotBuilderPanelContext } from './PivotBuilderPanelContext'; + makeDefaultPivotConfig, + type PivotConfig, +} from './pivotBuilderModel'; const log = Log.module('@deephaven/js-plugin-pivot-builder/CreatePivotPage'); /** - * Returns `true` when `model` quacks like the host - * `IrisGridProxyModel` (it owns a swappable inner model via `setNextModel` - * and exposes the current `table`). We avoid an `instanceof` check so the - * plugin doesn't pin its build to a specific iris-grid copy. + * Aggregation functions supported by the pivot service. `numericOnly` filters + * the column pool; functions like `Count` can be applied to any column (or to + * no columns at all, meaning a row count). */ -function isSwappableProxy(model: unknown): model is { - setNextModel(promise: Promise): void; - table?: DhType.Table; - model?: { table?: DhType.Table }; -} { - return ( - typeof model === 'object' && - model !== null && - typeof (model as { setNextModel?: unknown }).setNextModel === 'function' - ); -} +const PIVOT_FUNCTIONS: readonly { + value: string; + label: string; + numericOnly: boolean; +}[] = [ + { value: 'Sum', label: 'Sum', numericOnly: true }, + { value: 'AbsSum', label: 'Abs Sum', numericOnly: true }, + { value: 'Avg', label: 'Average', numericOnly: true }, + { value: 'Min', label: 'Min', numericOnly: false }, + { value: 'Max', label: 'Max', numericOnly: false }, + { value: 'Std', label: 'Standard deviation', numericOnly: true }, + { value: 'Var', label: 'Variance', numericOnly: true }, + { value: 'Median', label: 'Median', numericOnly: true }, + { value: 'First', label: 'First', numericOnly: false }, + { value: 'Last', label: 'Last', numericOnly: false }, + { value: 'Count', label: 'Count', numericOnly: false }, +]; -function getProxyTable(model: unknown): DhType.Table | null { - if (!isSwappableProxy(model)) return null; - // IrisGridProxyModel forwards `table` to the inner IrisGridTableModel. - const t = model.table ?? model.model?.table; - return t ?? null; +const DEFAULT_FUNCTION = 'Sum'; + +function isNumericOnly(fn: string): boolean { + return PIVOT_FUNCTIONS.find(f => f.value === fn)?.numericOnly ?? false; } /** * Sidebar `configPage` for the Create Pivot menu item. * - * Two paths: - * - **Non-panel widget path**: the active model is a - * `PivotBuilderIrisGridModel`; we just write a default `pivotConfig`. - * - **Panel path**: the active model is the host `IrisGridProxyModel`; - * we build the pivot ourselves and hand it to `setNextModel`. + * Selecting columns and clicking Apply sets `model.pivotConfig`; the + * pivot-builder proxy then swaps its inner model and fires the standard + * `COLUMNS_CHANGED`/`UPDATED` events, causing IrisGrid to re-render in + * place. */ export function CreatePivotPage({ model, - onBack, }: IrisGridTableOptionsPageProps): JSX.Element { - const dh = useApi(); - const panelContext = useContext(PivotBuilderPanelContext); const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); const isProxy = isPivotBuilderIrisGridModel(model); const hasPivot = isProxy && model.pivotConfig != null; - const swappableProxy = isSwappableProxy(model) ? model : null; - const panelPathReady = - !isProxy && - swappableProxy != null && - isCorePlusDh(dh) && - panelContext?.metadata != null; - - // Subscribe to the well-known `psp` PivotService when in panel mode. - const pspDescriptor = useMemo(() => { - if (!panelPathReady || panelContext?.metadata == null) { - return { type: 'PivotService', name: '__unavailable__' }; - } - return { ...panelContext.metadata, type: 'PivotService', name: 'psp' }; - }, [panelPathReady, panelContext]); - const pspFetch = useObjectFetch(pspDescriptor); - const canCreate = - isProxy || - (panelPathReady && - (pspFetch.status === 'ready' || pspFetch.status === 'loading')); + // Always source columns from the original (pre-pivot) table so the + // selectors don't shift to pivot output columns after Apply. + const columns = isProxy ? model.sourceTable.columns : model.columns; + const numericColumnNames = useMemo( + () => columns.filter(isNumericColumn).map(c => c.name), + [columns] + ); + const allColumnNames = useMemo(() => columns.map(c => c.name), [columns]); - const handleCreate = useCallback(async () => { - setError(null); - if (isPivotBuilderIrisGridModel(model)) { - try { - const defaults = PivotBuilderIrisGridModel.makeDefaultPivotConfig( - model.columns - ); - log.info('Applying default pivot config (widget path)', defaults); - model.pivotConfig = defaults; - onBack(); - } catch (e) { - log.error('Failed to apply pivot config', e); - setError(e instanceof Error ? e.message : String(e)); - } - return; - } + // Seed state from current pivotConfig (if any) or sensible defaults. + const seed = useMemo( + () => (isProxy && model.pivotConfig) || makeDefaultPivotConfig(columns), + [isProxy, model, columns] + ); - if (swappableProxy == null || !isCorePlusDh(dh)) { - setError('Create Pivot requires the CorePlus JS API.'); + // Pick the first function in `seed.aggregations` (the config supports a + // map of `function -> columns`, but the UI currently exposes a single + // function at a time). + const seededFunction = Object.keys(seed.aggregations)[0] ?? DEFAULT_FUNCTION; + + const [rowKeys, setRowKeys] = useState(seed.rowKeys); + const [columnKeys, setColumnKeys] = useState(seed.columnKeys); + const [aggFunction, setAggFunction] = useState(seededFunction); + const [aggColumns, setAggColumns] = useState( + seed.aggregations[seededFunction] ?? [] + ); + + useEffect(() => { + const fn = Object.keys(seed.aggregations)[0] ?? DEFAULT_FUNCTION; + setRowKeys(seed.rowKeys); + setColumnKeys(seed.columnKeys); + setAggFunction(fn); + setAggColumns(seed.aggregations[fn] ?? []); + }, [seed]); + + const aggPool = useMemo( + () => (isNumericOnly(aggFunction) ? numericColumnNames : allColumnNames), + [aggFunction, numericColumnNames, allColumnNames] + ); + + const handleFunctionChange = useCallback( + (value: string): void => { + setAggFunction(value); + // Drop columns that aren't eligible for the new function. + const nextPool = isNumericOnly(value) + ? new Set(numericColumnNames) + : new Set(allColumnNames); + setAggColumns(prev => prev.filter(n => nextPool.has(n))); + }, + [numericColumnNames, allColumnNames] + ); + + // Selecting a column in one role removes it from the other two (a column + // can only play one role at a time). All checkboxes stay active so the + // user can move a column between roles in a single click. Future work: + // surface a visual cue when a column is already claimed by another role. + const handleToggle = useCallback( + (role: 'row' | 'col' | 'agg', name: string, checked: boolean): void => { + const withRemoved = (prev: string[]): string[] => + prev.filter(n => n !== name); + const withAdded = (prev: string[]): string[] => + prev.includes(name) ? prev : [...prev, name]; + + setRowKeys(prev => { + if (role === 'row') { + return checked ? withAdded(prev) : withRemoved(prev); + } + return checked ? withRemoved(prev) : prev; + }); + setColumnKeys(prev => { + if (role === 'col') { + return checked ? withAdded(prev) : withRemoved(prev); + } + return checked ? withRemoved(prev) : prev; + }); + setAggColumns(prev => { + if (role === 'agg') { + return checked ? withAdded(prev) : withRemoved(prev); + } + return checked ? withRemoved(prev) : prev; + }); + }, + [] + ); + + const handleApply = useCallback(() => { + setError(null); + if (!isPivotBuilderIrisGridModel(model)) { + setError( + 'Create Pivot requires the pivot-builder proxy model (CorePlus PivotService).' + ); return; } - const table = getProxyTable(model); - if (table == null) { - setError('Active model has no underlying table.'); + if (rowKeys.length === 0) { + setError('Select at least one row key.'); return; } - if (pspFetch.status !== 'ready') { - setError('PivotService is not ready yet — try again in a moment.'); + // For `Count` an empty column list is meaningful (count rows). For other + // functions, require at least one column. + if (aggFunction !== 'Count' && aggColumns.length === 0) { + setError(`Select at least one column for ${aggFunction}.`); return; } - - setBusy(true); try { - const pspWidget = (await pspFetch.fetch()) as CorePlusDhType.Widget; - const config = PivotBuilderIrisGridModel.makeDefaultPivotConfig( - model.columns - ); - log.info('Building pivot via panel path', config); - const pivotService = await ( - dh as unknown as typeof CorePlusDhType - ).coreplus.pivot.PivotService.getInstance(pspWidget); - const pivotTable = await pivotService.createPivotTable({ - source: table as unknown as CorePlusDhType.Table, - rowKeys: config.rowKeys, - columnKeys: config.columnKeys, - aggregations: config.aggregations, - }); - const pivotModel = new IrisGridPivotModel( - dh as unknown as typeof CorePlusDhType, - pivotTable - ); - swappableProxy.setNextModel(Promise.resolve(pivotModel)); - onBack(); + const config: PivotConfig = { + rowKeys, + columnKeys, + aggregations: { [aggFunction]: aggColumns }, + }; + log.info('Applying pivot config', config); + model.pivotConfig = config; } catch (e) { - log.error('Failed to build pivot (panel path)', e); + log.error('Failed to apply pivot config', e); setError(e instanceof Error ? e.message : String(e)); - } finally { - setBusy(false); } - }, [model, swappableProxy, dh, pspFetch, onBack]); + }, [model, rowKeys, columnKeys, aggFunction, aggColumns]); const handleReset = useCallback(() => { if (!isPivotBuilderIrisGridModel(model)) return; @@ -147,29 +180,89 @@ export function CreatePivotPage({ setError(null); }, [model]); + // Prevent the same column from being selected in multiple roles. + const renderColumnList = ( + role: 'row' | 'col' | 'agg', + selected: string[], + pool: readonly string[] + ): JSX.Element => ( +
+ {pool.length === 0 && ( +
No columns
+ )} + {pool.map(name => ( + handleToggle(role, name, e.target.checked)} + > + {name} + + ))} +
+ ); + return (
-
Create Pivot
-

- Build a pivot view of this table using sensible defaults. The first - non-numeric column becomes the row key, the second non-numeric column - (if any) becomes the column key, and all numeric columns are summed. -

+
+
+ + {renderColumnList('row', rowKeys, allColumnNames)} +
+
+ + {renderColumnList('col', columnKeys, allColumnNames)} +
+
+ + +
+
+ + {renderColumnList('agg', aggColumns, aggPool)} +
+
{error != null && ( -

+

{error}

)} -
- - {hasPivot && ( +
+ {on &&
{children}
} +
+ ); +} + +type ColumnRowProps = { + name: string; + onDelete: () => void; +}; + +function ColumnRow({ name, onDelete }: ColumnRowProps): JSX.Element { + return ( +
+ {name} +
+ ); +} + +type AggregateRowProps = { + entry: AggregateEntry; + onEdit: () => void; + onDelete: () => void; +}; + +function AggregateRow({ + entry, + onEdit, + onDelete, +}: AggregateRowProps): JSX.Element { + const label = `${entry.fn} (${entry.columns.join(', ')})`; + return ( +
+ {label} +
+ ); +} + +export function PivotConfigSection({ + availableColumns, + rollupRows, + onRollupRowsChange, + rollupRowsOn, + onRollupRowsOnChange, + pivotColumns, + onPivotColumnsChange, + pivotColumnsOn, + onPivotColumnsOnChange, + aggregates, + onAggregatesChange, + aggregatesOn, + onAggregatesOnChange, + filterableColumns, + onFilterableColumnsChange, + filterableColumnsOn, + onFilterableColumnsOnChange, + includeConstituents, + onIncludeConstituentsChange, + nonAggregatedInRollup, + onNonAggregatedInRollupChange, +}: PivotConfigSectionProps): JSX.Element { + const handleAddRollupRow = useCallback(() => { + onRollupRowsChange([ + ...rollupRows, + nextPlaceholderColumn(availableColumns, rollupRows), + ]); + }, [availableColumns, rollupRows, onRollupRowsChange]); + + const handleAddPivotColumn = useCallback(() => { + onPivotColumnsChange([ + ...pivotColumns, + nextPlaceholderColumn(availableColumns, pivotColumns), + ]); + }, [availableColumns, pivotColumns, onPivotColumnsChange]); + + const handleAddFilterable = useCallback(() => { + onFilterableColumnsChange([ + ...filterableColumns, + nextPlaceholderColumn(availableColumns, filterableColumns), + ]); + }, [availableColumns, filterableColumns, onFilterableColumnsChange]); + + const handleAddAggregate = useCallback(() => { + const first = availableColumns[0] ?? 'Value'; + onAggregatesChange([ + ...aggregates, + { id: newId(), fn: 'Sum', columns: [first] }, + ]); + }, [availableColumns, aggregates, onAggregatesChange]); + + const handleEditAggregate = useCallback((entry: AggregateEntry) => { + log.info('Edit aggregate (not yet wired)', entry); + }, []); + + const removeAt = (arr: T[], index: number): T[] => { + const next = arr.slice(); + next.splice(index, 1); + return next; + }; + + return ( +
+ + {rollupRows.length === 0 ? ( +
No columns
+ ) : ( + rollupRows.map((name, i) => ( + onRollupRowsChange(removeAt(rollupRows, i))} + /> + )) + )} +
+ + + {pivotColumns.length === 0 ? ( +
No columns
+ ) : ( + pivotColumns.map((name, i) => ( + onPivotColumnsChange(removeAt(pivotColumns, i))} + /> + )) + )} +
+ + + {aggregates.length === 0 ? ( +
No aggregates
+ ) : ( + aggregates.map((entry, i) => ( + handleEditAggregate(entry)} + onDelete={() => onAggregatesChange(removeAt(aggregates, i))} + /> + )) + )} +
+ + + {filterableColumns.length === 0 ? ( +
No columns
+ ) : ( + filterableColumns.map((name, i) => ( + + onFilterableColumnsChange(removeAt(filterableColumns, i)) + } + /> + )) + )} +
+ +
+ onIncludeConstituentsChange(e.target.checked)} + > + Include constituents in rollups rows + + onNonAggregatedInRollupChange(e.target.checked)} + > + Non-aggregated in rollup rows + +
+
+ ); +} + +export default PivotConfigSection; From c8dbb7aaf2b7d082a4587a4909652740f0f7d5bd Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Fri, 29 May 2026 11:45:47 -0600 Subject: [PATCH 07/18] Rollup rows --- ...-21476-pivot-builder-rollup-rows-wiring.md | 203 +++++++++++ .../src/js/src/CreatePivotPage.tsx | 332 ++++-------------- .../src/js/src/PivotConfigSection.tsx | 227 ++++++++++-- 3 files changed, 473 insertions(+), 289 deletions(-) create mode 100644 plans/DH-21476-pivot-builder-rollup-rows-wiring.md diff --git a/plans/DH-21476-pivot-builder-rollup-rows-wiring.md b/plans/DH-21476-pivot-builder-rollup-rows-wiring.md new file mode 100644 index 000000000..81e9e66a0 --- /dev/null +++ b/plans/DH-21476-pivot-builder-rollup-rows-wiring.md @@ -0,0 +1,203 @@ +# DH-21476 (phase): Wire Rollup Rows UI to `model.rollupConfig` + +Follow-up to [DH-21476-pivot-builder-config-ui.md](./DH-21476-pivot-builder-config-ui.md). +That phase landed the mock card-based config panel +([PivotConfigSection.tsx](../plugins/pivot-builder/src/js/src/PivotConfigSection.tsx)). +This phase wires only the **Rollup rows** card (plus the two footer +checkboxes) to the host's existing rollup pipeline so the table +actually re-renders as a rollup when the user adds/removes columns. + +Pivot columns, Aggregate values, and Add filterable columns remain +mock-only for now. + +## TL;DR + +- Build a `UIRollupConfig` from the Rollup-rows card state and assign it + to `model.rollupConfig` (the host's `IrisGridProxyModel` already + knows how to swap to `table.rollup(...)` — same path the existing + Rollup Rows sidebar uses). +- Use the already-exported helpers from `@deephaven/iris-grid`: + `UIRollupConfig`, `AggregationSettings`, `AggregationOperation`, + `IrisGridUtils.getModelRollupConfig`. No new exports needed. +- Rollup and Pivot are **not** mutually exclusive. Rollup rows is + wired in this phase; Pivot columns stays mock-only and will be + composed with Rollup in a later phase. +- Comment out the existing pivot-builder column selectors (Row keys / + Column keys / Aggregation function / Columns / Apply / Reset) below + the new cards — they are being replaced by the card UI and the + duplicate controls are now misleading. Keep the JSX in place behind + a comment so the wiring is easy to revive while we iterate. +- Replace the Rollup-rows "Add" placeholder picker with a real + column-name dropdown so the value matches `model.columns`. +- Seed the card from `model.rollupConfig` on mount so the UI reflects + the persisted/applied state. + +## What web-client-ui already exposes (verified) + +From `@deephaven/iris-grid`: +- `UIRollupConfig` — `{ columns, showConstituents, showNonAggregatedColumns, includeDescriptions }` + ([sidebar/RollupRows.tsx](https://github.com/deephaven/web-client-ui/blob/vlad-DH-21476-table-options/packages/iris-grid/src/sidebar/RollupRows.tsx)). +- `IrisGridUtils.getModelRollupConfig(originalColumns, config, aggregationSettings)` → + `dh.RollupConfig | null` + ([IrisGridUtils.ts](https://github.com/deephaven/web-client-ui/blob/vlad-DH-21476-table-options/packages/iris-grid/src/IrisGridUtils.ts)). +- `AggregationSettings`, `Aggregation`, `AggregationOperation`, + `AggregationUtils` — from `sidebar/aggregations`. +- `IrisGridModel.rollupConfig` setter (abstract; implemented by + `IrisGridProxyModel` → calls `table.rollup(rollupConfig)` and + `setNextModel`). + +**No web-client-ui changes are required for this phase.** + +If we later expose the Aggregate-values card we'll likely want a helper +that builds the `AggregationSettings.aggregations` array from our +`AggregateEntry[]`; that can stay plugin-side for now since +`AggregationOperation` enum values + `AggregationUtils` are already +exported. + +## Files + +### Modified — plugin + +- `plugins/pivot-builder/src/js/src/CreatePivotPage.tsx` + - Seed `mockRollupRows` / `mockIncludeConstituents` / + `mockNonAggregatedInRollup` from the current + `model.rollupConfig` (read once on mount via `useState` initializer + + `IrisGridUtils`-equivalent inverse: we only need the columns and + the two flags, both available directly on `dh.RollupConfig`). + - On every change to those three pieces of state, recompute a + `UIRollupConfig`, convert via `IrisGridUtils.getModelRollupConfig`, + and assign to `model.rollupConfig` (only when the resulting + `dh.RollupConfig` differs from the current — deep-equal guard, mirroring + `IrisGridProxyModel`'s own short-circuit). + - Empty columns list → assign `null` (revert to flat). + - Gate writes on `isPivotBuilderIrisGridModel(model)`. + - Comment out the legacy Row keys / Column keys / aggregation + function / Columns selectors and Apply/Reset buttons. Wrap the + JSX in `{/* ... */}` plus a short `TODO(DH-21476)` note so the + structure is preserved for later removal. + +- `plugins/pivot-builder/src/js/src/PivotConfigSection.tsx` + - Replace the "next placeholder column" generator for Rollup rows + with a real column picker. Simplest UI: + - "Add" opens an inline `` + cancel button when Add is clicked on + the Rollup rows card; on selection, call `onRollupRowsChange` with + the appended column. +3. Leave the placeholder-name behaviour for the other three cards. + +### Phase 3 — Verify + +1. Refresh the browser (vite watcher should pick this up; if not, the + pivot-builder bundle has a known watcher flake — one-off + `npx vite build` in `plugins/pivot-builder/src/js`). +2. Manually confirm the behaviours listed in the Behaviour section. + +## Risks / open questions + +- **Persistence:** the host's normal rollup persistence runs through + `IrisGridPanel` state. Our panel goes through the pivot-builder + middleware; we already persist `pivotConfig` via `usePersistentState` + but **not** the rollup. Two options, both deferrable past this phase: + 1. Trust the host to round-trip `model.rollupConfig` (it already + does this for non-pivot panels). Needs verification with the + pivot-builder middleware in the call chain. + 2. Mirror what we do for `pivotConfig`: add another + `usePersistentState` in + `PivotBuilderPanelMiddleware` and hydrate at mount. + Recommendation: ship Phase 1–3 without persistence and re-evaluate. +- **Aggregations empty list:** `getModelRollupConfig` synthesises a + `First` aggregation for non-aggregated columns when + `showNonAggregatedColumns` is true. That matches the host's default + behaviour but produces an extra column the user didn't explicitly + ask for. Acceptable for this phase since it matches the existing + Rollup Rows sidebar. +- **Sort:** `RollupRows` keeps a `sort: 'ASC' | 'DESC'` for its + ungrouped list. Our card has no equivalent. Not needed — sort only + affects the picker, not the produced `RollupConfig`. +- **Selector seeding after Apply:** the existing pivot Apply flow is + commented out this phase, so the inner model only swaps via the + Rollup rows wiring. Once Pivot columns is wired in a later phase + we'll need to define how `rollupConfig` and `pivotConfig` compose + (e.g. apply rollup to the pivot source, or layer them in a fixed + order) — currently undefined. diff --git a/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx index 1d77b71ed..db736873d 100644 --- a/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx +++ b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx @@ -1,234 +1,102 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, Checkbox, Select } from '@deephaven/components'; -import { type IrisGridTableOptionsPageProps } from '@deephaven/iris-grid'; +import { useEffect, useMemo, useState } from 'react'; +import { IrisGridUtils, type IrisGridModel } from '@deephaven/iris-grid'; +import deepEqual from 'fast-deep-equal'; import Log from '@deephaven/log'; -import { - isNumericColumn, - isPivotBuilderIrisGridModel, - makeDefaultPivotConfig, - type PivotConfig, -} from './pivotBuilderModel'; +import { isPivotBuilderIrisGridModel } from './pivotBuilderModel'; import { PivotConfigSection, type AggregateEntry } from './PivotConfigSection'; -const log = Log.module('@deephaven/js-plugin-pivot-builder/CreatePivotPage'); - -/** - * Aggregation functions supported by the pivot service. `numericOnly` filters - * the column pool; functions like `Count` can be applied to any column (or to - * no columns at all, meaning a row count). - */ -const PIVOT_FUNCTIONS: readonly { - value: string; - label: string; - numericOnly: boolean; -}[] = [ - { value: 'Sum', label: 'Sum', numericOnly: true }, - { value: 'AbsSum', label: 'Abs Sum', numericOnly: true }, - { value: 'Avg', label: 'Average', numericOnly: true }, - { value: 'Min', label: 'Min', numericOnly: false }, - { value: 'Max', label: 'Max', numericOnly: false }, - { value: 'Std', label: 'Standard deviation', numericOnly: true }, - { value: 'Var', label: 'Variance', numericOnly: true }, - { value: 'Median', label: 'Median', numericOnly: true }, - { value: 'First', label: 'First', numericOnly: false }, - { value: 'Last', label: 'Last', numericOnly: false }, - { value: 'Count', label: 'Count', numericOnly: false }, -]; - -const DEFAULT_FUNCTION = 'Sum'; +// `IrisGridTableOptionsPageProps` is not yet in the installed +// `@deephaven/iris-grid` typings (added in a newer host build), but is +// emitted at runtime. Inline-type the prop until the dep bumps. +type IrisGridTableOptionsPageProps = { model: IrisGridModel }; -function isNumericOnly(fn: string): boolean { - return PIVOT_FUNCTIONS.find(f => f.value === fn)?.numericOnly ?? false; -} +const log = Log.module('@deephaven/js-plugin-pivot-builder/CreatePivotPage'); /** * Sidebar `configPage` for the Create Pivot menu item. * - * Selecting columns and clicking Apply sets `model.pivotConfig`; the - * pivot-builder proxy then swaps its inner model and fires the standard - * `COLUMNS_CHANGED`/`UPDATED` events, causing IrisGrid to re-render in - * place. + * Renders the card-based config panel. The Rollup rows card is wired to + * `model.rollupConfig`; the other three cards are mock-data only (see + * `plans/DH-21476-pivot-builder-rollup-rows-wiring.md`). + * + * The legacy column-selector UI below the cards is intentionally removed + * — it duplicated controls now owned by the cards and will be partially + * revived once Pivot columns / Aggregate values are wired. */ export function CreatePivotPage({ model, }: IrisGridTableOptionsPageProps): JSX.Element { - const [error, setError] = useState(null); - const isProxy = isPivotBuilderIrisGridModel(model); - const hasPivot = isProxy && model.pivotConfig != null; // Always source columns from the original (pre-pivot) table so the // selectors don't shift to pivot output columns after Apply. const columns = isProxy ? model.sourceTable.columns : model.columns; - const numericColumnNames = useMemo( - () => columns.filter(isNumericColumn).map(c => c.name), + const allColumnNames = useMemo( + () => columns.map((c: { name: string }) => c.name), [columns] ); - const allColumnNames = useMemo(() => columns.map(c => c.name), [columns]); - // Seed state from current pivotConfig (if any) or sensible defaults. - const seed = useMemo( - () => (isProxy && model.pivotConfig) || makeDefaultPivotConfig(columns), - [isProxy, model, columns] - ); - - // Pick the first function in `seed.aggregations` (the config supports a - // map of `function -> columns`, but the UI currently exposes a single - // function at a time). - const seededFunction = Object.keys(seed.aggregations)[0] ?? DEFAULT_FUNCTION; - - const [rowKeys, setRowKeys] = useState(seed.rowKeys); - const [columnKeys, setColumnKeys] = useState(seed.columnKeys); - const [aggFunction, setAggFunction] = useState(seededFunction); - const [aggColumns, setAggColumns] = useState( - seed.aggregations[seededFunction] ?? [] - ); + // Seed Rollup rows from the existing `model.rollupConfig` once. There's + // no faithful way to recover `showNonAggregatedColumns` from a + // `dh.RollupConfig` (it's a UI-only flag that controls whether + // `getModelRollupConfig` synthesises a `First` aggregation), so it + // defaults to `true`. + const [mockRollupRows, setMockRollupRows] = useState(() => { + const cfg = model.rollupConfig; + return cfg?.groupingColumns?.map((c: unknown) => String(c)) ?? []; + }); + const [mockRollupRowsOn, setMockRollupRowsOn] = useState(true); + const [mockIncludeConstituents, setMockIncludeConstituents] = + useState(() => model.rollupConfig?.includeConstituents ?? true); + const [mockNonAggregatedInRollup, setMockNonAggregatedInRollup] = + useState(true); - // Mock-data state for the new card-based config section. Not yet wired to - // any model setter — see plans/DH-21476-pivot-builder-config-ui.md. - const [mockRollupRows, setMockRollupRows] = useState([ - 'Sym', - 'Exchange', - ]); - const [mockRollupRowsOn, setMockRollupRowsOn] = useState(true); + // Mock-data state for the remaining cards. Not yet wired to any model + // setter — see plans/DH-21476-pivot-builder-rollup-rows-wiring.md. const [mockPivotColumns, setMockPivotColumns] = useState([]); - const [mockPivotColumnsOn, setMockPivotColumnsOn] = useState(false); + const [mockPivotColumnsOn, setMockPivotColumnsOn] = useState(true); const [mockAggregates, setMockAggregates] = useState([ { id: 'seed-sum', fn: 'Sum', columns: ['Price', 'Size'] }, ]); const [mockAggregatesOn, setMockAggregatesOn] = useState(true); const [mockFilterable, setMockFilterable] = useState([]); - const [mockFilterableOn, setMockFilterableOn] = useState(false); - const [mockIncludeConstituents, setMockIncludeConstituents] = useState(true); - const [mockNonAggregatedInRollup, setMockNonAggregatedInRollup] = - useState(true); + const [mockFilterableOn, setMockFilterableOn] = useState(true); + // Sync the Rollup rows card to `model.rollupConfig`. The host's + // `IrisGridProxyModel` swaps the inner model to `table.rollup(cfg)` + // when this property is assigned (mirroring the existing Rollup Rows + // sidebar). useEffect(() => { - const fn = Object.keys(seed.aggregations)[0] ?? DEFAULT_FUNCTION; - setRowKeys(seed.rowKeys); - setColumnKeys(seed.columnKeys); - setAggFunction(fn); - setAggColumns(seed.aggregations[fn] ?? []); - }, [seed]); - - const aggPool = useMemo( - () => (isNumericOnly(aggFunction) ? numericColumnNames : allColumnNames), - [aggFunction, numericColumnNames, allColumnNames] - ); - - const handleFunctionChange = useCallback( - (value: string): void => { - setAggFunction(value); - // Drop columns that aren't eligible for the new function. - const nextPool = isNumericOnly(value) - ? new Set(numericColumnNames) - : new Set(allColumnNames); - setAggColumns(prev => prev.filter(n => nextPool.has(n))); - }, - [numericColumnNames, allColumnNames] - ); - - // Selecting a column in one role removes it from the other two (a column - // can only play one role at a time). All checkboxes stay active so the - // user can move a column between roles in a single click. Future work: - // surface a visual cue when a column is already claimed by another role. - const handleToggle = useCallback( - (role: 'row' | 'col' | 'agg', name: string, checked: boolean): void => { - const withRemoved = (prev: string[]): string[] => - prev.filter(n => n !== name); - const withAdded = (prev: string[]): string[] => - prev.includes(name) ? prev : [...prev, name]; - - setRowKeys(prev => { - if (role === 'row') { - return checked ? withAdded(prev) : withRemoved(prev); - } - return checked ? withRemoved(prev) : prev; - }); - setColumnKeys(prev => { - if (role === 'col') { - return checked ? withAdded(prev) : withRemoved(prev); - } - return checked ? withRemoved(prev) : prev; - }); - setAggColumns(prev => { - if (role === 'agg') { - return checked ? withAdded(prev) : withRemoved(prev); - } - return checked ? withRemoved(prev) : prev; - }); - }, - [] - ); - - const handleApply = useCallback(() => { - setError(null); - if (!isPivotBuilderIrisGridModel(model)) { - setError( - 'Create Pivot requires the pivot-builder proxy model (CorePlus PivotService).' - ); - return; - } - if (rowKeys.length === 0) { - setError('Select at least one row key.'); - return; - } - // For `Count` an empty column list is meaningful (count rows). For other - // functions, require at least one column. - if (aggFunction !== 'Count' && aggColumns.length === 0) { - setError(`Select at least one column for ${aggFunction}.`); - return; - } - try { - const config: PivotConfig = { - rowKeys, - columnKeys, - aggregations: { [aggFunction]: aggColumns }, - }; - log.info('Applying pivot config', config); - model.pivotConfig = config; - } catch (e) { - log.error('Failed to apply pivot config', e); - setError(e instanceof Error ? e.message : String(e)); - } - }, [model, rowKeys, columnKeys, aggFunction, aggColumns]); - - const handleReset = useCallback(() => { if (!isPivotBuilderIrisGridModel(model)) return; - log.info('Reverting to flat table model'); - model.pivotConfig = null; - setError(null); - }, [model]); - // Prevent the same column from being selected in multiple roles. - const renderColumnList = ( - role: 'row' | 'col' | 'agg', - selected: string[], - pool: readonly string[] - ): JSX.Element => ( -
- {pool.length === 0 && ( -
No columns
- )} - {pool.map(name => ( - handleToggle(role, name, e.target.checked)} - > - {name} - - ))} -
- ); + const uiConfig = + mockRollupRowsOn && mockRollupRows.length > 0 + ? { + columns: mockRollupRows, + showConstituents: mockIncludeConstituents, + showNonAggregatedColumns: mockNonAggregatedInRollup, + includeDescriptions: true as const, + } + : undefined; + + const next = IrisGridUtils.getModelRollupConfig( + model.sourceTable.columns, + uiConfig, + { aggregations: [], showOnTop: false } + ); + + if (!deepEqual(next, model.rollupConfig)) { + log.debug('Applying rollupConfig from Rollup rows card', next); + // eslint-disable-next-line no-param-reassign + model.rollupConfig = next; + } + }, [ + model, + mockRollupRowsOn, + mockRollupRows, + mockIncludeConstituents, + mockNonAggregatedInRollup, + ]); return (
@@ -256,62 +124,14 @@ export function CreatePivotPage({ nonAggregatedInRollup={mockNonAggregatedInRollup} onNonAggregatedInRollupChange={setMockNonAggregatedInRollup} /> -
- - {renderColumnList('row', rowKeys, allColumnNames)} -
-
- - {renderColumnList('col', columnKeys, allColumnNames)} -
-
- - -
-
- - {renderColumnList('agg', aggColumns, aggPool)} -
-
- {error != null && ( -

- {error} -

- )} -
- - {hasPivot && ( - - )} + {/* + TODO(DH-21476): legacy column-selector UI (Row keys / Column + keys / aggregation function / Columns / Apply / Reset) was + removed in favour of the card-based PivotConfigSection above. + The previous implementation lives in git history (commit + "Mock builder UI") and will be partially revived as Pivot + columns / Aggregate values get wired to `model.pivotConfig`. + */}
); diff --git a/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx b/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx index f5b8ed8ca..d50819445 100644 --- a/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx +++ b/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx @@ -1,5 +1,5 @@ -import { useCallback } from 'react'; -import { Button, Checkbox, UISwitch } from '@deephaven/components'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Button, Checkbox, SearchInput, UISwitch } from '@deephaven/components'; import { vsAdd, vsEdit, vsGripper, vsTrash } from '@deephaven/icons'; import Log from '@deephaven/log'; @@ -7,8 +7,8 @@ const log = Log.module('@deephaven/js-plugin-pivot-builder/PivotConfigSection'); /** * Mock-data UI section that previews the eventual replacement for the - * Rollups & Aggregations sidebar. State is fully controlled by the parent; - * nothing here is wired to the pivot model yet. + * Rollups & Aggregations sidebar. State is fully controlled by the parent. + * Only the Rollup rows card is wired to the model (in CreatePivotPage). */ export type AggregateEntry = { @@ -18,7 +18,7 @@ export type AggregateEntry = { }; export type PivotConfigSectionProps = { - /** Available source columns, used to seed `Add` placeholders. */ + /** Available source columns. */ availableColumns: readonly string[]; rollupRows: string[]; @@ -86,6 +86,51 @@ const emptyStyle: React.CSSProperties = { padding: '4px 2px', }; +const disabledBodyStyle: React.CSSProperties = { + opacity: 0.4, + pointerEvents: 'none', + userSelect: 'none', +}; + +const popoverStyle: React.CSSProperties = { + position: 'absolute', + zIndex: 1000, + top: 'calc(100% + 4px)', + right: 0, + width: 240, + maxHeight: 320, + display: 'flex', + flexDirection: 'column', + background: 'var(--dh-color-bg-200, #1f1f1f)', + border: '1px solid var(--dh-color-border-base, #444)', + borderRadius: 8, + boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)', + overflow: 'hidden', +}; + +const popoverSearchStyle: React.CSSProperties = { + padding: 8, +}; + +const popoverListStyle: React.CSSProperties = { + overflowY: 'auto', + flex: 1, +}; + +const popoverItemStyle: React.CSSProperties = { + padding: '6px 12px', + cursor: 'pointer', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}; + +const popoverEmptyStyle: React.CSSProperties = { + padding: '8px 12px', + opacity: 0.6, + fontStyle: 'italic', +}; + function newId(): string { if ( typeof crypto !== 'undefined' && @@ -96,15 +141,113 @@ function newId(): string { return `id-${Math.random().toString(36).slice(2, 10)}`; } -/** Pick the next column not already in `taken`, falling back to a generated name. */ -function nextPlaceholderColumn( - available: readonly string[], - taken: readonly string[] -): string { - const used = new Set(taken); - const free = available.find(c => !used.has(c)); - if (free != null) return free; - return `Column ${taken.length + 1}`; +type ColumnPickerProps = { + available: readonly string[]; + excluded: readonly string[]; + onPick: (name: string) => void; + onClose: () => void; +}; + +function ColumnPicker({ + available, + excluded, + onPick, + onClose, +}: ColumnPickerProps): JSX.Element { + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const containerRef = useRef(null); + const excludedSet = useMemo(() => new Set(excluded), [excluded]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return available.filter( + c => !excludedSet.has(c) && (q === '' || c.toLowerCase().includes(q)) + ); + }, [available, excludedSet, query]); + + useEffect(() => { + setActiveIndex(0); + }, [query, filtered.length]); + + useEffect(() => { + function handleClickOutside(e: MouseEvent): void { + if ( + containerRef.current != null && + e.target instanceof Node && + !containerRef.current.contains(e.target) + ) { + onClose(); + } + } + function handleKey(e: KeyboardEvent): void { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKey); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKey); + }; + }, [onClose]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex(i => Math.min(filtered.length - 1, i + 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex(i => Math.max(0, i - 1)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const pick = filtered[activeIndex]; + if (pick != null) onPick(pick); + } + }, + [activeIndex, filtered, onPick] + ); + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+
+ {filtered.length === 0 ? ( +
No columns
+ ) : ( + filtered.map((name, i) => ( +
setActiveIndex(i)} + onMouseDown={e => { + // mousedown so click-outside handler doesn't fire first + e.preventDefault(); + onPick(name); + }} + > + {name} +
+ )) + )} +
+
+ ); } type ConfigCardProps = { @@ -112,6 +255,7 @@ type ConfigCardProps = { on: boolean; onToggle: (next: boolean) => void; onAdd: () => void; + picker?: React.ReactNode; children: React.ReactNode; }; @@ -120,6 +264,7 @@ function ConfigCard({ on, onToggle, onAdd, + picker, children, }: ConfigCardProps): JSX.Element { return ( @@ -127,11 +272,16 @@ function ConfigCard({
{title} onToggle(!on)} /> - +
+ + {picker} +
+
+
+ {children}
- {on &&
{children}
} ); } @@ -206,26 +356,27 @@ export function PivotConfigSection({ nonAggregatedInRollup, onNonAggregatedInRollupChange, }: PivotConfigSectionProps): JSX.Element { + const [rollupPickerOpen, setRollupPickerOpen] = useState(false); + const handleAddRollupRow = useCallback(() => { - onRollupRowsChange([ - ...rollupRows, - nextPlaceholderColumn(availableColumns, rollupRows), - ]); - }, [availableColumns, rollupRows, onRollupRowsChange]); + setRollupPickerOpen(open => !open); + }, []); + + const handlePickRollupRow = useCallback( + (name: string) => { + onRollupRowsChange([...rollupRows, name]); + setRollupPickerOpen(false); + }, + [rollupRows, onRollupRowsChange] + ); const handleAddPivotColumn = useCallback(() => { - onPivotColumnsChange([ - ...pivotColumns, - nextPlaceholderColumn(availableColumns, pivotColumns), - ]); - }, [availableColumns, pivotColumns, onPivotColumnsChange]); + log.info('Pivot column picker not yet wired'); + }, []); const handleAddFilterable = useCallback(() => { - onFilterableColumnsChange([ - ...filterableColumns, - nextPlaceholderColumn(availableColumns, filterableColumns), - ]); - }, [availableColumns, filterableColumns, onFilterableColumnsChange]); + log.info('Filterable column picker not yet wired'); + }, []); const handleAddAggregate = useCallback(() => { const first = availableColumns[0] ?? 'Value'; @@ -252,6 +403,16 @@ export function PivotConfigSection({ on={rollupRowsOn} onToggle={onRollupRowsOnChange} onAdd={handleAddRollupRow} + picker={ + rollupPickerOpen ? ( + setRollupPickerOpen(false)} + /> + ) : null + } > {rollupRows.length === 0 ? (
No columns
From 42aa026459be1cfed927c47d4b689f6a866c75ad Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Fri, 29 May 2026 12:19:22 -0600 Subject: [PATCH 08/18] Aggregations --- ...6-pivot-builder-aggregate-values-wiring.md | 266 ++++++++++ .../src/js/src/CreatePivotPage.tsx | 167 +++++-- .../src/js/src/PivotConfigSection.tsx | 472 ++++++++++++++++-- 3 files changed, 808 insertions(+), 97 deletions(-) create mode 100644 plans/DH-21476-pivot-builder-aggregate-values-wiring.md diff --git a/plans/DH-21476-pivot-builder-aggregate-values-wiring.md b/plans/DH-21476-pivot-builder-aggregate-values-wiring.md new file mode 100644 index 000000000..b0fa1f889 --- /dev/null +++ b/plans/DH-21476-pivot-builder-aggregate-values-wiring.md @@ -0,0 +1,266 @@ +# DH-21476 — Pivot Builder: Aggregate values wiring + +Phase 3 follow-up to +[DH-21476-pivot-builder-rollup-rows-wiring.md](./DH-21476-pivot-builder-rollup-rows-wiring.md). +Wires the **Aggregate values** card in `PivotConfigSection` to the +underlying `IrisGridModel` so it works: + +1. **standalone** (no rollup, no pivot) → totals row at top/bottom, and +2. **with rollup** (and later, with pivot) → aggregations merged into the + rollup config. + +Pivot composition is deferred to its own phase. This doc explicitly +ignores `pivotConfig` — when both pivot and aggregates are on, the +aggregates card just behaves as if no pivot were present. + +## TL;DR + +- Card state shape changes from "list of aggregate entries" (one fn + + many columns each) to the host-native [`AggregationSettings`][1] + shape (one operation per entry + ordered list). This unlocks reuse of + `IrisGridUtils.getModelRollupConfig` and the existing operation-map + builders. +- New sync effect in `CreatePivotPage` keyed on + `[model, aggregatesOn, aggregationSettings, rollupRowsOn, mockRollupRows, …]` + decides each render whether to push to `model.totalsConfig` (no + rollup) or to fold into `model.rollupConfig` (rollup active), then + clears the other. +- `AggregateRow` UI gains an inline expand that lets the user pick + operation + columns; "Edit" opens it (no separate modal). Picker + reuses the `ColumnPicker` introduced in the rollup-rows phase. +- Effect runs guarded by `deepEqual` to avoid resetting the model on + every render. + +## Background — how IrisGrid wires aggregations today + +Two model surfaces, both abstract on +[`IrisGridModel`](../../web-client-ui/packages/iris-grid/src/IrisGridModel.ts): + +| Property | Standalone aggregations? | Combined with rollup? | +| --------------------- | ------------------------ | --------------------- | +| `model.totalsConfig` | yes (totals row) | **must be cleared** | +| `model.rollupConfig` | n/a | yes (folded in) | + +`IrisGrid.tsx` composes both in [`getModelTotalsConfig`][2] and +[`getModelRollupConfig`][3], then `IrisGridModelUpdater` assigns them. +Critically: when rollup is on, `getModelTotalsConfig` returns `null` — +i.e. **rollup wins** and the totals row is suppressed. + +Source-of-truth state: +[`AggregationSettings`](../../web-client-ui/packages/iris-grid/src/sidebar/aggregations/Aggregations.tsx): + +```ts +type Aggregation = { + operation: AggregationOperation; // "Sum" | "Avg" | "Min" | … + selected: readonly string[]; // column names + invert: boolean; // when true, selected = excluded +}; +type AggregationSettings = { + aggregations: readonly Aggregation[]; + showOnTop: boolean; +}; +``` + +Two helpers from `IrisGrid` we will need (or re-implement small pieces +of): + +- `getOperationMap(columns, aggregations) → { [columnName]: operation[] }` + builds the per-column op array used by `UITotalsTableConfig`. +- `getOperationOrder(aggregations) → operation[]` preserves user order. + +Both currently live as instance methods on `IrisGrid` and aren't +exported. We can either: + +1. **Re-implement them inline in the plugin** (≤20 lines each, no + external deps), or +2. Push for promoting them to `IrisGridUtils` in `web-client-ui` first. + +Recommended: option 1 for now, with a `TODO(DH-21476): promote to +IrisGridUtils` comment. The current PR thread is already touching +iris-grid surface — defer the host change to avoid scope creep. + +## Phase A — Reshape card state (purely refactor, no behaviour change) + +Today's card state per entry is: + +```ts +type AggregateEntry = { id; fn: string; columns: string[] }; +``` + +This 1:many shape matches the design mock but **does not match +`Aggregation`**, which is 1:1 (one operation per entry, columns +multi-select). The mock UI groups by operation purely for display. + +Change `PivotConfigSection.tsx` so the props are: + +```ts +aggregationSettings: AggregationSettings; +onAggregationSettingsChange: (next: AggregationSettings) => void; +aggregatesOn: boolean; +onAggregatesOnChange: (next: boolean) => void; +``` + +Inside the card we keep rendering one row per `Aggregation` entry, +labelled `${operation} (${selected.join(', ')})`. `Add` opens an +operation picker (reuse `ColumnPicker` with the list of +`AggregationOperation` values minus already-used ones — matches +existing `Aggregations.tsx` UX). `Edit` toggles an inline expand below +the row with two controls: + +- operation ` setOperation(value)} + className="custom-select-box form-control" + > + {availableOperations.map(op => ( + + ))} + + +
+
+ Select column(s) + * +
+ setQuery(e.target.value)} + /> +
+ {filteredColumns.length === 0 ? ( +
No columns
+ ) : ( + filteredColumns.map(name => { + const valid = isColumnValid(name); + return ( + toggleColumn(name)} + > + {name} + + ); + }) + )} +
+
+
+ + + + +
+ + ); +} + export function PivotConfigSection({ availableColumns, + columnTypes, rollupRows, onRollupRowsChange, rollupRowsOn, @@ -343,8 +629,8 @@ export function PivotConfigSection({ onPivotColumnsChange, pivotColumnsOn, onPivotColumnsOnChange, - aggregates, - onAggregatesChange, + aggregationSettings, + onAggregationSettingsChange, aggregatesOn, onAggregatesOnChange, filterableColumns, @@ -357,6 +643,11 @@ export function PivotConfigSection({ onNonAggregatedInRollupChange, }: PivotConfigSectionProps): JSX.Element { const [rollupPickerOpen, setRollupPickerOpen] = useState(false); + // `null` = closed. `{ mode: 'add' }` = adding new. `{ mode: 'edit', index }` + // = editing existing entry. + const [aggPickerState, setAggPickerState] = useState< + { mode: 'add' } | { mode: 'edit'; index: number } | null + >(null); const handleAddRollupRow = useCallback(() => { setRollupPickerOpen(open => !open); @@ -378,18 +669,94 @@ export function PivotConfigSection({ log.info('Filterable column picker not yet wired'); }, []); + const usedOperations = useMemo( + () => aggregationSettings.aggregations.map(a => a.operation as string), + [aggregationSettings.aggregations] + ); + + const selectableOperations = useMemo( + () => + SELECTABLE_OPERATIONS.filter( + op => !AggregationUtils.isRollupProhibited(op) + ).map(op => op as string), + [] + ); + + const closeAggPicker = useCallback(() => setAggPickerState(null), []); + const handleAddAggregate = useCallback(() => { - const first = availableColumns[0] ?? 'Value'; - onAggregatesChange([ - ...aggregates, - { id: newId(), fn: 'Sum', columns: [first] }, - ]); - }, [availableColumns, aggregates, onAggregatesChange]); - - const handleEditAggregate = useCallback((entry: AggregateEntry) => { - log.info('Edit aggregate (not yet wired)', entry); + setAggPickerState(s => (s?.mode === 'add' ? null : { mode: 'add' })); }, []); + const handleEditAggregate = useCallback((index: number) => { + setAggPickerState(s => + s?.mode === 'edit' && s.index === index ? null : { mode: 'edit', index } + ); + }, []); + + const handleCommitAggregate = useCallback( + (next: Aggregation) => { + const aggregations = aggregationSettings.aggregations.slice(); + if (aggPickerState?.mode === 'edit') { + aggregations[aggPickerState.index] = next; + } else { + aggregations.push(next); + } + onAggregationSettingsChange({ ...aggregationSettings, aggregations }); + setAggPickerState(null); + }, + [aggPickerState, aggregationSettings, onAggregationSettingsChange] + ); + + const handleDeleteAggregate = useCallback( + (index: number) => { + const aggregations = aggregationSettings.aggregations.filter( + (_, i) => i !== index + ); + onAggregationSettingsChange({ ...aggregationSettings, aggregations }); + setAggPickerState(curr => { + if (curr?.mode !== 'edit') return curr; + if (curr.index === index) return null; + return curr.index > index + ? { mode: 'edit', index: curr.index - 1 } + : curr; + }); + }, + [aggregationSettings, onAggregationSettingsChange] + ); + + // Operations available to a given picker invocation. For "add" we exclude + // every already-used op; for "edit" we exclude others but keep the current. + const pickerAvailableOps = useMemo(() => { + if (aggPickerState == null) return selectableOperations; + const currentOp = + aggPickerState.mode === 'edit' + ? aggregationSettings.aggregations[aggPickerState.index]?.operation + : undefined; + return selectableOperations.filter( + op => op === currentOp || !usedOperations.includes(op) + ); + }, [ + aggPickerState, + aggregationSettings, + selectableOperations, + usedOperations, + ]); + + const pickerInitial = useMemo(() => { + if (aggPickerState?.mode === 'edit') { + const e = aggregationSettings.aggregations[aggPickerState.index]; + if (e != null) return e; + } + return { + operation: + (pickerAvailableOps[0] as AggregationOperation) ?? + AggregationOperation.SUM, + selected: [], + invert: false, + }; + }, [aggPickerState, aggregationSettings, pickerAvailableOps]); + const removeAt = (arr: T[], index: number): T[] => { const next = arr.slice(); next.splice(index, 1); @@ -419,6 +786,7 @@ export function PivotConfigSection({ ) : ( rollupRows.map((name, i) => ( onRollupRowsChange(removeAt(rollupRows, i))} @@ -438,6 +806,7 @@ export function PivotConfigSection({ ) : ( pivotColumns.map((name, i) => ( onPivotColumnsChange(removeAt(pivotColumns, i))} @@ -451,16 +820,30 @@ export function PivotConfigSection({ on={aggregatesOn} onToggle={onAggregatesOnChange} onAdd={handleAddAggregate} + addDisabled={usedOperations.length >= selectableOperations.length} + picker={ + aggPickerState != null ? ( + + ) : null + } > - {aggregates.length === 0 ? ( + {aggregationSettings.aggregations.length === 0 ? (
No aggregates
) : ( - aggregates.map((entry, i) => ( + aggregationSettings.aggregations.map((entry, i) => ( handleEditAggregate(entry)} - onDelete={() => onAggregatesChange(removeAt(aggregates, i))} + onEdit={() => handleEditAggregate(i)} + onDelete={() => handleDeleteAggregate(i)} /> )) )} @@ -477,6 +860,7 @@ export function PivotConfigSection({ ) : ( filterableColumns.map((name, i) => ( From 19fb80149ed7c6753285d9181da16ccbaf708452 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Fri, 29 May 2026 13:28:02 -0600 Subject: [PATCH 09/18] Pivot Builder wip --- ...1476-pivot-builder-pivot-columns-wiring.md | 194 +++++++++ .../src/js/src/CreatePivotPage.tsx | 56 ++- .../src/js/src/PivotConfigSection.tsx | 370 ++++++++++++++---- .../src/useComposedTableOptionsExtension.ts | 2 +- 4 files changed, 540 insertions(+), 82 deletions(-) create mode 100644 plans/DH-21476-pivot-builder-pivot-columns-wiring.md diff --git a/plans/DH-21476-pivot-builder-pivot-columns-wiring.md b/plans/DH-21476-pivot-builder-pivot-columns-wiring.md new file mode 100644 index 000000000..403cde3da --- /dev/null +++ b/plans/DH-21476-pivot-builder-pivot-columns-wiring.md @@ -0,0 +1,194 @@ +# DH-21476 — Pivot Builder: Pivot columns wiring + +Phase 4 follow-up to +[DH-21476-pivot-builder-aggregate-values-wiring.md](./DH-21476-pivot-builder-aggregate-values-wiring.md). +Wires the **Pivot columns** card in `PivotConfigSection` so that turning +it on (with at least one selected column) creates a pivot table via +`model.pivotConfig` — composing with the existing Rollup-rows and +Aggregate-values cards. + +## TL;DR + +- **Pivot wins.** When the Pivot card is on AND `pivotColumns.length > 0`, + build a `PivotConfig` and assign `model.pivotConfig = next`. The proxy + swaps its inner model to an `IrisGridPivotModel` via + `setNextModel(PivotService.createPivotTable(...))`. Otherwise behave + exactly as today (rollup-vs-totals). +- **Rollup-rows feed pivot rowKeys.** When pivot is active the row-source + for the pivot is `mockRollupRows` (the Rollup rows card selections), + regardless of the Rollup rows card's on/off switch. The Rollup card UI + still toggles whether a *rollup* would otherwise apply; when pivot is + active that toggle is moot. +- **Aggregate values feed pivot aggregations.** Convert + `AggregationSettings.aggregations` (`{ operation, selected }[]`) into + the pivot service's `Record` shape. Entries with + empty `selected` are skipped. `invert` is ignored (no semantic in + pivot service). +- When pivot is active, **clear** `model.rollupConfig` and + `model.totalsConfig` — they would race the inner-model swap otherwise. +- Replace the Pivot card's mock `Add` handler with a real + `ColumnPicker` (same component already used by Rollup rows). +- One combined effect in `CreatePivotPage` continues to own + `pivotConfig` / `rollupConfig` / `totalsConfig`. No new effects. + +## What's already in place + +- `pivotBuilderModel.ts` exposes `PivotBuilderProxyModel` with a + `pivotConfig: PivotConfig | null` setter that swaps the inner model + (mirrors `rollupConfig`). Same path the Toolbar's existing + `usePivotToggle` uses. + ```ts + interface PivotConfig { + rowKeys: string[]; + columnKeys: string[]; + aggregations: Record; // e.g. { Sum: ['price'] } + } + ``` +- `PivotConfigSection` already owns `pivotColumns: string[]` + + `pivotColumnsOn: boolean`, with a placeholder + `handleAddPivotColumn` that only logs. +- `CreatePivotPage` already builds `columns` from `model.sourceTable` + and holds `mockRollupRows`, `mockPivotColumns`, `aggregationSettings`, + etc. The combined rollup/totals effect lives there. + +## Activation rules + +| Pivot toggle | `pivotColumns` | `mockRollupRows` | Result | +| --- | --- | --- | --- | +| off | * | * | Today's rollup/totals behaviour | +| on | empty | * | Today's rollup/totals behaviour (pivot inactive) | +| on | ≥1 | empty | Today's rollup/totals behaviour, **disable** the Pivot card's Add button-row with a hint *"Add at least one Rollup row"* — pivot needs row keys | +| on | ≥1 | ≥1 | **Pivot active.** Clear rollup+totals, set `model.pivotConfig` | + +The "row keys come from the Rollup rows card" coupling is intentional +(matches the user request). The Rollup-rows on/off toggle has no effect +when pivot is active. + +## Phase A — UI: real picker for Pivot columns + +Edits to `PivotConfigSection.tsx`: + +1. Add a local `pivotPickerOpen` state, mirroring `rollupPickerOpen`. +2. Replace `handleAddPivotColumn` with one that opens the picker. +3. Pass a `` to the + `ConfigCard`'s `picker` prop (already supported). +4. Optional polish: pass `addDisabled` when the picker should be + suppressed because rollup rows are empty (see "Activation rules"). + +No new types. No host changes. + +## Phase B — Wire to `model.pivotConfig` + +Edits to `CreatePivotPage.tsx`: + +1. Add a tiny helper, inline (≤15 lines): + ```ts + function aggregationsToPivot( + settings: AggregationSettings + ): Record { + const out: Record = {}; + for (const agg of settings.aggregations) { + if (agg.selected.length === 0) continue; + const op = String(agg.operation); + out[op] = [...(out[op] ?? []), ...agg.selected]; + } + return out; + } + ``` + Pivot service tolerates a missing aggregations entry only weakly; + default to `{ Count: [] }` when `out` is empty (same fallback as + `makeDefaultPivotConfig`). +2. Extend the combined effect's branching: + ```ts + const pivotActive = + mockPivotColumnsOn && + mockPivotColumns.length > 0 && + mockRollupRows.length > 0; + + if (pivotActive) { + const next: PivotConfig = { + rowKeys: mockRollupRows, + columnKeys: mockPivotColumns, + aggregations: + Object.keys(aggregationsToPivot(effectiveAggregationSettings)) + .length === 0 + ? { Count: [] } + : aggregationsToPivot(effectiveAggregationSettings), + }; + if (!deepEqual(next, model.pivotConfig)) { + model.pivotConfig = next; + } + if (model.rollupConfig != null) model.rollupConfig = null; + if (model.totalsConfig != null) model.totalsConfig = null; + return; + } + + // Pivot inactive — clear any prior pivot before falling through to + // the existing rollup/totals logic. + if (model.pivotConfig != null) { + model.pivotConfig = null; + } + // ...existing rollupActive / standalone-totals branches unchanged + ``` +3. Add the new deps to the effect's dep array: + `mockPivotColumnsOn, mockPivotColumns`. + +Keep all assignments behind `deepEqual` guards to avoid re-creating the +pivot on unrelated re-renders (each `pivotConfig` write triggers a full +`PivotService.createPivotTable` round-trip). + +## Phase C — Verification scenarios + +| # | Setup | Expected | +| --- | --- | --- | +| 1 | Pivot off, Rollup on (cols), Agg off | Rollup, no totals (today) | +| 2 | Pivot off, Rollup off, Agg on (cols) | Source table + totals row (today) | +| 3 | Pivot off, Rollup on, Agg on | Rollup with folded aggs, no totals (today) | +| 4 | Pivot on, no pivot cols, Rollup on | Behaves as #1 (Pivot inactive) | +| 5 | Pivot on (cols), Rollup empty | Pivot inactive; Add button hint visible | +| 6 | Pivot on (cols), Rollup on (cols), Agg empty | Pivot table with `{Count:[]}` agg, no rollup, no totals row | +| 7 | Pivot on (cols), Rollup on (cols), Agg on (cols) | Pivot table with selected aggs, no rollup, no totals row | +| 8 | From #7 → toggle Pivot off | Falls back to rollup+aggs (scenario #3) without re-mounting the panel | +| 9 | From #7 → remove last Pivot col | Pivot inactive → behaves as #3 | +| 10 | From #7 → remove last Rollup row | Pivot inactive (no row keys) → behaves as #2 | +| 11 | Rapidly add/remove Pivot cols | No render storm (`deepEqual` guard); inner pivot is recreated at most once per debounce frame | + +## Open questions / non-goals + +- **Aggregation merging when same op repeats.** Current host + `AggregationSettings` allows only one entry per `operation` (the + picker excludes already-used ops). The helper above still merges + defensively — cheap insurance, no behaviour change. +- **`showOnTop` ignored.** Pivot has no totals row, so + `aggregationSettings.showOnTop` only affects the standalone-totals + branch. +- **`invert` ignored.** Same reason — pivot service has no equivalent. +- **Seeding from existing `model.pivotConfig`.** Out of scope; the + panel always starts with `mockPivotColumns = []` and lets the user + build up. Can be added later by reading `model.pivotConfig?.columnKeys` + in the initial `useState`. +- **Promote pivot-config helpers to a shared util.** Defer until a + second consumer exists. +- **Pivot column picker placement vs viewport.** Reuse the existing + flip-above logic from `AggregatePicker` if the bottom card's popover + needs it — not expected with the smaller `ColumnPicker`. + +## Files + +### Modified — plugin + +- `plugins/pivot-builder/src/js/src/PivotConfigSection.tsx` + - Add `pivotPickerOpen` state + real `handleAddPivotColumn`. + - Pass a `ColumnPicker` to the Pivot `ConfigCard`'s `picker` prop. +- `plugins/pivot-builder/src/js/src/CreatePivotPage.tsx` + - Add `aggregationsToPivot` helper. + - Extend the combined effect with a `pivotActive` branch and add + `mockPivotColumns`, `mockPivotColumnsOn` to the dep array. + - On `pivotActive == false`, ensure `model.pivotConfig` is cleared + before re-applying rollup/totals. + +### Not modified + +- `pivotBuilderModel.ts` — already exposes `pivotConfig` + `sourceTable`. +- `web-client-ui/packages/iris-grid/**` — no host changes needed. diff --git a/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx index f2ca96300..643f4d2da 100644 --- a/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx +++ b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx @@ -7,7 +7,10 @@ import { } from '@deephaven/iris-grid'; import deepEqual from 'fast-deep-equal'; import Log from '@deephaven/log'; -import { isPivotBuilderIrisGridModel } from './pivotBuilderModel'; +import { + isPivotBuilderIrisGridModel, + type PivotConfig, +} from './pivotBuilderModel'; import { PivotConfigSection } from './PivotConfigSection'; // `IrisGridTableOptionsPageProps` is not yet in the installed @@ -39,6 +42,18 @@ const EMPTY_AGGREGATION_SETTINGS: AggregationSettings = { showOnTop: false, }; +function aggregationsToPivot( + settings: AggregationSettings +): Record { + const out: Record = {}; + settings.aggregations.forEach(agg => { + if (agg.selected.length === 0) return; + const op = String(agg.operation); + out[op] = [...(out[op] ?? []), ...agg.selected]; + }); + return out; +} + const log = Log.module('@deephaven/js-plugin-pivot-builder/CreatePivotPage'); /** @@ -115,6 +130,43 @@ export function CreatePivotPage({ ? aggregationSettings : EMPTY_AGGREGATION_SETTINGS; + const pivotActive = + mockPivotColumnsOn && + mockPivotColumns.length > 0 && + mockRollupRows.length > 0 && + aggsActive; + + if (pivotActive) { + // Pivot swaps the proxy's inner model wholesale, superseding any + // active rollup/totals. We deliberately DO NOT clear rollupConfig + // or totalsConfig here: `IrisGridProxyModel.setNextModel` cancels + // the previous in-flight model promise by `.close()`-ing the + // resolved model, so clearing rollupConfig first (which queues a + // swap back to `originalModel`) and then setting pivotConfig + // would cancel that swap and close the source table before the + // pivot promise can use it. When pivot is later cleared, the + // pivot-inactive branch re-reconciles rollup/totals state. + const nextPivot: PivotConfig = { + rowKeys: mockRollupRows, + columnKeys: mockPivotColumns, + aggregations: aggregationsToPivot(effectiveAggregationSettings), + }; + if (!deepEqual(nextPivot, model.pivotConfig)) { + log.debug('Applying pivotConfig', nextPivot); + // eslint-disable-next-line no-param-reassign + model.pivotConfig = nextPivot; + } + return; + } + + // Pivot inactive — clear any prior pivot before falling through to + // the rollup/totals logic. + if (model.pivotConfig != null) { + log.debug('Clearing pivotConfig (pivot inactive)'); + // eslint-disable-next-line no-param-reassign + model.pivotConfig = null; + } + if (rollupActive) { const uiConfig = { columns: mockRollupRows, @@ -162,6 +214,8 @@ export function CreatePivotPage({ mockRollupRows, mockIncludeConstituents, mockNonAggregatedInRollup, + mockPivotColumnsOn, + mockPivotColumns, aggregatesOn, aggregationSettings, ]); diff --git a/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx b/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx index 3bf2d9b96..bbff1ab76 100644 --- a/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx +++ b/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx @@ -6,6 +6,7 @@ import { useRef, useState, } from 'react'; +import { createPortal } from 'react-dom'; import { Button, Checkbox, @@ -13,16 +14,13 @@ import { Select, UISwitch, } from '@deephaven/components'; -import { vsAdd, vsEdit, vsGripper, vsTrash } from '@deephaven/icons'; +import { vsEdit, vsGripper, vsTrash } from '@deephaven/icons'; import { AggregationOperation, AggregationUtils, type Aggregation, type AggregationSettings, } from '@deephaven/iris-grid'; -import Log from '@deephaven/log'; - -const log = Log.module('@deephaven/js-plugin-pivot-builder/PivotConfigSection'); /** * Mock-data UI section that previews the eventual replacement for the @@ -170,6 +168,7 @@ const popoverEmptyStyle: React.CSSProperties = { }; type PickerProps = { + anchorRef: React.RefObject; available: readonly string[]; excluded: readonly string[]; placeholder?: string; @@ -177,7 +176,43 @@ type PickerProps = { onClose: () => void; }; +/** + * Position a fixed-position popover so its top-right corner is anchored + * just below the anchor element. Flips above the anchor when the + * preferred placement would fall off the bottom of the viewport. + */ +function usePortalAnchorPosition( + anchorRef: React.RefObject, + popoverRef: React.RefObject +): { top: number; right: number } | null { + const [pos, setPos] = useState<{ top: number; right: number } | null>(null); + useLayoutEffect(() => { + const a = anchorRef.current; + const p = popoverRef.current; + if (a == null) return undefined; + const compute = (): void => { + const r = a.getBoundingClientRect(); + const ph = p?.getBoundingClientRect().height ?? 0; + const gap = 4; + const wantTop = r.bottom + gap; + const overflowsBottom = wantTop + ph > window.innerHeight - 8; + const top = overflowsBottom ? Math.max(8, r.top - gap - ph) : wantTop; + const right = window.innerWidth - r.right; + setPos({ top, right }); + }; + compute(); + window.addEventListener('resize', compute); + window.addEventListener('scroll', compute, true); + return () => { + window.removeEventListener('resize', compute); + window.removeEventListener('scroll', compute, true); + }; + }, [anchorRef, popoverRef]); + return pos; +} + function ColumnPicker({ + anchorRef, available, excluded, placeholder = 'Find column...', @@ -189,9 +224,11 @@ function ColumnPicker({ const containerRef = useRef(null); const searchRef = useRef(null); const excludedSet = useMemo(() => new Set(excluded), [excluded]); + const pos = usePortalAnchorPosition(anchorRef, containerRef); useEffect(() => { - searchRef.current?.focus(); + const id = requestAnimationFrame(() => searchRef.current?.focus()); + return () => cancelAnimationFrame(id); }, []); const filtered = useMemo(() => { @@ -243,8 +280,18 @@ function ColumnPicker({ [activeIndex, filtered, onPick] ); - return ( -
+ return createPortal( +
-
+
, + document.body ); } @@ -292,7 +340,9 @@ type ConfigCardProps = { onToggle: (next: boolean) => void; onAdd: () => void; addDisabled?: boolean; - picker?: React.ReactNode; + /** When true, the whole card is greyed-out and non-interactive. */ + disabled?: boolean; + picker?: (anchorRef: React.RefObject) => React.ReactNode; children: React.ReactNode; }; @@ -302,25 +352,32 @@ function ConfigCard({ onToggle, onAdd, addDisabled, + disabled, picker, children, }: ConfigCardProps): JSX.Element { + const buttonRef = useRef(null); return ( -
+
{title} onToggle(!on)} /> -
- - {picker} -
+ + {picker?.(buttonRef)}
{children} @@ -329,20 +386,102 @@ function ConfigCard({ ); } +/** + * Returns the props needed to make a row draggable for reordering, but only + * when the drag is initiated from the grip handle. Drag scope is limited to + * the given `groupKey` so rows in different cards don't cross-reorder. + */ +function useDraggableRow( + groupKey: string, + index: number, + onReorder: (from: number, to: number) => void +): { + draggable: boolean; + isDragOver: boolean; + onDragStart: React.DragEventHandler; + onDragEnd: React.DragEventHandler; + onDragOver: React.DragEventHandler; + onDragLeave: React.DragEventHandler; + onDrop: React.DragEventHandler; + onGripMouseDown: () => void; + onGripMouseUp: () => void; +} { + const [draggable, setDraggable] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const mime = `application/x-pivot-row+${groupKey}`; + return { + draggable, + isDragOver, + onDragStart: e => { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData(mime, String(index)); + }, + onDragEnd: () => { + setDraggable(false); + setIsDragOver(false); + }, + onDragOver: e => { + if (e.dataTransfer.types.includes(mime)) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + } + }, + onDragLeave: () => setIsDragOver(false), + onDrop: e => { + const raw = e.dataTransfer.getData(mime); + setIsDragOver(false); + if (raw === '') return; + const from = Number(raw); + if (!Number.isFinite(from) || from === index) return; + e.preventDefault(); + onReorder(from, index); + }, + onGripMouseDown: () => setDraggable(true), + onGripMouseUp: () => setDraggable(false), + }; +} + +const dragOverRowStyle: React.CSSProperties = { + boxShadow: 'inset 0 2px 0 0 var(--dh-color-accent, #4a90e2)', +}; +const gripStyle: React.CSSProperties = { cursor: 'grab' }; + type ColumnRowProps = { name: string; + index: number; + groupKey: string; + onReorder: (from: number, to: number) => void; onDelete: () => void; }; -function ColumnRow({ name, onDelete }: ColumnRowProps): JSX.Element { +function ColumnRow({ + name, + index, + groupKey, + onReorder, + onDelete, +}: ColumnRowProps): JSX.Element { + const d = useDraggableRow(groupKey, index, onReorder); return ( -
+
{name}
@@ -351,28 +490,46 @@ function ColumnRow({ name, onDelete }: ColumnRowProps): JSX.Element { type AggregateRowProps = { entry: Aggregation; + index: number; + groupKey: string; + onReorder: (from: number, to: number) => void; onEdit: () => void; onDelete: () => void; }; function AggregateRow({ entry, + index, + groupKey, + onReorder, onEdit, onDelete, }: AggregateRowProps): JSX.Element { + const d = useDraggableRow(groupKey, index, onReorder); const label = entry.selected.length > 0 ? `${entry.operation} (${entry.selected.join(', ')})` : entry.operation; return ( -
+
{label}
@@ -413,6 +570,7 @@ const aggregateFooterStyle: React.CSSProperties = { }; type AggregatePickerProps = { + anchorRef: React.RefObject; availableColumns: readonly string[]; columnTypes: Readonly>; availableOperations: readonly string[]; @@ -422,6 +580,7 @@ type AggregatePickerProps = { }; function AggregatePicker({ + anchorRef, availableColumns, columnTypes, availableOperations, @@ -436,16 +595,13 @@ function AggregatePicker({ () => new Set(initial.selected) ); const [query, setQuery] = useState(''); - const [placeAbove, setPlaceAbove] = useState(false); + const pos = usePortalAnchorPosition(anchorRef, containerRef); - useLayoutEffect(() => { - const el = containerRef.current; - if (el == null) return; - const rect = el.getBoundingClientRect(); - if (rect.bottom > window.innerHeight - 8) { - setPlaceAbove(true); - } - selectRef.current?.focus(); + useEffect(() => { + // Defer focus past portal mount + position so the browser actually + // gives the (visible)
Select column(s) @@ -614,7 +777,8 @@ function AggregatePicker({ Aggregate
-
+
, + document.body ); } @@ -643,6 +807,7 @@ export function PivotConfigSection({ onNonAggregatedInRollupChange, }: PivotConfigSectionProps): JSX.Element { const [rollupPickerOpen, setRollupPickerOpen] = useState(false); + const [pivotPickerOpen, setPivotPickerOpen] = useState(false); // `null` = closed. `{ mode: 'add' }` = adding new. `{ mode: 'edit', index }` // = editing existing entry. const [aggPickerState, setAggPickerState] = useState< @@ -662,18 +827,29 @@ export function PivotConfigSection({ ); const handleAddPivotColumn = useCallback(() => { - log.info('Pivot column picker not yet wired'); + setPivotPickerOpen(open => !open); }, []); - const handleAddFilterable = useCallback(() => { - log.info('Filterable column picker not yet wired'); - }, []); + const handlePickPivotColumn = useCallback( + (name: string) => { + onPivotColumnsChange([...pivotColumns, name]); + setPivotPickerOpen(false); + }, + [pivotColumns, onPivotColumnsChange] + ); const usedOperations = useMemo( () => aggregationSettings.aggregations.map(a => a.operation as string), [aggregationSettings.aggregations] ); + const hasAggregateSelections = useMemo( + () => + aggregatesOn && + aggregationSettings.aggregations.some(a => a.selected.length > 0), + [aggregatesOn, aggregationSettings.aggregations] + ); + const selectableOperations = useMemo( () => SELECTABLE_OPERATIONS.filter( @@ -763,6 +939,14 @@ export function PivotConfigSection({ return next; }; + const moveItem = (arr: readonly T[], from: number, to: number): T[] => { + const next = arr.slice(); + if (from === to) return next; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + return next; + }; + return (
rollupPickerOpen ? ( + onRollupRowsChange(moveItem(rollupRows, from, to)) + } onDelete={() => onRollupRowsChange(removeAt(rollupRows, i))} /> )) @@ -800,19 +990,45 @@ export function PivotConfigSection({ on={pivotColumnsOn} onToggle={onPivotColumnsOnChange} onAdd={handleAddPivotColumn} + addDisabled={rollupRows.length === 0 || !hasAggregateSelections} + picker={anchorRef => + pivotPickerOpen ? ( + setPivotPickerOpen(false)} + /> + ) : null + } > - {pivotColumns.length === 0 ? ( -
No columns
- ) : ( - pivotColumns.map((name, i) => ( + {(() => { + if (pivotColumnsOn && rollupRows.length === 0) { + return
Add at least one Rollup row
; + } + if (pivotColumnsOn && !hasAggregateSelections) { + return ( +
Add at least one Aggregate value
+ ); + } + if (pivotColumns.length === 0) { + return
No columns
; + } + return pivotColumns.map((name, i) => ( + onPivotColumnsChange(moveItem(pivotColumns, from, to)) + } onDelete={() => onPivotColumnsChange(removeAt(pivotColumns, i))} /> - )) - )} + )); + })()}
= selectableOperations.length} - picker={ + picker={anchorRef => aggPickerState != null ? ( + onAggregationSettingsChange({ + ...aggregationSettings, + aggregations: moveItem( + aggregationSettings.aggregations, + from, + to + ), + }) + } onEdit={() => handleEditAggregate(i)} onDelete={() => handleDeleteAggregate(i)} /> @@ -849,27 +1078,8 @@ export function PivotConfigSection({ )} - - {filterableColumns.length === 0 ? ( -
No columns
- ) : ( - filterableColumns.map((name, i) => ( - - onFilterableColumnsChange(removeAt(filterableColumns, i)) - } - /> - )) - )} -
+ {/* Filterable columns card hidden for now \u2014 props are still threaded + through so it can be re-enabled without churn. */}
Date: Mon, 1 Jun 2026 08:20:00 -0600 Subject: [PATCH 10/18] Initial drag-and-drop, fix crashes on model updates --- plugins/pivot-builder/src/js/package.json | 1 + .../src/js/src/CreatePivotPage.tsx | 48 +- .../src/js/src/PivotConfigSection.tsx | 721 +++++++++++------- .../src/js/src/pivotBuilderModel.ts | 3 + plugins/pivot-builder/src/js/vite.config.ts | 1 + 5 files changed, 497 insertions(+), 277 deletions(-) diff --git a/plugins/pivot-builder/src/js/package.json b/plugins/pivot-builder/src/js/package.json index b2462bfd0..33917b872 100644 --- a/plugins/pivot-builder/src/js/package.json +++ b/plugins/pivot-builder/src/js/package.json @@ -43,6 +43,7 @@ "react-dom": "^18.0.0" }, "peerDependencies": { + "@hello-pangea/dnd": "^18.0.1", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, diff --git a/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx index 643f4d2da..9ef6bc123 100644 --- a/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx +++ b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; import { + IrisGridModel, IrisGridUtils, type AggregationSettings, - type IrisGridModel, type UITotalsTableConfig, } from '@deephaven/iris-grid'; import deepEqual from 'fast-deep-equal'; @@ -12,6 +12,7 @@ import { type PivotConfig, } from './pivotBuilderModel'; import { PivotConfigSection } from './PivotConfigSection'; +import { usePivotServiceStatus } from './PivotServiceContext'; // `IrisGridTableOptionsPageProps` is not yet in the installed // `@deephaven/iris-grid` typings (added in a newer host build), but is @@ -43,7 +44,8 @@ const EMPTY_AGGREGATION_SETTINGS: AggregationSettings = { }; function aggregationsToPivot( - settings: AggregationSettings + settings: AggregationSettings, + fallbackCountColumn: string | undefined ): Record { const out: Record = {}; settings.aggregations.forEach(agg => { @@ -51,6 +53,9 @@ function aggregationsToPivot( const op = String(agg.operation); out[op] = [...(out[op] ?? []), ...agg.selected]; }); + if (Object.keys(out).length === 0 && fallbackCountColumn != null) { + out.Count = [fallbackCountColumn]; + } return out; } @@ -69,6 +74,8 @@ export function CreatePivotPage({ model, }: IrisGridTableOptionsPageProps): JSX.Element { const isProxy = isPivotBuilderIrisGridModel(model); + const pivotServiceStatus = usePivotServiceStatus(); + const pivotAvailable = pivotServiceStatus === 'ready'; // Always source columns from the original (pre-pivot) table so the // selectors don't shift to pivot output columns after Apply. @@ -111,6 +118,21 @@ export function CreatePivotPage({ const [mockFilterable, setMockFilterable] = useState([]); const [mockFilterableOn, setMockFilterableOn] = useState(true); + // `IrisGridProxyModel.totalsConfig`'s setter silently drops writes + // while a model swap is in progress (see the `modelPromise` guard). + // Clearing `rollupConfig` triggers exactly such a swap, so a same- + // tick assignment of `totalsConfig` is lost. Bump this counter on + // every COLUMNS_CHANGED so the combined effect re-runs after the + // swap settles and re-applies the totals config. + const [swapEpoch, setSwapEpoch] = useState(0); + useEffect(() => { + const handler = (): void => setSwapEpoch(e => e + 1); + model.addEventListener(IrisGridModel.EVENT.COLUMNS_CHANGED, handler); + return () => { + model.removeEventListener(IrisGridModel.EVENT.COLUMNS_CHANGED, handler); + }; + }, [model]); + // Combined effect that owns BOTH `model.rollupConfig` and // `model.totalsConfig`. The two surfaces are mutually exclusive in // IrisGrid: when a rollup is active, totals are suppressed and @@ -130,13 +152,18 @@ export function CreatePivotPage({ ? aggregationSettings : EMPTY_AGGREGATION_SETTINGS; + // Pivot is valid with empty rowKeys (PSP collapses to a single + // row). It is NOT valid with an empty aggregations map, so we + // synthesize a `Count` over the first source column that isn't + // already used as a row or pivot key. Also gate on PSP being + // available on this worker; otherwise createPivotTable hangs and + // the proxy times out after 10s. const pivotActive = - mockPivotColumnsOn && - mockPivotColumns.length > 0 && - mockRollupRows.length > 0 && - aggsActive; + pivotAvailable && mockPivotColumnsOn && mockPivotColumns.length > 0; if (pivotActive) { + const used = new Set([...mockRollupRows, ...mockPivotColumns]); + const countFallback = allColumnNames.find(c => !used.has(c)); // Pivot swaps the proxy's inner model wholesale, superseding any // active rollup/totals. We deliberately DO NOT clear rollupConfig // or totalsConfig here: `IrisGridProxyModel.setNextModel` cancels @@ -149,7 +176,10 @@ export function CreatePivotPage({ const nextPivot: PivotConfig = { rowKeys: mockRollupRows, columnKeys: mockPivotColumns, - aggregations: aggregationsToPivot(effectiveAggregationSettings), + aggregations: aggregationsToPivot( + effectiveAggregationSettings, + countFallback + ), }; if (!deepEqual(nextPivot, model.pivotConfig)) { log.debug('Applying pivotConfig', nextPivot); @@ -218,6 +248,9 @@ export function CreatePivotPage({ mockPivotColumns, aggregatesOn, aggregationSettings, + allColumnNames, + pivotAvailable, + swapEpoch, ]); return ( @@ -234,6 +267,7 @@ export function CreatePivotPage({ onPivotColumnsChange={setMockPivotColumns} pivotColumnsOn={mockPivotColumnsOn} onPivotColumnsOnChange={setMockPivotColumnsOn} + pivotColumnsDisabled={!pivotAvailable} aggregationSettings={aggregationSettings} onAggregationSettingsChange={setAggregationSettings} aggregatesOn={aggregatesOn} diff --git a/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx b/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx index bbff1ab76..11cc26cef 100644 --- a/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx +++ b/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx @@ -8,6 +8,14 @@ import { } from 'react'; import { createPortal } from 'react-dom'; import { + type BeforeCapture, + DragDropContext, + Draggable, + Droppable, + type DropResult, +} from '@hello-pangea/dnd'; +import { + ActionButton, Button, Checkbox, SearchInput, @@ -33,6 +41,61 @@ import { // web-client-ui/packages/iris-grid/src/sidebar/aggregations/AggregationUtils.ts. // Inlined because that constant is not re-exported from the package's // public surface. +// Marching-ants drop-zone styling, mirroring the `ants-base` mixin used by +// iris-grid's RollupRows.scss. Injected as a +
- {(() => { - if (pivotColumnsOn && rollupRows.length === 0) { - return
Add at least one Rollup row
; + + rollupPickerOpen ? ( + setRollupPickerOpen(false)} + /> + ) : null } - if (pivotColumnsOn && !hasAggregateSelections) { - return ( -
Add at least one Aggregate value
- ); + > + + {(provided, snapshot) => ( +
+ {rollupRows.map((name, i) => ( + onRollupRowsChange(removeAt(rollupRows, i))} + /> + ))} + {provided.placeholder} +
+ )} +
+
+ + + pivotPickerOpen ? ( + setPivotPickerOpen(false)} + /> + ) : null } - if (pivotColumns.length === 0) { - return
No columns
; + > + + {(provided, snapshot) => ( +
+ {pivotColumns.map((name, i) => ( + + onPivotColumnsChange(removeAt(pivotColumns, i)) + } + /> + ))} + {provided.placeholder} +
+ )} +
+
+ + = selectableOperations.length} + picker={anchorRef => + aggPickerState != null ? ( + + ) : null } - return pivotColumns.map((name, i) => ( - - onPivotColumnsChange(moveItem(pivotColumns, from, to)) - } - onDelete={() => onPivotColumnsChange(removeAt(pivotColumns, i))} - /> - )); - })()} - - - = selectableOperations.length} - picker={anchorRef => - aggPickerState != null ? ( - - ) : null - } - > - {aggregationSettings.aggregations.length === 0 ? ( -
No aggregates
- ) : ( - aggregationSettings.aggregations.map((entry, i) => ( - - onAggregationSettingsChange({ - ...aggregationSettings, - aggregations: moveItem( - aggregationSettings.aggregations, - from, - to - ), - }) - } - onEdit={() => handleEditAggregate(i)} - onDelete={() => handleDeleteAggregate(i)} - /> - )) - )} -
- - {/* Filterable columns card hidden for now \u2014 props are still threaded + > + + {(provided, snapshot) => ( +
+ {aggregationSettings.aggregations.map((entry, i) => ( + handleEditAggregate(i)} + onDelete={() => handleDeleteAggregate(i)} + /> + ))} + {provided.placeholder} +
+ )} +
+ + + {/* Filterable columns card hidden for now \u2014 props are still threaded through so it can be re-enabled without churn. */} -
- onIncludeConstituentsChange(e.target.checked)} - > - Include constituents in rollups rows - - onNonAggregatedInRollupChange(e.target.checked)} - > - Non-aggregated in rollup rows - +
+ onIncludeConstituentsChange(e.target.checked)} + > + Include constituents in rollups rows + + onNonAggregatedInRollupChange(e.target.checked)} + > + Non-aggregated in rollup rows + +
-
+ ); } diff --git a/plugins/pivot-builder/src/js/src/pivotBuilderModel.ts b/plugins/pivot-builder/src/js/src/pivotBuilderModel.ts index dadfdca9a..5cd6e7e66 100644 --- a/plugins/pivot-builder/src/js/src/pivotBuilderModel.ts +++ b/plugins/pivot-builder/src/js/src/pivotBuilderModel.ts @@ -200,6 +200,9 @@ export async function makePivotBuilderModel( }); return new IrisGridPivotModel(corePlusDh, pivotTable); })(); + promise.catch(e => { + log.error('createPivotTable failed for config', config, e); + }); proxy.setNextModel(promise); }, diff --git a/plugins/pivot-builder/src/js/vite.config.ts b/plugins/pivot-builder/src/js/vite.config.ts index ef4704eec..59232af36 100644 --- a/plugins/pivot-builder/src/js/vite.config.ts +++ b/plugins/pivot-builder/src/js/vite.config.ts @@ -23,6 +23,7 @@ export default defineConfig(({ mode }) => ({ external: [ 'react', 'react-dom', + '@hello-pangea/dnd', '@deephaven/components', '@deephaven/dashboard', '@deephaven/dashboard-core-plugins', From 68af69be4cebb3e4da0e617eb6dce3596fda5810 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Mon, 1 Jun 2026 10:48:22 -0600 Subject: [PATCH 11/18] dnd-kit drag and drop --- package-lock.json | 3 + plugins/pivot-builder/src/js/package.json | 6 +- .../src/js/src/PivotConfigSection.tsx | 587 +++++++++++------- plugins/pivot-builder/src/js/vite.config.ts | 6 +- 4 files changed, 387 insertions(+), 215 deletions(-) diff --git a/package-lock.json b/package-lock.json index d1d8cc485..742d5dcfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30962,6 +30962,9 @@ "@deephaven/log": "^1.8.0", "@deephaven/plugin": "^1.18.0", "@deephaven/utils": "^1.10.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "fast-deep-equal": "^3.1.3" }, "devDependencies": { diff --git a/plugins/pivot-builder/src/js/package.json b/plugins/pivot-builder/src/js/package.json index 33917b872..0638506e1 100644 --- a/plugins/pivot-builder/src/js/package.json +++ b/plugins/pivot-builder/src/js/package.json @@ -26,10 +26,10 @@ "@deephaven/dashboard-core-plugins": "^1.18.0", "@deephaven/icons": "^1.2.0", "@deephaven/iris-grid": "^1.18.0", + "@deephaven/js-plugin-pivot": "*", "@deephaven/jsapi-bootstrap": "^1.17.0", "@deephaven/jsapi-types": "^1.0.0-dev0.39.6", "@deephaven/jsapi-utils": "^1.16.0", - "@deephaven/js-plugin-pivot": "*", "@deephaven/log": "^1.8.0", "@deephaven/plugin": "^1.18.0", "@deephaven/utils": "^1.10.0", @@ -43,7 +43,9 @@ "react-dom": "^18.0.0" }, "peerDependencies": { - "@hello-pangea/dnd": "^18.0.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, diff --git a/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx b/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx index 11cc26cef..9e4020da0 100644 --- a/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx +++ b/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx @@ -8,12 +8,23 @@ import { } from 'react'; import { createPortal } from 'react-dom'; import { - type BeforeCapture, - DragDropContext, - Draggable, - Droppable, - type DropResult, -} from '@hello-pangea/dnd'; + closestCenter, + DndContext, + type DragEndEvent, + DragOverlay, + type DragStartEvent, + MeasuringStrategy, + PointerSensor, + useDroppable, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { ActionButton, Button, @@ -52,34 +63,19 @@ const PIVOT_DND_STYLES = ` border-radius: 2px; transition: background-color 0.15s ease; } -.pivot-config-section .pivot-droppable-placeholder { +.pivot-config-section .pivot-droppable-empty { min-height: 36px; margin: 4px 0; padding: 4px; border: dashed 1px transparent; border-radius: 2px; } -/* Collapse empty drop-zone visuals when no drag is in progress, or - * when the active drag's source is incompatible with this card. The - * droppable element stays in the DOM (with non-zero size for hit - * tests during drag), but its decoration is hidden. */ -.pivot-config-section:not(.is-dragging) .pivot-droppable-placeholder, -.pivot-config-section.is-dragging-aggregations - .pivot-droppable-columns.pivot-droppable-placeholder, -.pivot-config-section.is-dragging-columns - .pivot-droppable-aggregations.pivot-droppable-placeholder { - min-height: 0; - margin: 0; - padding: 0; - border: none; -} +/* Marching-ants on every active drop zone whose accepted source type + * matches the current drag. is-dragging-columns is set on the root + * while a column row (rollup/pivot) is being dragged; + * is-dragging-aggregations while an aggregation is being dragged. */ .pivot-config-section.is-dragging-columns .pivot-droppable-columns, -.pivot-config-section.is-dragging-columns - .pivot-droppable-columns.pivot-droppable-placeholder, -.pivot-config-section.is-dragging-aggregations - .pivot-droppable-aggregations, -.pivot-config-section.is-dragging-aggregations - .pivot-droppable-aggregations.pivot-droppable-placeholder { +.pivot-config-section.is-dragging-aggregations .pivot-droppable-aggregations { background-image: linear-gradient(to right, var(--dh-color-bg-200, #1a1a1a) 50%, var(--dh-color-fg, #f0f0ee) 50%), linear-gradient(to right, var(--dh-color-bg-200, #1a1a1a) 50%, var(--dh-color-fg, #f0f0ee) 50%), @@ -90,8 +86,8 @@ const PIVOT_DND_STYLES = ` background-repeat: repeat-x, repeat-x, repeat-y, repeat-y; animation: march 0.5s linear infinite; } -.pivot-config-section.is-dragging .pivot-droppable.is-dragging-over, -.pivot-config-section.is-dragging .pivot-droppable-placeholder.is-dragging-over { +.pivot-config-section .pivot-droppable.is-dragging-over, +.pivot-config-section .pivot-droppable-empty.is-dragging-over { background-color: var(--dh-color-item-list-selected-hover-bg, rgba(255, 255, 255, 0.08)); } `; @@ -500,50 +496,111 @@ const ROLLUP_ROWS_DROPPABLE = 'rollup-rows'; const PIVOT_COLUMNS_DROPPABLE = 'pivot-columns'; const AGGREGATIONS_DROPPABLE = 'aggregations'; +type DroppableListProps = { + id: string; + type: 'columns' | 'aggregations'; + itemIds: string[]; + isEmpty: boolean; + children: React.ReactNode; +}; + +/** + * A SortableContext-wrapped container that also registers as a + * droppable so empty lists can accept drops. `type` controls the CSS + * class so the marching-ants decoration toggles based on the active + * drag's source (set on the section root). + */ +function DroppableList({ + id, + type, + itemIds, + isEmpty, + children, +}: DroppableListProps): JSX.Element { + const { setNodeRef, isOver } = useDroppable({ id, data: { container: id } }); + const baseClass = + type === 'columns' + ? 'pivot-droppable-columns' + : 'pivot-droppable-aggregations'; + const stateClass = isEmpty ? 'pivot-droppable-empty' : 'pivot-droppable'; + const overClass = isOver ? ' is-dragging-over' : ''; + return ( + +
+ {children} +
+
+ ); +} + type ColumnRowProps = { name: string; - index: number; droppableId: string; onDelete: () => void; }; +function columnRowId(droppableId: string, name: string): string { + return `${droppableId}:${name}`; +} + function ColumnRow({ name, - index, droppableId, onDelete, }: ColumnRowProps): JSX.Element { + const id = columnRowId(droppableId, name); + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id, data: { type: 'column', container: droppableId } }); + const style: React.CSSProperties = { + ...rowStyle, + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0 : 1, + }; return ( - - {(provided, snapshot) => ( -
- {name} -
- )} -
+
+ {name} +
+ ); +} + +/** Static (non-dnd) rendering of a column row for use inside DragOverlay. */ +function ColumnRowPreview({ name }: { name: string }): JSX.Element { + return ( +
+ {name} +
); } @@ -554,51 +611,81 @@ type AggregateRowProps = { onDelete: () => void; }; +function aggregationRowId(index: number): string { + return `${AGGREGATIONS_DROPPABLE}:${index}`; +} + +function formatAggLabel(entry: Aggregation): string { + return entry.selected.length > 0 + ? `${entry.operation} (${entry.selected.join(', ')})` + : entry.operation; +} + function AggregateRow({ entry, index, onEdit, onDelete, }: AggregateRowProps): JSX.Element { - const label = - entry.selected.length > 0 - ? `${entry.operation} (${entry.selected.join(', ')})` - : entry.operation; + const id = aggregationRowId(index); + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id, + data: { type: 'aggregation', container: AGGREGATIONS_DROPPABLE }, + }); + const style: React.CSSProperties = { + ...rowStyle, + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0 : 1, + }; return ( - - {(provided, snapshot) => ( -
- {label} -
- )} -
+
+ {formatAggLabel(entry)} +
+ ); +} + +function AggregateRowPreview({ entry }: { entry: Aggregation }): JSX.Element { + return ( +
+ {formatAggLabel(entry)} +
); } @@ -1011,46 +1098,79 @@ export function PivotConfigSection({ return next; }; - // Flip `dragSource` in `onBeforeCapture` (NOT `onDragStart`) so the - // `is-dragging` / `is-dragging-columns` classes are applied — and - // any collapsed empty droppables expanded — *before* hello-pangea - // snapshots droppable rects. Using `onDragStart` here would make an - // empty droppable's hit-area register as 0 px high for the duration - // of the drag. - const handleBeforeCapture = useCallback((before: BeforeCapture): void => { - // draggableId is namespaced as `${droppableId}:...` (see ColumnRow - // and the AggregateRow draggableId below), so the prefix recovers - // the source droppable without waiting for onDragStart. - const id = before.draggableId; + // Flip `dragSource` in `onDragStart`. With @dnd-kit's + // MeasuringStrategy.Always (set on the DndContext), every droppable + // is re-measured continuously, so the empty drop-zones can expand + // from 0px to their full hit-area after the drag starts and the + // marching-ants class is applied. + // Track the active draggable's id for the DragOverlay preview. + const [activeId, setActiveId] = useState(null); + const handleDragStart = useCallback((event: DragStartEvent): void => { + const container = String(event.active.data.current?.container ?? ''); + setDragSource(container === '' ? null : container); + setActiveId(String(event.active.id)); + }, []); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }) + ); + + const resolveContainerOfId = useCallback((id: string): string | null => { + // Container ids are exact matches; item ids are namespaced as + // `${container}:...`. + if ( + id === ROLLUP_ROWS_DROPPABLE || + id === PIVOT_COLUMNS_DROPPABLE || + id === AGGREGATIONS_DROPPABLE + ) { + return id; + } const colonIdx = id.indexOf(':'); - setDragSource(colonIdx === -1 ? id : id.slice(0, colonIdx)); + return colonIdx === -1 ? null : id.slice(0, colonIdx); }, []); const handleDragEnd = useCallback( - (result: DropResult): void => { + (event: DragEndEvent): void => { setDragSource(null); - const { source, destination } = result; - if (destination == null) return; - const fromId = source.droppableId; - const toId = destination.droppableId; - if (fromId === toId && source.index === destination.index) return; + setActiveId(null); + const { active, over } = event; + if (over == null) return; + + const activeIdStr = String(active.id); + const overIdStr = String(over.id); + const fromId = resolveContainerOfId(activeIdStr); + const toId = resolveContainerOfId(overIdStr); + if (fromId == null || toId == null) return; // Aggregations are a separate scope — reorder only. if (fromId === AGGREGATIONS_DROPPABLE) { if (toId !== AGGREGATIONS_DROPPABLE) return; + // Active id is `aggregations:`, over may be `aggregations:` + // or the container id (drop at end). + const fromIdx = aggregationSettings.aggregations.findIndex( + (_, i) => aggregationRowId(i) === activeIdStr + ); + if (fromIdx < 0) return; + const toIdx = + overIdStr === AGGREGATIONS_DROPPABLE + ? aggregationSettings.aggregations.length - 1 + : aggregationSettings.aggregations.findIndex( + (_, i) => aggregationRowId(i) === overIdStr + ); + if (toIdx < 0 || fromIdx === toIdx) return; onAggregationSettingsChange({ ...aggregationSettings, aggregations: moveItem( aggregationSettings.aggregations, - source.index, - destination.index + fromIdx, + toIdx ), }); return; } + // Columns can never land in the aggregations list. if (toId === AGGREGATIONS_DROPPABLE) return; - // Column lists (rollup-rows ↔ pivot-columns). const lists: Record< string, { items: string[]; set: (next: string[]) => void } @@ -1068,18 +1188,38 @@ export function PivotConfigSection({ const to = lists[toId]; if (from == null || to == null) return; + // Recover the moved column name from the active id + // (`${container}:${name}`). + const colonIdx = activeIdStr.indexOf(':'); + if (colonIdx === -1) return; + const moved = activeIdStr.slice(colonIdx + 1); + const fromIdx = from.items.indexOf(moved); + if (fromIdx < 0) return; + + let toIdx: number; + if (overIdStr === toId) { + // Dropped on container background — append. + toIdx = to.items.length; + } else { + const overColon = overIdStr.indexOf(':'); + const overName = + overColon === -1 ? overIdStr : overIdStr.slice(overColon + 1); + const overIdx = to.items.indexOf(overName); + toIdx = overIdx < 0 ? to.items.length : overIdx; + } + if (fromId === toId) { - from.set(moveItem(from.items, source.index, destination.index)); + if (fromIdx === toIdx) return; + from.set(moveItem(from.items, fromIdx, toIdx)); return; } // Cross-list move. Drop silently if the column already exists in - // the destination list (we don't allow duplicates within a card). - const moved = from.items[source.index]; - if (moved == null || to.items.includes(moved)) return; - from.set(removeAt(from.items.slice(), source.index)); + // the destination list (no duplicates within a card). + if (to.items.includes(moved)) return; + from.set(removeAt(from.items.slice(), fromIdx)); const nextTo = to.items.slice(); - nextTo.splice(destination.index, 0, moved); + nextTo.splice(Math.min(toIdx, nextTo.length), 0, moved); to.set(nextTo); }, [ @@ -1088,14 +1228,65 @@ export function PivotConfigSection({ onPivotColumnsChange, onRollupRowsChange, pivotColumns, + resolveContainerOfId, rollupRows, ] ); + const handleDragCancel = useCallback((): void => { + setDragSource(null); + setActiveId(null); + }, []); + + const rollupItemIds = useMemo( + () => rollupRows.map(n => columnRowId(ROLLUP_ROWS_DROPPABLE, n)), + [rollupRows] + ); + const pivotItemIds = useMemo( + () => pivotColumns.map(n => columnRowId(PIVOT_COLUMNS_DROPPABLE, n)), + [pivotColumns] + ); + const aggItemIds = useMemo( + () => aggregationSettings.aggregations.map((_, i) => aggregationRowId(i)), + [aggregationSettings.aggregations] + ); + + // Resolve the preview for DragOverlay. + const activeColumnName = (() => { + if (activeId == null) { + return null; + } + const container = resolveContainerOfId(activeId); + if ( + container !== ROLLUP_ROWS_DROPPABLE && + container !== PIVOT_COLUMNS_DROPPABLE + ) { + return null; + } + const colonIdx = activeId.indexOf(':'); + return colonIdx === -1 ? null : activeId.slice(colonIdx + 1); + })(); + const activeAggregation = (() => { + if (activeId == null) { + return null; + } + const container = resolveContainerOfId(activeId); + if (container !== AGGREGATIONS_DROPPABLE) { + return null; + } + const colonIdx = activeId.indexOf(':'); + const idx = colonIdx === -1 ? -1 : Number(activeId.slice(colonIdx + 1)); + return aggregationSettings.aggregations[idx] ?? null; + })(); + return ( -
- - {(provided, snapshot) => ( -
- {rollupRows.map((name, i) => ( - onRollupRowsChange(removeAt(rollupRows, i))} - /> - ))} - {provided.placeholder} -
- )} -
+ + {rollupRows.map((name, i) => ( + onRollupRowsChange(removeAt(rollupRows, i))} + /> + ))} + - - {(provided, snapshot) => ( -
- {pivotColumns.map((name, i) => ( - - onPivotColumnsChange(removeAt(pivotColumns, i)) - } - /> - ))} - {provided.placeholder} -
- )} -
+ + {pivotColumns.map((name, i) => ( + onPivotColumnsChange(removeAt(pivotColumns, i))} + /> + ))} +
- - {(provided, snapshot) => ( -
- {aggregationSettings.aggregations.map((entry, i) => ( - handleEditAggregate(i)} - onDelete={() => handleDeleteAggregate(i)} - /> - ))} - {provided.placeholder} -
- )} -
+ {aggregationSettings.aggregations.map((entry, i) => ( + handleEditAggregate(i)} + onDelete={() => handleDeleteAggregate(i)} + /> + ))} +
{/* Filterable columns card hidden for now \u2014 props are still threaded @@ -1276,7 +1431,17 @@ export function PivotConfigSection({
- + {createPortal( + + {activeColumnName != null ? ( + + ) : activeAggregation != null ? ( + + ) : null} + , + document.body + )} + ); } diff --git a/plugins/pivot-builder/src/js/vite.config.ts b/plugins/pivot-builder/src/js/vite.config.ts index 59232af36..2672c3fec 100644 --- a/plugins/pivot-builder/src/js/vite.config.ts +++ b/plugins/pivot-builder/src/js/vite.config.ts @@ -18,12 +18,11 @@ export default defineConfig(({ mode }) => ({ // Externalize peer deps following the grid-toolbar pattern. // These are provided at runtime by DHE's remote-component.config.ts // resolve map (or by the loaded js-plugin-pivot bundle in DHE's - // plugin loader). `fast-deep-equal` is small and not in DHE's resolve, + // plugin loader). `fast-deep-equal` is not in DHE's resolve map, // so we let it bundle. external: [ 'react', 'react-dom', - '@hello-pangea/dnd', '@deephaven/components', '@deephaven/dashboard', '@deephaven/dashboard-core-plugins', @@ -34,6 +33,9 @@ export default defineConfig(({ mode }) => ({ '@deephaven/js-plugin-pivot', '@deephaven/log', '@deephaven/plugin', + '@dnd-kit/core', + '@dnd-kit/sortable', + '@dnd-kit/utilities', ], }, }, From 5d612b1e8993a125e702d50f3f3fc1ba66e28454 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Tue, 9 Jun 2026 13:06:13 -0600 Subject: [PATCH 12/18] Refactor PivotBuilder plugin --- .../src/js/src/CreatePivotPage.tsx | 225 ++++++------ .../src/js/src/PivotBuilderMiddleware.tsx | 200 ++++++++++- .../js/src/PivotBuilderPanelMiddleware.tsx | 253 ++++++++------ .../src/js/src/PivotBuilderWidget.tsx | 167 --------- .../src/js/src/PivotConfigSection.tsx | 40 ++- plugins/pivot-builder/src/js/src/index.ts | 15 +- .../src/js/src/makeCreatePivotTransform.ts | 47 +++ .../src/js/src/makePivotModelTransform.ts | 78 +++++ .../pivot-builder/src/js/src/modelTypes.ts | 26 ++ .../src/js/src/pivotBuilderModel.ts | 324 ++++++++++++++++-- .../src/js/src/tableOptionsTypes.ts | 54 +++ .../src/useComposedTableOptionsExtension.ts | 48 --- 12 files changed, 999 insertions(+), 478 deletions(-) delete mode 100644 plugins/pivot-builder/src/js/src/PivotBuilderWidget.tsx create mode 100644 plugins/pivot-builder/src/js/src/makeCreatePivotTransform.ts create mode 100644 plugins/pivot-builder/src/js/src/makePivotModelTransform.ts create mode 100644 plugins/pivot-builder/src/js/src/modelTypes.ts create mode 100644 plugins/pivot-builder/src/js/src/tableOptionsTypes.ts delete mode 100644 plugins/pivot-builder/src/js/src/useComposedTableOptionsExtension.ts diff --git a/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx index 9ef6bc123..d01e0a853 100644 --- a/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx +++ b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx @@ -1,12 +1,11 @@ import { useEffect, useMemo, useState } from 'react'; import { - IrisGridModel, + type IrisGridModel, IrisGridUtils, type AggregationSettings, type UITotalsTableConfig, } from '@deephaven/iris-grid'; -import deepEqual from 'fast-deep-equal'; -import Log from '@deephaven/log'; +import type { dh as DhType } from '@deephaven/jsapi-types'; import { isPivotBuilderIrisGridModel, type PivotConfig, @@ -43,6 +42,72 @@ const EMPTY_AGGREGATION_SETTINGS: AggregationSettings = { showOnTop: false, }; +/** + * Convert an `operation → columns` map (as stored on `RollupConfig` and + * `PivotConfig`) into the host's `AggregationSettings.aggregations` + * array. The `invert` flag is not recoverable from a map and defaults + * to `false`. + */ +function aggregationsFromOpMap( + map: Record +): AggregationSettings['aggregations'] { + return Object.entries(map) + .filter(([, cols]) => (cols?.length ?? 0) > 0) + .map(([operation, cols]) => ({ + operation: + operation as AggregationSettings['aggregations'][number]['operation'], + selected: [...(cols ?? [])], + invert: false, + })); +} + +/** + * Reverse-engineer `AggregationSettings` from a `RollupConfig` or + * `UITotalsTableConfig` so the sidebar's Aggregate values card hydrates + * from the proxy's last-seen state. The `invert` flag is not + * recoverable from either source and defaults to `false`. + */ +function seedAggregationSettings( + rollup: DhType.RollupConfig | null, + totals: UITotalsTableConfig | null +): AggregationSettings { + const rollupAggs = ( + rollup as { aggregations?: Record } | null + )?.aggregations; + if (rollupAggs) { + return { + aggregations: aggregationsFromOpMap(rollupAggs), + showOnTop: false, + }; + } + if (totals?.operationMap) { + const byOp = new Map(); + Object.entries(totals.operationMap).forEach(([col, ops]) => { + (ops ?? []).forEach(op => { + const list = byOp.get(op) ?? []; + list.push(col); + byOp.set(op, list); + }); + }); + const order = totals.operationOrder ?? [...byOp.keys()]; + const seen = new Set(); + const aggregations = order + .filter(op => { + if (seen.has(op)) return false; + seen.add(op); + return byOp.has(op); + }) + .map(op => ({ + operation: + op as AggregationSettings['aggregations'][number]['operation'], + selected: byOp.get(op) ?? [], + invert: false, + })); + return { aggregations, showOnTop: totals.showOnTop ?? false }; + } + return EMPTY_AGGREGATION_SETTINGS; +} + function aggregationsToPivot( settings: AggregationSettings, fallbackCountColumn: string | undefined @@ -59,8 +124,6 @@ function aggregationsToPivot( return out; } -const log = Log.module('@deephaven/js-plugin-pivot-builder/CreatePivotPage'); - /** * Sidebar `configPage` for the Create Pivot menu item. * @@ -92,16 +155,27 @@ export function CreatePivotPage({ return map; }, [columns]); - // Seed Rollup rows from the existing `model.rollupConfig` once. - // `showNonAggregatedColumns` is UI-only (not faithfully recoverable - // from a `dh.RollupConfig`) so it defaults to `true`. + // Seed all four configurable cards from the proxy's last applied + // intent (`builderConfig`) so reopening the Create Pivot page never + // sends a stripped config through `applyPivotBuilderConfig`. The proxy + // is the single source of truth for the user's intent — pivot's + // rowKeys/aggregations are NOT recoverable from `model.rollupConfig` / + // `model.totalsConfig` (those reflect the inner-model state, which + // pivot supersedes). `showNonAggregatedColumns` is UI-only (not + // faithfully recoverable from a `dh.RollupConfig`) so it defaults to + // `true`. + const intent = isProxy ? model.builderConfig : null; + const pivotIntent = intent?.pivot ?? null; + const rollupIntent = intent?.rollup ?? model.rollupConfig ?? null; + const totalsIntent = intent?.totals ?? model.totalsConfig ?? null; + const [mockRollupRows, setMockRollupRows] = useState(() => { - const cfg = model.rollupConfig; - return cfg?.groupingColumns?.map((c: unknown) => String(c)) ?? []; + if (pivotIntent != null) return [...pivotIntent.rowKeys]; + return rollupIntent?.groupingColumns?.map((c: unknown) => String(c)) ?? []; }); const [mockRollupRowsOn, setMockRollupRowsOn] = useState(true); const [mockIncludeConstituents, setMockIncludeConstituents] = - useState(() => model.rollupConfig?.includeConstituents ?? true); + useState(() => rollupIntent?.includeConstituents ?? true); const [mockNonAggregatedInRollup, setMockNonAggregatedInRollup] = useState(true); @@ -109,35 +183,31 @@ export function CreatePivotPage({ // `AggregationSettings` so we can hand it straight to // `IrisGridUtils.getModelRollupConfig` / `.getModelTotalsConfig`. const [aggregationSettings, setAggregationSettings] = - useState(EMPTY_AGGREGATION_SETTINGS); + useState(() => { + if (pivotIntent != null) { + return { + aggregations: aggregationsFromOpMap(pivotIntent.aggregations), + showOnTop: false, + }; + } + return seedAggregationSettings(rollupIntent, totalsIntent); + }); const [aggregatesOn, setAggregatesOn] = useState(true); - // Mock-data state for the remaining cards. - const [mockPivotColumns, setMockPivotColumns] = useState([]); + const [mockPivotColumns, setMockPivotColumns] = useState(() => + pivotIntent != null ? [...pivotIntent.columnKeys] : [] + ); const [mockPivotColumnsOn, setMockPivotColumnsOn] = useState(true); const [mockFilterable, setMockFilterable] = useState([]); const [mockFilterableOn, setMockFilterableOn] = useState(true); - // `IrisGridProxyModel.totalsConfig`'s setter silently drops writes - // while a model swap is in progress (see the `modelPromise` guard). - // Clearing `rollupConfig` triggers exactly such a swap, so a same- - // tick assignment of `totalsConfig` is lost. Bump this counter on - // every COLUMNS_CHANGED so the combined effect re-runs after the - // swap settles and re-applies the totals config. - const [swapEpoch, setSwapEpoch] = useState(0); - useEffect(() => { - const handler = (): void => setSwapEpoch(e => e + 1); - model.addEventListener(IrisGridModel.EVENT.COLUMNS_CHANGED, handler); - return () => { - model.removeEventListener(IrisGridModel.EVENT.COLUMNS_CHANGED, handler); - }; - }, [model]); - - // Combined effect that owns BOTH `model.rollupConfig` and - // `model.totalsConfig`. The two surfaces are mutually exclusive in - // IrisGrid: when a rollup is active, totals are suppressed and - // aggregations are folded into the rollup config; otherwise - // aggregations become a standalone totals row. + // Reconcile pivot/rollup/totals on every relevant state change. The + // proxy owns ordering, diffing against last intent, and the mid-swap + // queue for `totalsConfig` — see `applyPivotBuilderConfig`. Direct + // writes to `model.rollupConfig` / `model.totalsConfig` are silently + // dropped by the proxy (the host `IrisGridModelUpdater` writes those + // on every render and the pivot-builder sidebar replaces those host + // surfaces). useEffect(() => { if (!isPivotBuilderIrisGridModel(model)) return; @@ -161,19 +231,15 @@ export function CreatePivotPage({ const pivotActive = pivotAvailable && mockPivotColumnsOn && mockPivotColumns.length > 0; + let pivot: PivotConfig | null = null; + let rollup: ReturnType | null = + null; + let totals: UITotalsTableConfig | null = null; + if (pivotActive) { const used = new Set([...mockRollupRows, ...mockPivotColumns]); const countFallback = allColumnNames.find(c => !used.has(c)); - // Pivot swaps the proxy's inner model wholesale, superseding any - // active rollup/totals. We deliberately DO NOT clear rollupConfig - // or totalsConfig here: `IrisGridProxyModel.setNextModel` cancels - // the previous in-flight model promise by `.close()`-ing the - // resolved model, so clearing rollupConfig first (which queues a - // swap back to `originalModel`) and then setting pivotConfig - // would cancel that swap and close the source table before the - // pivot promise can use it. When pivot is later cleared, the - // pivot-inactive branch re-reconciles rollup/totals state. - const nextPivot: PivotConfig = { + pivot = { rowKeys: mockRollupRows, columnKeys: mockPivotColumns, aggregations: aggregationsToPivot( @@ -181,63 +247,29 @@ export function CreatePivotPage({ countFallback ), }; - if (!deepEqual(nextPivot, model.pivotConfig)) { - log.debug('Applying pivotConfig', nextPivot); - // eslint-disable-next-line no-param-reassign - model.pivotConfig = nextPivot; - } - return; - } - - // Pivot inactive — clear any prior pivot before falling through to - // the rollup/totals logic. - if (model.pivotConfig != null) { - log.debug('Clearing pivotConfig (pivot inactive)'); - // eslint-disable-next-line no-param-reassign - model.pivotConfig = null; - } - - if (rollupActive) { - const uiConfig = { - columns: mockRollupRows, - showConstituents: mockIncludeConstituents, - showNonAggregatedColumns: mockNonAggregatedInRollup, - includeDescriptions: true as const, - }; - const nextRollup = IrisGridUtils.getModelRollupConfig( + } else if (rollupActive) { + // Rollup folds aggregations into its config; standalone totals row + // is suppressed. + rollup = IrisGridUtils.getModelRollupConfig( model.sourceTable.columns, - uiConfig, + { + columns: mockRollupRows, + showConstituents: mockIncludeConstituents, + showNonAggregatedColumns: mockNonAggregatedInRollup, + includeDescriptions: true as const, + }, + effectiveAggregationSettings + ); + } else { + // No pivot, no rollup — aggregations become a standalone totals row. + totals = IrisGridUtilsExt.getModelTotalsConfig( + model.sourceTable.columns, + undefined, effectiveAggregationSettings ); - if (!deepEqual(nextRollup, model.rollupConfig)) { - log.debug('Applying rollupConfig (rollup active)', nextRollup); - // eslint-disable-next-line no-param-reassign - model.rollupConfig = nextRollup; - } - if (model.totalsConfig != null) { - log.debug('Clearing totalsConfig (rollup wins)'); - // eslint-disable-next-line no-param-reassign - model.totalsConfig = null; - } - return; } - // No rollup: clear it, then push totals (or null). - if (model.rollupConfig != null) { - log.debug('Clearing rollupConfig (rollup inactive)'); - // eslint-disable-next-line no-param-reassign - model.rollupConfig = null; - } - const nextTotals = IrisGridUtilsExt.getModelTotalsConfig( - model.sourceTable.columns, - undefined, - effectiveAggregationSettings - ); - if (!deepEqual(nextTotals, model.totalsConfig)) { - log.debug('Applying totalsConfig (standalone aggregations)', nextTotals); - // eslint-disable-next-line no-param-reassign - model.totalsConfig = nextTotals; - } + model.applyPivotBuilderConfig({ pivot, rollup, totals }); }, [ model, mockRollupRowsOn, @@ -250,7 +282,6 @@ export function CreatePivotPage({ aggregationSettings, allColumnNames, pivotAvailable, - swapEpoch, ]); return ( diff --git a/plugins/pivot-builder/src/js/src/PivotBuilderMiddleware.tsx b/plugins/pivot-builder/src/js/src/PivotBuilderMiddleware.tsx index b7d208151..ca2e728d6 100644 --- a/plugins/pivot-builder/src/js/src/PivotBuilderMiddleware.tsx +++ b/plugins/pivot-builder/src/js/src/PivotBuilderMiddleware.tsx @@ -1,29 +1,197 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentType, +} from 'react'; +import { IrisGridModel } from '@deephaven/iris-grid'; +import { useApi, useObjectFetcher } from '@deephaven/jsapi-bootstrap'; +import { + isCorePlusDh, + usePivotMouseHandlers, + usePivotRenderer, + usePivotMetricCalculatorFactory, + usePivotTheme, +} from '@deephaven/js-plugin-pivot'; import Log from '@deephaven/log'; -import type { WidgetMiddlewareComponentProps } from '@deephaven/plugin'; import type { dh as DhType } from '@deephaven/jsapi-types'; -import { PivotBuilderWidget } from './PivotBuilderWidget'; +import type { + WidgetComponentProps, + WidgetMiddlewareComponentProps, +} from '@deephaven/plugin'; +import { isPivotBuilderIrisGridModel } from './pivotBuilderModel'; +import { makeCreatePivotTransform } from './makeCreatePivotTransform'; +import { makePivotModelTransform } from './makePivotModelTransform'; +import { type IrisGridTableOptionsWidgetProps } from './tableOptionsTypes'; +import { type IrisGridModelWidgetProps } from './modelTypes'; const log = Log.module( '@deephaven/js-plugin-pivot-builder/PivotBuilderMiddleware' ); /** - * Middleware for the non-panel widget path (e.g. `GridWidgetPlugin`). + * Extra IrisGrid-aware props the chained widget host (`GridWidgetPlugin`) + * accepts. `transformModel` / `transformTableOptions` are added to + * `@deephaven/iris-grid` / `@deephaven/dashboard-core-plugins` in + * web-client-ui; widen locally until that version is published and installed. + */ +type ChainedWidgetProps = WidgetComponentProps & + IrisGridTableOptionsWidgetProps & + IrisGridModelWidgetProps & { + theme?: Record; + onModelChanged?: (model: IrisGridModel) => void; + }; + +/** + * Widget-path middleware (e.g. `GridWidgetPlugin`). + * + * A **chained** middleware: it renders the wrapped `Component` (the base + * `GridWidgetPlugin`) and injects a `transformModel` that augments the + * host-built model into a `PivotBuilderIrisGridModel`, plus a composed + * `transformTableOptions` that contributes the Create/Edit Pivot page. The + * sidebar drives the inner model swap via the proxy's `pivotConfig` setter + * without the pivot-builder mounting its own `IrisGrid`. * - * Note: this middleware **replaces** the downstream `Component`. It owns - * the `IrisGrid` mount so the proxy model can intercept Table Options - * actions. Anything contributed further down the middleware chain by way - * of `Component` is intentionally dropped for this spike. + * When CorePlus is unavailable (open-source DH) we omit `transformModel`, so + * the host renders a plain table while the Create Pivot page surfaces an + * error if opened. */ -export function PivotBuilderMiddleware( - props: WidgetMiddlewareComponentProps -): JSX.Element { - log.debug('Replacing default Table widget with pivot builder'); - // Strip the wrapped `Component` — we render IrisGrid ourselves. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { Component: _Component, ...rest } = props; - // eslint-disable-next-line react/jsx-props-no-spreading - return ; +export function PivotBuilderMiddleware({ + Component, + transformTableOptions, + transformModel: upstreamTransformModel, + ...props +}: WidgetMiddlewareComponentProps & + IrisGridTableOptionsWidgetProps & + IrisGridModelWidgetProps): JSX.Element { + const { metadata } = props; + const dh = useApi(); + const corePlusAvailable = isCorePlusDh(dh) === true; + const objectFetcher = useObjectFetcher(); + + // Pivot overrides. Hooks must be unconditional. Renderer / mouse handlers / + // metric calculator are routed through the proxy model itself (the host + // reads `model.getRenderer` / `model.getMouseHandlers` / + // `model.getMetricCalculator`), so they swap synchronously with the inner + // model. Theme is the lone React-state-driven override. + const pivotMouseHandlers = usePivotMouseHandlers(); + const pivotRenderer = usePivotRenderer(); + const pivotMetricCalculator = usePivotMetricCalculatorFactory(); + const pivotTheme = usePivotTheme(); + const pivotOverrides = useMemo( + () => ({ + getMetricCalculator: pivotMetricCalculator, + renderer: pivotRenderer, + mouseHandlers: pivotMouseHandlers, + }), + [pivotMetricCalculator, pivotRenderer, pivotMouseHandlers] + ); + const [model, setModel] = useState(null); + const [isPivot, setIsPivot] = useState(false); + + // Compose our Create/Edit Pivot contribution on top of any upstream + // transform. Rebuilt when `isPivot` (a snapshot derived from model events + // below) changes so `IrisGrid` re-runs the transform and relabels the item + // without the transform reading the mutable model directly. + const composedTransform = useMemo( + () => makeCreatePivotTransform(transformTableOptions, isPivot), + [transformTableOptions, isPivot] + ); + + // Stash latest `metadata` / `objectFetcher` in refs so the lazy PSP fetcher + // keeps a stable identity and the transform does not change. + const metadataRef = useRef(metadata); + metadataRef.current = metadata; + const objectFetcherRef = useRef(objectFetcher); + objectFetcherRef.current = objectFetcher; + const pspWidgetRef = useRef(null); + + const getPspWidget = useCallback(async (): Promise => { + if (pspWidgetRef.current != null) { + return pspWidgetRef.current; + } + const md = metadataRef.current; + if (md == null) { + throw new Error('Cannot fetch PivotService: widget metadata is missing'); + } + const descriptor: DhType.ide.VariableDescriptor = { + ...md, + type: 'PivotService', + name: 'psp', + }; + const widget = await objectFetcherRef.current(descriptor); + pspWidgetRef.current = widget; + return widget; + }, []); + + // The model transform handed to the host. Augments the host-built proxy + // into a pivot-builder model. Stable across renders so the host does not + // rebuild the model. + const transformModel = useMemo( + () => + corePlusAvailable + ? makePivotModelTransform( + dh, + getPspWidget, + pivotOverrides, + undefined, + upstreamTransformModel + ) + : upstreamTransformModel, + [ + corePlusAvailable, + dh, + getPspWidget, + pivotOverrides, + upstreamTransformModel, + ] + ); + + // Track whether the proxy is currently in pivot mode (used to gate the + // pivot theme override and the Create/Edit relabel). The model is received + // via `onModelChanged`. + useEffect(() => { + if (model == null) { + setIsPivot(false); + return undefined; + } + const update = (): void => { + const next = + isPivotBuilderIrisGridModel(model) && model.pivotConfig != null; + setIsPivot(prev => (prev === next ? prev : next)); + }; + update(); + const target = model as unknown as { + addEventListener: (type: string, fn: () => void) => void; + removeEventListener: (type: string, fn: () => void) => void; + }; + const handler = (): void => update(); + target.addEventListener(IrisGridModel.EVENT.COLUMNS_CHANGED, handler); + return () => { + target.removeEventListener(IrisGridModel.EVENT.COLUMNS_CHANGED, handler); + }; + }, [model]); + + if (!corePlusAvailable) { + log.debug('CorePlus not available; rendering wrapped widget'); + } + + // `Component` is typed for the generic widget props; widen locally to + // forward the IrisGrid-aware props. + const Next = Component as ComponentType; + + return ( + ) : undefined} + onModelChanged={setModel} + /> + ); } export default PivotBuilderMiddleware; diff --git a/plugins/pivot-builder/src/js/src/PivotBuilderPanelMiddleware.tsx b/plugins/pivot-builder/src/js/src/PivotBuilderPanelMiddleware.tsx index 45b9baa34..e676e2a2c 100644 --- a/plugins/pivot-builder/src/js/src/PivotBuilderPanelMiddleware.tsx +++ b/plugins/pivot-builder/src/js/src/PivotBuilderPanelMiddleware.tsx @@ -1,12 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - IrisGridPanel, - useLoadTablePlugin, -} from '@deephaven/dashboard-core-plugins'; -import { - IrisGridModel, - IrisGridTableOptionsContext, -} from '@deephaven/iris-grid'; + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentType, +} from 'react'; +import { type IrisGridModel } from '@deephaven/iris-grid'; import { useApi, useObjectFetcher } from '@deephaven/jsapi-bootstrap'; import { isCorePlusDh, @@ -18,43 +18,69 @@ import { import { usePersistentState } from '@deephaven/dashboard'; import Log from '@deephaven/log'; import type { dh as DhType } from '@deephaven/jsapi-types'; -import { type WidgetMiddlewarePanelProps } from '@deephaven/plugin'; +import { + type WidgetMiddlewarePanelProps, + type WidgetPanelProps, +} from '@deephaven/plugin'; import { isPivotBuilderIrisGridModel, - makePivotBuilderModel, + PIVOT_BUILDER_CONFIG_CHANGED, + type PivotBuilderConfig, type PivotConfig, } from './pivotBuilderModel'; -import { useComposedTableOptionsExtension } from './useComposedTableOptionsExtension'; +import { makeCreatePivotTransform } from './makeCreatePivotTransform'; +import { makePivotModelTransform } from './makePivotModelTransform'; import { PivotServiceContext, type PivotServiceStatus, } from './PivotServiceContext'; +import { type IrisGridTableOptionsWidgetProps } from './tableOptionsTypes'; +import { type IrisGridModelWidgetProps } from './modelTypes'; const log = Log.module( '@deephaven/js-plugin-pivot-builder/PivotBuilderPanelMiddleware' ); +/** + * Extra IrisGrid-aware props the chained panel host (`IrisGridPanel`, via the + * base `GridPanelPlugin`) accepts. `transformModel` / `transformTableOptions` + * are added to `@deephaven/iris-grid` in web-client-ui; widen locally until + * that version is published and installed. + */ +type ChainedPanelProps = WidgetPanelProps & + IrisGridTableOptionsWidgetProps & + IrisGridModelWidgetProps & { + theme?: Record; + onModelChanged?: (model: IrisGridModel) => void; + }; + /** * Panel-path middleware. * - * Renders our own `IrisGridPanel` backed by `PivotBuilderIrisGridModel` so - * the sidebar `Create Pivot` page can drive the inner model swap via the - * proxy's `pivotConfig` setter (mirrors how rollups work) without changing - * the host IrisGrid. The CorePlus pivot service widget is fetched lazily on - * first `pivotConfig` apply — that way the panel mounts identically on - * workers with and without PSP, and we never unmount/remount the inner - * IrisGridPanel mid-fetch based on a transient PSP fetch state. When - * CorePlus itself is unavailable (open-source DH) we fall back to wrapping - * the host `Component` since `makePivotBuilderModel` requires DHE. + * A **chained** middleware: it renders the wrapped `Component` (the base + * `IrisGridPanel`) and injects a `transformModel` that augments the + * host-built model into a `PivotBuilderIrisGridModel`, plus a composed + * `transformTableOptions` that contributes the Create/Edit Pivot page. The + * sidebar drives the inner model swap via the proxy's `pivotConfig` setter + * (mirrors how rollups work) without the pivot-builder mounting its own + * `IrisGridPanel`. + * + * The CorePlus pivot service widget is fetched lazily on first `pivotConfig` + * apply — that way the panel mounts identically on workers with and without + * PSP. When CorePlus itself is unavailable (open-source DH) we simply omit + * `transformModel`, so the host renders a plain table while the Create Pivot + * page surfaces an error if opened. */ export function PivotBuilderPanelMiddleware({ Component, + transformTableOptions, + transformModel: upstreamTransformModel, ...props -}: WidgetMiddlewarePanelProps): JSX.Element { - const { fetch, metadata, localDashboardId, glContainer, glEventHub } = props; +}: WidgetMiddlewarePanelProps & + IrisGridTableOptionsWidgetProps & + IrisGridModelWidgetProps): JSX.Element { + const { metadata } = props; const dh = useApi(); - const extension = useComposedTableOptionsExtension(); - const loadPlugin = useLoadTablePlugin(); const corePlusAvailable = isCorePlusDh(dh) === true; const objectFetcher = useObjectFetcher(); @@ -79,25 +105,48 @@ export function PivotBuilderPanelMiddleware({ const [model, setModel] = useState(null); const [isPivot, setIsPivot] = useState(false); - // Persist the applied `pivotConfig` per panel so reloads / dashboard - // rehydration restore the user's pivot. Stored value is the same - // `PivotConfig` shape consumed by `PivotBuilderProxyModel.pivotConfig`. + // Compose our Create/Edit Pivot contribution on top of any transform + // threaded down the middleware chain. Rebuilt when `isPivot` (a snapshot + // derived from model events below) changes so `IrisGrid` re-runs the + // transform and relabels the item without the transform reading the + // mutable model directly. + const composedTransform = useMemo( + () => makeCreatePivotTransform(transformTableOptions, isPivot), + [transformTableOptions, isPivot] + ); + + // Persist the applied builder config (pivot + rollup + totals) per + // panel so reloads / dashboard rehydration restore the user's view. + // Bumped to v2 when persistence widened from `pivotConfig` to the full + // `PivotBuilderConfig`. v1 entries hold a bare `PivotConfig | null`; + // wrap them into the v2 envelope with empty rollup/totals. const [persistedConfig, setPersistedConfig] = - usePersistentState(null, { + usePersistentState(null, { type: 'PivotBuilderPanel', - version: 1, + version: 2, + migrations: [ + { + from: 1, + migrate: (state: unknown): PivotBuilderConfig | null => { + if (state == null) return null; + return { + pivot: state as PivotConfig, + rollup: null, + totals: null, + }; + }, + }, + ], }); - // Keep latest persisted config in a ref so the hydration effect can read - // it once per `model` swap without re-running every time it changes. + // Keep latest persisted config in a ref so the transform can read it once + // per model build without re-running every time it changes. const persistedConfigRef = useRef(persistedConfig); persistedConfigRef.current = persistedConfig; + const getPersistedConfig = useCallback(() => persistedConfigRef.current, []); - // Stash the latest `fetch` / `metadata` / `objectFetcher` in refs so the - // lazy PSP fetcher and makeModel keep stable identities and IrisGridPanel - // does not re-init on every parent re-render. - const fetchRef = useRef(fetch); - fetchRef.current = fetch; + // Stash the latest `metadata` / `objectFetcher` in refs so the lazy PSP + // fetcher keeps a stable identity and the transform does not change. const metadataRef = useRef(metadata); metadataRef.current = metadata; const objectFetcherRef = useRef(objectFetcher); @@ -174,95 +223,87 @@ export function PivotBuilderPanelMiddleware({ return widget; }, []); - const makeModel = useCallback(async () => { - const table = (await fetchRef.current()) as DhType.Table; - log.info('Constructing pivot builder proxy model'); - const built = await makePivotBuilderModel( + // The model transform handed to the host panel. Augments the host-built + // proxy into a pivot-builder model and hydrates any persisted config + // synchronously before the model is published. Stable across renders + // (all inputs are stable) so the host does not rebuild the model — it is + // applied once per (re)build, never on this prop's identity changing. + const transformModel = useMemo( + () => + corePlusAvailable + ? makePivotModelTransform( + dh, + getPspWidget, + pivotOverrides, + getPersistedConfig, + upstreamTransformModel + ) + : upstreamTransformModel, + [ + corePlusAvailable, dh, - table, getPspWidget, - pivotOverrides - ); - // Hydrate persisted pivotConfig synchronously *before* publishing the - // model. Doing this here (instead of via a post-mount effect) avoids a - // race where the COLUMNS_CHANGED listener fires once with - // `pivotConfig === null` and writes `null` back over the persisted - // value before hydration runs. - const persisted = persistedConfigRef.current; - if (persisted != null) { - try { - log.info('Restoring persisted pivot config', persisted); - built.pivotConfig = persisted; - } catch (err) { - log.warn('Failed to restore persisted pivot config', err); - } - } - setModel(built); - return built; - // pivotOverrides is stable (all source hooks useMemo with []); getPspWidget - // is stable (refs carry latest fetcher/metadata). - }, [dh, getPspWidget, pivotOverrides]); + pivotOverrides, + getPersistedConfig, + upstreamTransformModel, + ] + ); // Track whether the proxy is currently in pivot mode (used only to gate // the pivot theme override on the panel; renderer / mouse handlers / // metric calculator are routed through the proxy itself so they swap // synchronously with the inner model and don't race React renders). - // Also persists the current pivotConfig on every change so reloads - // restore the user's pivot. + // Also persists the current builder config on every change so reloads + // restore the user's view. The model is received via `onModelChanged`. useEffect(() => { - if (model == null) { + if (model == null || !isPivotBuilderIrisGridModel(model)) { setIsPivot(false); return undefined; } // Sync the initial render-time isPivot value, but DO NOT persist on - // mount: `makeModel` has already applied any persisted state and the - // first COLUMNS_CHANGED event after mount typically reflects the - // hydrated state, which is the same value we just read. - setIsPivot(isPivotBuilderIrisGridModel(model) && model.pivotConfig != null); - const handler = (): void => { - const next = - isPivotBuilderIrisGridModel(model) && model.pivotConfig != null; - setIsPivot(prev => (prev === next ? prev : next)); - if (isPivotBuilderIrisGridModel(model)) { - setPersistedConfig(model.pivotConfig); - } + // mount: the transform has already applied any persisted state. + setIsPivot(model.builderConfig.pivot != null); + const handler = (e: Event): void => { + const cfg = (e as CustomEvent).detail; + setIsPivot(prev => + prev === (cfg.pivot != null) ? prev : cfg.pivot != null + ); + setPersistedConfig(cfg); + }; + // The model uses the dh event shim, whose listener type differs from the + // DOM `Event` used by our handler; bridge with an isolated cast (the shim + // dispatches a standard `CustomEvent` at runtime). + const target = model as unknown as { + addEventListener: (type: string, fn: (e: Event) => void) => void; + removeEventListener: (type: string, fn: (e: Event) => void) => void; }; - model.addEventListener(IrisGridModel.EVENT.COLUMNS_CHANGED, handler); + target.addEventListener(PIVOT_BUILDER_CONFIG_CHANGED, handler); return () => { - model.removeEventListener(IrisGridModel.EVENT.COLUMNS_CHANGED, handler); + target.removeEventListener(PIVOT_BUILDER_CONFIG_CHANGED, handler); }; }, [model, setPersistedConfig]); - // Fallback: CorePlus not available -> render the wrapped Component (still - // providing the sidebar extension so we surface the Create Pivot page, - // which will display an error if the user opens it). This branch is - // deterministic from `dh`, so it never flips at runtime — no - // unmount/remount of the inner panel. - if (!corePlusAvailable) { - log.debug('CorePlus not available; rendering wrapped panel'); - return ( - - - {/* eslint-disable-next-line react/jsx-props-no-spreading */} - - - - ); - } + // `Component` is typed for the generic panel props; widen locally to + // forward the IrisGrid-aware props (model/options transforms, pivot theme, + // and the model-ready callback). When CorePlus is unavailable `transformModel` + // falls back to the upstream transform (often `undefined`), so the host + // renders a plain table while the Create Pivot page surfaces an error if + // opened. This branch is deterministic from `dh`, so it never flips at + // runtime — no unmount/remount of the inner panel. + const Next = Component as ComponentType; return ( - - - - + + ) : undefined} + onModelChanged={setModel} + /> ); } diff --git a/plugins/pivot-builder/src/js/src/PivotBuilderWidget.tsx b/plugins/pivot-builder/src/js/src/PivotBuilderWidget.tsx deleted file mode 100644 index e300f3b8f..000000000 --- a/plugins/pivot-builder/src/js/src/PivotBuilderWidget.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { - IrisGrid, - IrisGridModel, - IrisGridTableOptionsContext, -} from '@deephaven/iris-grid'; -import { LoadingOverlay } from '@deephaven/components'; -import { useApi, useObjectFetch } from '@deephaven/jsapi-bootstrap'; -import { - isCorePlusDh, - usePivotMouseHandlers, - usePivotRenderer, - usePivotMetricCalculatorFactory, - usePivotTheme, -} from '@deephaven/js-plugin-pivot'; -import Log from '@deephaven/log'; -import type { dh as DhType } from '@deephaven/jsapi-types'; -import type { WidgetMiddlewareComponentProps } from '@deephaven/plugin'; -import { - isPivotBuilderIrisGridModel, - makePivotBuilderModel, - type PivotBuilderProxyModel, -} from './pivotBuilderModel'; -import { useComposedTableOptionsExtension } from './useComposedTableOptionsExtension'; - -const log = Log.module('@deephaven/js-plugin-pivot-builder/PivotBuilderWidget'); - -/** - * Replaces the default Table widget renderer with an `IrisGrid` driven by - * a `PivotBuilderIrisGridModel`. The proxy model swaps its inner model - * when `pivotConfig` is written from the sidebar. - */ -export function PivotBuilderWidget({ - fetch, - metadata, -}: WidgetMiddlewareComponentProps): JSX.Element { - const dh = useApi(); - const extension = useComposedTableOptionsExtension(); - const [model, setModel] = useState(null); - const [error, setError] = useState(null); - const [isPivot, setIsPivot] = useState(false); - const builtModelRef = useRef(null); - - // Pivot-specific overrides. Built unconditionally (hooks must be stable); - // we only forward them to `` when the proxy is currently in - // pivot mode (i.e. the inner model is an `IrisGridPivotModel`). - const pivotMouseHandlers = usePivotMouseHandlers(); - const pivotRenderer = usePivotRenderer(); - const pivotMetricCalculator = usePivotMetricCalculatorFactory(); - const pivotTheme = usePivotTheme(); - - // Subscribe to the well-known `psp` PivotService on the same query. - const pspDescriptor = useMemo(() => { - if (!isCorePlusDh(dh) || metadata == null) { - // Use a sentinel name so ObjectFetchManager stays inert when unavailable. - return { type: 'PivotService', name: '__unavailable__' }; - } - return { ...metadata, type: 'PivotService', name: 'psp' }; - }, [dh, metadata]); - const pspFetch = useObjectFetch(pspDescriptor); - - useEffect(() => { - let cancelled = false; - setModel(null); - setError(null); - - if (!isCorePlusDh(dh)) { - setError( - new Error('CorePlus API not available; pivot builder requires DHE') - ); - return undefined; - } - if (pspFetch.status !== 'ready') { - // Wait for psp to resolve before fetching the table. - return undefined; - } - - (async () => { - try { - const [table, pspWidget] = await Promise.all([ - fetch(), - pspFetch.fetch(), - ]); - if (cancelled) { - table?.close?.(); - return; - } - const built = await makePivotBuilderModel( - dh, - table, - pspWidget as DhType.Widget - ); - builtModelRef.current = built; - setModel(built); - } catch (e) { - if (cancelled) return; - log.error('Failed to build pivot builder model', e); - setError(e); - } - })(); - - return () => { - cancelled = true; - }; - }, [dh, fetch, pspFetch]); - - // Close the model when the component unmounts or is replaced. - useEffect( - () => () => { - builtModelRef.current?.close(); - builtModelRef.current = null; - }, - [] - ); - - // Track whether the proxy is currently in pivot mode by watching - // COLUMNS_CHANGED (which fires after `setNextModel` swaps the inner - // model). The pivot-specific renderer / mouse handlers / metric - // calculator must only be applied when the inner model is actually a - // pivot model — otherwise they'd crash on the flat table. - useEffect(() => { - if (model == null) { - setIsPivot(false); - return undefined; - } - const update = (): void => { - const next = - isPivotBuilderIrisGridModel(model) && model.pivotConfig != null; - setIsPivot(prev => (prev === next ? prev : next)); - }; - update(); - const handler = (): void => update(); - model.addEventListener(IrisGridModel.EVENT.COLUMNS_CHANGED, handler); - return () => { - model.removeEventListener(IrisGridModel.EVENT.COLUMNS_CHANGED, handler); - }; - }, [model]); - - if (error != null) { - return ( - - ); - } - if (model == null) { - return ; - } - - return ( - - {isPivot ? ( - - ) : ( - - )} - - ); -} - -export default PivotBuilderWidget; diff --git a/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx b/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx index 9e4020da0..afebba490 100644 --- a/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx +++ b/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx @@ -31,7 +31,7 @@ import { Checkbox, SearchInput, Select, - UISwitch, + Switch, } from '@deephaven/components'; import { vsEdit, vsGripper, vsTrash } from '@deephaven/icons'; import { @@ -469,7 +469,25 @@ function ConfigCard({ >
{title} - onToggle(!on)} /> + {/* + Controlled Spectrum Switch. Guard onChange against echoes: + react-spectrum can fire onChange during prop-driven internal + state sync, which — if blindly forwarded — would re-set the + parent's on/off state to the same value and, in some + re-render orderings, oscillate the switch. Forwarding only + when the value actually flips makes the toggle a pure + user-driven event. + */} + { + if (next !== on) { + onToggle(next); + } + }} + isDisabled={disabled === true} + aria-label={title} + /> 0 && pivotColumnsDisabled !== true; + // Resolve the preview for DragOverlay. const activeColumnName = (() => { if (activeId == null) { @@ -1362,6 +1389,7 @@ export function PivotConfigSection({ type="columns" itemIds={pivotItemIds} isEmpty={pivotColumns.length === 0} + disabled={pivotColumnsDisabled === true} > {pivotColumns.map((name, i) => ( onIncludeConstituentsChange(e.target.checked)} > Include constituents in rollups rows onNonAggregatedInRollupChange(e.target.checked)} > Non-aggregated in rollup rows diff --git a/plugins/pivot-builder/src/js/src/index.ts b/plugins/pivot-builder/src/js/src/index.ts index d24710aeb..039e12d2f 100644 --- a/plugins/pivot-builder/src/js/src/index.ts +++ b/plugins/pivot-builder/src/js/src/index.ts @@ -3,16 +3,25 @@ import { PivotBuilderPlugin } from './PivotBuilderPlugin'; export { CREATE_PIVOT_ITEM_TYPE } from './createPivotItemType'; export { CreatePivotPage } from './CreatePivotPage'; export { + augmentPivotBuilderModel, isPivotBuilderIrisGridModel, makeDefaultPivotConfig, - makePivotBuilderModel, type PivotBuilderProxyModel, type PivotConfig, } from './pivotBuilderModel'; +export { makePivotModelTransform } from './makePivotModelTransform'; export { PivotBuilderMiddleware } from './PivotBuilderMiddleware'; export { PivotBuilderPanelMiddleware } from './PivotBuilderPanelMiddleware'; export { PivotBuilderPlugin } from './PivotBuilderPlugin'; -export { PivotBuilderWidget } from './PivotBuilderWidget'; -export { useComposedTableOptionsExtension } from './useComposedTableOptionsExtension'; +export { makeCreatePivotTransform } from './makeCreatePivotTransform'; +export type { + IrisGridTableOptionsWidgetProps, + OptionItem, + TableOptionsTransform, +} from './tableOptionsTypes'; +export type { + IrisGridModelTransform, + IrisGridModelWidgetProps, +} from './modelTypes'; export default PivotBuilderPlugin; diff --git a/plugins/pivot-builder/src/js/src/makeCreatePivotTransform.ts b/plugins/pivot-builder/src/js/src/makeCreatePivotTransform.ts new file mode 100644 index 000000000..46c92f4d1 --- /dev/null +++ b/plugins/pivot-builder/src/js/src/makeCreatePivotTransform.ts @@ -0,0 +1,47 @@ +import { OptionType } from '@deephaven/iris-grid'; +import { dhPivotTable } from '@deephaven/icons'; +import { CreatePivotPage } from './CreatePivotPage'; +import { CREATE_PIVOT_ITEM_TYPE } from './createPivotItemType'; +import { + type OptionItem, + type TableOptionsTransform, +} from './tableOptionsTypes'; + +/** + * Builds a pure `transformTableOptions` that runs the upstream transform + * (if any) first so contributions compose, then inserts the Create/Edit + * Pivot item just before the built-in Aggregate Columns entry. + * + * `isPivot` is a snapshot of model state (derived in the middleware from + * model events), passed in as a value rather than read from the model + * inside the transform — this keeps the transform pure and lets + * `IrisGrid` re-run it only when the snapshot (and thus the transform + * identity) changes. + */ +export function makeCreatePivotTransform( + upstream: TableOptionsTransform | undefined, + isPivot: boolean +): TableOptionsTransform { + return defaults => { + const base = upstream != null ? upstream(defaults) : defaults; + const item: OptionItem = { + type: CREATE_PIVOT_ITEM_TYPE, + title: 'Rollup, Aggregate and Pivot', + icon: dhPivotTable, + configPage: CreatePivotPage, + }; + const aggregationsIndex = base.findIndex( + o => o.type === OptionType.AGGREGATIONS + ); + if (aggregationsIndex < 0) { + return [...base, item]; + } + return [ + ...base.slice(0, aggregationsIndex), + item, + ...base.slice(aggregationsIndex), + ]; + }; +} + +export default makeCreatePivotTransform; diff --git a/plugins/pivot-builder/src/js/src/makePivotModelTransform.ts b/plugins/pivot-builder/src/js/src/makePivotModelTransform.ts new file mode 100644 index 000000000..6641153a4 --- /dev/null +++ b/plugins/pivot-builder/src/js/src/makePivotModelTransform.ts @@ -0,0 +1,78 @@ +import Log from '@deephaven/log'; +import { type IrisGridModel } from '@deephaven/iris-grid'; +import { isCorePlusDh } from '@deephaven/js-plugin-pivot'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import type { dh as CorePlusDhType } from '@deephaven-enterprise/jsapi-coreplus-types'; +import { + augmentPivotBuilderModel, + type PivotBuilderConfig, + type PivotBuilderProxyModel, + type PivotOverrides, +} from './pivotBuilderModel'; +import { type IrisGridModelTransform } from './modelTypes'; + +const log = Log.module( + '@deephaven/js-plugin-pivot-builder/makePivotModelTransform' +); + +/** + * Build an {@link IrisGridModelTransform} that augments the host-built + * `IrisGridProxyModel` into a pivot-builder proxy (see + * {@link augmentPivotBuilderModel}) and, optionally, hydrates a persisted + * builder config before the model is published. + * + * Designed to be referentially stable: pass `getPersistedConfig` as a + * stable function (e.g. a ref reader) so the latest persisted value is read + * at model-build time without the transform's identity changing whenever + * the persisted config changes (which would rebuild the model). + * + * Composes on top of any `upstream` transform threaded down the middleware + * chain, so the host-built model is first passed through the upstream + * transform and then augmented here. + * + * @param dh CorePlus-capable API. + * @param getPspWidget Lazily fetches the PivotService widget on first apply. + * @param pivotOverrides Renderer / mouse handlers / metric calculator routed + * through the proxy whenever its inner model is a pivot. + * @param getPersistedConfig Reads the latest persisted builder config (or + * `null`). Read once per model build; restored synchronously before the + * model is published to avoid a hydration race. + * @param upstream Optional upstream model transform to compose under. + */ +export function makePivotModelTransform( + dh: typeof DhType | typeof CorePlusDhType, + getPspWidget: () => Promise, + pivotOverrides: PivotOverrides, + getPersistedConfig: () => PivotBuilderConfig | null = () => null, + upstream?: IrisGridModelTransform +): IrisGridModelTransform { + if (!isCorePlusDh(dh)) { + throw new Error('CorePlus is not available; pivot builder requires DHE'); + } + return async (model: IrisGridModel) => { + const base = upstream != null ? await upstream(model) : model; + log.info('Augmenting host model into pivot builder proxy'); + const augmented: PivotBuilderProxyModel = augmentPivotBuilderModel( + dh, + base, + getPspWidget, + pivotOverrides + ); + // Hydrate persisted builder config synchronously *before* returning the + // model. Doing this here (instead of via a post-mount effect) avoids a + // race where a listener fires with the pre-hydration (empty) config and + // overwrites the persisted value. + const persisted = getPersistedConfig(); + if (persisted != null) { + try { + log.info('Restoring persisted builder config', persisted); + augmented.applyPivotBuilderConfig(persisted); + } catch (err) { + log.warn('Failed to restore persisted builder config', err); + } + } + return augmented; + }; +} + +export default makePivotModelTransform; diff --git a/plugins/pivot-builder/src/js/src/modelTypes.ts b/plugins/pivot-builder/src/js/src/modelTypes.ts new file mode 100644 index 000000000..ba11b6d44 --- /dev/null +++ b/plugins/pivot-builder/src/js/src/modelTypes.ts @@ -0,0 +1,26 @@ +import { type IrisGridModel } from '@deephaven/iris-grid'; + +/** + * Local copy of the model-transform props contract added to + * `@deephaven/iris-grid` in web-client-ui. Duplicated here until that + * version is published and installed, at which point these can be replaced + * with imports from `@deephaven/iris-grid`. + */ + +/** + * Transform applied to the model an IrisGrid host (panel or widget) builds, + * before it is handed to ``. Lets middleware wrap/augment the + * host-built model without taking over model construction. + */ +export type IrisGridModelTransform = ( + model: IrisGridModel +) => IrisGridModel | Promise; + +/** + * Opt-in prop for components that build an `IrisGridModel` from a `fetch` + * (e.g. `IrisGridPanel`, `GridWidgetPlugin`), threaded down the middleware + * chain. Must be referentially stable. + */ +export interface IrisGridModelWidgetProps { + transformModel?: IrisGridModelTransform; +} diff --git a/plugins/pivot-builder/src/js/src/pivotBuilderModel.ts b/plugins/pivot-builder/src/js/src/pivotBuilderModel.ts index 5cd6e7e66..5643f66a2 100644 --- a/plugins/pivot-builder/src/js/src/pivotBuilderModel.ts +++ b/plugins/pivot-builder/src/js/src/pivotBuilderModel.ts @@ -1,10 +1,10 @@ import deepEqual from 'fast-deep-equal'; import type { GridMouseHandler } from '@deephaven/grid'; import { - type IrisGridModel, - IrisGridModelFactory, + IrisGridModel, type GetMetricCalculatorType, type IrisGridRenderer, + type UITotalsTableConfig, } from '@deephaven/iris-grid'; import { IrisGridPivotModel, @@ -12,6 +12,7 @@ import { isIrisGridPivotModel, } from '@deephaven/js-plugin-pivot'; import Log from '@deephaven/log'; +import { EventShimCustomEvent } from '@deephaven/utils'; import type { dh as DhType } from '@deephaven/jsapi-types'; import type { dh as CorePlusDhType } from '@deephaven-enterprise/jsapi-coreplus-types'; @@ -52,6 +53,14 @@ const PIVOT_BUILDER_TAG = Symbol.for( '@deephaven/js-plugin-pivot-builder/PivotBuilderProxy' ); +/** + * Event dispatched on the proxy whenever `applyPivotBuilderConfig` runs. + * The `detail` is the new `PivotBuilderConfig`. Used by the panel + * middleware to persist the latest intent via `usePersistentState`. + */ +export const PIVOT_BUILDER_CONFIG_CHANGED = + '@deephaven/js-plugin-pivot-builder/PIVOT_BUILDER_CONFIG_CHANGED'; + /** * User-configured pivot settings. Shape mirrors the request payload accepted * by `coreplus.pivot.PivotService#createPivotTable`. @@ -63,6 +72,17 @@ export interface PivotConfig { aggregations: Record; } +/** + * High-level pivot-builder intent. The proxy diffs against its last + * applied intent internally; callers can pass the same value across + * unrelated re-renders without causing redundant writes. + */ +export interface PivotBuilderConfig { + pivot: PivotConfig | null; + rollup: DhType.RollupConfig | null; + totals: UITotalsTableConfig | null; +} + /** * An `IrisGridProxyModel` (the host's own proxy) augmented with a * `pivotConfig` accessor that swaps its inner model between the flat @@ -72,6 +92,26 @@ export interface PivotBuilderProxyModel extends IrisGridModel { pivotConfig: PivotConfig | null; /** The original (pre-pivot) source table. */ readonly sourceTable: DhType.Table; + /** Last applied builder config; mirrors `applyPivotBuilderConfig` input. */ + readonly builderConfig: PivotBuilderConfig; + /** + * Apply pivot/rollup/totals atomically. + * + * The proxy owns ordering (pivot supersedes rollup/totals; otherwise + * rollup is cleared/applied before totals), diffs each field against + * the last applied intent, and queues `totals` writes that land while + * a model swap is in progress (the host proxy's `set totalsConfig` + * silently drops mid-swap writes). Queued totals are flushed on the + * next `COLUMNS_CHANGED` / `TABLE_CHANGED`. + * + * Dispatches `PIVOT_BUILDER_CONFIG_CHANGED` with the new config as + * `detail` after each call so listeners (e.g. the panel middleware's + * persistence layer) can react. Direct writes to + * `proxy.rollupConfig` / `proxy.totalsConfig` are stored on the proxy + * but NOT propagated to the inner model — the pivot-builder sidebar + * replaces those host surfaces and owns inner-model swaps. + */ + applyPivotBuilderConfig: (config: PivotBuilderConfig) => void; [PIVOT_BUILDER_TAG]: true; } @@ -89,35 +129,107 @@ export function isPivotBuilderIrisGridModel( ); } +class SupersededError extends Error { + constructor() { + super('superseded'); + this.name = 'SupersededError'; + } +} + /** - * Build an `IrisGridProxyModel` for `table` (using the host factory so we get - * all the working overrides for free), then install a `pivotConfig` accessor - * that — when set — produces a pivot via `PivotService.createPivotTable` and - * hands it to the proxy's `setNextModel`. The proxy fires the standard + * Augment a host-built `IrisGridProxyModel` (the model the host's + * `IrisGridPanel` / `GridWidgetPlugin` constructs from the source table) + * **in place**, installing a `pivotConfig` accessor that — when set — + * produces a pivot via `PivotService.createPivotTable` and hands it to the + * proxy's `setNextModel`. The proxy fires the standard * `COLUMNS_CHANGED` / `UPDATED` events, so IrisGrid re-renders in place * exactly like rollups. + * + * This is wired as an `IrisGridModelTransform` (see the host + * `transformModel` seam): the host owns model construction, error/loading + * state, and `close()`; the pivot-builder only wraps the result. That lets + * the pivot-builder middleware stay a *chained* layer (rendering the host + * `Component`) instead of mounting its own `IrisGrid` / `IrisGridPanel`. + * + * Returns the same proxy instance it was given (mutated), narrowed to + * `PivotBuilderProxyModel`. */ -export async function makePivotBuilderModel( +export function augmentPivotBuilderModel( dh: typeof DhType | typeof CorePlusDhType, - table: DhType.Table, + model: IrisGridModel, getPspWidget: () => Promise, pivotOverrides: PivotOverrides -): Promise { +): PivotBuilderProxyModel { if (!isCorePlusDh(dh)) { throw new Error('CorePlus is not available; pivot builder requires DHE'); } const corePlusDh = dh as typeof CorePlusDhType; - const proxy = (await IrisGridModelFactory.makeModel( - dh as typeof DhType, - table - )) as IrisGridModel & { - setNextModel(promise: Promise): void; - // IrisGridProxyModel exposes `originalModel` publicly. + const proxy = model as IrisGridModel & { + setNextModel: (promise: Promise) => void; + // IrisGridProxyModel exposes `originalModel` (own prop reachable via + // the model's Proxy get-trap); the pivot is always built off the + // original (pre-pivot) source table. originalModel: IrisGridModel; }; + // The original (pre-pivot) source table, taken from the host proxy's + // original flat model so the pivot is always built off the source table + // regardless of the proxy's current inner model. + const { table } = proxy.originalModel as unknown as { table: DhType.Table }; + let current: PivotConfig | null = null; + // Monotonic token for in-flight pivot creations. Every `pivotConfig` write + // increments it; async build steps abort early when their captured token + // is stale. The host already cancels superseded model promises, but + // bailing out before contacting the pivot service avoids wasted RPCs and + // makes `pivotConfig` writes safe under rapid succession (e.g. drag flows + // that flip config several times before the first build resolves). + let pivotToken = 0; + + const applyPivotConfig = (config: PivotConfig | null): void => { + if (deepEqual(current, config)) return; + current = config; + pivotToken += 1; + const token = pivotToken; + + if (config == null) { + proxy.setNextModel(Promise.resolve(proxy.originalModel)); + return; + } + + const promise = (async (): Promise => { + log.info('Creating pivot with config:', config); + const pspWidget = await getPspWidget(); + if (token !== pivotToken) throw new SupersededError(); + const pivotService = + await corePlusDh.coreplus.pivot.PivotService.getInstance(pspWidget); + if (token !== pivotToken) throw new SupersededError(); + const pivotTable = await pivotService.createPivotTable({ + source: table as unknown as CorePlusDhType.Table, + rowKeys: config.rowKeys, + columnKeys: config.columnKeys, + aggregations: config.aggregations, + }); + if (token !== pivotToken) { + // Build resolved after a newer request superseded it. Close the + // orphan table directly — the host's cancel handler won't run on a + // promise that throws. + pivotTable.close?.(); + throw new SupersededError(); + } + return new IrisGridPivotModel(corePlusDh, pivotTable); + })(); + promise.catch(e => { + if (e instanceof SupersededError) { + log.debug2('pivot build superseded', config); + return; + } + log.error('createPivotTable failed for config', config, e); + }); + + proxy.setNextModel(promise); + }; Object.defineProperty(proxy, PIVOT_BUILDER_TAG, { value: true, @@ -179,32 +291,172 @@ export async function makePivotBuilderModel( }, set(config: PivotConfig | null): void { log.debug('set pivotConfig', config); - if (deepEqual(current, config)) return; - current = config; + applyPivotConfig(config); + }, + }); + + // The proxy owns `rollupConfig` / `totalsConfig` storage so dehydration + // captures the pivot-builder's latest intent. Direct writes (from the + // host's `IrisGridModelUpdater` at hydration time, or any other host + // surface) are stored but NOT applied to the inner model — the + // pivot-builder sidebar replaces those host surfaces and routes + // inner-model swaps through `applyPivotBuilderConfig`. + // `totalsConfig` writes from `applyPivotBuilderConfig` are queued when + // a model swap is in progress, because the host proxy's `set + // totalsConfig` silently drops mid-swap writes. + const proto = Object.getPrototypeOf(proxy); + const rollupDesc = Object.getOwnPropertyDescriptor(proto, 'rollupConfig'); + + let storedRollup: DhType.RollupConfig | null = null; + let storedTotals: UITotalsTableConfig | null = null; + let lastIntent: PivotBuilderConfig = { + pivot: null, + rollup: null, + totals: null, + }; + let pendingTotals: UITotalsTableConfig | null | undefined; + + const proxyAsAny = proxy as unknown as { modelPromise: unknown }; + const innerWritable = proxy as unknown as { + model: IrisGridModel & { totalsConfig: UITotalsTableConfig | null }; + }; + + const writeTotalsToInner = (v: UITotalsTableConfig | null): void => { + // Bypass the host proxy's lossy mid-swap setter — write directly to + // the resolved inner model. + innerWritable.model.totalsConfig = v; + }; - if (config == null) { - proxy.setNextModel(Promise.resolve(proxy.originalModel)); + const flushPendingTotals = (): void => { + if (pendingTotals === undefined) return; + if (proxyAsAny.modelPromise != null) return; // wait for next event + const v = pendingTotals; + pendingTotals = undefined; + writeTotalsToInner(v); + }; + + // Same-columns swaps (e.g. rollup-A → rollup-B) only fire TABLE_CHANGED; + // pivot transitions only fire COLUMNS_CHANGED. Listen to both. + proxy.addEventListener( + IrisGridModel.EVENT.COLUMNS_CHANGED, + flushPendingTotals + ); + proxy.addEventListener(IrisGridModel.EVENT.TABLE_CHANGED, flushPendingTotals); + + Object.defineProperty(proxy, 'rollupConfig', { + configurable: true, + enumerable: true, + get(): DhType.RollupConfig | null { + return storedRollup; + }, + set(v: DhType.RollupConfig | null): void { + // Store-only — host writes do not reach the inner model. The + // pivot-builder sidebar drives inner-model swaps via + // `applyPivotBuilderConfig`. + if (deepEqual(v, storedRollup)) return; + log.debug2('storing rollupConfig (no inner-model write)', v); + storedRollup = v; + // `IrisGridPanel`'s pre-`modelInitialized` `modelQueue` advances + // on COLUMNS_CHANGED (the event the host's own rollup setter + // emits after `setNextModel` resolves). Since we suppressed the + // inner-model swap, emit it ourselves so the queue advances and + // hydration completes for legacy rollup+aggregations layouts. + proxy.dispatchEvent( + new EventShimCustomEvent(IrisGridModel.EVENT.COLUMNS_CHANGED, { + detail: proxy.columns, + }) + ); + }, + }); + + Object.defineProperty(proxy, 'totalsConfig', { + configurable: true, + enumerable: true, + get(): UITotalsTableConfig | null { + return storedTotals; + }, + set(v: UITotalsTableConfig | null): void { + log.debug2('storing totalsConfig (no inner-model write)', v); + storedTotals = v; + }, + }); + + Object.defineProperty(proxy, 'builderConfig', { + configurable: true, + enumerable: true, + get(): PivotBuilderConfig { + return lastIntent; + }, + }); + + Object.defineProperty(proxy, 'applyPivotBuilderConfig', { + configurable: true, + enumerable: false, + value(config: PivotBuilderConfig): void { + const proxyWithPivot = proxy as unknown as { + pivotConfig: PivotConfig | null; + }; + if (config.pivot != null) { + // Pivot supersedes rollup/totals. The pivot itself is built off + // the source table directly, so we don't apply rollup/totals to + // the inner model — but we must clear the host's *internal* + // `this.rollup` cache (only updated via the host setter) so a + // later rollup-back transition can't be `deepEqual`-suppressed + // against a stale cached value. The transient + // `setNextModel(originalModel)` queued by this clear is + // immediately superseded — and safely cancelled — by the pivot + // `setNextModel` below; `originalModel` is special-cased to not + // close on cancel. + if (lastIntent.rollup != null) { + log.debug('Clearing host rollup cache before pivot'); + rollupDesc?.set?.call(proxy, null); + } + if (!deepEqual(config.pivot, lastIntent.pivot)) { + log.debug('Applying pivotConfig', config.pivot); + proxyWithPivot.pivotConfig = config.pivot; + } + // Mirror intent into proxy storage so dehydration is correct. + storedRollup = config.rollup; + storedTotals = config.totals; + lastIntent = config; + proxy.dispatchEvent( + new EventShimCustomEvent(PIVOT_BUILDER_CONFIG_CHANGED, { + detail: config, + }) + ); return; } - const promise = (async (): Promise => { - log.info('Creating pivot with config:', config); - const pspWidget = await getPspWidget(); - const pivotService = - await corePlusDh.coreplus.pivot.PivotService.getInstance(pspWidget); - const pivotTable = await pivotService.createPivotTable({ - source: table as unknown as CorePlusDhType.Table, - rowKeys: config.rowKeys, - columnKeys: config.columnKeys, - aggregations: config.aggregations, - }); - return new IrisGridPivotModel(corePlusDh, pivotTable); - })(); - promise.catch(e => { - log.error('createPivotTable failed for config', config, e); - }); + // Pivot inactive — clear it before reconciling rollup/totals. + if (lastIntent.pivot != null) { + log.debug('Clearing pivotConfig (pivot inactive)'); + proxyWithPivot.pivotConfig = null; + } + + if (!deepEqual(config.rollup, lastIntent.rollup)) { + log.debug('Applying rollupConfig', config.rollup); + rollupDesc?.set?.call(proxy, config.rollup); + } + storedRollup = config.rollup; + + if (!deepEqual(config.totals, lastIntent.totals)) { + log.debug('Applying totalsConfig', config.totals); + if (proxyAsAny.modelPromise != null) { + // Mid-swap — queue and flush on next COLUMNS_CHANGED/TABLE_CHANGED. + pendingTotals = config.totals; + } else { + pendingTotals = undefined; + writeTotalsToInner(config.totals); + } + } + storedTotals = config.totals; - proxy.setNextModel(promise); + lastIntent = config; + proxy.dispatchEvent( + new EventShimCustomEvent(PIVOT_BUILDER_CONFIG_CHANGED, { + detail: config, + }) + ); }, }); diff --git a/plugins/pivot-builder/src/js/src/tableOptionsTypes.ts b/plugins/pivot-builder/src/js/src/tableOptionsTypes.ts new file mode 100644 index 000000000..8703b0909 --- /dev/null +++ b/plugins/pivot-builder/src/js/src/tableOptionsTypes.ts @@ -0,0 +1,54 @@ +import { type ComponentType } from 'react'; +import { type OptionType, type IrisGridModel } from '@deephaven/iris-grid'; +import { type IconDefinition } from '@deephaven/icons'; + +/** + * Local copies of the Table Options props contract added to + * `@deephaven/iris-grid` in web-client-ui (PR #2688). Duplicated here + * until that version is published and installed, at which point these + * can be replaced with imports from `@deephaven/iris-grid`. + */ + +/** + * Built-in items use the `OptionType` enum; plugin-contributed items use + * a namespaced string key (convention `plugin::`). + */ +export type OptionItemKey = OptionType | string; + +/** + * Props passed to a plugin-supplied sidebar page (an item whose + * `configPage` is set). + */ +export type IrisGridTableOptionsPageProps = { + /** Current model the grid is rendering. */ + model: IrisGridModel; + /** Pop the current page off the sidebar stack. */ + onBack: () => void; +}; + +/** A single entry in the Table Options sidebar menu. */ +export type OptionItem = { + type: OptionItemKey; + title: string; + subtitle?: string; + icon?: IconDefinition; + isOn?: boolean; + onChange?: () => void; + configPage?: ComponentType; +}; + +/** + * Transform applied to the built-in Table Options items before they are + * rendered. Must be referentially stable and side-effect-free. + */ +export type TableOptionsTransform = ( + defaults: readonly OptionItem[] +) => readonly OptionItem[]; + +/** + * Opt-in props for components that wrap `` / ``, + * threaded down the middleware chain. + */ +export interface IrisGridTableOptionsWidgetProps { + transformTableOptions?: TableOptionsTransform; +} diff --git a/plugins/pivot-builder/src/js/src/useComposedTableOptionsExtension.ts b/plugins/pivot-builder/src/js/src/useComposedTableOptionsExtension.ts deleted file mode 100644 index f789f595d..000000000 --- a/plugins/pivot-builder/src/js/src/useComposedTableOptionsExtension.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useContext, useMemo } from 'react'; -import { - IrisGridTableOptionsContext, - OptionType, - type IrisGridTableOptionsExtension, - type OptionItem, -} from '@deephaven/iris-grid'; -import { dhPivotTable } from '@deephaven/icons'; -import { CreatePivotPage } from './CreatePivotPage'; -import { CREATE_PIVOT_ITEM_TYPE } from './createPivotItemType'; - -const CREATE_PIVOT_ITEM: OptionItem = { - type: CREATE_PIVOT_ITEM_TYPE, - title: 'Rollup, Aggregate and Pivot', - icon: dhPivotTable, - configPage: CreatePivotPage, -}; - -/** - * Composes this plugin's sidebar contribution on top of any parent - * `IrisGridTableOptionsContext` already in scope. Pattern lifted from - * `table-options-example/useComposedTableOptionsExtension`. - */ -export function useComposedTableOptionsExtension(): IrisGridTableOptionsExtension { - const parent = useContext(IrisGridTableOptionsContext); - return useMemo(() => { - const parentTransform = parent?.transformTableOptions; - return { - transformTableOptions: defaults => { - const base = - parentTransform != null ? parentTransform(defaults) : defaults; - const aggregationsIndex = base.findIndex( - item => item.type === OptionType.AGGREGATIONS - ); - if (aggregationsIndex < 0) { - return [...base, CREATE_PIVOT_ITEM]; - } - return [ - ...base.slice(0, aggregationsIndex), - CREATE_PIVOT_ITEM, - ...base.slice(aggregationsIndex), - ]; - }, - }; - }, [parent]); -} - -export default useComposedTableOptionsExtension; From 0228f31ba8408256e424a053a8dd2663333219b4 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Thu, 11 Jun 2026 17:46:03 -0600 Subject: [PATCH 13/18] Bug fixes --- plugins/pivot-builder/src/js/package.json | 8 +- .../src/js/src/CreatePivotPage.tsx | 198 ++-- .../js/src/PivotBuilderPanelMiddleware.tsx | 22 +- .../src/js/src/PivotConfigSection.tsx | 924 +++++++++++++++--- .../src/js/src/makeCreatePivotTransform.ts | 35 +- .../src/js/src/pivotBuilderModel.ts | 89 +- .../src/js/src/tableOptionsTypes.ts | 7 + plugins/pivot-builder/src/js/vite.config.ts | 8 +- .../js/src/IrisGridPivotMetricCalculator.ts | 27 + .../pivot/src/js/src/IrisGridPivotModel.ts | 79 +- .../pivot/src/js/src/IrisGridPivotRenderer.ts | 13 +- plugins/pivot/src/js/src/PivotUtils.test.ts | 96 ++ plugins/pivot/src/js/src/PivotUtils.ts | 86 +- 13 files changed, 1354 insertions(+), 238 deletions(-) diff --git a/plugins/pivot-builder/src/js/package.json b/plugins/pivot-builder/src/js/package.json index 0638506e1..4ebfe26da 100644 --- a/plugins/pivot-builder/src/js/package.json +++ b/plugins/pivot-builder/src/js/package.json @@ -33,6 +33,11 @@ "@deephaven/log": "^1.8.0", "@deephaven/plugin": "^1.18.0", "@deephaven/utils": "^1.10.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@fortawesome/fontawesome-svg-core": "^6.2.1", + "@fortawesome/react-fontawesome": "^0.2.0", "fast-deep-equal": "^3.1.3" }, "devDependencies": { @@ -43,9 +48,6 @@ "react-dom": "^18.0.0" }, "peerDependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, diff --git a/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx index d01e0a853..8e0bcb5c7 100644 --- a/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx +++ b/plugins/pivot-builder/src/js/src/CreatePivotPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { type IrisGridModel, IrisGridUtils, @@ -9,6 +9,7 @@ import type { dh as DhType } from '@deephaven/jsapi-types'; import { isPivotBuilderIrisGridModel, type PivotConfig, + type PivotBuilderUiState, } from './pivotBuilderModel'; import { PivotConfigSection } from './PivotConfigSection'; import { usePivotServiceStatus } from './PivotServiceContext'; @@ -109,8 +110,7 @@ function seedAggregationSettings( } function aggregationsToPivot( - settings: AggregationSettings, - fallbackCountColumn: string | undefined + settings: AggregationSettings ): Record { const out: Record = {}; settings.aggregations.forEach(agg => { @@ -118,20 +118,18 @@ function aggregationsToPivot( const op = String(agg.operation); out[op] = [...(out[op] ?? []), ...agg.selected]; }); - if (Object.keys(out).length === 0 && fallbackCountColumn != null) { - out.Count = [fallbackCountColumn]; - } return out; } /** * Sidebar `configPage` for the Create Pivot menu item. * - * Renders the card-based config panel. The Rollup rows and Aggregate - * values cards are wired to `model.rollupConfig` / `model.totalsConfig`; - * the other two cards are mock-data only (see + * Renders the card-based config panel. The Rollup rows, Aggregate values, + * and Pivot columns cards drive the model via `applyPivotBuilderConfig`; + * the Filterable columns card is still a placeholder (not yet wired to the + * model) — see * `plans/DH-21476-pivot-builder-rollup-rows-wiring.md` and - * `plans/DH-21476-pivot-builder-aggregate-values-wiring.md`). + * `plans/DH-21476-pivot-builder-aggregate-values-wiring.md`. */ export function CreatePivotPage({ model, @@ -143,9 +141,21 @@ export function CreatePivotPage({ // Always source columns from the original (pre-pivot) table so the // selectors don't shift to pivot output columns after Apply. const columns = isProxy ? model.sourceTable.columns : model.columns; + // `model.sourceTable.columns` (and `IrisGridModel.columns`) is a JS API + // getter that can hand back a fresh array on every access. Keying the + // memos below on the raw array would change `allColumnNames` / + // `columnTypes` identity every render, re-firing the reconcile effect + // below — which dispatches `PIVOT_BUILDER_CONFIG_CHANGED`, which calls + // `setPersistedConfig` upstream, which re-renders us — an infinite loop + // that thrashes the whole sidebar (including the host's back button). + // Derive a stable signature from the column names+types instead. + const columnsKey = columns + .map((c: { name: string; type: string }) => `${c.name}\u0000${c.type}`) + .join('\u0001'); const allColumnNames = useMemo( () => columns.map((c: { name: string }) => c.name), - [columns] + // eslint-disable-next-line react-hooks/exhaustive-deps + [columnsKey] ); const columnTypes = useMemo(() => { const map: Record = {}; @@ -153,7 +163,8 @@ export function CreatePivotPage({ map[c.name] = c.type; }); return map; - }, [columns]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnsKey]); // Seed all four configurable cards from the proxy's last applied // intent (`builderConfig`) so reopening the Create Pivot page never @@ -168,22 +179,35 @@ export function CreatePivotPage({ const pivotIntent = intent?.pivot ?? null; const rollupIntent = intent?.rollup ?? model.rollupConfig ?? null; const totalsIntent = intent?.totals ?? model.totalsConfig ?? null; + // Persisted UI/card state (switch positions + contents). When present it + // is the authoritative seed source — it restores cards exactly as the + // user left them, including toggled-off cards whose contents are dropped + // from the derived model config. Absent on legacy configs, in which case + // we fall back to deriving seed state from the model config below. + const uiIntent: PivotBuilderUiState | null = intent?.ui ?? null; - const [mockRollupRows, setMockRollupRows] = useState(() => { + const [rollupRows, setRollupRows] = useState(() => { + if (uiIntent != null) return [...uiIntent.rollupRows]; if (pivotIntent != null) return [...pivotIntent.rowKeys]; return rollupIntent?.groupingColumns?.map((c: unknown) => String(c)) ?? []; }); - const [mockRollupRowsOn, setMockRollupRowsOn] = useState(true); - const [mockIncludeConstituents, setMockIncludeConstituents] = - useState(() => rollupIntent?.includeConstituents ?? true); - const [mockNonAggregatedInRollup, setMockNonAggregatedInRollup] = - useState(true); + const [rollupRowsOn, setRollupRowsOn] = useState( + () => uiIntent?.rollupRowsOn ?? true + ); + const [includeConstituents, setIncludeConstituents] = useState( + () => + uiIntent?.includeConstituents ?? rollupIntent?.includeConstituents ?? true + ); + const [nonAggregatedInRollup, setNonAggregatedInRollup] = useState( + () => uiIntent?.nonAggregatedInRollup ?? true + ); // Aggregate values state. Source-of-truth shape matches the host's // `AggregationSettings` so we can hand it straight to // `IrisGridUtils.getModelRollupConfig` / `.getModelTotalsConfig`. const [aggregationSettings, setAggregationSettings] = useState(() => { + if (uiIntent != null) return uiIntent.aggregations; if (pivotIntent != null) { return { aggregations: aggregationsFromOpMap(pivotIntent.aggregations), @@ -192,14 +216,32 @@ export function CreatePivotPage({ } return seedAggregationSettings(rollupIntent, totalsIntent); }); - const [aggregatesOn, setAggregatesOn] = useState(true); + const [aggregatesOn, setAggregatesOn] = useState( + () => uiIntent?.aggregatesOn ?? true + ); - const [mockPivotColumns, setMockPivotColumns] = useState(() => - pivotIntent != null ? [...pivotIntent.columnKeys] : [] + const [pivotColumns, setPivotColumns] = useState(() => { + if (uiIntent != null) return [...uiIntent.pivotColumns]; + return pivotIntent != null ? [...pivotIntent.columnKeys] : []; + }); + const [pivotColumnsOn, setPivotColumnsOn] = useState( + () => uiIntent?.pivotColumnsOn ?? true ); - const [mockPivotColumnsOn, setMockPivotColumnsOn] = useState(true); - const [mockFilterable, setMockFilterable] = useState([]); - const [mockFilterableOn, setMockFilterableOn] = useState(true); + const [placeholderFilterable, setPlaceholderFilterable] = useState( + () => uiIntent?.filterableColumns ?? [] + ); + const [placeholderFilterableOn, setPlaceholderFilterableOn] = + useState(() => uiIntent?.filterableOn ?? true); + + // Skip the mount reconcile: the model transform has already applied any + // persisted intent, so on first render the cards are seeded to match the + // model's current config and there are no user changes to write. Writing + // it back would dispatch `PIVOT_BUILDER_CONFIG_CHANGED`, persist identical + // state, and re-render the host one frame into the sidebar slide-in — + // tearing the animation and (with equivalent-by-key Stack children) + // remounting this page, re-running this effect → loop. Only persist once + // the user actually changes a card. + const hasReconciledRef = useRef(false); // Reconcile pivot/rollup/totals on every relevant state change. The // proxy owns ordering, diffing against last intent, and the mid-swap @@ -211,7 +253,12 @@ export function CreatePivotPage({ useEffect(() => { if (!isPivotBuilderIrisGridModel(model)) return; - const rollupActive = mockRollupRowsOn && mockRollupRows.length > 0; + if (!hasReconciledRef.current) { + hasReconciledRef.current = true; + return; + } + + const rollupActive = rollupRowsOn && rollupRows.length > 0; const aggsActive = aggregatesOn && aggregationSettings.aggregations.some( @@ -223,13 +270,14 @@ export function CreatePivotPage({ : EMPTY_AGGREGATION_SETTINGS; // Pivot is valid with empty rowKeys (PSP collapses to a single - // row). It is NOT valid with an empty aggregations map, so we - // synthesize a `Count` over the first source column that isn't - // already used as a row or pivot key. Also gate on PSP being + // row). It is NOT valid with an empty aggregations map, but that + // `Count` fallback is synthesized quietly at the `createPivotTable` + // call (see pivotBuilderModel) so it never leaks into the persisted + // intent or the Aggregate values card. Also gate on PSP being // available on this worker; otherwise createPivotTable hangs and // the proxy times out after 10s. const pivotActive = - pivotAvailable && mockPivotColumnsOn && mockPivotColumns.length > 0; + pivotAvailable && pivotColumnsOn && pivotColumns.length > 0; let pivot: PivotConfig | null = null; let rollup: ReturnType | null = @@ -237,15 +285,15 @@ export function CreatePivotPage({ let totals: UITotalsTableConfig | null = null; if (pivotActive) { - const used = new Set([...mockRollupRows, ...mockPivotColumns]); - const countFallback = allColumnNames.find(c => !used.has(c)); + // Rollup rows become the pivot's row keys, but only when the rollup + // card is active; disabling the rollup card while pivot is on must + // collapse the pivot to a single row (otherwise the config is + // unchanged and the table doesn't react). + const rowKeys = rollupActive ? rollupRows : []; pivot = { - rowKeys: mockRollupRows, - columnKeys: mockPivotColumns, - aggregations: aggregationsToPivot( - effectiveAggregationSettings, - countFallback - ), + rowKeys, + columnKeys: pivotColumns, + aggregations: aggregationsToPivot(effectiveAggregationSettings), }; } else if (rollupActive) { // Rollup folds aggregations into its config; standalone totals row @@ -253,9 +301,9 @@ export function CreatePivotPage({ rollup = IrisGridUtils.getModelRollupConfig( model.sourceTable.columns, { - columns: mockRollupRows, - showConstituents: mockIncludeConstituents, - showNonAggregatedColumns: mockNonAggregatedInRollup, + columns: rollupRows, + showConstituents: includeConstituents, + showNonAggregatedColumns: nonAggregatedInRollup, includeDescriptions: true as const, }, effectiveAggregationSettings @@ -269,18 +317,40 @@ export function CreatePivotPage({ ); } - model.applyPivotBuilderConfig({ pivot, rollup, totals }); + model.applyPivotBuilderConfig({ + pivot, + rollup, + totals, + // Persist the full card UI state (switch positions + contents) so the + // sidebar restores exactly what the user left — the derived + // pivot/rollup/totals above collapse "card off" and "card on but + // empty" into the same value and so can't recover the switches (or a + // toggled-off card's contents) on their own. + ui: { + rollupRowsOn, + rollupRows, + includeConstituents, + nonAggregatedInRollup, + aggregatesOn, + aggregations: aggregationSettings, + pivotColumnsOn, + pivotColumns, + filterableOn: placeholderFilterableOn, + filterableColumns: placeholderFilterable, + }, + }); }, [ model, - mockRollupRowsOn, - mockRollupRows, - mockIncludeConstituents, - mockNonAggregatedInRollup, - mockPivotColumnsOn, - mockPivotColumns, + rollupRowsOn, + rollupRows, + includeConstituents, + nonAggregatedInRollup, + pivotColumnsOn, + pivotColumns, aggregatesOn, aggregationSettings, - allColumnNames, + placeholderFilterableOn, + placeholderFilterable, pivotAvailable, ]); @@ -290,27 +360,27 @@ export function CreatePivotPage({
diff --git a/plugins/pivot-builder/src/js/src/PivotBuilderPanelMiddleware.tsx b/plugins/pivot-builder/src/js/src/PivotBuilderPanelMiddleware.tsx index e676e2a2c..f39ecd76d 100644 --- a/plugins/pivot-builder/src/js/src/PivotBuilderPanelMiddleware.tsx +++ b/plugins/pivot-builder/src/js/src/PivotBuilderPanelMiddleware.tsx @@ -166,6 +166,10 @@ export function PivotBuilderPanelMiddleware({ const [pivotServiceStatus, setPivotServiceStatus] = useState(corePlusAvailable ? 'loading' : 'unavailable'); + // Bumped to retry the probe (e.g. after a worker restart surfaces a fresh + // model). Adding this to the probe effect's deps re-runs it. + const [probeRetryKey, setProbeRetryKey] = useState(0); + useEffect(() => { if (!corePlusAvailable) { setPivotServiceStatus('unavailable'); @@ -203,7 +207,23 @@ export function PivotBuilderPanelMiddleware({ return () => { cancelled = true; }; - }, [corePlusAvailable, metadata, objectFetcher]); + }, [corePlusAvailable, metadata, objectFetcher, probeRetryKey]); + + // Re-probe when the host publishes a (re)built model and PSP was + // previously unavailable. Worker restarts surface as a new model from + // `IrisGridPanel`; if the fresh worker has PSP we want the panel to + // recognize it without the user reloading. We deliberately skip + // re-probing once status is `ready` so the happy path doesn't flicker + // through `loading` on every model swap (we assume PSP doesn't + // disappear mid-session). + useEffect(() => { + if (model != null && pivotServiceStatus === 'unavailable') { + setProbeRetryKey(k => k + 1); + } + // Intentionally only react to `model` changes; `pivotServiceStatus` is + // read as a snapshot to gate the retry, not to drive it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [model]); const getPspWidget = useCallback(async (): Promise => { if (pspWidgetRef.current != null) { diff --git a/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx b/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx index afebba490..4c96f2763 100644 --- a/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx +++ b/plugins/pivot-builder/src/js/src/PivotConfigSection.tsx @@ -29,11 +29,25 @@ import { ActionButton, Button, Checkbox, + Icon, + Item, + MenuTrigger, + Picker, SearchInput, + Section, Select, + SpectrumMenu, Switch, + Text, } from '@deephaven/components'; -import { vsEdit, vsGripper, vsTrash } from '@deephaven/icons'; +import { + vsBlank, + vsCheck, + vsGripper, + vsKebabVertical, + vsTrash, +} from '@deephaven/icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { AggregationOperation, AggregationUtils, @@ -64,8 +78,8 @@ const PIVOT_DND_STYLES = ` transition: background-color 0.15s ease; } .pivot-config-section .pivot-droppable-empty { - min-height: 36px; - margin: 4px 0; + min-height: 26px; + margin: 2px 0; padding: 4px; border: dashed 1px transparent; border-radius: 2px; @@ -148,7 +162,7 @@ export type PivotConfigSectionProps = { const cardStyle: React.CSSProperties = { border: '1px solid var(--dh-color-border-base, #444)', borderRadius: 4, - padding: '8px 10px', + padding: '6px 8px', background: 'var(--dh-color-bg-200, transparent)', }; @@ -156,7 +170,7 @@ const cardHeaderStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, - marginBottom: 6, + marginBottom: 4, }; const cardTitleStyle: React.CSSProperties = { @@ -168,7 +182,7 @@ const rowStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 6, - padding: '4px 2px', + padding: '2px 2px', }; const rowLabelStyle: React.CSSProperties = { @@ -198,29 +212,9 @@ const gripHandleStyle: React.CSSProperties = { color: 'var(--dh-color-fg-200, #888)', }; -/** Inline SVG icon to avoid bundling `@fortawesome/react-fontawesome` - * (which is not in the host's remote-component resolve map). */ +/** Drag-handle grip icon. */ function GripIcon(): JSX.Element { - // vsGripper.icon = [width, height, _ligs, _unicode, svgPathData] - const [w, h, , , path] = vsGripper.icon as [ - number, - number, - string[], - string, - string, - ]; - return ( - - ); + return ; } const disabledBodyStyle: React.CSSProperties = { @@ -435,6 +429,99 @@ function ColumnPicker({ ); } +type OverflowMenuItem = { + /** Stable key identifying the item; passed back to `onAction`. */ + key: string; + /** Visible label. */ + label: string; + /** + * Toggle state. When `true` the item shows a leading checkmark; when `false` + * the checkmark column is blank. When `undefined` the item is a plain action + * (also blank), so toggles and actions can be mixed in the same menu. Every + * item reserves the leading icon column so labels stay aligned. + */ + isSelected?: boolean; +}; + +/** + * A group of {@link OverflowMenuItem}s. Spectrum draws a divider before every + * section after the first, so each section boundary renders a separator — + * mirroring the grouped "Organize Columns" overflow menu in + * `@deephaven/iris-grid`. + */ +type OverflowMenuSection = { + /** Stable key identifying the section. */ + key: string; + /** Items rendered within the section. */ + items: OverflowMenuItem[]; +}; + +type OverflowMenuProps = { + /** + * Sections rendered in the menu, with a separator drawn between each. + * Memoize for a stable reference. + */ + sections: OverflowMenuSection[]; + /** Keys of items rendered as disabled. */ + disabledKeys?: Iterable; + /** Accessible label / tooltip for the kebab (⋮) trigger. */ + tooltip: string; + /** Invoked with the key of the activated item. */ + onAction: (key: string) => void; + /** Invoked when the menu opens (e.g. to dismiss other open popovers). */ + onOpen?: () => void; +}; + +/** + * A kebab (⋮) button that opens a Spectrum `Menu`, mirroring the Organize + * Columns overflow menu in `@deephaven/iris-grid`. `MenuTrigger` owns the + * open/close state. Items may be plain actions or checkable toggles + * (`isSelected` defined): a toggle's leading checkmark is swapped between + * `vsCheck` and `vsBlank` via `FontAwesomeIcon`, exactly like the + * "Show hidden columns" item there. + */ +function OverflowMenu({ + sections, + disabledKeys, + tooltip, + onAction, + onOpen, +}: OverflowMenuProps): JSX.Element { + return ( + { + if (isOpen) { + onOpen?.(); + } + }} + > + + + + onAction(String(key))} + > + {sections.map(section => ( +
+ {section.items.map(item => ( + + + + + {item.label} + + ))} +
+ ))} +
+
+ ); +} + type ConfigCardProps = { title: string; on: boolean; @@ -443,6 +530,8 @@ type ConfigCardProps = { addDisabled?: boolean; /** When true, the whole card is greyed-out and non-interactive. */ disabled?: boolean; + /** Optional overflow (⋮) menu rendered after the Add button. */ + overflow?: React.ReactNode; picker?: (anchorRef: React.RefObject) => React.ReactNode; children: React.ReactNode; }; @@ -454,6 +543,7 @@ function ConfigCard({ onAdd, addDisabled, disabled, + overflow, picker, children, }: ConfigCardProps): JSX.Element { @@ -496,6 +586,7 @@ function ConfigCard({ Add + {overflow} {picker?.(buttonRef)}
@@ -628,15 +719,35 @@ function ColumnRowPreview({ name }: { name: string }): JSX.Element { ); } -type AggregateRowProps = { - entry: Aggregation; - index: number; - onEdit: () => void; +type AggregateSelectRowProps = { + id: string; + operation: string; + columnLabels: readonly string[]; + availableOperations: readonly string[]; + onOperationChange: (operation: string) => void; onDelete: () => void; }; -function aggregationRowId(index: number): string { - return `${AGGREGATIONS_DROPPABLE}:${index}`; +/** + * Stable sortable id for a grouped aggregate row (one row per operation). + * Keyed by the operation rather than its index so a reorder moves the DOM + * node (and animates) instead of mutating content in place — positional ids + * stay at the same slot after a reorder, which makes dnd-kit's drop animation + * snap the dragged row back to where it started. + */ +function aggregationRowId(operation: string): string { + return `${AGGREGATIONS_DROPPABLE}:${operation}`; +} + +/** + * Stable sortable id for a single function/column pair (used by the ungrouped + * aggregate layout). Keyed by the operation + column content (separated by a + * NUL so it never collides with a column name) rather than positional indices, + * for the same reorder-animation reason as `aggregationRowId`. The container + * still resolves on the first `:`. + */ +function aggregationPairId(operation: string, column: string): string { + return `${AGGREGATIONS_DROPPABLE}:${operation}\u0000${column}`; } function formatAggLabel(entry: Aggregation): string { @@ -645,13 +756,21 @@ function formatAggLabel(entry: Aggregation): string { : entry.operation; } -function AggregateRow({ - entry, - index, - onEdit, +/** + * Two-line aggregate row: the aggregate function rendered as a quiet + * Spectrum picker (changeable inline) on the first line and the column + * label on the second. Used both for the grouped layout (one row per + * function, all columns joined) and the ungrouped layout (one row per + * function/column pair). + */ +function AggregateSelectRow({ + id, + operation, + columnLabels, + availableOperations, + onOperationChange, onDelete, -}: AggregateRowProps): JSX.Element { - const id = aggregationRowId(index); +}: AggregateSelectRowProps): JSX.Element { const { attributes, listeners, @@ -666,26 +785,60 @@ function AggregateRow({ }); const style: React.CSSProperties = { ...rowStyle, + // Stack the function/columns vertically so the delete + drag icons can + // sit on the same centered line as the aggregate-function picker (the + // column labels flow underneath). + flexDirection: 'column', + alignItems: 'stretch', + gap: 0, transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0 : 1, }; return (
- {formatAggLabel(entry)} -
+ {columnLabels.map(label => ( + + {label} + + ))}
); } @@ -694,12 +847,6 @@ function AggregateRowPreview({ entry }: { entry: Aggregation }): JSX.Element { return (
{formatAggLabel(entry)} -