diff --git a/.github/copilot/skills/use-log-debug.md b/.github/copilot/skills/use-log-debug.md new file mode 100644 index 0000000000..e5e63ec7d9 --- /dev/null +++ b/.github/copilot/skills/use-log-debug.md @@ -0,0 +1,56 @@ +# Skill: Use log.debug Instead of console.log + +When adding debug logging in JavaScript/TypeScript files in this codebase, use the `@deephaven/log` module instead of `console.log` or `console.debug`. + +## Pattern + +1. **Import the Log module** at the top of the file: + ```typescript + import Log from '@deephaven/log'; + ``` + +2. **Create a module-specific logger** after imports: + ```typescript + const log = Log.module('ModuleName'); + ``` + Replace `'ModuleName'` with the name of the current file/module (e.g., `'ChartUtils'`, `'TableUtils'`). + +3. **Use the logger** for debug output: + ```typescript + log.debug('message', data); + log.debug2('more verbose message', data); // For very verbose logging + ``` + +## Why + +- Consistent logging across the codebase +- Log levels can be configured at runtime +- Module-specific filtering is possible +- Avoids ESLint `no-console` warnings +- Better production behavior (logs can be silenced) + +## Example + +```typescript +import Log from '@deephaven/log'; + +const log = Log.module('MyComponent'); + +function processData(data: SomeType): void { + log.debug('Processing data:', data); + // ... processing logic + log.debug2('Detailed step completed'); +} +``` + +## Avoid + +```typescript +// ❌ Don't use console directly +console.log('Processing data:', data); +console.debug('Step completed'); + +// ❌ Don't add eslint-disable for console +// eslint-disable-next-line no-console +console.log('debug info'); +``` diff --git a/packages/code-studio/src/index.tsx b/packages/code-studio/src/index.tsx index fa508823df..492a7e92d0 100644 --- a/packages/code-studio/src/index.tsx +++ b/packages/code-studio/src/index.tsx @@ -46,6 +46,7 @@ async function getCorePlugins() { ); const { GridPluginConfig, + TableHistoryPluginConfig, PandasPluginConfig, ChartPluginConfig, ChartBuilderPluginConfig, @@ -56,6 +57,7 @@ async function getCorePlugins() { } = dashboardCorePlugins; return [ GridPluginConfig, + TableHistoryPluginConfig, PandasPluginConfig, ChartPluginConfig, ChartBuilderPluginConfig, diff --git a/packages/dashboard-core-plugins/src/GridMiddlewarePlugin.tsx b/packages/dashboard-core-plugins/src/GridMiddlewarePlugin.tsx new file mode 100644 index 0000000000..7b120114ed --- /dev/null +++ b/packages/dashboard-core-plugins/src/GridMiddlewarePlugin.tsx @@ -0,0 +1,265 @@ +import React, { useCallback, useEffect } from 'react'; +import { + PluginType, + type WidgetMiddlewarePlugin, + type WidgetMiddlewareComponentProps, + type WidgetMiddlewarePanelProps, +} from '@deephaven/plugin'; +import { type dh } from '@deephaven/jsapi-types'; +import Log from '@deephaven/log'; +import { Button } from '@deephaven/components'; +import { vsGear } from '@deephaven/icons'; +import { + type TableOption, + type TableOptionPanelProps, + useTableOptionsHost, + defaultTableOptionsRegistry, +} from '@deephaven/iris-grid'; + +const log = Log.module('GridMiddlewarePlugin'); + +/** + * Example middleware plugin that wraps the GridWidgetPlugin. + * This demonstrates how middleware can intercept and enhance widget rendering. + * + * Middleware plugins: + * - Must set `isMiddleware: true` + * - Receive the wrapped component as `Component` prop + * - Must render `Component` to continue the chain + * - Are chained in registration order (first registered = outermost wrapper) + */ +function GridMiddleware({ + Component, + ...props +}: WidgetMiddlewareComponentProps): JSX.Element { + // Register the option when the middleware mounts (not as a module side effect) + useEffect(() => { + defaultTableOptionsRegistry.register(MiddlewareCustomOption); + return () => { + defaultTableOptionsRegistry.unregister(MIDDLEWARE_OPTION_TYPE); + }; + }, []); + + // Log when middleware is mounted (for debugging) + useEffect(() => { + log.debug('GridMiddleware (component) mounted'); + return () => { + log.debug('GridMiddleware (component) unmounted'); + }; + }, []); + + // Pass through to the wrapped component + // Middleware can add context providers, state, or modify props here + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + +GridMiddleware.displayName = 'GridMiddleware'; + +/** + * Custom option type for the middleware plugin. + * Using a unique string to avoid conflicts with built-in option types. + */ +const MIDDLEWARE_OPTION_TYPE = 'middleware-custom-option'; + +/** + * A sample configuration panel similar to SelectDistinctBuilder. + * Demonstrates how middleware plugins can use the useTableOptionsHost hook + * to access and modify grid state. + */ +function MiddlewareConfigPanel(_props: TableOptionPanelProps): JSX.Element { + // Access the Table Options context for state and dispatch + const { gridState, dispatch, closePanel } = useTableOptionsHost(); + const { + model, + selectDistinctColumns, + customColumns, + quickFilters, + advancedFilters, + searchValue, + selectedSearchColumns, + sorts, + reverse, + } = gridState; + + const handleButtonClick = useCallback(() => { + log.info('MiddlewareConfigPanel button clicked!'); + // eslint-disable-next-line no-console + console.log('MiddlewareConfigPanel: Sample button clicked!'); + // eslint-disable-next-line no-console + console.log('Current selectDistinctColumns:', selectDistinctColumns); + // eslint-disable-next-line no-console + console.log('Current customColumns:', customColumns); + // eslint-disable-next-line no-console + console.log('Current quickFilters:', quickFilters); + // eslint-disable-next-line no-console + console.log('Current advancedFilters:', advancedFilters); + // eslint-disable-next-line no-console + console.log('Current searchValue:', searchValue, 'columns:', selectedSearchColumns); + // eslint-disable-next-line no-console + console.log('Current sorts:', sorts, 'reverse:', reverse); + }, [ + selectDistinctColumns, + customColumns, + quickFilters, + advancedFilters, + searchValue, + selectedSearchColumns, + sorts, + reverse, + ]); + + const handleClearSelectDistinct = useCallback(() => { + log.info('Clearing selectDistinctColumns'); + dispatch({ type: 'SET_SELECT_DISTINCT_COLUMNS', columns: [] }); + closePanel(); + }, [dispatch, closePanel]); + + const handleClearFilters = useCallback(() => { + log.info('Clearing all filters'); + dispatch({ type: 'CLEAR_ALL_FILTERS' }); + closePanel(); + }, [dispatch, closePanel]); + + const hasFilters = + quickFilters.size > 0 || + advancedFilters.size > 0 || + searchValue !== '' || + sorts.length > 0; + + return ( +
+

Columns: {model.columns?.length ?? 0}

+

+ Select Distinct:{' '} + {selectDistinctColumns.length > 0 + ? selectDistinctColumns.join(', ') + : 'None'} +

+

+ Custom Columns:{' '} + {customColumns.length > 0 ? customColumns.join(', ') : 'None'} +

+

+ Quick Filters: {quickFilters.size > 0 ? quickFilters.size : 'None'} +

+

+ Advanced Filters:{' '} + {advancedFilters.size > 0 ? advancedFilters.size : 'None'} +

+

+ Cross-Column Search:{' '} + {searchValue || 'None'} + {selectedSearchColumns.length > 0 + ? ` (in ${selectedSearchColumns.join(', ')})` + : ''} +

+

+ Sorts: {sorts.length > 0 ? sorts.length : 'None'} + {reverse ? ' (reversed)' : ''} +

+ +
+ + {selectDistinctColumns.length > 0 && ( + + )} + {hasFilters && ( + + )} +
+ +

+ This panel demonstrates using the useTableOptionsHost hook to access and + modify grid state from a plugin. +

+
+ ); +} + +MiddlewareConfigPanel.displayName = 'MiddlewareConfigPanel'; + +/** + * Middleware custom option registered with the Table Options registry. + * This demonstrates how plugins can add custom options via the registry. + */ +const MiddlewareCustomOption: TableOption = { + type: MIDDLEWARE_OPTION_TYPE, + + menuItem: { + title: 'Middleware Custom Option', + subtitle: 'Opens a configuration panel', + icon: vsGear, + // Show at top of menu + order: -100, + // Always available + isAvailable: () => true, + }, + + Panel: MiddlewareConfigPanel, +}; + +// Note: Registration moved to GridMiddleware component to avoid side effects at module load time +// defaultTableOptionsRegistry.register(MiddlewareCustomOption); + +/** + * Panel middleware that wraps the GridPanelPlugin. + * This is used when the base plugin has a panelComponent defined. + */ +function GridPanelMiddleware({ + Component, + ...props +}: WidgetMiddlewarePanelProps): JSX.Element { + // Log when panel middleware is mounted (for debugging) + useEffect(() => { + log.debug('GridMiddleware (panel) mounted'); + return () => { + log.debug('GridMiddleware (panel) unmounted'); + }; + }, []); + + // Simply pass through - registry handles the option + return ( + + ); +} + +GridPanelMiddleware.displayName = 'GridPanelMiddleware'; + +/** + * Middleware plugin configuration for GridWidgetPlugin. + * This plugin wraps the base grid widget and can be used to: + * - Add custom Table Options menu items + * - Inject additional context or state + * - Add UI elements around the grid + * - Intercept and modify props before they reach the grid + * + * Since GridPluginConfig has a panelComponent, we must also provide + * a panelComponent to have our middleware applied. + */ +const GridMiddlewarePluginConfig: WidgetMiddlewarePlugin = { + name: '@deephaven/grid-middleware', + title: 'Grid Middleware', + type: PluginType.WIDGET_PLUGIN, + component: GridMiddleware, + panelComponent: GridPanelMiddleware, + supportedTypes: [ + 'Table', + 'TreeTable', + 'HierarchicalTable', + 'PartitionedTable', + ], + isMiddleware: true, +}; + +export { GridMiddleware, GridPanelMiddleware }; +export default GridMiddlewarePluginConfig; diff --git a/packages/dashboard-core-plugins/src/TableHistoryPlugin.tsx b/packages/dashboard-core-plugins/src/TableHistoryPlugin.tsx new file mode 100644 index 0000000000..8cf7bb94c3 --- /dev/null +++ b/packages/dashboard-core-plugins/src/TableHistoryPlugin.tsx @@ -0,0 +1,570 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + PluginType, + type WidgetMiddlewarePlugin, + type WidgetMiddlewareComponentProps, + type WidgetMiddlewarePanelProps, +} from '@deephaven/plugin'; +import { type dh } from '@deephaven/jsapi-types'; +import { + Button, + ConfirmationDialog, + DialogTrigger, + SpectrumButton, + Text, +} from '@deephaven/components'; +import { vsEdit, vsHistory, vsTrash } from '@deephaven/icons'; +import { usePersistentState } from '@deephaven/dashboard'; +import { + type TableOption, + type TableOptionPanelProps, + type GridStateSnapshot, + useTableOptionsHost, + defaultTableOptionsRegistry, + IrisGridUtils, + type DehydratedIrisGridState, + type DehydratedGridState, +} from '@deephaven/iris-grid'; + +/** + * Dehydrated (JSON-serializable) snapshot of table state. + * Stored via usePersistentState for cross-session persistence. + * Uses the same structure as IrisGridUtils dehydration for proper hydration. + */ +interface DehydratedStateSnapshot { + /** Unique identifier */ + id: string; + /** When the snapshot was taken (ISO string for JSON serialization) */ + timestamp: string; + /** User-defined name for the snapshot (optional) */ + name?: string; + /** Dehydrated IrisGrid state (filters, sorts, custom columns, etc.) */ + irisGridState: Partial; + /** Dehydrated Grid state (movedColumns, movedRows, etc.) */ + gridState: Partial; +} + +/** + * Persisted state structure for the Table History plugin. + */ +interface TableHistoryPersistedState { + snapshots: DehydratedStateSnapshot[]; +} + +/** + * Custom option type for the Table History plugin. + */ +const TABLE_HISTORY_OPTION_TYPE = 'table-history-option'; + +/** + * Dehydrates current grid state to a JSON-serializable snapshot. + * Uses IrisGridUtils dehydration methods for proper serialization. + */ +function dehydrateSnapshot( + gridState: GridStateSnapshot, + irisGridUtils: IrisGridUtils +): Omit { + const { model, quickFilters, advancedFilters, sorts } = gridState; + const { columns } = model; + + // Dehydrate IrisGrid state (filters, sorts, custom columns, etc.) + const irisGridDehydrated: Partial = { + quickFilters: IrisGridUtils.dehydrateQuickFilters(quickFilters), + advancedFilters: irisGridUtils.dehydrateAdvancedFilters( + columns, + advancedFilters + ), + sorts: IrisGridUtils.dehydrateSort(sorts), + reverse: gridState.reverse, + searchValue: gridState.searchValue, + selectedSearchColumns: [...gridState.selectedSearchColumns], + invertSearchColumns: gridState.invertSearchColumns, + selectDistinctColumns: [...gridState.selectDistinctColumns], + customColumns: [...gridState.customColumns], + }; + + // Dehydrate Grid state (movedColumns) + // Use dehydrateGridState to properly convert column indices to names + const gridDehydrated = IrisGridUtils.dehydrateGridState(model, { + isStuckToBottom: false, + isStuckToRight: false, + movedColumns: [...gridState.movedColumns], + movedRows: [], + }); + + return { + irisGridState: irisGridDehydrated, + gridState: gridDehydrated, + }; +} + +/** + * Formats a timestamp string for display. + */ +function formatTimestamp(isoString: string): string { + return new Date(isoString).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +/** + * Table History panel that allows saving and restoring table state snapshots. + */ +function TableHistoryPanel(_props: TableOptionPanelProps): JSX.Element { + const { gridState, dispatch } = useTableOptionsHost(); + const { model } = gridState; + + // Create IrisGridUtils instance for hydration/dehydration + const irisGridUtils = useMemo(() => new IrisGridUtils(model.dh), [model.dh]); + + // Persist snapshots across sessions + const [state, setState] = usePersistentState( + { snapshots: [] }, + { + type: 'TableHistoryPlugin', + version: 1, + deleteOnUnmount: false, // Keep snapshots when panel is closed + } + ); + + const { snapshots } = state; + + const handleSaveSnapshot = useCallback(() => { + const snapshot: DehydratedStateSnapshot = { + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + ...dehydrateSnapshot(gridState, irisGridUtils), + }; + setState(prev => ({ + snapshots: [...prev.snapshots, snapshot], + })); + }, [gridState, irisGridUtils, setState]); + + const handleRestoreSnapshot = useCallback( + (snapshot: DehydratedStateSnapshot) => { + // Use RESTORE_DEHYDRATED_STATE action which handles hydration properly, + // including graceful handling of missing columns when the table structure + // has changed (e.g., columns hidden via Organize Columns). + dispatch({ + type: 'RESTORE_DEHYDRATED_STATE', + irisGridState: snapshot.irisGridState, + gridState: snapshot.gridState, + }); + + // Stay on the Table History screen after restoring + }, + [dispatch] + ); + + const handleDeleteSnapshot = useCallback( + (id: string) => { + setState(prev => ({ + snapshots: prev.snapshots.filter(s => s.id !== id), + })); + }, + [setState] + ); + + const handleClearAll = useCallback(() => { + setState({ snapshots: [] }); + }, [setState]); + + /** + * Reset the table to its initial state. + * Clears all filters, sorts, search, select distinct, custom columns, + * and column re-ordering, restoring the table to its default view. + */ + const handleResetTable = useCallback(() => { + // Clear select distinct columns first (triggers handler that resets other state) + dispatch({ + type: 'SET_SELECT_DISTINCT_COLUMNS', + columns: [], + }); + + // Clear custom columns + dispatch({ + type: 'SET_CUSTOM_COLUMNS', + columns: [], + }); + + // Clear all filters + dispatch({ + type: 'SET_QUICK_FILTERS', + filters: new Map(), + }); + dispatch({ + type: 'SET_ADVANCED_FILTERS', + filters: new Map(), + }); + + // Clear search + dispatch({ + type: 'SET_CROSS_COLUMN_SEARCH', + searchValue: '', + selectedSearchColumns: [], + invertSearchColumns: false, + }); + + // Clear sorts and reverse + dispatch({ type: 'SET_SORTS', sorts: [] }); + dispatch({ type: 'SET_REVERSE', reverse: false }); + + // Reset column order + dispatch({ + type: 'SET_MOVED_COLUMNS', + columns: [], + }); + }, [dispatch]); + + // State for inline editing of snapshot names + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(''); + + const handleStartEdit = useCallback((snapshot: DehydratedStateSnapshot) => { + setEditingId(snapshot.id); + setEditValue(snapshot.name ?? ''); + }, []); + + const handleSaveEdit = useCallback(() => { + if (editingId == null) return; + setState(prev => ({ + snapshots: prev.snapshots.map(s => + s.id === editingId ? { ...s, name: editValue.trim() || undefined } : s + ), + })); + setEditingId(null); + setEditValue(''); + }, [editingId, editValue, setState]); + + const handleCancelEdit = useCallback(() => { + setEditingId(null); + setEditValue(''); + }, []); + + // Compute what has changed since the last saved snapshot + const changedProperties = useMemo(() => { + const lastSnapshot = snapshots[snapshots.length - 1]; + if (lastSnapshot == null) { + return null; // No previous snapshot to compare against + } + + const currentDehydrated = dehydrateSnapshot(gridState, irisGridUtils); + const currentIris = currentDehydrated.irisGridState; + const currentGrid = currentDehydrated.gridState; + + // Handle backward compatibility with old snapshot format + // Old format had properties at top level, new format nests them in irisGridState/gridState + const lastIris = lastSnapshot.irisGridState ?? {}; + const lastGrid = lastSnapshot.gridState ?? {}; + const changes: string[] = []; + + // Compare sorts + const sortsChanged = + JSON.stringify(currentIris.sorts) !== JSON.stringify(lastIris.sorts); + if (sortsChanged && currentIris.sorts != null) { + changes.push(`Sorts: ${currentIris.sorts.length} column(s)`); + } + + // Compare quick filters + const quickFiltersChanged = + JSON.stringify(currentIris.quickFilters) !== + JSON.stringify(lastIris.quickFilters); + if (quickFiltersChanged && currentIris.quickFilters != null) { + changes.push( + `Quick Filters: ${currentIris.quickFilters.length} filter(s)` + ); + } + + // Compare advanced filters + const advancedFiltersChanged = + JSON.stringify(currentIris.advancedFilters) !== + JSON.stringify(lastIris.advancedFilters); + if (advancedFiltersChanged && currentIris.advancedFilters != null) { + changes.push( + `Advanced Filters: ${currentIris.advancedFilters.length} filter(s)` + ); + } + + // Compare search + if (currentIris.searchValue !== lastIris.searchValue) { + const searchDisplay = + currentIris.searchValue != null && currentIris.searchValue.length > 0 + ? currentIris.searchValue + : '(empty)'; + changes.push(`Search: "${searchDisplay}"`); + } + + // Compare reverse + if (currentIris.reverse !== lastIris.reverse) { + changes.push(`Reverse: ${currentIris.reverse}`); + } + + // Compare select distinct + const selectDistinctChanged = + JSON.stringify(currentIris.selectDistinctColumns) !== + JSON.stringify(lastIris.selectDistinctColumns); + if (selectDistinctChanged && currentIris.selectDistinctColumns != null) { + changes.push( + `Select Distinct: ${currentIris.selectDistinctColumns.length} column(s)` + ); + } + + // Compare custom columns + const customColumnsChanged = + JSON.stringify(currentIris.customColumns) !== + JSON.stringify(lastIris.customColumns); + if (customColumnsChanged && currentIris.customColumns != null) { + changes.push( + `Custom Columns: ${currentIris.customColumns.length} column(s)` + ); + } + + // Compare moved columns + const movedColumnsChanged = + JSON.stringify(currentGrid.movedColumns) !== + JSON.stringify(lastGrid.movedColumns); + if (movedColumnsChanged && currentGrid.movedColumns != null) { + changes.push(`Column Order: ${currentGrid.movedColumns.length} move(s)`); + } + + return changes; + }, [snapshots, gridState, irisGridUtils]); + + return ( +
+ {/* Show changed properties since last snapshot */} + {changedProperties != null && changedProperties.length > 0 && ( +
+
Changed since last snapshot:
+
    + {changedProperties.map(change => ( +
  • • {change}
  • + ))} +
+
+ )} + {changedProperties != null && changedProperties.length === 0 && ( +

No changes since last snapshot.

+ )} + +
+ +
+ + {snapshots.length > 0 && ( + <> +
+
Saved Snapshots (newest first)
+
    + {[...snapshots].reverse().map(snapshot => ( +
  • + {editingId === snapshot.id ? ( +
    + setEditValue(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleSaveEdit(); + if (e.key === 'Escape') handleCancelEdit(); + }} + placeholder={formatTimestamp(snapshot.timestamp)} + autoFocus + /> + + +
    + ) : ( + <> + +
    +
    + + )} +
  • + ))} +
+
+ +
+ + + Clear All Snapshots + + {(close: () => void) => ( + { + close(); + handleClearAll(); + }} + > + + Are you sure you want to clear all {snapshots.length}{' '} + snapshot{snapshots.length === 1 ? '' : 's'}? This action + cannot be undone. + + + )} + +
+ + )} + +

+ Save the current table state (filters, sorts, search) and restore it + later by clicking on a timestamp. +

+ +
+ + Reset Table + {(close: () => void) => ( + { + close(); + handleResetTable(); + }} + > + + Reset the table to its initial state? This will clear all + filters, sorts, search, and column re-ordering. + + + )} + +

+ Clear all filters, sorts, search, custom columns, and column + re-ordering to restore the table to its default state. +

+
+
+ ); +} + +TableHistoryPanel.displayName = 'TableHistoryPanel'; + +/** + * Table History option for the Table Options menu. + */ +const TableHistoryOption: TableOption = { + type: TABLE_HISTORY_OPTION_TYPE, + + menuItem: { + title: 'Table History', + // subtitle: 'Save and restore table state', + icon: vsHistory, + order: -50, + isAvailable: () => true, + }, + + Panel: TableHistoryPanel, +}; + +// Register the option with the default registry +defaultTableOptionsRegistry.register(TableHistoryOption); + +/** + * Middleware component that passes through to the wrapped component. + * The table option is registered via the module-level side effect above. + */ +function TableHistoryMiddleware({ + Component, + ...props +}: WidgetMiddlewareComponentProps): JSX.Element { + useEffect(() => { + // Registration happens at module level, nothing to do here + }, []); + + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + +TableHistoryMiddleware.displayName = 'TableHistoryMiddleware'; + +/** + * Panel middleware that passes through to the wrapped component. + */ +function TableHistoryPanelMiddleware({ + Component, + ...props +}: WidgetMiddlewarePanelProps): JSX.Element { + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + +TableHistoryPanelMiddleware.displayName = 'TableHistoryPanelMiddleware'; + +/** + * Plugin configuration for TableHistory. + * This middleware plugin registers the Table History option in the Table Options menu. + * Add this to your plugins array to enable the Table History feature. + */ +const TableHistoryPluginConfig: WidgetMiddlewarePlugin = { + name: '@deephaven/table-history', + title: 'Table History', + type: PluginType.WIDGET_PLUGIN, + component: TableHistoryMiddleware, + panelComponent: TableHistoryPanelMiddleware, + supportedTypes: [ + 'Table', + 'TreeTable', + 'HierarchicalTable', + 'PartitionedTable', + ], + isMiddleware: true, +}; + +export { TableHistoryPanel, TableHistoryOption, TableHistoryMiddleware }; +export default TableHistoryPluginConfig; diff --git a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx index b383b5aa90..63b5c4619f 100644 --- a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.test.tsx @@ -4,7 +4,9 @@ import { PluginType, PluginsContext, type WidgetPlugin, + type WidgetMiddlewarePlugin, type WidgetComponentProps, + type WidgetMiddlewareComponentProps, } from '@deephaven/plugin'; import { Provider } from 'react-redux'; import { Dashboard, PanelEvent } from '@deephaven/dashboard'; @@ -267,3 +269,168 @@ describe('component wrapper', () => { ); }); }); + +describe('middleware plugin chaining', () => { + function TestMiddlewareWrapper({ + Component, + ...props + }: WidgetMiddlewareComponentProps) { + return ( +
+ MiddlewareWrapper + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + +
+ ); + } + + function TestMiddlewareWrapperTwo({ + Component, + ...props + }: WidgetMiddlewareComponentProps) { + return ( +
+ MiddlewareWrapperTwo + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + +
+ ); + } + + const testMiddlewarePlugin: WidgetMiddlewarePlugin = { + name: 'test-middleware', + type: PluginType.WIDGET_PLUGIN, + component: TestMiddlewareWrapper, + supportedTypes: 'test-widget', + isMiddleware: true, + }; + + const testMiddlewarePluginTwo: WidgetMiddlewarePlugin = { + name: 'test-middleware-two', + type: PluginType.WIDGET_PLUGIN, + component: TestMiddlewareWrapperTwo, + supportedTypes: 'test-widget', + isMiddleware: true, + }; + + it('chains middleware plugin around base widget', async () => { + const layoutManager = createAndMountDashboard([ + ['test-widget-plugin', testWidgetPlugin], + ['test-middleware-plugin', testMiddlewarePlugin], + ]); + + act( + () => + layoutManager?.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'test-widget' }, + }) + ); + + // Both the middleware wrapper and the base widget should be rendered + expect(screen.queryAllByText('MiddlewareWrapper').length).toBe(1); + expect(screen.queryAllByText('TestWidget').length).toBe(1); + + // The widget should be inside the middleware wrapper + const wrapper = screen.getByTestId('middleware-wrapper'); + expect(wrapper).toContainElement(screen.getByText('TestWidget')); + }); + + it('chains multiple middleware plugins in registration order', async () => { + const layoutManager = createAndMountDashboard([ + ['test-widget-plugin', testWidgetPlugin], + ['test-middleware-plugin', testMiddlewarePlugin], + ['test-middleware-plugin-two', testMiddlewarePluginTwo], + ]); + + act( + () => + layoutManager?.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'test-widget' }, + }) + ); + + // All components should be rendered + expect(screen.queryAllByText('MiddlewareWrapper').length).toBe(1); + expect(screen.queryAllByText('MiddlewareWrapperTwo').length).toBe(1); + expect(screen.queryAllByText('TestWidget').length).toBe(1); + + // Middleware should be chained in registration order (first middleware is outermost) + const wrapperOne = screen.getByTestId('middleware-wrapper'); + const wrapperTwo = screen.getByTestId('middleware-wrapper-two'); + expect(wrapperOne).toContainElement(wrapperTwo); + expect(wrapperTwo).toContainElement(screen.getByText('TestWidget')); + }); + + it('middleware registered before base plugin is still applied', async () => { + const layoutManager = createAndMountDashboard([ + ['test-middleware-plugin', testMiddlewarePlugin], + ['test-widget-plugin', testWidgetPlugin], + ]); + + act( + () => + layoutManager?.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'test-widget' }, + }) + ); + + // Middleware should wrap the base widget + expect(screen.queryAllByText('MiddlewareWrapper').length).toBe(1); + expect(screen.queryAllByText('TestWidget').length).toBe(1); + const wrapper = screen.getByTestId('middleware-wrapper'); + expect(wrapper).toContainElement(screen.getByText('TestWidget')); + }); + + it('middleware without base plugin is not rendered', async () => { + const layoutManager = createAndMountDashboard([ + [ + 'test-middleware-only', + { + ...testMiddlewarePlugin, + supportedTypes: 'middleware-only-type', + }, + ], + ]); + + act( + () => + layoutManager?.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'middleware-only-type' }, + }) + ); + + // Nothing should be rendered since there's no base plugin + expect(screen.queryAllByText('MiddlewareWrapper').length).toBe(0); + }); + + it('base plugin replacement keeps middleware chain', async () => { + const layoutManager = createAndMountDashboard([ + ['test-widget-plugin', testWidgetPlugin], + ['test-middleware-plugin', testMiddlewarePlugin], + [ + 'test-widget-plugin-two', + { + name: 'test-widget-plugin-two', + type: PluginType.WIDGET_PLUGIN, + component: TestWidgetTwo, + supportedTypes: 'test-widget', + }, + ], + ]); + + act( + () => + layoutManager?.eventHub.emit(PanelEvent.OPEN, { + widget: { type: 'test-widget' }, + }) + ); + + // The second base plugin should replace the first, but middleware should still apply + expect(screen.queryAllByText('MiddlewareWrapper').length).toBe(1); + expect(screen.queryAllByText('TestWidget').length).toBe(0); + expect(screen.queryAllByText('TestWidgetTwo').length).toBe(1); + + const wrapper = screen.getByTestId('middleware-wrapper'); + expect(wrapper).toContainElement(screen.getByText('TestWidgetTwo')); + }); +}); diff --git a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx index 7623d1fd09..54cf308860 100644 --- a/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx +++ b/packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx @@ -14,14 +14,110 @@ import { import Log from '@deephaven/log'; import { isWidgetPlugin, + isWidgetMiddlewarePlugin, usePlugins, type WidgetPlugin, + type WidgetMiddlewarePlugin, + type WidgetComponentProps, + type WidgetPanelProps, + type WidgetMiddlewarePanelProps, } from '@deephaven/plugin'; import { WidgetPanel } from './panels'; import { type WidgetPanelDescriptor } from './panels/WidgetPanelTypes'; const log = Log.module('WidgetLoaderPlugin'); +/** + * Information about a widget type including its base plugin and any middleware. + */ +interface WidgetTypeInfo { + /** The base plugin that handles this widget type */ + basePlugin: WidgetPlugin; + /** Middleware plugins to apply, in order from outermost to innermost */ + middleware: WidgetMiddlewarePlugin[]; +} + +/** + * Creates a component that chains middleware around a base component. + * Each middleware wraps the next, with the base component at the innermost layer. + */ +function createChainedComponent( + baseComponent: React.ComponentType>, + middleware: WidgetMiddlewarePlugin[] +): React.ComponentType> { + if (middleware.length === 0) { + return baseComponent; + } + + // Build the chain from inside out (base component is innermost) + // Middleware is ordered outermost to innermost, so we reverse to build from inside out + return [...middleware] + .reverse() + .reduce>>( + (WrappedComponent, middlewarePlugin) => { + const MiddlewareComponent = middlewarePlugin.component; + + function ChainedComponent(props: WidgetComponentProps) { + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); + } + ChainedComponent.displayName = `${middlewarePlugin.name}(${ + (WrappedComponent as React.ComponentType).displayName ?? + (WrappedComponent as React.ComponentType).name ?? + 'Component' + })`; + return ChainedComponent; + }, + baseComponent + ); +} + +/** + * Creates a panel component that chains middleware around a base panel component. + * Each middleware panel wraps the next, with the base panel at the innermost layer. + */ +function createChainedPanelComponent( + basePanelComponent: React.ComponentType>, + middleware: WidgetMiddlewarePlugin[] +): React.ComponentType> { + // Filter to middleware that has a panelComponent and extract just the panel components + type MiddlewareWithPanel = WidgetMiddlewarePlugin & { + panelComponent: React.ComponentType>; + }; + const panelMiddleware = middleware.filter( + (m): m is MiddlewareWithPanel => m.panelComponent != null + ); + + if (panelMiddleware.length === 0) { + return basePanelComponent; + } + + // Build the chain from inside out (base panel is innermost) + return [...panelMiddleware] + .reverse() + .reduce>>( + (WrappedPanel, middlewarePlugin) => { + const { panelComponent: MiddlewarePanelComponent } = middlewarePlugin; + + function ChainedPanel(props: WidgetPanelProps) { + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); + } + ChainedPanel.displayName = `${middlewarePlugin.name}Panel(${ + (WrappedPanel as React.ComponentType).displayName ?? + (WrappedPanel as React.ComponentType).name ?? + 'Panel' + })`; + return ChainedPanel; + }, + basePanelComponent + ); +} + export function WrapWidgetPlugin( plugin: WidgetPlugin ): React.ForwardRefExoticComponent> { @@ -76,6 +172,10 @@ export function WrapWidgetPlugin( * Does not open panels for widgets that are not supported by any plugins. * Does not open panels for widgets that are a component of a larger widget or UI element. * + * Supports plugin chaining via middleware plugins. When multiple plugins + * support the same widget type, middleware plugins are chained around + * the base plugin in registration order. + * * @param props Dashboard plugin props * @returns React element */ @@ -83,28 +183,83 @@ export function WidgetLoaderPlugin( props: Partial ): JSX.Element | null { const plugins = usePlugins(); + + /** + * Build a map of widget types to their plugin chain info. + * For each type, we have a base plugin and a list of middleware to apply. + */ const supportedTypes = useMemo(() => { - const typeMap = new Map(); + const typeMap = new Map(); + plugins.forEach(plugin => { if (!isWidgetPlugin(plugin)) { return; } + const isMiddleware = isWidgetMiddlewarePlugin(plugin); + [plugin.supportedTypes].flat().forEach(supportedType => { - if (supportedType != null && supportedType !== '') { - if (typeMap.has(supportedType)) { - log.warn( - `Multiple WidgetPlugins handling type ${supportedType}. Replacing ${typeMap.get( - supportedType - )?.name} with ${plugin.name} to handle ${supportedType}` + if (supportedType == null || supportedType === '') { + return; + } + + const existing = typeMap.get(supportedType); + + if (isMiddleware) { + // Add middleware to existing chain or create pending chain + if (existing != null) { + existing.middleware.push(plugin); + log.debug( + `Adding middleware ${plugin.name} to chain for type ${supportedType}` + ); + } else { + // No base plugin yet, create entry with just middleware + // The base plugin will be set when a non-middleware plugin is registered + typeMap.set(supportedType, { + // Use a placeholder that will be replaced + basePlugin: plugin as unknown as WidgetPlugin, + middleware: [plugin], + }); + log.debug( + `Creating pending middleware chain for type ${supportedType} with ${plugin.name}` ); } - typeMap.set(supportedType, plugin); + } else { + // Non-middleware plugin: becomes the base plugin + if (existing != null) { + if (!isWidgetMiddlewarePlugin(existing.basePlugin)) { + // Already have a base plugin, warn about replacement + log.warn( + `Multiple WidgetPlugins handling type ${supportedType}. ` + + `Replacing ${existing.basePlugin.name} with ${plugin.name} as base plugin` + ); + } + // Keep existing middleware, update the base plugin + existing.basePlugin = plugin; + } else { + typeMap.set(supportedType, { + basePlugin: plugin, + middleware: [], + }); + } + log.debug(`Set base plugin ${plugin.name} for type ${supportedType}`); } }); }); - return typeMap; + // Filter out entries that only have middleware (no base plugin) + const validEntries = new Map(); + typeMap.forEach((info, type) => { + if (!isWidgetMiddlewarePlugin(info.basePlugin)) { + validEntries.set(type, info); + } else { + log.warn( + `No base plugin found for type ${type}, middleware will not be applied` + ); + } + }); + + return validEntries; }, [plugins]); assertIsDashboardPluginProps(props); @@ -118,8 +273,8 @@ export function WidgetLoaderPlugin( widget, }: PanelOpenEventDetail) => { const { type } = widget; - const plugin = type != null ? supportedTypes.get(type) : null; - if (plugin == null) { + const typeInfo = type != null ? supportedTypes.get(type) : null; + if (typeInfo == null) { return; } const name = widget.name ?? type; @@ -134,7 +289,7 @@ export function WidgetLoaderPlugin( const config: ReactComponentConfig = { type: 'react-component', - component: plugin.name, + component: typeInfo.basePlugin.name, props: panelProps, title: name, id: panelId, @@ -147,14 +302,55 @@ export function WidgetLoaderPlugin( ); useEffect(() => { - const deregisterFns = [...new Set(supportedTypes.values())].map(plugin => { - const { panelComponent } = plugin; - if (panelComponent == null) { - return registerComponent(plugin.name, WrapWidgetPlugin(plugin)); + // Get unique base plugins (a plugin may handle multiple types) + const uniquePluginInfos = new Map(); + supportedTypes.forEach((info, type) => { + // Use the base plugin name as the key to get unique plugins + if (!uniquePluginInfos.has(info.basePlugin.name)) { + uniquePluginInfos.set(info.basePlugin.name, info); + } else { + // Merge middleware from multiple type registrations for the same base plugin + const existingInfo = uniquePluginInfos.get(info.basePlugin.name); + if (existingInfo != null) { + info.middleware.forEach(m => { + if (!existingInfo.middleware.includes(m)) { + existingInfo.middleware.push(m); + } + }); + } } - return registerComponent(plugin.name, panelComponent); }); + const deregisterFns = [...uniquePluginInfos.values()].map( + ({ basePlugin, middleware }) => { + const { panelComponent } = basePlugin; + + if (panelComponent == null) { + // No panel component - chain the widget components and wrap in default panel + const chainedComponent = createChainedComponent( + basePlugin.component, + middleware + ); + const wrappedPlugin: WidgetPlugin = { + ...basePlugin, + component: chainedComponent, + }; + return registerComponent( + basePlugin.name, + WrapWidgetPlugin(wrappedPlugin) + ); + } + + // Has panel component - chain both component and panel + const chainedPanelComponent = createChainedPanelComponent( + panelComponent, + middleware + ); + + return registerComponent(basePlugin.name, chainedPanelComponent); + } + ); + return () => { deregisterFns.forEach(deregister => deregister()); }; diff --git a/packages/dashboard-core-plugins/src/index.ts b/packages/dashboard-core-plugins/src/index.ts index 6031c90520..a95dd13280 100644 --- a/packages/dashboard-core-plugins/src/index.ts +++ b/packages/dashboard-core-plugins/src/index.ts @@ -9,6 +9,14 @@ export { default as FilterPluginConfig } from './FilterPluginConfig'; export { default as GridPanelPlugin } from './GridPanelPlugin'; export { default as GridWidgetPlugin } from './GridWidgetPlugin'; export { default as GridPluginConfig } from './GridPluginConfig'; +export { default as GridMiddlewarePluginConfig } from './GridMiddlewarePlugin'; +export { GridMiddleware } from './GridMiddlewarePlugin'; +export { default as TableHistoryPluginConfig } from './TableHistoryPlugin'; +export { + TableHistoryPanel, + TableHistoryOption, + TableHistoryMiddleware, +} from './TableHistoryPlugin'; export { default as LinkerPlugin } from './LinkerPlugin'; export { default as LinkerPluginConfig } from './LinkerPluginConfig'; export { default as MarkdownPlugin } from './MarkdownPlugin'; diff --git a/packages/dashboard-core-plugins/src/panels/WidgetPanelTooltip.tsx b/packages/dashboard-core-plugins/src/panels/WidgetPanelTooltip.tsx index 85b1dc6596..020a2f045f 100644 --- a/packages/dashboard-core-plugins/src/panels/WidgetPanelTooltip.tsx +++ b/packages/dashboard-core-plugins/src/panels/WidgetPanelTooltip.tsx @@ -9,7 +9,7 @@ function WidgetPanelTooltip(props: WidgetPanelTooltipProps): ReactElement { // Convert PascalCase to Title Case // ex. PartitionedTable -> Partitioned Table - const formattedType = type.replace(/([a-z])([A-Z])/g, '$1 $2'); + const formattedType = type?.replace(/([a-z])([A-Z])/g, '$1 $2') ?? ''; return (
diff --git a/packages/embed-widget/src/index.tsx b/packages/embed-widget/src/index.tsx index d49e48d22e..349a7c5221 100644 --- a/packages/embed-widget/src/index.tsx +++ b/packages/embed-widget/src/index.tsx @@ -44,12 +44,14 @@ async function getCorePlugins() { ); const { GridPluginConfig, + TableHistoryPluginConfig, PandasPluginConfig, ChartPluginConfig, WidgetLoaderPluginConfig, } = dashboardCorePlugins; return [ GridPluginConfig, + TableHistoryPluginConfig, PandasPluginConfig, ChartPluginConfig, WidgetLoaderPluginConfig, diff --git a/packages/iris-grid/src/CommonTypes.tsx b/packages/iris-grid/src/CommonTypes.tsx index a9c20f007b..ffe7818e73 100644 --- a/packages/iris-grid/src/CommonTypes.tsx +++ b/packages/iris-grid/src/CommonTypes.tsx @@ -1,3 +1,4 @@ +import type React from 'react'; import { type AdvancedFilterOptions, type SortDescriptor, @@ -43,14 +44,29 @@ export type Action = { }; export type OptionItem = { - type: OptionType; + type: OptionType | string; title: string; subtitle?: string; icon?: IconDefinition; isOn?: boolean; onChange?: () => void; + /** + * Optional render function for custom option screens. + * When provided, this will be called to render the configuration panel + * when the option is selected from the menu. + */ + render?: () => React.ReactNode; }; +/** + * Function type for modifying the Table Options menu items. + * Receives the current list of options and returns a modified list. + * Can be used to add, remove, reorder, or modify options. + */ +export type OptionItemsModifier = ( + options: readonly OptionItem[] +) => readonly OptionItem[]; + export interface UITotalsTableConfig extends dh.TotalsTableConfig { operationOrder: AggregationOperation[]; showOnTop: boolean; diff --git a/packages/iris-grid/src/IrisGrid.tsx b/packages/iris-grid/src/IrisGrid.tsx index 412c5d3941..4e56f582ef 100644 --- a/packages/iris-grid/src/IrisGrid.tsx +++ b/packages/iris-grid/src/IrisGrid.tsx @@ -13,9 +13,6 @@ import Log from '@deephaven/log'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { ContextActions, - Stack, - Menu, - Page, Popper, ThemeExport, Tooltip, @@ -46,23 +43,7 @@ import { isDeletableGridModel, isExpandableColumnGridModel, } from '@deephaven/grid'; -import { - dhEye, - dhFilterFilled, - dhGraphLineUp, - dhTriangleDownSquare, - vsClose, - vsCloudDownload, - vsEdit, - vsFilter, - vsMenu, - vsReply, - vsRuby, - vsSearch, - vsSplitHorizontal, - vsSymbolOperator, - vsTools, -} from '@deephaven/icons'; +import { dhFilterFilled, vsClose, vsFilter, vsMenu } from '@deephaven/icons'; import type { dh as DhType } from '@deephaven/jsapi-types'; import { DateUtils, @@ -138,19 +119,16 @@ import ColumnStatistics from './ColumnStatistics'; import './IrisGrid.scss'; import AdvancedFilterCreator from './AdvancedFilterCreator'; import { - Aggregations, - AggregationEdit, AggregationUtils, - ChartBuilder, - CustomColumnBuilder, OptionType, - RollupRows, TableCsvExporter, TableSaver, - VisibilityOrderingBuilder, DownloadServiceWorkerUtils, } from './sidebar'; -import IrisGridUtils from './IrisGridUtils'; +import IrisGridUtils, { + type DehydratedIrisGridState, + type DehydratedGridState, +} from './IrisGridUtils'; import CrossColumnSearch from './CrossColumnSearch'; import IrisGridModel from './IrisGridModel'; import { @@ -159,15 +137,9 @@ import { type PartitionedGridModel, } from './PartitionedGridModel'; import IrisGridPartitionSelector from './IrisGridPartitionSelector'; -import SelectDistinctBuilder from './sidebar/SelectDistinctBuilder'; import AdvancedSettingsType from './sidebar/AdvancedSettingsType'; -import AdvancedSettingsMenu, { - type AdvancedSettingsMenuCallback, -} from './sidebar/AdvancedSettingsMenu'; +import { type AdvancedSettingsMenuCallback } from './sidebar/AdvancedSettingsMenu'; import SHORTCUTS from './IrisGridShortcuts'; -import ConditionalFormattingMenu from './sidebar/conditional-formatting/ConditionalFormattingMenu'; - -import ConditionalFormatEditor from './sidebar/conditional-formatting/ConditionalFormatEditor'; import IrisGridCellOverflowModal from './IrisGridCellOverflowModal'; import GotoRow, { type GotoRowElement } from './GotoRow'; import { @@ -196,12 +168,30 @@ import { } from './CommonTypes'; import type ColumnHeaderGroup from './ColumnHeaderGroup'; import { IrisGridThemeContext } from './IrisGridThemeProvider'; +import { TableOptionsWrapper } from './table-options/TableOptionsWrapper'; +import type { + GridStateSnapshot, + GridDispatch, + GridAction, +} from './table-options/TableOption'; import { isMissingPartitionError } from './MissingPartitionError'; import { NoPastePermissionModal } from './NoPastePermissionModal'; import { isColumnHeaderGroup } from './ColumnHeaderGroup'; const log = Log.module('IrisGrid'); +/** + * Pending state restoration data. + * Stored when customColumns or selectDistinctColumns change, + * and applied after the columnschanged event fires. + */ +interface PendingRestoreState { + /** The dehydrated IrisGrid state to restore */ + irisGridState: Partial; + /** The dehydrated Grid state to restore */ + gridState?: Partial; +} + const VIEWPORT_LOADING_DELAY = 500; const UPDATE_DOWNLOAD_THROTTLE = 500; @@ -658,6 +648,11 @@ class IrisGrid extends Component { this.setFilterMap = this.setFilterMap.bind(this); this.handleFrozenColumnsChanged = this.handleFrozenColumnsChanged.bind(this); + this.handleSetQuickFilters = this.handleSetQuickFilters.bind(this); + this.handleSetAdvancedFilters = this.handleSetAdvancedFilters.bind(this); + this.handleSetSorts = this.handleSetSorts.bind(this); + this.handleSetReverse = this.handleSetReverse.bind(this); + this.clearAllFilters = this.clearAllFilters.bind(this); this.grid = null; this.lastLoadedConfig = null; @@ -1061,6 +1056,14 @@ class IrisGrid extends Component { decimalFormatOptions: { defaultFormatString?: string }; + /** + * Pending state restoration. + * When restoring a snapshot with different customColumns or selectDistinctColumns, + * we first apply the column changes and store the rest here. + * After columnschanged fires, we hydrate and apply the remaining state. + */ + pendingRestoreState: PendingRestoreState | null = null; + integerFormatOptions: { defaultFormatString?: string }; truncateNumbersWithPound: boolean; @@ -1146,122 +1149,160 @@ class IrisGrid extends Component { { max: 50 } ); - getCachedOptionItems = memoize( + /** + * Creates the GridStateSnapshot for TableOptionsHostContext. + */ + getGridStateSnapshot = memoize( ( - isChartBuilderAvailable: boolean, - isCustomColumnsAvailable: boolean, - isFormatColumnsAvailable: boolean, - isOrganizeColumnsAvailable: boolean, - isRollupAvailable: boolean, - isTotalsAvailable: boolean, - isSelectDistinctAvailable: boolean, - isExportAvailable: boolean, - toggleFilterBarAction: Action, - toggleSearchBarAction: Action, - toggleGotoRowAction: Action, - isFilterBarShown: boolean, - showSearchBar: boolean, - canDownloadCsv: boolean, - canToggleSearch: boolean, - showGotoRow: boolean, - hasAdvancedSettings: boolean - ): readonly OptionItem[] => { - const optionItems: OptionItem[] = []; - if (isChartBuilderAvailable) { - optionItems.push({ - type: OptionType.CHART_BUILDER, - title: 'Chart Builder', - icon: dhGraphLineUp, - }); - } - if (isOrganizeColumnsAvailable) { - optionItems.push({ - type: OptionType.VISIBILITY_ORDERING_BUILDER, - title: 'Organize Columns', - icon: dhEye, - }); - } - if (isFormatColumnsAvailable) { - optionItems.push({ - type: OptionType.CONDITIONAL_FORMATTING, - title: 'Conditional Formatting', - icon: vsEdit, - }); - } - if (isCustomColumnsAvailable) { - optionItems.push({ - type: OptionType.CUSTOM_COLUMN_BUILDER, - title: 'Custom Columns', - icon: vsSplitHorizontal, - }); - } - if (isRollupAvailable) { - optionItems.push({ - type: OptionType.ROLLUP_ROWS, - title: 'Rollup Rows', - icon: dhTriangleDownSquare, - }); - } - if (isTotalsAvailable) { - optionItems.push({ - type: OptionType.AGGREGATIONS, - title: 'Aggregate Columns', - icon: vsSymbolOperator, - }); - } - if (isSelectDistinctAvailable) { - optionItems.push({ - type: OptionType.SELECT_DISTINCT, - title: 'Select Distinct Values', - icon: vsRuby, - }); - } - if (isExportAvailable && canDownloadCsv) { - optionItems.push({ - type: OptionType.TABLE_EXPORTER, - title: 'Download CSV', - icon: vsCloudDownload, - }); - } - if (hasAdvancedSettings) { - optionItems.push({ - type: OptionType.ADVANCED_SETTINGS, - title: 'Advanced Settings', - icon: vsTools, - }); - } - optionItems.push({ - type: OptionType.QUICK_FILTERS, - title: 'Quick Filters', - subtitle: toggleFilterBarAction.shortcut.getDisplayText(), - icon: vsFilter, - isOn: isFilterBarShown, - onChange: toggleFilterBarAction.action, - }); - if (canToggleSearch) { - optionItems.push({ - type: OptionType.SEARCH_BAR, - title: 'Search Bar', - subtitle: toggleSearchBarAction.shortcut.getDisplayText(), - icon: vsSearch, - isOn: showSearchBar, - onChange: toggleSearchBarAction.action, - }); - } - optionItems.push({ - type: OptionType.GOTO, - title: 'Go to', - subtitle: toggleGotoRowAction.shortcut.getDisplayText(), - icon: vsReply, - isOn: showGotoRow, - onChange: toggleGotoRowAction.action, - }); - - return Object.freeze(optionItems); - }, + model: IrisGridModel, + customColumns: readonly ColumnName[], + selectDistinctColumns: readonly ColumnName[], + aggregationSettings: AggregationSettings, + rollupConfig: UIRollupConfig | undefined, + conditionalFormats: readonly SidebarFormattingRule[], + movedColumns: readonly MoveOperation[], + frozenColumns: readonly ColumnName[], + columnHeaderGroups: readonly ColumnHeaderGroup[], + hiddenColumns: readonly ModelIndex[], + isRollup: boolean, + name: string | undefined, + userColumnWidths: ModelSizeMap | undefined, + selectedRanges: readonly GridRange[] | undefined, + isTableDownloading: boolean, + tableDownloadStatus: string, + tableDownloadProgress: number, + tableDownloadEstimatedTime: number | null, + quickFilters: ReadonlyQuickFilterMap, + advancedFilters: ReadonlyAdvancedFilterMap, + searchFilter: DhType.FilterCondition | undefined, + searchValue: string, + selectedSearchColumns: readonly ColumnName[], + invertSearchColumns: boolean, + sorts: readonly SortDescriptor[], + reverse: boolean + ): GridStateSnapshot => ({ + model, + customColumns, + selectDistinctColumns, + aggregationSettings, + rollupConfig, + conditionalFormats, + movedColumns, + frozenColumns, + columnHeaderGroups, + hiddenColumns, + isRollup, + name, + userColumnWidths, + selectedRanges, + isTableDownloading, + tableDownloadStatus, + tableDownloadProgress, + tableDownloadEstimatedTime, + quickFilters, + advancedFilters, + searchFilter, + searchValue, + selectedSearchColumns, + invertSearchColumns, + sorts, + reverse, + }), { max: 1 } ); + /** + * Creates the dispatch function for Table Options. + * Translates GridAction dispatches to the appropriate handler calls. + */ + handleTableOptionsDispatch: GridDispatch = (action: GridAction): void => { + switch (action.type) { + case 'SET_CUSTOM_COLUMNS': + this.handleUpdateCustomColumns(action.columns); + break; + case 'SET_SELECT_DISTINCT_COLUMNS': + this.handleSelectDistinctChanged(action.columns); + break; + case 'SET_AGGREGATION_SETTINGS': + this.handleAggregationsChange(action.settings); + break; + case 'SET_ROLLUP_CONFIG': + if (action.config != null) { + this.handleRollupChange(action.config); + } else { + this.setState({ rollupConfig: undefined }); + } + break; + case 'SET_CONDITIONAL_FORMATS': + this.handleConditionalFormatsChange(action.formats); + break; + case 'SET_MOVED_COLUMNS': + this.handleMovedColumnsChanged(action.columns, action.onChangeApplied); + break; + case 'SET_FROZEN_COLUMNS': + this.handleFrozenColumnsChanged(action.columns); + break; + case 'SET_COLUMN_HEADER_GROUPS': + this.handleHeaderGroupsChanged(action.groups); + break; + case 'SET_COLUMN_VISIBILITY': + this.handleColumnVisibilityChanged(action.columns, action.isVisible); + break; + case 'RESET_COLUMN_VISIBILITY': + this.handleColumnVisibilityReset(); + break; + case 'START_DOWNLOAD': + this.handleDownloadTableStart(); + break; + case 'DOWNLOAD_TABLE': + this.handleDownloadTable( + action.fileName, + action.frozenTable as DhType.Table, + action.tableSubscription as DhType.TableViewportSubscription, + action.snapshotRanges as readonly GridRange[], + action.modelRanges as readonly GridRange[], + action.includeColumnHeaders, + action.useUnformattedValues + ); + break; + case 'CANCEL_DOWNLOAD': + this.handleCancelDownloadTable(); + break; + case 'SET_QUICK_FILTERS': + this.handleSetQuickFilters(action.filters); + break; + case 'SET_ADVANCED_FILTERS': + this.handleSetAdvancedFilters(action.filters); + break; + case 'SET_SORTS': + this.handleSetSorts(action.sorts); + break; + case 'SET_REVERSE': + this.handleSetReverse(action.reverse); + break; + case 'CLEAR_ALL_FILTERS': + this.clearAllFilters(); + break; + case 'SET_CROSS_COLUMN_SEARCH': + this.handleCrossColumnSearch( + action.searchValue, + action.selectedSearchColumns, + action.invertSearchColumns + ); + break; + case 'RESTORE_DEHYDRATED_STATE': + this.handleRestoreDehydratedState( + action.irisGridState, + action.gridState + ); + break; + default: + log.warn( + `Unknown TableOptions action type: ${(action as GridAction).type}` + ); + } + }; + getCachedHiddenColumns = memoize( ( metricCalculator: IrisGridMetricCalculator, @@ -1908,6 +1949,224 @@ class IrisGrid extends Component { }); } + /** + * Handler for setting quick filters from table options. + */ + handleSetQuickFilters(filters: ReadonlyQuickFilterMap): void { + log.debug('Setting quick filters', filters); + this.startLoading('Applying Filters...'); + this.setState({ quickFilters: filters }); + } + + /** + * Handler for setting advanced filters from table options. + */ + handleSetAdvancedFilters(filters: ReadonlyAdvancedFilterMap): void { + log.debug('Setting advanced filters', filters); + this.startLoading('Applying Filters...'); + this.setState({ advancedFilters: filters }); + } + + /** + * Handler for setting sorts from table options. + */ + handleSetSorts(newSorts: readonly SortDescriptor[]): void { + log.debug('Setting sorts', newSorts); + this.startLoading('Sorting...'); + this.setState({ sorts: newSorts }); + } + + /** + * Handler for setting reverse sort from table options. + */ + handleSetReverse(newReverse: boolean): void { + log.debug('Setting reverse', newReverse); + this.startLoading('Sorting...'); + this.setState({ reverse: newReverse }); + } + + /** + * Handler for restoring a dehydrated state snapshot. + * Uses IrisGridUtils hydration methods for proper column resolution, + * and handles the proper sequencing of state changes. + * + * Similar to IrisGridPanel's modelQueue pattern: + * - If customColumns, selectDistinctColumns, or rollupConfig are changing, + * we first apply those column-changing operations + * - Then wait for the columnschanged event before hydrating filters/sorts + * - This ensures hydration uses the correct column set + */ + handleRestoreDehydratedState( + irisGridState: Partial, + gridState?: Partial + ): void { + log.debug('Restoring dehydrated state', irisGridState, gridState); + this.startLoading('Restoring state...'); + + const { + customColumns: currentCustomColumns, + selectDistinctColumns: currentSelectDistinctColumns, + rollupConfig: currentRollupConfig, + } = this.state; + + // Check if any column-changing properties are different from current state + // These operations alter model.columns and require waiting for columnschanged + const newCustomColumns = + irisGridState.customColumns ?? currentCustomColumns; + const customColumnsChanging = + JSON.stringify(newCustomColumns) !== JSON.stringify(currentCustomColumns); + + const newSelectDistinctColumns = + irisGridState.selectDistinctColumns ?? currentSelectDistinctColumns; + const selectDistinctChanging = + JSON.stringify(newSelectDistinctColumns) !== + JSON.stringify(currentSelectDistinctColumns); + + const newRollupConfig = irisGridState.rollupConfig ?? currentRollupConfig; + const rollupChanging = + JSON.stringify(newRollupConfig) !== JSON.stringify(currentRollupConfig); + + const columnsChanging = + customColumnsChanging || selectDistinctChanging || rollupChanging; + + if (columnsChanging) { + // Columns will change - store pending state and apply after columnschanged + log.debug( + 'Column-changing operation detected, queueing state restoration', + { customColumnsChanging, selectDistinctChanging, rollupChanging } + ); + this.pendingRestoreState = { irisGridState, gridState }; + + // Apply only the column-changing state now + // The rest will be applied in handleCustomColumnsChanged -> completePendingRestore + const columnState: Partial = {}; + if (irisGridState.customColumns != null) { + columnState.customColumns = [...irisGridState.customColumns]; + } + if (irisGridState.selectDistinctColumns != null) { + columnState.selectDistinctColumns = [ + ...irisGridState.selectDistinctColumns, + ]; + } + if (irisGridState.rollupConfig !== undefined) { + columnState.rollupConfig = irisGridState.rollupConfig; + } + + this.setState(columnState as IrisGridState); + // Loading will continue until handleCustomColumnsChanged calls stopLoading + } else { + // Columns not changing - apply everything immediately + this.applyHydratedState(irisGridState, gridState); + } + } + + /** + * Hydrates and applies dehydrated state using current model columns. + * Called either immediately (if columns aren't changing) or after columnschanged. + */ + applyHydratedState( + irisGridState: Partial, + gridState?: Partial + ): void { + const { model } = this.props; + const { columns, formatter } = model; + const { customColumns } = this.state; + const irisGridUtils = new IrisGridUtils(model.dh); + + // Build the new state from dehydrated values using existing hydration utilities + const newState: Partial = {}; + + // Hydrate quick filters using current model columns + if (irisGridState.quickFilters != null) { + newState.quickFilters = irisGridUtils.hydrateQuickFilters( + columns, + irisGridState.quickFilters, + formatter.timeZone + ); + } + + // Hydrate advanced filters using current model columns + if (irisGridState.advancedFilters != null) { + newState.advancedFilters = irisGridUtils.hydrateAdvancedFilters( + columns, + irisGridState.advancedFilters, + formatter.timeZone + ); + } + + // Hydrate sorts using current model columns + if (irisGridState.sorts != null) { + newState.sorts = irisGridUtils.hydrateSort(columns, irisGridState.sorts); + } + + // Simple values that don't need hydration + if (irisGridState.reverse != null) { + newState.reverse = irisGridState.reverse; + } + + if (irisGridState.searchValue != null) { + newState.searchValue = irisGridState.searchValue; + } + + if (irisGridState.selectedSearchColumns != null) { + newState.selectedSearchColumns = [...irisGridState.selectedSearchColumns]; + } + + if (irisGridState.invertSearchColumns != null) { + newState.invertSearchColumns = irisGridState.invertSearchColumns; + } + + // selectDistinctColumns and customColumns should already be applied + // if they were changing (via handleRestoreDehydratedState) + // But if not changing, apply them here + if (irisGridState.selectDistinctColumns != null) { + newState.selectDistinctColumns = [...irisGridState.selectDistinctColumns]; + } + + if (irisGridState.customColumns != null) { + newState.customColumns = [...irisGridState.customColumns]; + } + + // Hydrate grid state (movedColumns, etc.) using existing utility + // Use current model columns for hydration (they now include custom columns) + if (gridState?.movedColumns != null) { + const hydratedGridState = IrisGridUtils.hydrateGridState( + model, + { + isStuckToBottom: gridState.isStuckToBottom ?? false, + isStuckToRight: gridState.isStuckToRight ?? false, + movedColumns: gridState.movedColumns, + movedRows: gridState.movedRows ?? [], + }, + irisGridState.customColumns ?? customColumns + ); + newState.movedColumns = hydratedGridState.movedColumns; + if (gridState.movedRows != null) { + newState.movedRows = hydratedGridState.movedRows; + } + } + + this.setState(newState as IrisGridState); + this.stopLoading(); + } + + /** + * Completes any pending state restoration after columns have changed. + * Called from handleCustomColumnsChanged when pendingRestoreState exists. + */ + completePendingRestore(): void { + if (this.pendingRestoreState == null) { + return; + } + + log.debug('Completing pending state restoration'); + const { irisGridState, gridState } = this.pendingRestoreState; + this.pendingRestoreState = null; + + // Now hydrate and apply using the new columns + this.applyHydratedState(irisGridState, gridState); + } + clearAllAggregations(): void { log.debug('Clearing all aggregations'); @@ -2187,6 +2446,66 @@ class IrisGrid extends Component { this.initFormatter(); } + /** + * Get the current grid state as a JSON-serializable object. + * This can be used to save the state and restore it later with {@link restoreState}. + * @returns The dehydrated grid state + */ + getState(): DehydratedIrisGridState { + const { model } = this.props; + const irisGridUtils = new IrisGridUtils(model.dh); + return irisGridUtils.dehydrateIrisGridState(model, this.state); + } + + /** + * Restore the grid state from a previously saved state. + * This applies the state similarly to how loadTableState works for consistency. + * @param dehydratedState The state to restore, obtained from {@link getState} + */ + restoreState(dehydratedState: DehydratedIrisGridState): void { + const { model } = this.props; + const irisGridUtils = new IrisGridUtils(model.dh); + const hydratedState = irisGridUtils.hydrateIrisGridState( + model, + dehydratedState + ); + + // Create search filter from search settings + const searchFilter = CrossColumnSearch.createSearchFilter( + model.dh, + hydratedState.searchValue, + hydratedState.selectedSearchColumns ?? [], + model.columns, + hydratedState.invertSearchColumns + ); + + // Apply the hydrated state + this.startLoading('Restoring state...', { resetRanges: true }); + this.setState({ + advancedFilters: hydratedState.advancedFilters, + aggregationSettings: hydratedState.aggregationSettings, + customColumnFormatMap: hydratedState.customColumnFormatMap, + columnAlignmentMap: hydratedState.columnAlignmentMap, + isFilterBarShown: hydratedState.isFilterBarShown, + quickFilters: hydratedState.quickFilters, + sorts: hydratedState.sorts, + customColumns: hydratedState.customColumns, + conditionalFormats: hydratedState.conditionalFormats, + reverse: hydratedState.reverse, + rollupConfig: hydratedState.rollupConfig, + showSearchBar: hydratedState.showSearchBar, + searchValue: hydratedState.searchValue, + searchFilter, + selectDistinctColumns: hydratedState.selectDistinctColumns, + selectedSearchColumns: hydratedState.selectedSearchColumns, + invertSearchColumns: hydratedState.invertSearchColumns, + pendingDataMap: hydratedState.pendingDataMap, + frozenColumns: hydratedState.frozenColumns, + columnHeaderGroups: hydratedState.columnHeaderGroups, + partitionConfig: hydratedState.partitionConfig, + }); + } + async loadPartitionsTable(model: PartitionedGridModel): Promise { try { const { partitionConfig } = this.state; @@ -3632,6 +3951,11 @@ class IrisGrid extends Component { if (isReady) { this.updateMetrics(); + // Complete any pending state restoration now that columns have changed + if (this.pendingRestoreState != null) { + this.completePendingRestore(); + } + // Make sure stopLoading() is called after the updateMetrics call, // otherwise IrisGridModelUpdater queues an extra setViewport based on old metrics. this.stopLoading(); @@ -4965,164 +5289,11 @@ class IrisGrid extends Component { } } - const optionItems = this.getCachedOptionItems( - onCreateChart !== undefined && model.isChartBuilderAvailable, - model.isCustomColumnsAvailable, - model.isFormatColumnsAvailable, - model.isOrganizeColumnsAvailable, - model.isRollupAvailable, - model.isTotalsAvailable || isRollup, - model.isSelectDistinctAvailable, - model.isExportAvailable, - this.toggleFilterBarAction, - this.toggleSearchBarAction, - this.toggleGotoRowAction, - isFilterBarShown, - showSearchBar, - canDownloadCsv, - this.isTableSearchAvailable(), - isGotoShown, - advancedSettings.size > 0 - ); - const hiddenColumns = this.getCachedHiddenColumns( metricCalculator, userColumnWidths ); - const openOptionsStack = openOptions.map(option => { - switch (option.type) { - case OptionType.CHART_BUILDER: - return ( - - ); - case OptionType.VISIBILITY_ORDERING_BUILDER: - return ( - - ); - case OptionType.CONDITIONAL_FORMATTING: - return ( - - ); - case OptionType.CONDITIONAL_FORMATTING_EDIT: - assertNotNull(model.columns); - assertNotNull(this.handleConditionalFormatEditorUpdate); - return ( - - ); - case OptionType.CUSTOM_COLUMN_BUILDER: - return ( - - ); - case OptionType.ROLLUP_ROWS: - return ( - - ); - case OptionType.AGGREGATIONS: - return ( - - ); - case OptionType.AGGREGATION_EDIT: - return ( - selectedAggregation && ( - - ) - ); - case OptionType.TABLE_EXPORTER: - return ( - - ); - case OptionType.SELECT_DISTINCT: - return ( - - ); - case OptionType.ADVANCED_SETTINGS: - return ( - - ); - - default: - throw Error(`Unexpected option type ${option.type}`); - } - }); - return (
@@ -5361,24 +5532,76 @@ class IrisGrid extends Component { unmountOnExit >
- - - this.handleMenuSelect(optionItems[i])} - items={optionItems} - /> - - {openOptionsStack.map((option, i) => ( - - {option} - - ))} - + this.toggleFilterBar()} + onToggleSearchBar={() => this.toggleSearchBar()} + onToggleGoto={() => this.toggleGotoRow()} + isFilterBarShown={isFilterBarShown} + showSearchBar={showSearchBar} + isGotoShown={isGotoShown} + canToggleSearch={this.isTableSearchAvailable()} + canDownloadCsv={canDownloadCsv} + hasAdvancedSettings={advancedSettings.size > 0} + advancedSettings={advancedSettings} + onAdvancedSettingsChange={onAdvancedSettingsChange} + isChartBuilderAvailable={ + onCreateChart !== undefined && model.isChartBuilderAvailable + } + quickFilters={quickFilters} + advancedFilters={advancedFilters} + searchFilter={searchFilter} + searchValue={searchValue} + selectedSearchColumns={selectedSearchColumns} + invertSearchColumns={invertSearchColumns} + sorts={sorts} + reverse={reverse} + onCreateChart={ + onCreateChart + ? settings => onCreateChart(settings, model) + : undefined + } + onChartChange={() => { + // TODO: IDS-4242 Update Chart Preview + }} + onSetQuickFilters={this.handleSetQuickFilters} + onSetAdvancedFilters={this.handleSetAdvancedFilters} + onSetSorts={this.handleSetSorts} + onSetReverse={this.handleSetReverse} + onClearAllFilters={this.clearAllFilters} + onSetCrossColumnSearch={this.handleCrossColumnSearch} + onClose={this.handleMenuClose} + />
diff --git a/packages/iris-grid/src/IrisGridUtils.ts b/packages/iris-grid/src/IrisGridUtils.ts index ed325ec339..6f9066e2b9 100644 --- a/packages/iris-grid/src/IrisGridUtils.ts +++ b/packages/iris-grid/src/IrisGridUtils.ts @@ -1431,38 +1431,57 @@ class IrisGridUtils { savedAdvancedFilters: readonly DehydratedAdvancedFilter[], timeZone: string ): ReadonlyAdvancedFilterMap { - const importedFilters = savedAdvancedFilters.map( - ([columnIndex, advancedFilter]: DehydratedAdvancedFilter): [ - number, - { - options: AdvancedFilterOptions; - filter: DhType.FilterCondition | null; - }, - ] => { - const column = IrisGridUtils.getColumn(columns, columnIndex); - assertNotNull(column); - const options = this.hydrateAdvancedFilterOptions( - column, - advancedFilter.options - ); - let filter = null; + const importedFilters = savedAdvancedFilters + .map( + ([columnIndex, advancedFilter]: DehydratedAdvancedFilter): + | [ + number, + { + options: AdvancedFilterOptions; + filter: DhType.FilterCondition | null; + }, + ] + | null => { + const column = IrisGridUtils.getColumn(columns, columnIndex); + // Skip filters for columns that no longer exist + // (e.g., columns hidden via Organize Columns) + if (column == null) { + log.debug( + 'hydrateAdvancedFilters skipping filter for missing column', + columnIndex + ); + return null; + } + const options = this.hydrateAdvancedFilterOptions( + column, + advancedFilter.options + ); + let filter = null; - try { - const columnRetrieved = IrisGridUtils.getColumn(columns, columnIndex); - if (columnRetrieved != null) { + try { filter = this.tableUtils.makeAdvancedFilter( column, options, timeZone ); + } catch (error) { + log.error('hydrateAdvancedFilters error with', options, error); } - } catch (error) { - log.error('hydrateAdvancedFilters error with', options, error); - } - return [columnIndex, { options, filter }]; - } - ); + return [columnIndex, { options, filter }]; + } + ) + .filter( + ( + entry + ): entry is [ + number, + { + options: AdvancedFilterOptions; + filter: DhType.FilterCondition | null; + }, + ] => entry != null + ); return new Map(importedFilters); } diff --git a/packages/iris-grid/src/TableOptionsContext.tsx b/packages/iris-grid/src/TableOptionsContext.tsx new file mode 100644 index 0000000000..96cb1784cd --- /dev/null +++ b/packages/iris-grid/src/TableOptionsContext.tsx @@ -0,0 +1,326 @@ +import { useCallback, useMemo } from 'react'; +import type { MoveOperation } from '@deephaven/grid'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import type { SortDescriptor } from '@deephaven/jsapi-utils'; +import type { + ColumnName, + ReadonlyQuickFilterMap, + ReadonlyAdvancedFilterMap, +} from './CommonTypes'; +import type ColumnHeaderGroup from './ColumnHeaderGroup'; +import type IrisGridModel from './IrisGridModel'; +import type { + AggregationSettings, + UIRollupConfig, + SidebarFormattingRule, +} from './sidebar'; +import { useTableOptionsHost } from './table-options/TableOptionsHostContext'; + +/** + * Context value for Table Options panel components. + * Provides access to IrisGrid state and update methods that custom + * Table Options can use to read and modify grid configuration. + * + * This is a convenience interface that wraps the dispatch-based + * TableOptionsHostContext with ergonomic setter methods. + */ +export interface TableOptionsContextValue { + /** The IrisGrid model for accessing column info, etc. */ + model: IrisGridModel; + + // ===== State Values ===== + + /** Custom columns created by user */ + customColumns: readonly ColumnName[]; + + /** Columns used for Select Distinct operation */ + selectDistinctColumns: readonly ColumnName[]; + + /** Aggregation configuration */ + aggregationSettings: AggregationSettings; + + /** Rollup rows configuration */ + rollupConfig?: UIRollupConfig; + + /** Conditional formatting rules */ + conditionalFormats: readonly SidebarFormattingRule[]; + + /** Column move/reorder operations */ + movedColumns: readonly MoveOperation[]; + + /** Frozen/pinned columns */ + frozenColumns: readonly ColumnName[]; + + /** Column header grouping configuration */ + columnHeaderGroups: readonly ColumnHeaderGroup[]; + + // ===== Filters and Sorts ===== + + /** Quick filters applied to columns */ + quickFilters: ReadonlyQuickFilterMap; + + /** Advanced filters applied to columns */ + advancedFilters: ReadonlyAdvancedFilterMap; + + /** Search filter from the search bar */ + searchFilter?: DhType.FilterCondition; + + /** Current search bar text value */ + searchValue: string; + + /** Columns selected for cross-column search */ + selectedSearchColumns: readonly ColumnName[]; + + /** Whether search column selection is inverted */ + invertSearchColumns: boolean; + + /** Current sort configuration */ + sorts: readonly SortDescriptor[]; + + /** Whether sort order is reversed */ + reverse: boolean; + + // ===== Update Methods ===== + + /** Update custom columns */ + setCustomColumns: (columns: readonly ColumnName[]) => void; + + /** Update select distinct columns */ + setSelectDistinctColumns: (columns: readonly ColumnName[]) => void; + + /** Update aggregation settings */ + setAggregationSettings: (settings: AggregationSettings) => void; + + /** Update rollup configuration */ + setRollupConfig: (config: UIRollupConfig | undefined) => void; + + /** Update conditional formatting rules */ + setConditionalFormats: (formats: readonly SidebarFormattingRule[]) => void; + + /** Update moved columns (reorder) */ + setMovedColumns: ( + columns: readonly MoveOperation[], + onChangeApplied?: () => void + ) => void; + + /** Update frozen columns */ + setFrozenColumns: (columns: readonly ColumnName[]) => void; + + /** Update column header groups */ + setColumnHeaderGroups: (groups: readonly ColumnHeaderGroup[]) => void; + + /** Update quick filters */ + setQuickFilters: (filters: ReadonlyQuickFilterMap) => void; + + /** Update advanced filters */ + setAdvancedFilters: (filters: ReadonlyAdvancedFilterMap) => void; + + /** Update sorts */ + setSorts: (sorts: readonly SortDescriptor[]) => void; + + /** Update reverse sort */ + setReverse: (reverse: boolean) => void; + + /** Clear all filters (quick, advanced, and search) */ + clearAllFilters: () => void; + + /** Update cross-column search */ + setCrossColumnSearch: ( + searchValue: string, + selectedSearchColumns: readonly ColumnName[], + invertSearchColumns: boolean + ) => void; + + // ===== Navigation Methods ===== + + /** Close the current option panel (go back) */ + closeCurrentOption: () => void; +} + +/** + * Hook to access Table Options state and update methods. + * This hook wraps the dispatch-based TableOptionsHostContext + * and provides ergonomic setter methods for common operations. + * + * Must be used within a TableOptionsHostContext.Provider + * (which is provided by TableOptionsHost or TableOptionsWrapper). + * + * @returns The Table Options context value with setter methods + * @throws Error if used outside of TableOptionsHostContext.Provider + * + * @example + * function MyCustomOptionPanel() { + * const { selectDistinctColumns, setSelectDistinctColumns, closeCurrentOption } = useTableOptions(); + * + * const handleApply = (columns: string[]) => { + * setSelectDistinctColumns(columns); + * closeCurrentOption(); + * }; + * + * return ; + * } + */ +export function useTableOptions(): TableOptionsContextValue { + const { gridState, dispatch, closePanel } = useTableOptionsHost(); + + // Create stable setter callbacks that dispatch actions + const setCustomColumns = useCallback( + (columns: readonly ColumnName[]) => { + dispatch({ type: 'SET_CUSTOM_COLUMNS', columns }); + }, + [dispatch] + ); + + const setSelectDistinctColumns = useCallback( + (columns: readonly ColumnName[]) => { + dispatch({ type: 'SET_SELECT_DISTINCT_COLUMNS', columns }); + }, + [dispatch] + ); + + const setAggregationSettings = useCallback( + (settings: AggregationSettings) => { + dispatch({ type: 'SET_AGGREGATION_SETTINGS', settings }); + }, + [dispatch] + ); + + const setRollupConfig = useCallback( + (config: UIRollupConfig | undefined) => { + dispatch({ type: 'SET_ROLLUP_CONFIG', config }); + }, + [dispatch] + ); + + const setConditionalFormats = useCallback( + (formats: readonly SidebarFormattingRule[]) => { + dispatch({ type: 'SET_CONDITIONAL_FORMATS', formats }); + }, + [dispatch] + ); + + const setMovedColumns = useCallback( + (columns: readonly MoveOperation[], onChangeApplied?: () => void) => { + dispatch({ type: 'SET_MOVED_COLUMNS', columns, onChangeApplied }); + }, + [dispatch] + ); + + const setFrozenColumns = useCallback( + (columns: readonly ColumnName[]) => { + dispatch({ type: 'SET_FROZEN_COLUMNS', columns }); + }, + [dispatch] + ); + + const setColumnHeaderGroups = useCallback( + (groups: readonly ColumnHeaderGroup[]) => { + dispatch({ type: 'SET_COLUMN_HEADER_GROUPS', groups }); + }, + [dispatch] + ); + + const setQuickFilters = useCallback( + (filters: ReadonlyQuickFilterMap) => { + dispatch({ type: 'SET_QUICK_FILTERS', filters }); + }, + [dispatch] + ); + + const setAdvancedFilters = useCallback( + (filters: ReadonlyAdvancedFilterMap) => { + dispatch({ type: 'SET_ADVANCED_FILTERS', filters }); + }, + [dispatch] + ); + + const setSorts = useCallback( + (sorts: readonly SortDescriptor[]) => { + dispatch({ type: 'SET_SORTS', sorts }); + }, + [dispatch] + ); + + const setReverse = useCallback( + (reverse: boolean) => { + dispatch({ type: 'SET_REVERSE', reverse }); + }, + [dispatch] + ); + + const clearAllFilters = useCallback(() => { + dispatch({ type: 'CLEAR_ALL_FILTERS' }); + }, [dispatch]); + + const setCrossColumnSearch = useCallback( + ( + searchValue: string, + selectedSearchColumns: readonly ColumnName[], + invertSearchColumns: boolean + ) => { + dispatch({ + type: 'SET_CROSS_COLUMN_SEARCH', + searchValue, + selectedSearchColumns, + invertSearchColumns, + }); + }, + [dispatch] + ); + + // Build the context value with memoization + return useMemo( + () => ({ + model: gridState.model, + customColumns: gridState.customColumns, + selectDistinctColumns: gridState.selectDistinctColumns, + aggregationSettings: gridState.aggregationSettings, + rollupConfig: gridState.rollupConfig, + conditionalFormats: gridState.conditionalFormats, + movedColumns: gridState.movedColumns, + frozenColumns: gridState.frozenColumns, + columnHeaderGroups: gridState.columnHeaderGroups, + quickFilters: gridState.quickFilters, + advancedFilters: gridState.advancedFilters, + searchFilter: gridState.searchFilter, + searchValue: gridState.searchValue, + selectedSearchColumns: gridState.selectedSearchColumns, + invertSearchColumns: gridState.invertSearchColumns, + sorts: gridState.sorts, + reverse: gridState.reverse, + setCustomColumns, + setSelectDistinctColumns, + setAggregationSettings, + setRollupConfig, + setConditionalFormats, + setMovedColumns, + setFrozenColumns, + setColumnHeaderGroups, + setQuickFilters, + setAdvancedFilters, + setSorts, + setReverse, + clearAllFilters, + setCrossColumnSearch, + closeCurrentOption: closePanel, + }), + [ + gridState, + setCustomColumns, + setSelectDistinctColumns, + setAggregationSettings, + setRollupConfig, + setConditionalFormats, + setMovedColumns, + setFrozenColumns, + setColumnHeaderGroups, + setQuickFilters, + setAdvancedFilters, + setSorts, + setReverse, + clearAllFilters, + setCrossColumnSearch, + closePanel, + ] + ); +} diff --git a/packages/iris-grid/src/index.ts b/packages/iris-grid/src/index.ts index d3c077be9e..3eaccef8a3 100644 --- a/packages/iris-grid/src/index.ts +++ b/packages/iris-grid/src/index.ts @@ -25,6 +25,11 @@ export { default as IrisGridModelFactory } from './IrisGridModelFactory'; export { createDefaultIrisGridTheme } from './IrisGridTheme'; export type { IrisGridThemeType } from './IrisGridTheme'; export * from './IrisGridThemeProvider'; +export * from './table-options'; +export { + useTableOptions, + type TableOptionsContextValue, +} from './TableOptionsContext'; export { default as IrisGridTestUtils } from './IrisGridTestUtils'; export { default as IrisGridUtils } from './IrisGridUtils'; export * from './IrisGridUtils'; diff --git a/packages/iris-grid/src/table-options/TableOption.ts b/packages/iris-grid/src/table-options/TableOption.ts new file mode 100644 index 0000000000..248a836e5f --- /dev/null +++ b/packages/iris-grid/src/table-options/TableOption.ts @@ -0,0 +1,333 @@ +import type { IconDefinition } from '@fortawesome/fontawesome-common-types'; +import type { MoveOperation, ModelIndex, ModelSizeMap } from '@deephaven/grid'; +import type { Shortcut } from '@deephaven/components'; +import type { SortDescriptor } from '@deephaven/jsapi-utils'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import type { + ColumnName, + ReadonlyQuickFilterMap, + ReadonlyAdvancedFilterMap, +} from '../CommonTypes'; +import type ColumnHeaderGroup from '../ColumnHeaderGroup'; +import type IrisGridModel from '../IrisGridModel'; +import type { + AggregationSettings, + UIRollupConfig, + SidebarFormattingRule, + ChartBuilderSettings, +} from '../sidebar'; +import type AdvancedSettingsType from '../sidebar/AdvancedSettingsType'; +import type { + DehydratedIrisGridState, + DehydratedGridState, +} from '../IrisGridUtils'; + +// ============================================================================ +// Grid State Snapshot (Read-Only) +// ============================================================================ + +/** + * Read-only snapshot of IrisGrid state. + * Panels receive this to read current configuration. + */ +export interface GridStateSnapshot { + /** The IrisGrid model for accessing column info, etc. */ + model: IrisGridModel; + + /** Custom columns created by user */ + customColumns: readonly ColumnName[]; + + /** Columns used for Select Distinct operation */ + selectDistinctColumns: readonly ColumnName[]; + + /** Aggregation configuration */ + aggregationSettings: AggregationSettings; + + /** Rollup rows configuration */ + rollupConfig?: UIRollupConfig; + + /** Conditional formatting rules */ + conditionalFormats: readonly SidebarFormattingRule[]; + + /** Column move/reorder operations */ + movedColumns: readonly MoveOperation[]; + + /** Frozen/pinned columns */ + frozenColumns: readonly ColumnName[]; + + /** Column header grouping configuration */ + columnHeaderGroups: readonly ColumnHeaderGroup[]; + + /** Hidden column indices */ + hiddenColumns: readonly ModelIndex[]; + + /** Whether the table has a rollup applied */ + isRollup: boolean; + + /** User column widths for download */ + userColumnWidths?: ModelSizeMap; + + /** Table name for download */ + name?: string; + + /** Selected ranges for download */ + selectedRanges?: readonly unknown[]; + + /** Download in progress */ + isTableDownloading?: boolean; + + /** Download status */ + tableDownloadStatus?: string; + + /** Download progress (0-1) */ + tableDownloadProgress?: number; + + /** Estimated download time */ + tableDownloadEstimatedTime?: number | null; + + // ============================================================================ + // Toggle UI State + // ============================================================================ + + /** Whether the filter bar is shown */ + isFilterBarShown?: boolean; + + /** Whether the search bar is shown */ + showSearchBar?: boolean; + + /** Whether the Go To row panel is shown */ + isGotoShown?: boolean; + + /** Whether search bar can be toggled */ + canToggleSearch?: boolean; + + /** Whether CSV download is available */ + canDownloadCsv?: boolean; + + /** Whether there are advanced settings to show */ + hasAdvancedSettings?: boolean; + + /** Advanced settings map */ + advancedSettings?: ReadonlyMap; + + /** Whether chart builder is available */ + isChartBuilderAvailable?: boolean; + + // ============================================================================ + // Filters and Sorts + // ============================================================================ + + /** Quick filters applied to columns */ + quickFilters: ReadonlyQuickFilterMap; + + /** Advanced filters applied to columns */ + advancedFilters: ReadonlyAdvancedFilterMap; + + /** Search filter from the search bar */ + searchFilter?: DhType.FilterCondition; + + /** Current search bar text value */ + searchValue: string; + + /** Columns selected for cross-column search */ + selectedSearchColumns: readonly ColumnName[]; + + /** Whether search column selection is inverted (search all except selected) */ + invertSearchColumns: boolean; + + /** Current sort configuration */ + sorts: readonly SortDescriptor[]; + + /** Whether sort order is reversed */ + reverse: boolean; +} + +// ============================================================================ +// Grid Actions (Dispatch) +// ============================================================================ + +/** + * Actions that can be dispatched to modify grid state. + */ +export type GridAction = + | { type: 'SET_CUSTOM_COLUMNS'; columns: readonly ColumnName[] } + | { type: 'SET_SELECT_DISTINCT_COLUMNS'; columns: readonly ColumnName[] } + | { type: 'SET_AGGREGATION_SETTINGS'; settings: AggregationSettings } + | { type: 'SET_ROLLUP_CONFIG'; config: UIRollupConfig | undefined } + | { + type: 'SET_CONDITIONAL_FORMATS'; + formats: readonly SidebarFormattingRule[]; + } + | { + type: 'SET_MOVED_COLUMNS'; + columns: readonly MoveOperation[]; + onChangeApplied?: () => void; + } + | { type: 'SET_FROZEN_COLUMNS'; columns: readonly ColumnName[] } + | { + type: 'SET_COLUMN_HEADER_GROUPS'; + groups: readonly ColumnHeaderGroup[]; + } + | { + type: 'SET_COLUMN_VISIBILITY'; + columns: readonly ModelIndex[]; + isVisible: boolean; + } + | { type: 'RESET_COLUMN_VISIBILITY' } + | { type: 'START_DOWNLOAD' } + | { + type: 'DOWNLOAD_TABLE'; + fileName: string; + frozenTable: unknown; + tableSubscription: unknown; + snapshotRanges: unknown; + modelRanges: unknown; + includeColumnHeaders: boolean; + useUnformattedValues: boolean; + } + | { type: 'CANCEL_DOWNLOAD' } + | { type: 'TOGGLE_FILTER_BAR' } + | { type: 'TOGGLE_SEARCH_BAR' } + | { type: 'TOGGLE_GOTO' } + | { type: 'SET_ADVANCED_SETTING'; key: AdvancedSettingsType; isOn: boolean } + | { type: 'CREATE_CHART'; settings: ChartBuilderSettings } + | { type: 'UPDATE_CHART_PREVIEW'; settings: ChartBuilderSettings } + | { type: 'SET_QUICK_FILTERS'; filters: ReadonlyQuickFilterMap } + | { type: 'SET_ADVANCED_FILTERS'; filters: ReadonlyAdvancedFilterMap } + | { type: 'SET_SORTS'; sorts: readonly SortDescriptor[] } + | { type: 'SET_REVERSE'; reverse: boolean } + | { type: 'CLEAR_ALL_FILTERS' } + | { + type: 'SET_CROSS_COLUMN_SEARCH'; + searchValue: string; + selectedSearchColumns: readonly ColumnName[]; + invertSearchColumns: boolean; + } + | { + /** + * Restore a dehydrated state snapshot. + * Uses IrisGridUtils hydration methods internally for proper column resolution. + * This handles the sequencing of custom columns, filters, sorts, etc. + */ + type: 'RESTORE_DEHYDRATED_STATE'; + /** Dehydrated IrisGrid state (filters, sorts, custom columns, etc.) */ + irisGridState: Partial; + /** Dehydrated Grid state (movedColumns, movedRows, etc.) */ + gridState?: Partial; + }; + +/** + * Function to dispatch grid actions. + */ +export type GridDispatch = (action: GridAction) => void; + +// ============================================================================ +// Table Option Panel Props +// ============================================================================ + +/** + * Props passed to Table Option panel components. + * @template TOptionState - Type of option-local state (void if no local state) + */ +export interface TableOptionPanelProps { + /** Read-only snapshot of grid state */ + gridState: GridStateSnapshot; + + /** Dispatch function to modify grid state */ + dispatch: GridDispatch; + + /** Option-local state (if the option defines a reducer) */ + optionState: TOptionState; + + /** Dispatch function for option-local actions */ + dispatchOption: (action: unknown) => void; + + /** Open a sub-panel (e.g., AGGREGATION_EDIT from AGGREGATIONS) */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + openSubPanel: (option: TableOption) => void; + + /** Close the current panel (go back) */ + closePanel: () => void; +} + +// ============================================================================ +// Table Option Definition +// ============================================================================ + +/** + * Menu item configuration for a Table Option. + */ +export interface TableOptionMenuItem { + /** Display title */ + title: string; + + /** Optional subtitle/description */ + subtitle?: string; + + /** Icon to display */ + icon?: IconDefinition; + + /** Whether the option is available (e.g., model-dependent) */ + isAvailable?: (gridState: GridStateSnapshot) => boolean; + + /** Whether the option should be visible in the menu */ + isVisible?: (gridState: GridStateSnapshot) => boolean; + + /** Order for sorting in menu (lower = higher in list) */ + order?: number; +} + +/** + * Toggle configuration for options that act as on/off switches. + * Used for Quick Filters, Search Bar, Go To Row. + */ +export interface TableOptionToggle { + /** Get current toggle state */ + getValue: (gridState: GridStateSnapshot) => boolean; + + /** Action type to dispatch when toggled */ + actionType: GridAction['type']; + + /** Keyboard shortcut */ + shortcut?: Shortcut; +} + +/** + * Self-contained Table Option definition. + * Each option is a module that defines its menu item, panel component, + * and optionally its own local state management. + * + * @template TOptionState - Type of option-local state + * @template TOptionAction - Type of option-local actions + */ +export interface TableOption { + /** Unique type identifier */ + type: string; + + /** Menu item configuration */ + menuItem: TableOptionMenuItem; + + /** + * Panel component to render when option is selected. + * If undefined, the option is a toggle or action-only. + */ + Panel?: React.ComponentType>; + + /** + * For toggle options (Quick Filters, Search Bar, Go To). + * If defined, renders a toggle button instead of opening a panel. + */ + toggle?: TableOptionToggle; + + /** + * Initial state for option-local state. + * Required if the option has a reducer. + */ + initialState?: TOptionState; + + /** + * Reducer for option-local state management. + * Use this for complex options that need their own state (e.g., TableExporter). + */ + reducer?: (state: TOptionState, action: TOptionAction) => TOptionState; +} diff --git a/packages/iris-grid/src/table-options/TableOptionsHost.tsx b/packages/iris-grid/src/table-options/TableOptionsHost.tsx new file mode 100644 index 0000000000..32d3884c6d --- /dev/null +++ b/packages/iris-grid/src/table-options/TableOptionsHost.tsx @@ -0,0 +1,311 @@ +import React, { useCallback, useMemo, useReducer, useState } from 'react'; +import { Menu, Stack, Page } from '@deephaven/components'; +import type { GridStateSnapshot, GridDispatch } from './TableOption'; +import { TableOptionsHostContext } from './TableOptionsHostContext'; +import type { + TableOptionsRegistry, + AnyTableOption, +} from './TableOptionsRegistry'; + +interface OptionStackEntry { + option: AnyTableOption; + state: unknown; +} + +interface TableOptionsHostProps { + /** Registry containing available options */ + registry: TableOptionsRegistry; + + /** Current grid state snapshot */ + gridState: GridStateSnapshot; + + /** Dispatch function for grid actions */ + dispatch: GridDispatch; + + /** Callback when menu should close */ + onClose: () => void; + + /** + * Legacy options stack for backward compatibility. + * Used during migration from the old architecture. + */ + legacyOptionsStack?: React.ReactNode[]; + + /** + * Legacy open options for backward compatibility. + * Used during migration from the old architecture. + */ + legacyOpenOptions?: readonly { type: string; title: string }[]; + + /** + * Legacy menu select handler for backward compatibility. + */ + legacyOnMenuSelect?: (index: number) => void; + + /** + * Legacy menu back handler for backward compatibility. + */ + legacyOnMenuBack?: () => void; +} + +/** + * Reducer for option-local state. + * Manages state for options that define their own reducer. + */ +interface OptionStateAction { + type: 'INIT_OPTION' | 'DISPATCH_OPTION' | 'POP_OPTION'; + optionType?: string; + action?: unknown; + initialState?: unknown; +} + +interface OptionStatesMap { + [optionType: string]: unknown; +} + +function optionStatesReducer( + state: OptionStatesMap, + action: OptionStateAction +): OptionStatesMap { + switch (action.type) { + case 'INIT_OPTION': + if (action.optionType == null) return state; + return { ...state, [action.optionType]: action.initialState }; + case 'DISPATCH_OPTION': + // Option actions are handled by the option's own reducer + // This is just a placeholder - actual dispatch happens in the component + return state; + case 'POP_OPTION': { + if (action.optionType == null) return state; + const newState = { ...state }; + delete newState[action.optionType]; + return newState; + } + default: + return state; + } +} + +/** + * Host component for Table Options. + * Renders the options menu and manages panel navigation. + * + * This component: + * - Gets available options from the registry + * - Renders the menu + * - Manages the navigation stack + * - Provides context to panels + * - Manages option-local state for options with reducers + */ +export function TableOptionsHost({ + registry, + gridState, + dispatch, + onClose, + legacyOptionsStack, + legacyOpenOptions, + legacyOnMenuSelect, + legacyOnMenuBack, +}: TableOptionsHostProps): JSX.Element { + // Stack of open options (for sub-panel navigation) + const [optionStack, setOptionStack] = useState([]); + + // Option-local states (for options with reducers) + const [optionStates, dispatchOptionStates] = useReducer( + optionStatesReducer, + {} + ); + + // Get menu items from registry + const registryOptions = useMemo( + () => registry.getOptions(gridState), + [registry, gridState] + ); + + // Build menu items for display + const menuItems = useMemo( + () => + registryOptions.map(opt => { + const baseItem = { + type: opt.type, + title: opt.menuItem.title, + subtitle: opt.menuItem.subtitle, + icon: opt.menuItem.icon, + }; + + // Handle toggle options + if (opt.toggle != null) { + const { toggle } = opt; + return { + ...baseItem, + isOn: toggle.getValue(gridState), + onChange: () => { + dispatch({ type: toggle.actionType } as Parameters< + typeof dispatch + >[0]); + }, + }; + } + + return baseItem; + }), + [registryOptions, gridState, dispatch] + ); + + // Handle menu item selection + const handleMenuSelect = useCallback( + (index: number) => { + const option = registryOptions[index]; + if (option == null) { + // Fall back to legacy handler if option not in registry + legacyOnMenuSelect?.(index); + return; + } + + // If option has a panel, open it + if (option.Panel != null) { + // Initialize option state if it has a reducer + if (option.initialState !== undefined) { + dispatchOptionStates({ + type: 'INIT_OPTION', + optionType: option.type, + initialState: option.initialState, + }); + } + + setOptionStack(prev => [ + ...prev, + { option, state: option.initialState }, + ]); + } + + // If option is a toggle, dispatch the toggle action + if (option.toggle != null) { + dispatch({ type: option.toggle.actionType } as Parameters< + typeof dispatch + >[0]); + } + }, + [registryOptions, legacyOnMenuSelect, dispatch] + ); + + // Handle back navigation + const handleBack = useCallback(() => { + if (optionStack.length > 0) { + const poppedOption = optionStack[optionStack.length - 1]; + dispatchOptionStates({ + type: 'POP_OPTION', + optionType: poppedOption.option.type, + }); + setOptionStack(prev => prev.slice(0, -1)); + } else { + legacyOnMenuBack?.(); + } + }, [optionStack, legacyOnMenuBack]); + + // Open a sub-panel + const openSubPanel = useCallback((option: AnyTableOption) => { + if (option.initialState !== undefined) { + dispatchOptionStates({ + type: 'INIT_OPTION', + optionType: option.type, + initialState: option.initialState, + }); + } + setOptionStack(prev => [...prev, { option, state: option.initialState }]); + }, []); + + // Close current panel + const closePanel = useCallback(() => { + handleBack(); + }, [handleBack]); + + // Create dispatch function for option-local actions + const createOptionDispatch = useCallback( + (option: AnyTableOption) => (action: unknown) => { + if (option.reducer == null) return; + + const { reducer } = option; + setOptionStack(prev => + prev.map(entry => + entry.option.type === option.type + ? { + ...entry, + // Using reducer from closure since we already checked it's not null + state: reducer(entry.state as never, action as never), + } + : entry + ) + ); + }, + [] + ); + + // Context value for panels + const contextValue = useMemo( + () => ({ + gridState, + dispatch, + openSubPanel, + closePanel, + goBack: closePanel, // alias for closePanel + }), + [gridState, dispatch, openSubPanel, closePanel] + ); + + // Render panels from registry + const registryPanels = optionStack.map(({ option, state }) => { + if (option.Panel == null) return null; + + const { Panel } = option; + const optionState = optionStates[option.type] ?? state; + + return ( + + + + ); + }); + + return ( + + + + + + {/* Registry-managed panels */} + {registryPanels} + {/* Legacy panels for backward compatibility during migration */} + {legacyOptionsStack?.map((panel, i) => { + const legacyOption = legacyOpenOptions?.[i]; + if (legacyOption == null) return null; + return ( + + {panel} + + ); + })} + + + ); +} + +export default TableOptionsHost; diff --git a/packages/iris-grid/src/table-options/TableOptionsHostContext.ts b/packages/iris-grid/src/table-options/TableOptionsHostContext.ts new file mode 100644 index 0000000000..cf7fc2338d --- /dev/null +++ b/packages/iris-grid/src/table-options/TableOptionsHostContext.ts @@ -0,0 +1,64 @@ +import { createContext, useContext } from 'react'; +import type { + GridStateSnapshot, + GridDispatch, + TableOption, +} from './TableOption'; + +/** + * Context value for the Table Options Host. + * Provides grid state, dispatch, and navigation to option panels. + */ +export interface TableOptionsHostContextValue { + /** Read-only snapshot of grid state */ + gridState: GridStateSnapshot; + + /** Dispatch function to modify grid state */ + dispatch: GridDispatch; + + /** Open a sub-panel */ + openSubPanel: (option: TableOption) => void; + + /** Close the current panel (go back to menu or previous panel) */ + closePanel: () => void; + + /** Alias for closePanel - go back to menu or previous panel */ + goBack: () => void; +} + +/** + * Context for Table Options panels. + * Provides access to grid state, dispatch, and navigation. + */ +export const TableOptionsHostContext = + createContext(null); +TableOptionsHostContext.displayName = 'TableOptionsHostContext'; + +/** + * Hook to access the Table Options Host context. + * Use this in option panels to access grid state and dispatch. + * + * @returns The context value + * @throws Error if used outside of TableOptionsHostContext.Provider + * + * @example + * function SelectDistinctPanel() { + * const { gridState, dispatch, closePanel } = useTableOptionsHost(); + * const { model, selectDistinctColumns } = gridState; + * + * const handleChange = (columns: string[]) => { + * dispatch({ type: 'SET_SELECT_DISTINCT_COLUMNS', columns }); + * }; + * + * return ; + * } + */ +export function useTableOptionsHost(): TableOptionsHostContextValue { + const context = useContext(TableOptionsHostContext); + if (context == null) { + throw new Error( + 'useTableOptionsHost must be used within a TableOptionsHostContext.Provider' + ); + } + return context; +} diff --git a/packages/iris-grid/src/table-options/TableOptionsRegistry.ts b/packages/iris-grid/src/table-options/TableOptionsRegistry.ts new file mode 100644 index 0000000000..7339f40fcf --- /dev/null +++ b/packages/iris-grid/src/table-options/TableOptionsRegistry.ts @@ -0,0 +1,135 @@ +import type { TableOption, GridStateSnapshot } from './TableOption'; + +// Use `any` generic defaults to accept options with any state/action types +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyTableOption = TableOption; + +/** + * Registry for Table Options. + * Manages the collection of available options and provides methods + * for plugins to register, unregister, and modify options. + */ +export class TableOptionsRegistry { + private options = new Map(); + + private listeners = new Set<() => void>(); + + /** + * Register a new table option. + * @param option - The option to register + */ + register(option: AnyTableOption): void { + this.options.set(option.type, option); + this.notifyListeners(); + } + + /** + * Register multiple options at once. + * @param options - Array of options to register + */ + registerAll(options: readonly AnyTableOption[]): void { + options.forEach(option => { + this.options.set(option.type, option); + }); + this.notifyListeners(); + } + + /** + * Unregister an option by type. + * @param type - The option type to remove + */ + unregister(type: string): void { + this.options.delete(type); + this.notifyListeners(); + } + + /** + * Check if an option type is registered. + * @param type - The option type to check + */ + has(type: string): boolean { + return this.options.has(type); + } + + /** + * Get an option by type. + * @param type - The option type + */ + get(type: string): AnyTableOption | undefined { + return this.options.get(type); + } + + /** + * Get all registered options, sorted by order. + * @param gridState - Current grid state (for filtering visibility) + */ + getOptions(gridState?: GridStateSnapshot): AnyTableOption[] { + const allOptions = [...this.options.values()]; + + // Filter by visibility if grid state is provided + const visibleOptions = + gridState != null + ? allOptions.filter( + opt => + opt.menuItem.isVisible == null || + opt.menuItem.isVisible(gridState) + ) + : allOptions; + + // Sort by order (default to 100 if not specified) + return visibleOptions.sort( + (a, b) => (a.menuItem.order ?? 100) - (b.menuItem.order ?? 100) + ); + } + + /** + * Modify an existing option. + * Useful for plugins that want to change built-in options. + * @param type - The option type to modify + * @param modifier - Function that receives the current option and returns modified version + */ + modify( + type: string, + modifier: (option: T) => T + ): void { + const existing = this.options.get(type) as T | undefined; + if (existing != null) { + this.options.set(type, modifier(existing)); + this.notifyListeners(); + } + } + + /** + * Subscribe to registry changes. + * @param listener - Callback when options change + * @returns Unsubscribe function + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private notifyListeners(): void { + this.listeners.forEach(listener => listener()); + } + + /** + * Clear all registered options. + * Mainly useful for testing. + */ + clear(): void { + this.options.clear(); + this.notifyListeners(); + } +} + +/** + * Default global registry instance. + * Built-in options are registered here. + */ +export const defaultTableOptionsRegistry = new TableOptionsRegistry(); + +export type { AnyTableOption }; +export default TableOptionsRegistry; diff --git a/packages/iris-grid/src/table-options/TableOptionsWrapper.tsx b/packages/iris-grid/src/table-options/TableOptionsWrapper.tsx new file mode 100644 index 0000000000..6b0b59189c --- /dev/null +++ b/packages/iris-grid/src/table-options/TableOptionsWrapper.tsx @@ -0,0 +1,429 @@ +import React, { useCallback, useMemo } from 'react'; +import Log from '@deephaven/log'; +import type { + GridRange, + MoveOperation, + ModelIndex, + ModelSizeMap, +} from '@deephaven/grid'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import type { SortDescriptor } from '@deephaven/jsapi-utils'; +import type { + GridStateSnapshot, + GridAction, + GridDispatch, +} from './TableOption'; +import type { TableOptionsRegistry } from './TableOptionsRegistry'; +import { defaultTableOptionsRegistry } from './TableOptionsRegistry'; +import { TableOptionsHost } from './TableOptionsHost'; +import type IrisGridModel from '../IrisGridModel'; +import type ColumnHeaderGroup from '../ColumnHeaderGroup'; +import type { + ColumnName, + ReadonlyQuickFilterMap, + ReadonlyAdvancedFilterMap, +} from '../CommonTypes'; +import type { + AggregationSettings, + UIRollupConfig, + SidebarFormattingRule, + ChartBuilderSettings, +} from '../sidebar'; +import type AdvancedSettingsType from '../sidebar/AdvancedSettingsType'; + +// Import to register built-in options +import './registerBuiltinOptions'; + +const log = Log.module('TableOptionsWrapper'); + +/** + * Props for the TableOptionsWrapper component. + * These are the values from IrisGrid state/props needed to build GridStateSnapshot. + */ +export interface TableOptionsWrapperProps { + /** The IrisGrid model */ + model: IrisGridModel; + + /** Grid state values */ + customColumns: readonly ColumnName[]; + selectDistinctColumns: readonly ColumnName[]; + aggregationSettings: AggregationSettings; + rollupConfig: UIRollupConfig | undefined; + conditionalFormats: readonly SidebarFormattingRule[]; + movedColumns: readonly MoveOperation[]; + frozenColumns: readonly ColumnName[]; + columnHeaderGroups: readonly ColumnHeaderGroup[]; + hiddenColumns: readonly ModelIndex[]; + isRollup: boolean; + + /** Download state */ + name?: string; + userColumnWidths?: ModelSizeMap; + selectedRanges?: readonly GridRange[]; + isTableDownloading?: boolean; + tableDownloadStatus?: string; + tableDownloadProgress?: number; + tableDownloadEstimatedTime?: number | null; + + /** Toggle UI state */ + isFilterBarShown?: boolean; + showSearchBar?: boolean; + isGotoShown?: boolean; + canToggleSearch?: boolean; + canDownloadCsv?: boolean; + hasAdvancedSettings?: boolean; + advancedSettings?: ReadonlyMap; + isChartBuilderAvailable?: boolean; + + /** Filters and sorts */ + quickFilters: ReadonlyQuickFilterMap; + advancedFilters: ReadonlyAdvancedFilterMap; + searchFilter?: DhType.FilterCondition; + searchValue: string; + selectedSearchColumns: readonly ColumnName[]; + invertSearchColumns: boolean; + sorts: readonly SortDescriptor[]; + reverse: boolean; + + /** Callbacks for grid actions */ + onSetCustomColumns: (columns: readonly ColumnName[]) => void; + onSetSelectDistinctColumns: (columns: readonly ColumnName[]) => void; + onSetAggregationSettings: (settings: AggregationSettings) => void; + onSetRollupConfig: (config: UIRollupConfig) => void; + onSetConditionalFormats: (formats: readonly SidebarFormattingRule[]) => void; + onSetMovedColumns: ( + columns: readonly MoveOperation[], + onChangeApplied?: () => void + ) => void; + onSetFrozenColumns: (columns: readonly ColumnName[]) => void; + onSetColumnHeaderGroups: (groups: readonly ColumnHeaderGroup[]) => void; + onSetColumnVisibility: ( + columns: readonly ModelIndex[], + isVisible: boolean + ) => void; + onResetColumnVisibility: () => void; + onStartDownload: () => void; + onDownloadTable: ( + fileName: string, + frozenTable: DhType.Table, + tableSubscription: DhType.TableViewportSubscription, + snapshotRanges: readonly GridRange[], + modelRanges: readonly GridRange[], + includeColumnHeaders: boolean, + useUnformattedValues: boolean + ) => void; + onCancelDownload: () => void; + + /** Toggle callbacks */ + onToggleFilterBar: () => void; + onToggleSearchBar: () => void; + onToggleGoto: () => void; + + /** Advanced settings callback */ + onAdvancedSettingsChange?: (key: AdvancedSettingsType, isOn: boolean) => void; + + /** Chart builder callbacks */ + onCreateChart?: (settings: ChartBuilderSettings) => void; + onChartChange?: (settings: ChartBuilderSettings) => void; + + /** Filter/sort callbacks */ + onSetQuickFilters: (filters: ReadonlyQuickFilterMap) => void; + onSetAdvancedFilters: (filters: ReadonlyAdvancedFilterMap) => void; + onSetSorts: (sorts: readonly SortDescriptor[]) => void; + onSetReverse: (reverse: boolean) => void; + onClearAllFilters: () => void; + onSetCrossColumnSearch: ( + searchValue: string, + selectedSearchColumns: readonly ColumnName[], + invertSearchColumns: boolean + ) => void; + + /** Menu callbacks */ + onClose: () => void; + + /** Optional custom registry (defaults to defaultTableOptionsRegistry) */ + registry?: TableOptionsRegistry; +} + +/** + * Wrapper component that bridges IrisGrid (class component) to TableOptionsHost. + * Creates GridStateSnapshot and GridDispatch from IrisGrid props/callbacks. + */ +export function TableOptionsWrapper({ + model, + customColumns, + selectDistinctColumns, + aggregationSettings, + rollupConfig, + conditionalFormats, + movedColumns, + frozenColumns, + columnHeaderGroups, + hiddenColumns, + isRollup, + name, + userColumnWidths, + selectedRanges, + isTableDownloading, + tableDownloadStatus, + tableDownloadProgress, + tableDownloadEstimatedTime, + isFilterBarShown, + showSearchBar, + isGotoShown, + canToggleSearch, + canDownloadCsv, + hasAdvancedSettings, + advancedSettings, + isChartBuilderAvailable, + quickFilters, + advancedFilters, + searchFilter, + searchValue, + selectedSearchColumns, + invertSearchColumns, + sorts, + reverse, + onSetCustomColumns, + onSetSelectDistinctColumns, + onSetAggregationSettings, + onSetRollupConfig, + onSetConditionalFormats, + onSetMovedColumns, + onSetFrozenColumns, + onSetColumnHeaderGroups, + onSetColumnVisibility, + onResetColumnVisibility, + onStartDownload, + onDownloadTable, + onCancelDownload, + onToggleFilterBar, + onToggleSearchBar, + onToggleGoto, + onAdvancedSettingsChange, + onCreateChart, + onChartChange, + onSetQuickFilters, + onSetAdvancedFilters, + onSetSorts, + onSetReverse, + onClearAllFilters, + onSetCrossColumnSearch, + onClose, + registry = defaultTableOptionsRegistry, +}: TableOptionsWrapperProps): JSX.Element { + // Create grid state snapshot from props + const gridState = useMemo( + () => ({ + model, + customColumns, + selectDistinctColumns, + aggregationSettings, + rollupConfig, + conditionalFormats, + movedColumns, + frozenColumns, + columnHeaderGroups, + hiddenColumns, + isRollup, + name, + userColumnWidths, + selectedRanges, + isTableDownloading, + tableDownloadStatus, + tableDownloadProgress, + tableDownloadEstimatedTime, + isFilterBarShown, + showSearchBar, + isGotoShown, + canToggleSearch, + canDownloadCsv, + hasAdvancedSettings, + advancedSettings, + isChartBuilderAvailable, + quickFilters, + advancedFilters, + searchFilter, + searchValue, + selectedSearchColumns, + invertSearchColumns, + sorts, + reverse, + }), + [ + model, + customColumns, + selectDistinctColumns, + aggregationSettings, + rollupConfig, + conditionalFormats, + movedColumns, + frozenColumns, + columnHeaderGroups, + hiddenColumns, + isRollup, + name, + userColumnWidths, + selectedRanges, + isTableDownloading, + tableDownloadStatus, + tableDownloadProgress, + tableDownloadEstimatedTime, + isFilterBarShown, + showSearchBar, + isGotoShown, + canToggleSearch, + canDownloadCsv, + hasAdvancedSettings, + advancedSettings, + isChartBuilderAvailable, + quickFilters, + advancedFilters, + searchFilter, + searchValue, + selectedSearchColumns, + invertSearchColumns, + sorts, + reverse, + ] + ); + + // Create dispatch function + const dispatch = useCallback( + (action: GridAction) => { + switch (action.type) { + case 'SET_CUSTOM_COLUMNS': + onSetCustomColumns(action.columns); + break; + case 'SET_SELECT_DISTINCT_COLUMNS': + onSetSelectDistinctColumns(action.columns); + break; + case 'SET_AGGREGATION_SETTINGS': + onSetAggregationSettings(action.settings); + break; + case 'SET_ROLLUP_CONFIG': + // Only call if config is defined - undefined means clear rollup + if (action.config != null) { + onSetRollupConfig(action.config); + } + break; + case 'SET_CONDITIONAL_FORMATS': + onSetConditionalFormats(action.formats); + break; + case 'SET_MOVED_COLUMNS': + onSetMovedColumns(action.columns, action.onChangeApplied); + break; + case 'SET_FROZEN_COLUMNS': + onSetFrozenColumns(action.columns); + break; + case 'SET_COLUMN_HEADER_GROUPS': + onSetColumnHeaderGroups(action.groups); + break; + case 'SET_COLUMN_VISIBILITY': + onSetColumnVisibility(action.columns, action.isVisible); + break; + case 'RESET_COLUMN_VISIBILITY': + onResetColumnVisibility(); + break; + case 'START_DOWNLOAD': + onStartDownload(); + break; + case 'DOWNLOAD_TABLE': + onDownloadTable( + action.fileName, + action.frozenTable as DhType.Table, + action.tableSubscription as DhType.TableViewportSubscription, + action.snapshotRanges as readonly GridRange[], + action.modelRanges as readonly GridRange[], + action.includeColumnHeaders, + action.useUnformattedValues + ); + break; + case 'CANCEL_DOWNLOAD': + onCancelDownload(); + break; + case 'TOGGLE_FILTER_BAR': + onToggleFilterBar(); + break; + case 'TOGGLE_SEARCH_BAR': + onToggleSearchBar(); + break; + case 'TOGGLE_GOTO': + onToggleGoto(); + break; + case 'SET_ADVANCED_SETTING': + onAdvancedSettingsChange?.(action.key, action.isOn); + break; + case 'CREATE_CHART': + onCreateChart?.(action.settings); + break; + case 'UPDATE_CHART_PREVIEW': + onChartChange?.(action.settings); + break; + case 'SET_QUICK_FILTERS': + onSetQuickFilters(action.filters); + break; + case 'SET_ADVANCED_FILTERS': + onSetAdvancedFilters(action.filters); + break; + case 'SET_SORTS': + onSetSorts(action.sorts); + break; + case 'SET_REVERSE': + onSetReverse(action.reverse); + break; + case 'CLEAR_ALL_FILTERS': + onClearAllFilters(); + break; + case 'SET_CROSS_COLUMN_SEARCH': + onSetCrossColumnSearch( + action.searchValue, + action.selectedSearchColumns, + action.invertSearchColumns + ); + break; + default: + // eslint-disable-next-line @typescript-eslint/no-explicit-any + log.warn(`Unknown action type: ${(action as any).type}`); + } + }, + [ + onSetCustomColumns, + onSetSelectDistinctColumns, + onSetAggregationSettings, + onSetRollupConfig, + onSetConditionalFormats, + onSetMovedColumns, + onSetFrozenColumns, + onSetColumnHeaderGroups, + onSetColumnVisibility, + onResetColumnVisibility, + onStartDownload, + onDownloadTable, + onCancelDownload, + onToggleFilterBar, + onToggleSearchBar, + onToggleGoto, + onAdvancedSettingsChange, + onCreateChart, + onChartChange, + onSetQuickFilters, + onSetAdvancedFilters, + onSetSorts, + onSetReverse, + onClearAllFilters, + onSetCrossColumnSearch, + ] + ); + + return ( + + ); +} + +export default TableOptionsWrapper; diff --git a/packages/iris-grid/src/table-options/index.ts b/packages/iris-grid/src/table-options/index.ts new file mode 100644 index 0000000000..c29502601b --- /dev/null +++ b/packages/iris-grid/src/table-options/index.ts @@ -0,0 +1,42 @@ +// Core types +export type { + GridStateSnapshot, + GridAction, + GridDispatch, + TableOptionPanelProps, + TableOptionMenuItem, + TableOptionToggle, + TableOption, +} from './TableOption'; + +// Registry +export { + TableOptionsRegistry, + defaultTableOptionsRegistry, +} from './TableOptionsRegistry'; + +// Context +export { + TableOptionsHostContext, + useTableOptionsHost, + type TableOptionsHostContextValue, +} from './TableOptionsHostContext'; + +// Host component +export { TableOptionsHost } from './TableOptionsHost'; + +// Wrapper for class components +export { TableOptionsWrapper } from './TableOptionsWrapper'; +export type { TableOptionsWrapperProps } from './TableOptionsWrapper'; + +// Registration +export { registerBuiltinOptions } from './registerBuiltinOptions'; + +// Built-in options +export { SelectDistinctOption } from './options/SelectDistinctOption'; +export { CustomColumnOption } from './options/CustomColumnOption'; +export { RollupRowsOption } from './options/RollupRowsOption'; +export { VisibilityOrderingOption } from './options/VisibilityOrderingOption'; +export { AggregationsOption } from './options/AggregationsOption'; +export { TableExporterOption } from './options/TableExporterOption'; +export { ConditionalFormattingOption } from './options/ConditionalFormattingOption'; diff --git a/packages/iris-grid/src/table-options/options/AdvancedSettingsOption.tsx b/packages/iris-grid/src/table-options/options/AdvancedSettingsOption.tsx new file mode 100644 index 0000000000..6e561056c7 --- /dev/null +++ b/packages/iris-grid/src/table-options/options/AdvancedSettingsOption.tsx @@ -0,0 +1,47 @@ +import React, { useCallback } from 'react'; +import { vsTools } from '@deephaven/icons'; +import AdvancedSettingsMenu from '../../sidebar/AdvancedSettingsMenu'; +import type AdvancedSettingsType from '../../sidebar/AdvancedSettingsType'; +import { useTableOptionsHost } from '../TableOptionsHostContext'; +import type { TableOption, TableOptionPanelProps } from '../TableOption'; + +/** + * Panel component for Advanced Settings option. + * Wraps the existing AdvancedSettingsMenu component. + */ +function AdvancedSettingsPanel(_props: TableOptionPanelProps): JSX.Element { + const { gridState, dispatch } = useTableOptionsHost(); + const { advancedSettings } = gridState; + + const handleChange = useCallback( + (key: AdvancedSettingsType, isOn: boolean) => { + dispatch({ type: 'SET_ADVANCED_SETTING', key, isOn }); + }, + [dispatch] + ); + + return ( + + ); +} + +/** + * Advanced Settings option configuration. + * Shows when there are advanced settings available. + */ +export const AdvancedSettingsOption: TableOption = { + type: 'advanced-settings', + + menuItem: { + title: 'Advanced Settings', + icon: vsTools, + isAvailable: gridState => gridState.hasAdvancedSettings ?? false, + }, + + Panel: AdvancedSettingsPanel, +}; + +export default AdvancedSettingsOption; diff --git a/packages/iris-grid/src/table-options/options/AggregationsOption.tsx b/packages/iris-grid/src/table-options/options/AggregationsOption.tsx new file mode 100644 index 0000000000..6564296bef --- /dev/null +++ b/packages/iris-grid/src/table-options/options/AggregationsOption.tsx @@ -0,0 +1,187 @@ +import React, { useCallback, useMemo } from 'react'; +import { vsSymbolOperator } from '@deephaven/icons'; +import { isEditableGridModel } from '@deephaven/grid'; +import { + Aggregations, + AggregationEdit, + type Aggregation, + type AggregationSettings, +} from '../../sidebar'; +import type { + TableOption, + TableOptionPanelProps, + GridStateSnapshot, +} from '../TableOption'; +import type AggregationOperation from '../../sidebar/aggregations/AggregationOperation'; + +// ============================================================================ +// Aggregation Option Local State +// ============================================================================ + +interface AggregationsOptionState { + /** The aggregation currently being edited (for sub-panel) */ + selectedAggregation: Aggregation | null; +} + +type AggregationsOptionAction = + | { type: 'SELECT_AGGREGATION'; aggregation: Aggregation } + | { type: 'CLEAR_SELECTION' }; + +function aggregationsReducer( + state: AggregationsOptionState, + action: AggregationsOptionAction +): AggregationsOptionState { + switch (action.type) { + case 'SELECT_AGGREGATION': + return { ...state, selectedAggregation: action.aggregation }; + case 'CLEAR_SELECTION': + return { ...state, selectedAggregation: null }; + default: + return state; + } +} + +// ============================================================================ +// Aggregations Panel +// ============================================================================ + +function AggregationsPanel({ + gridState, + dispatch, + optionState, + dispatchOption, + openSubPanel, +}: TableOptionPanelProps): JSX.Element { + const { model, aggregationSettings, isRollup } = gridState; + + const availablePlacements = useMemo(() => { + if (isEditableGridModel(model) && model.isEditable) { + return ['top'] as const; + } + return ['top', 'bottom'] as const; + }, [model]); + + const handleChange = useCallback( + ( + settings: AggregationSettings, + _added: AggregationOperation[], + _removed: AggregationOperation[] + ) => { + dispatch({ type: 'SET_AGGREGATION_SETTINGS', settings }); + }, + [dispatch] + ); + + const handleEdit = useCallback( + (aggregation: Aggregation) => { + dispatchOption({ type: 'SELECT_AGGREGATION', aggregation }); + openSubPanel(AggregationEditOption); + }, + [dispatchOption, openSubPanel] + ); + + return ( + + ); +} + +// ============================================================================ +// Aggregation Edit Sub-Panel +// ============================================================================ + +/** + * Sub-panel for editing a single aggregation's column selection. + * This option is not registered in the menu - it's opened via openSubPanel. + */ +function AggregationEditPanel({ + gridState, + dispatch, + optionState, + closePanel, +}: TableOptionPanelProps): JSX.Element { + const { model, aggregationSettings } = gridState; + const { selectedAggregation } = optionState; + + const handleChange = useCallback( + (aggregation: Aggregation) => { + // Update the aggregation in settings + const newAggregations = aggregationSettings.aggregations.map(agg => + agg.operation === aggregation.operation ? aggregation : agg + ); + dispatch({ + type: 'SET_AGGREGATION_SETTINGS', + settings: { ...aggregationSettings, aggregations: newAggregations }, + }); + }, + [dispatch, aggregationSettings] + ); + + if (selectedAggregation == null) { + // If no aggregation selected, close the panel + closePanel(); + return
No aggregation selected
; + } + + return ( + + ); +} + +// Sub-panel option definition (not registered in menu) +// Uses the same state type as parent to share selectedAggregation +const AggregationEditOption: TableOption< + AggregationsOptionState, + AggregationsOptionAction +> = { + type: 'aggregation-edit', + menuItem: { + title: 'Edit Aggregation', + // No visibility - this is a sub-panel only + isVisible: () => false, + }, + Panel: AggregationEditPanel, +}; + +// ============================================================================ +// Main Option Export +// ============================================================================ + +/** + * Aggregations option configuration. + * Shows when totals are available on the model. + */ +export const AggregationsOption: TableOption< + AggregationsOptionState, + AggregationsOptionAction +> = { + type: 'aggregations', + + menuItem: { + title: 'Aggregate Columns', + icon: vsSymbolOperator, + isAvailable: (gridState: GridStateSnapshot) => + gridState.model.isTotalsAvailable, + order: 30, + }, + + Panel: AggregationsPanel, + + initialState: { + selectedAggregation: null, + }, + + reducer: aggregationsReducer, +}; + +export default AggregationsOption; diff --git a/packages/iris-grid/src/table-options/options/ChartBuilderOption.tsx b/packages/iris-grid/src/table-options/options/ChartBuilderOption.tsx new file mode 100644 index 0000000000..fc42ef1dd0 --- /dev/null +++ b/packages/iris-grid/src/table-options/options/ChartBuilderOption.tsx @@ -0,0 +1,57 @@ +import React, { useCallback } from 'react'; +import { dhGraphLineUp } from '@deephaven/icons'; +import ChartBuilder, { + type ChartBuilderSettings, +} from '../../sidebar/ChartBuilder'; +import { useTableOptionsHost } from '../TableOptionsHostContext'; +import type { TableOption, TableOptionPanelProps } from '../TableOption'; + +/** + * Panel component for Chart Builder option. + * Wraps the existing ChartBuilder component. + */ +function ChartBuilderPanel(_props: TableOptionPanelProps): JSX.Element { + const { gridState, dispatch, closePanel } = useTableOptionsHost(); + const { model } = gridState; + + const handleChange = useCallback( + (settings: ChartBuilderSettings) => { + dispatch({ type: 'UPDATE_CHART_PREVIEW', settings }); + }, + [dispatch] + ); + + const handleSubmit = useCallback( + (settings: ChartBuilderSettings) => { + dispatch({ type: 'CREATE_CHART', settings }); + closePanel(); + }, + [dispatch, closePanel] + ); + + return ( + + ); +} + +/** + * Chart Builder option configuration. + * Shows when chart builder is available (model supports it and onCreateChart is provided). + */ +export const ChartBuilderOption: TableOption = { + type: 'chart-builder', + + menuItem: { + title: 'Chart Builder', + icon: dhGraphLineUp, + isAvailable: gridState => gridState.isChartBuilderAvailable ?? false, + }, + + Panel: ChartBuilderPanel, +}; + +export default ChartBuilderOption; diff --git a/packages/iris-grid/src/table-options/options/ConditionalFormattingOption.tsx b/packages/iris-grid/src/table-options/options/ConditionalFormattingOption.tsx new file mode 100644 index 0000000000..d36c3698c4 --- /dev/null +++ b/packages/iris-grid/src/table-options/options/ConditionalFormattingOption.tsx @@ -0,0 +1,211 @@ +import React, { useCallback, useMemo } from 'react'; +import { vsEdit } from '@deephaven/icons'; +import ConditionalFormattingMenu from '../../sidebar/conditional-formatting/ConditionalFormattingMenu'; +import ConditionalFormatEditor from '../../sidebar/conditional-formatting/ConditionalFormatEditor'; +import type { + TableOption, + TableOptionPanelProps, + GridStateSnapshot, +} from '../TableOption'; +import type { SidebarFormattingRule } from '../../sidebar'; + +// ============================================================================ +// Conditional Formatting Option Local State +// ============================================================================ + +interface ConditionalFormattingState { + /** Index of the rule being edited (-1 for new rule, null if not editing) */ + editIndex: number | null; + /** Preview of the rule being edited (for live updates) */ + preview: SidebarFormattingRule | null; +} + +type ConditionalFormattingAction = + | { type: 'START_CREATE' } + | { type: 'START_EDIT'; index: number; rule: SidebarFormattingRule } + | { type: 'UPDATE_PREVIEW'; preview: SidebarFormattingRule | null } + | { type: 'CLEAR_EDIT' }; + +function conditionalFormattingReducer( + state: ConditionalFormattingState, + action: ConditionalFormattingAction +): ConditionalFormattingState { + switch (action.type) { + case 'START_CREATE': + return { editIndex: -1, preview: null }; + case 'START_EDIT': + // Clone the rule for preview + return { editIndex: action.index, preview: { ...action.rule } }; + case 'UPDATE_PREVIEW': + return { ...state, preview: action.preview }; + case 'CLEAR_EDIT': + return { editIndex: null, preview: null }; + default: + return state; + } +} + +// ============================================================================ +// Conditional Formatting Menu Panel +// ============================================================================ + +function ConditionalFormattingPanel({ + gridState, + dispatch, + dispatchOption, + openSubPanel, +}: TableOptionPanelProps): JSX.Element { + const { conditionalFormats } = gridState; + + const handleChange = useCallback( + (formats: readonly SidebarFormattingRule[]) => { + dispatch({ type: 'SET_CONDITIONAL_FORMATS', formats }); + }, + [dispatch] + ); + + const handleCreate = useCallback(() => { + dispatchOption({ type: 'START_CREATE' }); + openSubPanel(ConditionalFormattingEditOption); + }, [dispatchOption, openSubPanel]); + + const handleSelect = useCallback( + (index: number) => { + dispatchOption({ + type: 'START_EDIT', + index, + rule: conditionalFormats[index], + }); + openSubPanel(ConditionalFormattingEditOption); + }, + [conditionalFormats, dispatchOption, openSubPanel] + ); + + return ( + + ); +} + +// ============================================================================ +// Conditional Formatting Edit Sub-Panel +// ============================================================================ + +function ConditionalFormattingEditPanel({ + gridState, + dispatch, + optionState, + dispatchOption, + closePanel, +}: TableOptionPanelProps): JSX.Element { + const { model, conditionalFormats } = gridState; + const { editIndex, preview } = optionState; + + // Get the rule being edited + const rule = useMemo(() => { + if (editIndex === -1) { + // Creating new rule - return undefined to let editor create default + return undefined; + } + if (editIndex != null && editIndex >= 0) { + return preview ?? conditionalFormats[editIndex]; + } + return undefined; + }, [editIndex, preview, conditionalFormats]); + + const handleUpdate = useCallback( + (updatedRule?: SidebarFormattingRule) => { + dispatchOption({ + type: 'UPDATE_PREVIEW', + preview: updatedRule ?? null, + }); + }, + [dispatchOption] + ); + + const handleSave = useCallback( + (savedRule: SidebarFormattingRule) => { + if (editIndex === -1) { + // Add new rule + dispatch({ + type: 'SET_CONDITIONAL_FORMATS', + formats: [...conditionalFormats, savedRule], + }); + } else if (editIndex != null) { + // Update existing rule + const newFormats = [...conditionalFormats]; + newFormats[editIndex] = savedRule; + dispatch({ type: 'SET_CONDITIONAL_FORMATS', formats: newFormats }); + } + dispatchOption({ type: 'CLEAR_EDIT' }); + closePanel(); + }, + [editIndex, conditionalFormats, dispatch, dispatchOption, closePanel] + ); + + const handleCancel = useCallback(() => { + dispatchOption({ type: 'CLEAR_EDIT' }); + closePanel(); + }, [dispatchOption, closePanel]); + + return ( + + ); +} + +// Sub-panel option definition (not registered in menu) +const ConditionalFormattingEditOption: TableOption = + { + type: 'conditional-formatting-edit', + menuItem: { + title: 'Edit Formatting Rule', + // Not visible in menu - only opened via openSubPanel + isVisible: () => false, + }, + Panel: ConditionalFormattingEditPanel, + }; + +// ============================================================================ +// Main Option Export +// ============================================================================ + +/** + * Conditional Formatting option configuration. + * Shows when format columns are available on the model. + */ +export const ConditionalFormattingOption: TableOption< + ConditionalFormattingState, + ConditionalFormattingAction +> = { + type: 'conditional-formatting', + + menuItem: { + title: 'Conditional Formatting', + icon: vsEdit, + isAvailable: (gridState: GridStateSnapshot) => + gridState.model.isFormatColumnsAvailable, + order: 20, + }, + + Panel: ConditionalFormattingPanel, + + initialState: { + editIndex: null, + preview: null, + }, + + reducer: conditionalFormattingReducer, +}; + +export default ConditionalFormattingOption; diff --git a/packages/iris-grid/src/table-options/options/CustomColumnOption.tsx b/packages/iris-grid/src/table-options/options/CustomColumnOption.tsx new file mode 100644 index 0000000000..4c7625722c --- /dev/null +++ b/packages/iris-grid/src/table-options/options/CustomColumnOption.tsx @@ -0,0 +1,53 @@ +import React, { useCallback } from 'react'; +import { vsSplitHorizontal } from '@deephaven/icons'; +import { CustomColumnBuilder } from '../../sidebar'; +import { useTableOptionsHost } from '../TableOptionsHostContext'; +import type { TableOption, TableOptionPanelProps } from '../TableOption'; +import type { ColumnName } from '../../CommonTypes'; + +/** + * Panel component for Custom Column Builder option. + * Wraps the existing CustomColumnBuilder component. + */ +function CustomColumnPanel(_props: TableOptionPanelProps): JSX.Element { + const { gridState, dispatch, goBack } = useTableOptionsHost(); + const { model, customColumns } = gridState; + + const handleSave = useCallback( + (columns: readonly ColumnName[]) => { + dispatch({ type: 'SET_CUSTOM_COLUMNS', columns }); + }, + [dispatch] + ); + + const handleCancel = useCallback(() => { + goBack(); + }, [goBack]); + + return ( + + ); +} + +/** + * Custom Column Builder option configuration. + * Shows when `model.isCustomColumnsAvailable` is true. + */ +export const CustomColumnOption: TableOption = { + type: 'custom-columns', + + menuItem: { + title: 'Custom Columns', + icon: vsSplitHorizontal, + isAvailable: gridState => gridState.model.isCustomColumnsAvailable, + }, + + Panel: CustomColumnPanel, +}; + +export default CustomColumnOption; diff --git a/packages/iris-grid/src/table-options/options/GotoRowOption.ts b/packages/iris-grid/src/table-options/options/GotoRowOption.ts new file mode 100644 index 0000000000..0f349982ef --- /dev/null +++ b/packages/iris-grid/src/table-options/options/GotoRowOption.ts @@ -0,0 +1,27 @@ +import { vsReply } from '@deephaven/icons'; +import type { TableOption } from '../TableOption'; +import SHORTCUTS from '../../IrisGridShortcuts'; + +/** + * Go To Row toggle option. + * Shows/hides the Go To row panel. + */ +export const GotoRowOption: TableOption = { + type: 'goto-row', + + menuItem: { + title: 'Go to', + subtitle: SHORTCUTS.TABLE.GOTO_ROW.getDisplayText(), + icon: vsReply, + // Always available + isAvailable: () => true, + }, + + toggle: { + getValue: gridState => gridState.isGotoShown ?? false, + actionType: 'TOGGLE_GOTO', + shortcut: SHORTCUTS.TABLE.GOTO_ROW, + }, +}; + +export default GotoRowOption; diff --git a/packages/iris-grid/src/table-options/options/QuickFiltersOption.ts b/packages/iris-grid/src/table-options/options/QuickFiltersOption.ts new file mode 100644 index 0000000000..63092ed538 --- /dev/null +++ b/packages/iris-grid/src/table-options/options/QuickFiltersOption.ts @@ -0,0 +1,27 @@ +import { vsFilter } from '@deephaven/icons'; +import type { TableOption } from '../TableOption'; +import SHORTCUTS from '../../IrisGridShortcuts'; + +/** + * Quick Filters toggle option. + * Shows/hides the quick filter bar. + */ +export const QuickFiltersOption: TableOption = { + type: 'quick-filters', + + menuItem: { + title: 'Quick Filters', + subtitle: SHORTCUTS.TABLE.TOGGLE_QUICK_FILTER.getDisplayText(), + icon: vsFilter, + // Always available + isAvailable: () => true, + }, + + toggle: { + getValue: gridState => gridState.isFilterBarShown ?? false, + actionType: 'TOGGLE_FILTER_BAR', + shortcut: SHORTCUTS.TABLE.TOGGLE_QUICK_FILTER, + }, +}; + +export default QuickFiltersOption; diff --git a/packages/iris-grid/src/table-options/options/RollupRowsOption.tsx b/packages/iris-grid/src/table-options/options/RollupRowsOption.tsx new file mode 100644 index 0000000000..aecdf952df --- /dev/null +++ b/packages/iris-grid/src/table-options/options/RollupRowsOption.tsx @@ -0,0 +1,48 @@ +import React, { useCallback } from 'react'; +import { dhTriangleDownSquare } from '@deephaven/icons'; +import { RollupRows } from '../../sidebar'; +import { useTableOptionsHost } from '../TableOptionsHostContext'; +import type { TableOption, TableOptionPanelProps } from '../TableOption'; +import type { UIRollupConfig } from '../../sidebar'; + +/** + * Panel component for Rollup Rows option. + * Wraps the existing RollupRows component. + */ +function RollupRowsPanel(_props: TableOptionPanelProps): JSX.Element { + const { gridState, dispatch } = useTableOptionsHost(); + const { model, rollupConfig } = gridState; + + const handleChange = useCallback( + (config: UIRollupConfig) => { + dispatch({ type: 'SET_ROLLUP_CONFIG', config }); + }, + [dispatch] + ); + + return ( + + ); +} + +/** + * Rollup Rows option configuration. + * Shows when `model.isRollupAvailable` is true. + */ +export const RollupRowsOption: TableOption = { + type: 'rollup-rows', + + menuItem: { + title: 'Rollup Rows', + icon: dhTriangleDownSquare, + isAvailable: gridState => gridState.model.isRollupAvailable, + }, + + Panel: RollupRowsPanel, +}; + +export default RollupRowsOption; diff --git a/packages/iris-grid/src/table-options/options/SearchBarOption.ts b/packages/iris-grid/src/table-options/options/SearchBarOption.ts new file mode 100644 index 0000000000..2ce28d64c1 --- /dev/null +++ b/packages/iris-grid/src/table-options/options/SearchBarOption.ts @@ -0,0 +1,27 @@ +import { vsSearch } from '@deephaven/icons'; +import type { TableOption } from '../TableOption'; +import SHORTCUTS from '../../IrisGridShortcuts'; + +/** + * Search Bar toggle option. + * Shows/hides the cross-column search bar. + */ +export const SearchBarOption: TableOption = { + type: 'search-bar', + + menuItem: { + title: 'Search Bar', + subtitle: SHORTCUTS.TABLE.TOGGLE_SEARCH.getDisplayText(), + icon: vsSearch, + // Only available when canToggleSearch is true + isAvailable: gridState => gridState.canToggleSearch ?? true, + }, + + toggle: { + getValue: gridState => gridState.showSearchBar ?? false, + actionType: 'TOGGLE_SEARCH_BAR', + shortcut: SHORTCUTS.TABLE.TOGGLE_SEARCH, + }, +}; + +export default SearchBarOption; diff --git a/packages/iris-grid/src/table-options/options/SelectDistinctOption.tsx b/packages/iris-grid/src/table-options/options/SelectDistinctOption.tsx new file mode 100644 index 0000000000..1ba4a95906 --- /dev/null +++ b/packages/iris-grid/src/table-options/options/SelectDistinctOption.tsx @@ -0,0 +1,48 @@ +import React, { useCallback } from 'react'; +import { vsRuby } from '@deephaven/icons'; +import SelectDistinctBuilder from '../../sidebar/SelectDistinctBuilder'; +import { useTableOptionsHost } from '../TableOptionsHostContext'; +import type { TableOption, TableOptionPanelProps } from '../TableOption'; +import type { ColumnName } from '../../CommonTypes'; + +/** + * Panel component for Select Distinct option. + * Wraps the existing SelectDistinctBuilder component. + */ +function SelectDistinctPanel(_props: TableOptionPanelProps): JSX.Element { + const { gridState, dispatch } = useTableOptionsHost(); + const { model, selectDistinctColumns } = gridState; + + const handleChange = useCallback( + (columns: readonly ColumnName[]) => { + dispatch({ type: 'SET_SELECT_DISTINCT_COLUMNS', columns }); + }, + [dispatch] + ); + + return ( + + ); +} + +/** + * Select Distinct option configuration. + * Shows when `model.isSelectDistinctAvailable` is true. + */ +export const SelectDistinctOption: TableOption = { + type: 'select-distinct', + + menuItem: { + title: 'Select Distinct Values', + icon: vsRuby, + isAvailable: gridState => gridState.model.isSelectDistinctAvailable, + }, + + Panel: SelectDistinctPanel, +}; + +export default SelectDistinctOption; diff --git a/packages/iris-grid/src/table-options/options/TableExporterOption.tsx b/packages/iris-grid/src/table-options/options/TableExporterOption.tsx new file mode 100644 index 0000000000..90816fddc5 --- /dev/null +++ b/packages/iris-grid/src/table-options/options/TableExporterOption.tsx @@ -0,0 +1,102 @@ +import React, { useCallback } from 'react'; +import { vsCloudDownload } from '@deephaven/icons'; +import type { GridRange } from '@deephaven/grid'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import { TableCsvExporter } from '../../sidebar'; +import { useTableOptionsHost } from '../TableOptionsHostContext'; +import type { + TableOption, + TableOptionPanelProps, + GridStateSnapshot, +} from '../TableOption'; + +/** + * Panel component for Table CSV Exporter option. + * Wraps the existing TableCsvExporter component. + * + * Download state (isDownloading, progress, status) is managed by IrisGrid + * and passed through gridState. This keeps the state management centralized. + */ +function TableExporterPanel(_props: TableOptionPanelProps): JSX.Element { + const { gridState, dispatch } = useTableOptionsHost(); + const { + model, + name = '', + userColumnWidths = new Map(), + movedColumns, + isTableDownloading = false, + tableDownloadStatus = '', + tableDownloadProgress = 0, + tableDownloadEstimatedTime, + selectedRanges = [], + } = gridState; + + const handleDownloadStart = useCallback(() => { + dispatch({ type: 'START_DOWNLOAD' }); + }, [dispatch]); + + const handleDownload = useCallback( + ( + fileName: string, + frozenTable: DhType.Table, + tableSubscription: DhType.TableViewportSubscription, + snapshotRanges: readonly GridRange[], + modelRanges: readonly GridRange[], + includeColumnHeaders: boolean, + useUnformattedValues: boolean + ) => { + dispatch({ + type: 'DOWNLOAD_TABLE', + fileName, + frozenTable, + tableSubscription, + snapshotRanges, + modelRanges, + includeColumnHeaders, + useUnformattedValues, + }); + }, + [dispatch] + ); + + const handleCancel = useCallback(() => { + dispatch({ type: 'CANCEL_DOWNLOAD' }); + }, [dispatch]); + + return ( + + ); +} + +/** + * Table Exporter option configuration. + * Shows when export is available and user has download CSV permission. + */ +export const TableExporterOption: TableOption = { + type: 'table-exporter', + + menuItem: { + title: 'Download CSV', + icon: vsCloudDownload, + isAvailable: (gridState: GridStateSnapshot) => + gridState.model.isExportAvailable, + order: 50, + }, + + Panel: TableExporterPanel, +}; + +export default TableExporterOption; diff --git a/packages/iris-grid/src/table-options/options/VisibilityOrderingOption.tsx b/packages/iris-grid/src/table-options/options/VisibilityOrderingOption.tsx new file mode 100644 index 0000000000..e0aa743cd6 --- /dev/null +++ b/packages/iris-grid/src/table-options/options/VisibilityOrderingOption.tsx @@ -0,0 +1,81 @@ +import React, { useCallback } from 'react'; +import { vsSymbolStructure } from '@deephaven/icons'; +import type { MoveOperation, ModelIndex } from '@deephaven/grid'; +import { VisibilityOrderingBuilder } from '../../sidebar'; +import { useTableOptionsHost } from '../TableOptionsHostContext'; +import type { TableOption, TableOptionPanelProps } from '../TableOption'; +import type ColumnHeaderGroup from '../../ColumnHeaderGroup'; +import type { ColumnName } from '../../CommonTypes'; + +/** + * Panel component for Visibility & Ordering option. + * Wraps the existing VisibilityOrderingBuilder component. + */ +function VisibilityOrderingPanel(_props: TableOptionPanelProps): JSX.Element { + const { gridState, dispatch } = useTableOptionsHost(); + const { model, movedColumns, hiddenColumns, columnHeaderGroups } = gridState; + + const handleColumnVisibilityChanged = useCallback( + (columns: readonly ModelIndex[], isVisible: boolean) => { + dispatch({ type: 'SET_COLUMN_VISIBILITY', columns, isVisible }); + }, + [dispatch] + ); + + const handleReset = useCallback(() => { + dispatch({ type: 'RESET_COLUMN_VISIBILITY' }); + }, [dispatch]); + + const handleMovedColumnsChanged = useCallback( + (columns: readonly MoveOperation[], onChangeApplied?: () => void) => { + dispatch({ type: 'SET_MOVED_COLUMNS', columns, onChangeApplied }); + }, + [dispatch] + ); + + const handleColumnHeaderGroupChanged = useCallback( + (groups: readonly ColumnHeaderGroup[]) => { + dispatch({ type: 'SET_COLUMN_HEADER_GROUPS', groups }); + }, + [dispatch] + ); + + const handleFrozenColumnsChanged = useCallback( + (columns: readonly ColumnName[]) => { + dispatch({ type: 'SET_FROZEN_COLUMNS', columns }); + }, + [dispatch] + ); + + return ( + + ); +} + +/** + * Visibility & Ordering option configuration. + * Always available (no isAvailable check needed). + */ +export const VisibilityOrderingOption: TableOption = { + type: 'visibility-ordering', + + menuItem: { + title: 'Organize Columns', + icon: vsSymbolStructure, + order: 10, // Near the top + }, + + Panel: VisibilityOrderingPanel, +}; + +export default VisibilityOrderingOption; diff --git a/packages/iris-grid/src/table-options/options/index.ts b/packages/iris-grid/src/table-options/options/index.ts new file mode 100644 index 0000000000..266e7d54e1 --- /dev/null +++ b/packages/iris-grid/src/table-options/options/index.ts @@ -0,0 +1,21 @@ +// Built-in Table Options + +// Phase B: Low-complexity options +export { SelectDistinctOption } from './SelectDistinctOption'; +export { CustomColumnOption } from './CustomColumnOption'; +export { RollupRowsOption } from './RollupRowsOption'; + +// Phase C: Medium-complexity options +export { VisibilityOrderingOption } from './VisibilityOrderingOption'; +export { AggregationsOption } from './AggregationsOption'; + +// Phase D: High-complexity options +export { TableExporterOption } from './TableExporterOption'; +export { ConditionalFormattingOption } from './ConditionalFormattingOption'; +export { ChartBuilderOption } from './ChartBuilderOption'; +export { AdvancedSettingsOption } from './AdvancedSettingsOption'; + +// Toggle options +export { QuickFiltersOption } from './QuickFiltersOption'; +export { SearchBarOption } from './SearchBarOption'; +export { GotoRowOption } from './GotoRowOption'; diff --git a/packages/iris-grid/src/table-options/registerBuiltinOptions.ts b/packages/iris-grid/src/table-options/registerBuiltinOptions.ts new file mode 100644 index 0000000000..b1652b4359 --- /dev/null +++ b/packages/iris-grid/src/table-options/registerBuiltinOptions.ts @@ -0,0 +1,50 @@ +/** + * Registers all built-in table options with the default registry. + * Import this file to populate the registry with built-in options. + */ +import { defaultTableOptionsRegistry } from './TableOptionsRegistry'; +import { SelectDistinctOption } from './options/SelectDistinctOption'; +import { CustomColumnOption } from './options/CustomColumnOption'; +import { RollupRowsOption } from './options/RollupRowsOption'; +import { VisibilityOrderingOption } from './options/VisibilityOrderingOption'; +import { AggregationsOption } from './options/AggregationsOption'; +import { TableExporterOption } from './options/TableExporterOption'; +import { ConditionalFormattingOption } from './options/ConditionalFormattingOption'; +import { ChartBuilderOption } from './options/ChartBuilderOption'; +import { AdvancedSettingsOption } from './options/AdvancedSettingsOption'; +import { QuickFiltersOption } from './options/QuickFiltersOption'; +import { SearchBarOption } from './options/SearchBarOption'; +import { GotoRowOption } from './options/GotoRowOption'; + +/** + * Register all built-in options with the default registry. + * Call this once at application startup. + */ +export function registerBuiltinOptions(): void { + defaultTableOptionsRegistry.registerAll([ + // Phase B: Low-complexity options + SelectDistinctOption, + CustomColumnOption, + RollupRowsOption, + + // Phase C: Medium-complexity options + VisibilityOrderingOption, + AggregationsOption, + + // Phase D: High-complexity options + TableExporterOption, + ConditionalFormattingOption, + ChartBuilderOption, + AdvancedSettingsOption, + + // Toggle options + QuickFiltersOption, + SearchBarOption, + GotoRowOption, + ]); +} + +// Auto-register when this module is imported +registerBuiltinOptions(); + +export { defaultTableOptionsRegistry }; diff --git a/packages/plugin/src/PluginTypes.test.ts b/packages/plugin/src/PluginTypes.test.ts index 9ded422672..d76a11afe8 100644 --- a/packages/plugin/src/PluginTypes.test.ts +++ b/packages/plugin/src/PluginTypes.test.ts @@ -7,7 +7,10 @@ import { isTablePlugin, isThemePlugin, isWidgetPlugin, + isWidgetMiddlewarePlugin, type Plugin, + type WidgetPlugin, + type WidgetMiddlewarePlugin, isPlugin, } from './PluginTypes'; @@ -51,3 +54,45 @@ describe('isPlugin', () => { expect(isPlugin({})).toBe(false); }); }); + +describe('isWidgetMiddlewarePlugin', () => { + const baseWidgetPlugin: WidgetPlugin = { + name: 'test-widget', + type: PluginType.WIDGET_PLUGIN, + component: () => null, + supportedTypes: 'test-type', + }; + + const middlewarePlugin: WidgetMiddlewarePlugin = { + name: 'test-middleware', + type: PluginType.WIDGET_PLUGIN, + component: () => null, + supportedTypes: 'test-type', + isMiddleware: true, + }; + + it('returns true for middleware plugins', () => { + expect(isWidgetMiddlewarePlugin(middlewarePlugin)).toBe(true); + }); + + it('returns false for regular widget plugins', () => { + expect(isWidgetMiddlewarePlugin(baseWidgetPlugin)).toBe(false); + }); + + it('returns false for widget plugins with isMiddleware set to false', () => { + const notMiddleware = { + ...baseWidgetPlugin, + isMiddleware: false, + }; + expect(isWidgetMiddlewarePlugin(notMiddleware)).toBe(false); + }); + + it('returns false for non-widget plugins', () => { + expect( + isWidgetMiddlewarePlugin({ + name: 'test', + type: PluginType.DASHBOARD_PLUGIN, + }) + ).toBe(false); + }); +}); diff --git a/packages/plugin/src/PluginTypes.ts b/packages/plugin/src/PluginTypes.ts index 82c9b54010..8c046e1c70 100644 --- a/packages/plugin/src/PluginTypes.ts +++ b/packages/plugin/src/PluginTypes.ts @@ -144,6 +144,77 @@ export interface WidgetPanelProps extends WidgetComponentProps { glEventHub: EventEmitter; } +/** + * Props passed to middleware components that wrap a base widget. + * Extends WidgetComponentProps with the wrapped component. + */ +export interface WidgetMiddlewareComponentProps + extends WidgetComponentProps { + /** + * The next component in the middleware chain. + * Middleware should render this component to continue the chain. + */ + Component: React.ComponentType>; +} + +/** + * Props passed to middleware panel components that wrap a base panel. + * Extends WidgetPanelProps with the wrapped panel component. + */ +export interface WidgetMiddlewarePanelProps + extends WidgetPanelProps { + /** + * The next panel component in the middleware chain. + * Middleware should render this component to continue the chain. + */ + Component: React.ComponentType>; +} + +/** + * A middleware plugin that can wrap and enhance another widget plugin. + * Middleware plugins are chained together in registration order, + * with each middleware wrapping the next in the chain. + * + * The middleware pattern allows plugins to: + * - Add additional UI elements around a widget + * - Intercept and modify props before they reach the wrapped component + * - Provide additional context or state to the wrapped component + * - Add menu items or other extensions to the widget + */ +export interface WidgetMiddlewarePlugin + extends Omit, 'component' | 'panelComponent'> { + /** + * Marks this plugin as middleware that should be chained + * with other plugins of the same supportedTypes. + */ + isMiddleware: true; + + /** + * The middleware component that wraps the base widget component. + * Receives the wrapped component as a prop and should render it. + */ + component: React.ComponentType>; + + /** + * The middleware panel component that wraps the base panel component. + * If omitted, only the component middleware will be applied. + */ + panelComponent?: React.ComponentType>; +} + +/** + * Type guard to check if a plugin is a middleware plugin. + */ +export function isWidgetMiddlewarePlugin( + plugin: PluginModule +): plugin is WidgetMiddlewarePlugin { + return ( + isWidgetPlugin(plugin) && + 'isMiddleware' in plugin && + plugin.isMiddleware === true + ); +} + export interface WidgetPlugin extends Plugin { type: typeof PluginType.WIDGET_PLUGIN; /** diff --git a/packages/plugin/src/WidgetView.tsx b/packages/plugin/src/WidgetView.tsx index 6e92c17ff7..c7376c4241 100644 --- a/packages/plugin/src/WidgetView.tsx +++ b/packages/plugin/src/WidgetView.tsx @@ -1,6 +1,12 @@ import React, { useMemo } from 'react'; import usePlugins from './usePlugins'; -import { isWidgetPlugin } from './PluginTypes'; +import { + isWidgetPlugin, + isWidgetMiddlewarePlugin, + type WidgetPlugin, + type WidgetMiddlewarePlugin, + type WidgetComponentProps, +} from './PluginTypes'; export type WidgetViewProps = { /** Fetch function to return the widget */ @@ -10,19 +16,75 @@ export type WidgetViewProps = { type: string; }; +/** + * Creates a component that chains middleware around a base component. + * Each middleware wraps the next, with the base component at the innermost layer. + */ +function createChainedComponent( + baseComponent: React.ComponentType>, + middleware: WidgetMiddlewarePlugin[] +): React.ComponentType> { + if (middleware.length === 0) { + return baseComponent; + } + + // Build the chain from inside out (base component is innermost) + // Middleware is ordered outermost to innermost, so we reverse to build from inside out + return [...middleware] + .reverse() + .reduce>>( + (WrappedComponent, middlewarePlugin) => { + const MiddlewareComponent = middlewarePlugin.component; + + function ChainedComponent(props: WidgetComponentProps) { + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); + } + ChainedComponent.displayName = `${middlewarePlugin.name}(${ + (WrappedComponent as React.ComponentType).displayName ?? + (WrappedComponent as React.ComponentType).name ?? + 'Component' + })`; + return ChainedComponent; + }, + baseComponent + ); +} + export function WidgetView({ fetch, type }: WidgetViewProps): JSX.Element { const plugins = usePlugins(); - const plugin = useMemo( - () => - [...plugins.values()] - .filter(isWidgetPlugin) - .find(p => [p.supportedTypes].flat().includes(type)), - [plugins, type] - ); - - if (plugin != null) { - const Component = plugin.component; - return ; + + const { basePlugin, middleware } = useMemo(() => { + let foundBasePlugin: WidgetPlugin | undefined; + const foundMiddleware: WidgetMiddlewarePlugin[] = []; + + [...plugins.values()].filter(isWidgetPlugin).forEach(p => { + const supportsType = [p.supportedTypes].flat().includes(type); + if (!supportsType) { + return; + } + + if (isWidgetMiddlewarePlugin(p)) { + foundMiddleware.push(p); + } else if (foundBasePlugin == null) { + foundBasePlugin = p; + } + }); + + return { basePlugin: foundBasePlugin, middleware: foundMiddleware }; + }, [plugins, type]); + + const ChainedComponent = useMemo(() => { + if (basePlugin == null) { + return null; + } + return createChainedComponent(basePlugin.component, middleware); + }, [basePlugin, middleware]); + + if (ChainedComponent != null) { + return ; } throw new Error(`Unknown widget type '${type}'`); diff --git a/plans/Extensible Table Options.md b/plans/Extensible Table Options.md new file mode 100644 index 0000000000..2b14c0a4b7 --- /dev/null +++ b/plans/Extensible Table Options.md @@ -0,0 +1,413 @@ +# Extensible Table Options Menu for IrisGrid + +**Jira Ticket**: DH-xxxxx (To be filled) + +Enable plugins to register custom Table Options menu items without requiring modifications to the web-client-ui code. + +--- + +## Team(s) + +### Primary Team +- **UI Team** (web-client-ui, deephaven-plugins) + +### Cross-Team Dependencies +- None + +--- + +## Problem / Feature Gap + +### Current Limitation +The Table Options menu is hard-coded in the `IrisGrid` component with a fixed set of built-in options. To add a custom option, developers must: +1. Modify iris-grid package in the web-client-ui repository +2. Add new `OptionType` enum values +3. Update multiple switch statements +4. Publish a new version of the updated package + +This prevents plugins from providing their own table transformation options. + +### Desired State + +At a minimum, we want to enable plugins to register custom Table Options menu items for `IrisGrid` at runtime without modifying the `IrisGrid` code. Same custom menu configuration should be usable with `ui.table` component. + +Table Options menu consists of the menu screen (potentially nested), and an optional configuration UI rendered when an item is selected. See if we can use Adobe Spectrum menu components for the menu screen. + +Ideally, we want a generic architecture that can be reused in other container components in the future, such as a Sliding Sidebar. + +Plugins should be able to + - Register custom menu items at runtime without any code changes on the web-client-ui side. A custom Table Options menu item should: + - Appear at the specified position in the Table Options menu alongside built-in options + - When selected, render a custom UI for configuration, or run an action directly, e.g. toggle a setting in the context of the host component + - Allow custom renderer for the menu item itself to support menu items with UI Switches or other controls similar to the built-in Quick Filters option + - Control the visibility of existing menu options. This may be achievable via a custom table model that hides certain options, so this is lower priority. + - Access and modify the state of the host component via a defined interface + - Provide custom hydration/dehydration logic if needed + +Changes made via the custom Table Options items should be persistent. `IrisGrid` should be able to apply the required changes on load. + + +--- + +## Scope + +### In Scope / Requirements + + - Define the interface for custom menu items, including custom renderers, behaviors, state update mechanism + + - Define the interface for `IrisGrid` state access and modification by plugins + + - Convert the existing built-in Table Options menu to use the new extensible architecture + + - The plugins should implement the Middleware pattern. Currently, we only allow one plugin per widget type and ignore any subsequent plugins of the same type. Update the WidgetLoaderPlugin to check if the subsequent plugins define the middleware interface and chain them together if so. The order of execution should be defined by the plugin registration order. + + - Pass the built-in options to the plugins so they can extend/modify/hide them if needed; return the original list by default + + - Make sure we expose ordering/priority of menu items to plugins to control where the items appear in the menu + + - Provide example plugin(s) demonstrating how to register custom Table Options menu items (if this is implemented separately from the Pivot Builder plugin) + + - For the Pivot Builder - make the pivot configuration persistent. See if we can store the order of operations as part of the table state. This should probably be on the plugin side. TBD: behavior execution order when multiple plugins modify the same host component state. + + - Test the fallback mechanism when the plugin (or one of the plugin chain) is not present + + - Minimize breaking changes in the `IrisGrid` API + + +### Risks + +### Out of Scope / Limitations +1. **Convert Table Options** to use Spectrum menu components - Future work. + +2. **Pivot Builder** - Future work to make it a plugin using the new architecture. Focus on the extensible architecture for now. + +3. **UI.Table support** - Future work. + + +--- + +## Technical Design + +### Architecture Overview + - Plugin chaining mechanism - middleware pattern + - Registry-based architecture where options are self-contained modules + - Plugins register options via `defaultTableOptionsRegistry.register(option)` + - Options define their own menu item, Panel component, and toggle behavior + +### Key Files Created +- `packages/iris-grid/src/table-options/TableOption.ts` - Core interfaces (GridStateSnapshot, GridAction, TableOption, etc.) +- `packages/iris-grid/src/table-options/TableOptionsRegistry.ts` - Registry class with register/unregister/modify/subscribe +- `packages/iris-grid/src/table-options/TableOptionsHostContext.ts` - Context with useTableOptionsHost() hook +- `packages/iris-grid/src/table-options/TableOptionsHost.tsx` - Host component with Stack/Page/Menu rendering +- `packages/iris-grid/src/table-options/TableOptionsWrapper.tsx` - Bridge between IrisGrid class component and TableOptionsHost +- `packages/iris-grid/src/table-options/registerBuiltinOptions.ts` - Registers all built-in options +- `packages/iris-grid/src/table-options/options/*.tsx` - Individual option implementations +- `packages/dashboard-core-plugins/src/GridMiddlewarePlugin.tsx` - Example middleware demonstrating registry usage + + +### Decisions + +1. Custom option hydration/dehydration should be taken care of by the plugin itself via the `usePersistentState` hook + +2. Host component state management - plugins update only what they need, not entire state + +3. Provide a mechanism to enable/disable plugins, and define the order of execution for plugins + +4. Widget plugin/panel plugin distinction - ensure the architecture supports both use cases + +5. **Registry-based architecture**: Plugins register options via `defaultTableOptionsRegistry.register()` instead of using modifier props + +--- + +## Development Plan + +### Phase 1: Middleware Plugin Infrastructure ✅ +- [x] Investigate if plugin chains are possible on the same widget type + - Yes, we can chain plugins implementing a middleware interface. The order of execution is determined by the registration order. +- [x] Investigate Widget vs Panel plugins, support for core plugins in Enterprise + - Enterprise supports widget plugins via WidgetPluginLoader in GrizzlyPlus. Grizzly does not have a similar mechanism for panel plugins, support for panel plugins is out of scope for now. +- [x] Research Adobe Spectrum menu components + - Skipping conversion for now. +- [x] Implement middleware interface in WidgetPlugin type (`packages/plugin/src/PluginTypes.ts`) + - Added `WidgetMiddlewarePlugin` interface with `isMiddleware: true` marker + - Added `WidgetMiddlewareComponentProps` and `WidgetMiddlewarePanelProps` for middleware components + - Added `isWidgetMiddlewarePlugin()` type guard +- [x] Implement plugin chain support in WidgetLoaderPlugin (`packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx`) + - Updated `supportedTypes` logic to collect middleware plugins instead of replacing + - Added `createChainedComponent()` and `createChainedPanelComponent()` helper functions + - Middleware is chained in registration order (first registered = outermost wrapper) + - Middleware registered before or after base plugin is correctly applied + - Middleware-only registrations (no base plugin) are gracefully ignored +- [x] Add unit tests for middleware chaining + - Added 4 type guard tests in `PluginTypes.test.ts` + - Added 5 middleware chaining tests in `WidgetLoaderPlugin.test.tsx` +- [x] Create example middleware plugin (`packages/dashboard-core-plugins/src/GridMiddlewarePlugin.tsx`) + - Demonstrates component and panel middleware for Table/TreeTable/HierarchicalTable/PartitionedTable + - Shows how to register custom Table Options via registry + - Registered in `code-studio` and `embed-widget` for testing + + +### Phase 2: IrisGrid State Interface & Table Options Registry ✅ +- [x] Define IrisGrid state access/update interface for built-in options + - Created `TableOptionsHostContext.ts` with `TableOptionsHostContextValue` interface + - Interface provides: gridState (read-only snapshot), dispatch (action handlers), navigation + - Added `useTableOptionsHost()` hook for functional components +- [x] Convert built-in Table Options to use the interface for updates + - All built-in options now use `useTableOptionsHost()` hook + - Panels call `dispatch({ type: 'SET_*', payload })` to update grid state +- [x] Define the interface for built-in Table Options menu items + - `TableOption` interface with `type`, `menuItem`, `Panel`, optional `toggle`, optional `order` + - `TableOptionToggle` interface for toggle options with `getValue`, `actionType`, `shortcut` + - `TableOptionPanelProps` interface with `gridState`, `dispatch`, `onBack`, navigation helpers +- [x] Write the configuration for the existing built-in options + - Created self-contained option files for all 15 built-in options + +#### Table Options Registry Architecture ✅ + +**Goal:** Fully decouple IrisGrid from Table Options by creating a registry-based architecture where options are self-contained modules. + +**Architecture Overview:** +``` +┌─────────────────────────────────────────────────────────────┐ +│ TableOptionsRegistry │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │SelectDistinct│ │ RollupRows │ │ CustomColumn │ ... │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ IrisGrid │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ TableOptionsHost ││ +│ │ - Renders menu from registry ││ +│ │ - Manages navigation stack ││ +│ │ - Provides GridState + GridDispatch via context ││ +│ └─────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────┘ +``` + +**Core Interfaces:** +- `TableOption` - Self-contained option definition with menu item config, Panel component, optional toggle config +- `TableOptionPanelProps` - What panels receive: gridState (read-only), dispatch (actions), navigation +- `GridStateSnapshot` - Read-only view of grid state (model, columns, settings, toggle states) +- `GridAction / GridDispatch` - Actions to modify grid state (SET_*, TOGGLE_*, CREATE_CHART, etc.) + +**Benefits:** +| Aspect | Before | After | +|--------|--------|-------| +| Adding new option | Modify IrisGrid switch, add handler methods | Register single self-contained file | +| Option-specific state | IrisGrid state | Local to option via reducer | +| Sub-panels | Hardcoded in IrisGrid | Generic via `openSubPanel` | +| Plugin integration | optionsModifier function | Registry register/unregister | +| Testing | Need full IrisGrid | Test option in isolation | +| Bundle size | All options bundled | Could lazy-load options | + +**Migration Phases:** +- **Phase A:** Create registry, types, and `TableOptionsHost` component ✅ +- **Phase B:** Migrate low-complexity options (SelectDistinct, CustomColumn, RollupRows) ✅ +- **Phase C:** Migrate medium-complexity options (Aggregations, VisibilityOrdering) ✅ +- **Phase D:** Migrate high-complexity options (TableExporter, ConditionalFormatting) ✅ +- **Phase E:** Remove legacy switch statement ✅ + +**Files Created:** +- `packages/iris-grid/src/table-options/TableOption.ts` - Core interfaces ✅ +- `packages/iris-grid/src/table-options/TableOptionsRegistry.ts` - Registry class ✅ +- `packages/iris-grid/src/table-options/TableOptionsHostContext.ts` - Context + hook ✅ +- `packages/iris-grid/src/table-options/TableOptionsHost.tsx` - Host component ✅ +- `packages/iris-grid/src/table-options/TableOptionsWrapper.tsx` - Bridge component ✅ +- `packages/iris-grid/src/table-options/registerBuiltinOptions.ts` - Built-in registration ✅ +- `packages/iris-grid/src/table-options/options/SelectDistinctOption.tsx` ✅ +- `packages/iris-grid/src/table-options/options/CustomColumnOption.tsx` ✅ +- `packages/iris-grid/src/table-options/options/RollupRowsOption.tsx` ✅ +- `packages/iris-grid/src/table-options/options/VisibilityOrderingOption.tsx` ✅ +- `packages/iris-grid/src/table-options/options/AggregationsOption.tsx` ✅ +- `packages/iris-grid/src/table-options/options/TableExporterOption.tsx` ✅ +- `packages/iris-grid/src/table-options/options/ConditionalFormattingOption.tsx` ✅ +- `packages/iris-grid/src/table-options/options/QuickFiltersOption.ts` ✅ +- `packages/iris-grid/src/table-options/options/SearchBarOption.ts` ✅ +- `packages/iris-grid/src/table-options/options/GotoRowOption.ts` ✅ +- `packages/iris-grid/src/table-options/options/AdvancedSettingsOption.tsx` ✅ +- `packages/iris-grid/src/table-options/options/ChartBuilderOption.tsx` ✅ +- `packages/iris-grid/src/table-options/options/index.ts` ✅ +- `packages/iris-grid/src/table-options/index.ts` ✅ + +**Legacy Code Removed from IrisGrid.tsx:** +- Removed `useRegistryOptions` prop (registry is now always used) ✅ +- Removed `additionalMenuOptions` prop (plugins use registry instead) ✅ +- Removed `optionsModifier` prop (plugins use registry instead) ✅ +- Removed `getCachedOptionItems` method (~100 lines) ✅ +- Removed `getTableOptionsHostContextValue` method ✅ +- Removed legacy switch statement (~140 lines) ✅ +- Removed dead code variables ✅ +- Removed 11 unused icon imports ✅ +- Removed unused type import `OptionItemsModifier` ✅ + +**GridMiddlewarePlugin Updates:** +- Updated to use `defaultTableOptionsRegistry.register()` ✅ +- Demonstrates proper plugin pattern for adding custom Table Options ✅ + + +### Phase 3: Widget Replacement Architecture 🔲 + +**Goal:** Enable Table Options to transform a table into a different widget type (e.g., table → pivot table) and seamlessly swap the panel content. + +**Current Architecture:** +``` +Widget (type: "Table") + ↓ +WidgetLoaderPlugin.handlePanelOpen() + ↓ supportedTypes.get(type) +WidgetTypeInfo { basePlugin: IrisGridPlugin } + ↓ registerComponent(basePlugin.name, ...) +IrisGridPanel renders IrisGrid +``` + +**Problem:** +When a plugin transforms a table (e.g., creates a pivot), the result may be a different widget type. Currently there's no mechanism for: +1. The panel to detect the transformed type +2. Swapping the renderer to the appropriate widget component +3. Maintaining panel identity (same tab) while changing content + +**Research Questions:** +- [ ] What widget types exist? (Table, TreeTable, HierarchicalTable, Figure, etc.) +- [ ] How does IrisGridPanel currently handle model changes? +- [ ] Can a middleware change the widget type dynamically? +- [ ] Should widget replacement be handled at the Panel level or WidgetLoaderPlugin level? +- [ ] What state needs to be preserved vs reset during widget swap? + +**Proposed Architecture Options:** + +**Option A: Panel-level widget swap** +``` +IrisGridPanel + ├─ detects model type change + ├─ looks up new widget type from WidgetLoaderPlugin + └─ renders appropriate component (IrisGrid, PivotGrid, etc.) +``` +- Pros: Panel identity preserved, simpler +- Cons: IrisGridPanel becomes aware of other widget types + +**Option B: WidgetLoaderPlugin-level swap** +``` +WidgetLoaderPlugin + ├─ middleware can dispatch "REPLACE_WIDGET" action + ├─ closes current panel, opens new panel with new type + └─ transfers relevant state +``` +- Pros: Clean separation of concerns +- Cons: Panel identity lost, state transfer complexity + +**Option C: Dynamic component registry** +``` +Panel + ├─ subscribes to "currentWidgetType" state + ├─ components registered dynamically + └─ renders component matching currentWidgetType +``` +- Pros: Most flexible, preserves panel identity +- Cons: More complex state management + +**Tasks:** +- [ ] Research current widget type handling in WidgetLoaderPlugin +- [ ] Identify which transformations change widget type (pivot, rollup, etc.) +- [ ] Design widget replacement API for middleware +- [ ] Implement widget swap mechanism +- [ ] Test with pivot/rollup transformations +- [ ] Handle state preservation during swap + + +### Phase 4: Examples, Documentation & Polish 🔲 +- [ ] Clean up the example plugin (GridMiddlewarePlugin), add tests +- [ ] Add persistence example using `usePersistentState` +- [ ] Add another plugin to demonstrate chaining with configurable order of execution +- [ ] Write documentation for the new extensible Table Options menu architecture +- [ ] Add unit tests for new table-options components + + +### Phase 5: Integration Testing & Verification ✅ +- [x] Test in code-studio with sample database +- [x] Verify Quick Filters toggle works +- [x] Verify Search Bar toggle works +- [x] Verify Go To Row toggle works +- [x] Verify Chart Builder opens correctly +- [x] Verify Advanced Settings opens correctly +- [x] Verify all sidebar options (SelectDistinct, CustomColumn, etc.) work +- [x] Verify table download CSV works +- [x] Verify GridMiddlewarePlugin custom option appears and works +- [x] Test enterprise with custom plugins (tested via `npm run start-community` which runs enterprise server with updated packages) + +**Bug Fixed:** Toggle switches were not working because `TableOptionsHost` created menu items with `isOn` but without `onChange`. `MenuItem` ignores `onSelect` when `isOn` is defined, expecting `onChange` instead. Fixed by adding `onChange` handler that dispatches the toggle action. + +--- + +## Delivery Plan + +### Deliverables + +| Phase | Deliverables | Status | +|-------|--------------|--------| +| 1 | Middleware plugin infrastructure, chaining, tests, example plugin | ✅ Complete | +| 2 | Registry architecture, built-in options refactor, legacy removal | ✅ Complete | +| 3 | Widget replacement architecture (table → pivot swap) | 🔲 Not started | +| 4 | Documentation, additional examples, tests | 🔲 Not started | +| 5 | Integration testing and verification | ✅ Complete | + +### Testing Strategy + +- **Unit Tests**: + - Helper functions + - Registry operations + - Individual option components (TBD) + +- **Integration and E2E Tests**: + - Custom options render and work with IrisGrid + - Multiple plugins modifying the same host component + - Persistence of changes made via custom options + +### Success Criteria + +- [x] Plugins can register custom options without code changes +- [x] Custom options appear in menu and render correctly +- [x] Custom options can modify IrisGrid state (via `useTableOptionsHost()` hook) +- [x] Approach is generic and reusable (registry pattern implemented) +- [x] All existing built-in options work unchanged +- [ ] XX% test coverage +- [x] Minimal breaking changes (removed deprecated props) +- [ ] Documentation complete with examples + +--- + +## API Changes + +### Breaking Changes +The following props have been removed from `IrisGrid` and `IrisGridPanel`: +- `additionalMenuOptions` - Use `defaultTableOptionsRegistry.register()` instead +- `optionsModifier` - Use registry `modify()` or custom option registration instead +- `useRegistryOptions` - Registry is now always used + +### Migration Guide +**Before (deprecated):** +```tsx + [...opts, myOption]} +/> +``` + +**After:** +```tsx +import { defaultTableOptionsRegistry } from '@deephaven/iris-grid'; + +// In plugin initialization +defaultTableOptionsRegistry.register(MyCustomOption); +``` + +### New Public Exports from `@deephaven/iris-grid` +- `TableOption` - Interface for custom options +- `TableOptionPanelProps` - Props interface for Panel components +- `GridStateSnapshot` - Read-only grid state interface +- `GridAction` / `GridDispatch` - Action types and dispatcher +- `useTableOptionsHost` - Hook for accessing grid state in panels +- `defaultTableOptionsRegistry` - Registry singleton for option registration +- `TableOptionsRegistry` - Registry class (for creating custom registries)