diff --git a/packages/code-studio/src/index.tsx b/packages/code-studio/src/index.tsx index d623865a0f..ba090db11c 100644 --- a/packages/code-studio/src/index.tsx +++ b/packages/code-studio/src/index.tsx @@ -51,6 +51,7 @@ async function getCorePlugins() { FilterPluginConfig, MarkdownPluginConfig, LinkerPluginConfig, + SimplePivotPluginConfig, WidgetLoaderPluginConfig, } = dashboardCorePlugins; return [ @@ -61,6 +62,7 @@ async function getCorePlugins() { FilterPluginConfig, MarkdownPluginConfig, LinkerPluginConfig, + SimplePivotPluginConfig, WidgetLoaderPluginConfig, ]; } diff --git a/packages/dashboard-core-plugins/src/SimplePivotPluginConfig.ts b/packages/dashboard-core-plugins/src/SimplePivotPluginConfig.ts new file mode 100644 index 0000000000..6dbb522a81 --- /dev/null +++ b/packages/dashboard-core-plugins/src/SimplePivotPluginConfig.ts @@ -0,0 +1,15 @@ +import { PluginType, type WidgetPlugin } from '@deephaven/plugin'; +import { dhTable } from '@deephaven/icons'; +import type { dh } from '@deephaven/jsapi-types'; +import { SimplePivotWidgetPlugin } from './SimplePivotWidgetPlugin'; + +const SimplePivotPluginConfig: WidgetPlugin = { + name: 'SimplePivotPanel', + title: 'SimplePivot', + type: PluginType.WIDGET_PLUGIN, + component: SimplePivotWidgetPlugin, + supportedTypes: 'simplepivot.SimplePivotTable', + icon: dhTable, +}; + +export default SimplePivotPluginConfig; diff --git a/packages/dashboard-core-plugins/src/SimplePivotWidgetPlugin.tsx b/packages/dashboard-core-plugins/src/SimplePivotWidgetPlugin.tsx new file mode 100644 index 0000000000..1ddc49b14e --- /dev/null +++ b/packages/dashboard-core-plugins/src/SimplePivotWidgetPlugin.tsx @@ -0,0 +1,111 @@ +import { useCallback } from 'react'; +import { type WidgetComponentProps } from '@deephaven/plugin'; +import { type dh as DhType } from '@deephaven/jsapi-types'; +import IrisGrid, { + getSimplePivotColumnMap, + KEY_TABLE_PIVOT_COLUMN, + type KeyColumnArray, + type KeyTableSubscriptionData, +} from '@deephaven/iris-grid'; +import { useApi } from '@deephaven/jsapi-bootstrap'; +import { LoadingOverlay } from '@deephaven/components'; +import { getErrorMessage } from '@deephaven/utils'; +import { + useIrisGridSimplePivotModel, + type SimplePivotFetchResult, +} from './useIrisGridSimplePivotModel'; + +export function SimplePivotWidgetPlugin({ + fetch, +}: WidgetComponentProps): JSX.Element | null { + const dh = useApi(); + const loadKeys = useCallback( + (keyTable: DhType.Table): Promise => + new Promise((resolve, reject) => { + const pivotIdColumn = keyTable.findColumn(KEY_TABLE_PIVOT_COLUMN); + const columns = keyTable.columns.filter( + c => c.name !== KEY_TABLE_PIVOT_COLUMN + ); + const subscription = keyTable.subscribe(keyTable.columns); + subscription.addEventListener( + dh.Table.EVENT_UPDATED, + e => { + subscription.close(); + resolve(getSimplePivotColumnMap(e.detail, columns, pivotIdColumn)); + } + ); + }), + [dh] + ); + + const fetchTable = useCallback( + async function fetchModel() { + const pivotWidget = await fetch(); + const schema = JSON.parse(pivotWidget.getDataAsString()); + + // The initial state is our keys to use for column headers + const keyTablePromise = pivotWidget.exportedObjects[0].fetch(); + const columnMapPromise = keyTablePromise.then(loadKeys); + + return new Promise((resolve, reject) => { + // Add a listener for each pivot schema change, so we get the first update, with the table to render. + // Note that there is no await between this line and the pivotWidget being returned, or we would miss the first update + const removeEventListener = pivotWidget.addEventListener( + dh.Widget.EVENT_MESSAGE, + async e => { + removeEventListener(); + const data = e.detail.getDataAsString(); + const response = JSON.parse(data === '' ? '{}' : data); + if (response.error != null) { + reject(new Error(response.error)); + return; + } + // Get the object, and make sure the keytable is fetched and usable + const tables = e.detail.exportedObjects; + const tableToRenderPromise = tables[0].fetch(); + const totalsPromise = + tables.length === 2 ? tables[1].fetch() : Promise.resolve(null); + + // Wait for all four promises to have resolved, then render the table. Note that after + // the first load, the keytable will remain loaded, we'll only wait for the main table, + // and optionally the totals table. + const fetchResult = await Promise.all([ + tableToRenderPromise, + totalsPromise, + keyTablePromise, + columnMapPromise, + ]).then(([table, totalsTable, keyTable, columnMap]) => ({ + table, + totalsTable, + keyTable, + columnMap, + })); + resolve({ ...fetchResult, schema, pivotWidget }); + } + ); + }); + }, + [fetch, dh, loadKeys] + ); + + const fetchResult = useIrisGridSimplePivotModel(fetchTable); + + if (fetchResult.status === 'loading') { + return ; + } + + if (fetchResult.status === 'error') { + return ( + + ); + } + + const { model } = fetchResult; + + return ; +} + +export default SimplePivotWidgetPlugin; diff --git a/packages/dashboard-core-plugins/src/index.ts b/packages/dashboard-core-plugins/src/index.ts index 475db25a53..c9361657ee 100644 --- a/packages/dashboard-core-plugins/src/index.ts +++ b/packages/dashboard-core-plugins/src/index.ts @@ -16,6 +16,7 @@ export { default as MarkdownPluginConfig } from './MarkdownPluginConfig'; export { default as PandasPanelPlugin } from './PandasPanelPlugin'; export { default as PandasWidgetPlugin } from './PandasWidgetPlugin'; export { default as PandasPluginConfig } from './PandasPluginConfig'; +export { default as SimplePivotPluginConfig } from './SimplePivotPluginConfig'; export { default as WidgetLoaderPlugin } from './WidgetLoaderPlugin'; export { default as WidgetLoaderPluginConfig } from './WidgetLoaderPluginConfig'; export { default as ControlType } from './controls/ControlType'; diff --git a/packages/dashboard-core-plugins/src/useIrisGridSimplePivotModel.ts b/packages/dashboard-core-plugins/src/useIrisGridSimplePivotModel.ts new file mode 100644 index 0000000000..2672fa8ae1 --- /dev/null +++ b/packages/dashboard-core-plugins/src/useIrisGridSimplePivotModel.ts @@ -0,0 +1,134 @@ +import { type dh } from '@deephaven/jsapi-types'; +import { useApi } from '@deephaven/jsapi-bootstrap'; +import { useCallback, useEffect, useState } from 'react'; +import { + type IrisGridModel, + IrisGridSimplePivotModel, + type KeyColumnArray, + type SimplePivotSchema, +} from '@deephaven/iris-grid'; +import Log from '@deephaven/log'; + +const log = Log.module('useIrisGridSimplePivotModel'); + +export interface SimplePivotFetchResult { + columnMap: KeyColumnArray; + schema: SimplePivotSchema; + table: dh.Table; + keyTable: dh.Table; + totalsTable: dh.Table | null; + pivotWidget: dh.Widget; +} + +export type IrisGridModelFetch = () => Promise; + +export type IrisGridModelFetchErrorResult = { + error: NonNullable; + status: 'error'; +}; + +export type IrisGridModelFetchLoadingResult = { + status: 'loading'; +}; + +export type IrisGridModelFetchSuccessResult = { + status: 'success'; + model: IrisGridModel; +}; + +export type IrisGridModelFetchResult = ( + | IrisGridModelFetchErrorResult + | IrisGridModelFetchLoadingResult + | IrisGridModelFetchSuccessResult +) & { + reload: () => void; +}; + +/** Pass in a table `fetch` function, will load the model and handle any errors */ +export function useIrisGridSimplePivotModel( + fetch: IrisGridModelFetch +): IrisGridModelFetchResult { + const dh = useApi(); + const [model, setModel] = useState(); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(true); + + log.debug('render useIrisGridSimplePivotModel', model, error); + + // Close the model when component is unmounted + useEffect( + () => () => { + if (model) { + model.close(); + } + }, + [model] + ); + + const makeModel = useCallback(async () => { + log.debug('Fetching model'); + const { columnMap, keyTable, pivotWidget, schema, table, totalsTable } = + await fetch(); + log.debug('Fetching model before new Model'); + return new IrisGridSimplePivotModel( + dh, + table, + keyTable, + totalsTable, + columnMap, + schema, + pivotWidget + ); + }, [dh, fetch]); + + const reload = useCallback(async () => { + setIsLoading(true); + setError(undefined); + try { + const newModel = await makeModel(); + setModel(newModel); + setIsLoading(false); + } catch (e) { + setError(e); + setIsLoading(false); + } + }, [makeModel]); + + useEffect(() => { + log.debug('useEffect makeModel'); + let cancelled = false; + async function init() { + setIsLoading(true); + setError(undefined); + try { + const newModel = await makeModel(); + if (!cancelled) { + setModel(newModel); + setIsLoading(false); + } + } catch (e) { + if (!cancelled) { + setError(e); + setIsLoading(false); + } + } + } + + init(); + + return () => { + cancelled = true; + }; + }, [makeModel]); + + if (isLoading) { + return { reload, status: 'loading' }; + } + if (error != null) { + return { error, reload, status: 'error' }; + } + if (model != null) { + return { model, reload, status: 'success' }; + } + throw new Error('Invalid state'); +} diff --git a/packages/embed-widget/src/index.tsx b/packages/embed-widget/src/index.tsx index 3848d3422f..885145ea7f 100644 --- a/packages/embed-widget/src/index.tsx +++ b/packages/embed-widget/src/index.tsx @@ -45,12 +45,14 @@ async function getCorePlugins() { GridPluginConfig, PandasPluginConfig, ChartPluginConfig, + SimplePivotPluginConfig, WidgetLoaderPluginConfig, } = dashboardCorePlugins; return [ GridPluginConfig, PandasPluginConfig, ChartPluginConfig, + SimplePivotPluginConfig, WidgetLoaderPluginConfig, ]; } diff --git a/packages/grid/src/Grid.tsx b/packages/grid/src/Grid.tsx index 2db9435578..a222fe4cbf 100644 --- a/packages/grid/src/Grid.tsx +++ b/packages/grid/src/Grid.tsx @@ -562,12 +562,14 @@ class Grid extends PureComponent { movedRows: currentStateMovedRows, } = this.state; + const stateUpdates: Partial = {}; + if (prevPropMovedColumns !== movedColumns) { - this.setState({ movedColumns }); + stateUpdates.movedColumns = movedColumns; } if (prevPropMovedRows !== movedRows) { - this.setState({ movedRows }); + stateUpdates.movedRows = movedRows; } if (prevStateMovedColumns !== currentStateMovedColumns) { @@ -587,14 +589,16 @@ class Grid extends PureComponent { } if (isStickyBottom !== prevIsStickyBottom) { - this.setState({ isStuckToBottom: false }); + stateUpdates.isStuckToBottom = false; } if (isStickyRight !== prevIsStickyRight) { - this.setState({ isStuckToRight: false }); + stateUpdates.isStuckToRight = false; } - this.updateMetrics(); + const updatedState = { ...this.state, ...stateUpdates }; + + this.updateMetrics(updatedState); this.requestUpdateCanvas(); @@ -603,6 +607,8 @@ class Grid extends PureComponent { if (this.validateSelection()) { this.checkSelectionChange(prevState); } + + this.setState(updatedState); } componentWillUnmount(): void { diff --git a/packages/iris-grid/src/IrisGrid.tsx b/packages/iris-grid/src/IrisGrid.tsx index 3430d64d9c..6e65df157c 100644 --- a/packages/iris-grid/src/IrisGrid.tsx +++ b/packages/iris-grid/src/IrisGrid.tsx @@ -587,6 +587,7 @@ class IrisGrid extends Component { this.handleMovedColumnsChanged = this.handleMovedColumnsChanged.bind(this); this.handleHeaderGroupsChanged = this.handleHeaderGroupsChanged.bind(this); this.handleUpdate = this.handleUpdate.bind(this); + this.handleTableChanged = this.handleTableChanged.bind(this); this.handleTooltipRef = this.handleTooltipRef.bind(this); this.handleViewChanged = this.handleViewChanged.bind(this); this.handleFormatSelection = this.handleFormatSelection.bind(this); @@ -1539,6 +1540,17 @@ class IrisGrid extends Component { return null; } + const { model } = this.props; + + if ( + columnIndex != null && + modelColumns.get(columnIndex) != null && + model.columns[columnIndex] == null + ) { + log.debug('getModelColumn', columnIndex, model.columns); + // debugger; + } + return columnIndex != null ? modelColumns.get(columnIndex) : null; } @@ -2377,6 +2389,10 @@ class IrisGrid extends Component { IrisGridModel.EVENT.VIEWPORT_UPDATED, this.handleViewportUpdated ); + model.addEventListener( + IrisGridModel.EVENT.TABLE_CHANGED, + this.handleTableChanged + ); } stopListening(model: IrisGridModel): void { @@ -2397,6 +2413,10 @@ class IrisGrid extends Component { IrisGridModel.EVENT.VIEWPORT_UPDATED, this.handleViewportUpdated ); + model.removeEventListener( + IrisGridModel.EVENT.TABLE_CHANGED, + this.handleTableChanged + ); } focus(): void { @@ -3190,6 +3210,12 @@ class IrisGrid extends Component { this.stopLoading(); } + handleTableChanged(): void { + const { model } = this.props; + // movedColumns update triggers metricCalculator update in the Grid component + this.setState({ movedColumns: model.initialMovedColumns }); + } + handleViewChanged(metrics?: GridMetrics): void { const { model } = this.props; const { selectionEndRow = 0 } = this.grid?.state ?? {}; diff --git a/packages/iris-grid/src/IrisGridSimplePivotModel.ts b/packages/iris-grid/src/IrisGridSimplePivotModel.ts new file mode 100644 index 0000000000..d365db7737 --- /dev/null +++ b/packages/iris-grid/src/IrisGridSimplePivotModel.ts @@ -0,0 +1,804 @@ +/* eslint class-methods-use-this: "off" */ +/* eslint no-underscore-dangle: "off" */ +import memoize from 'memoize-one'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import Log from '@deephaven/log'; +import { Formatter, TableUtils } from '@deephaven/jsapi-utils'; +import { + assertNotNull, + EMPTY_ARRAY, + EventShimCustomEvent, + PromiseUtils, + type CancelablePromise, +} from '@deephaven/utils'; +import { + GridRange, + type ModelIndex, + type MoveOperation, +} from '@deephaven/grid'; +import { type ColumnName } from './CommonTypes'; +import type { DisplayColumn } from './IrisGridModel'; +import ColumnHeaderGroup from './ColumnHeaderGroup'; +import IrisGridModel from './IrisGridModel'; +import IrisGridTableModel from './IrisGridTableModel'; +import { isIrisGridTableModelTemplate } from './IrisGridTableModelTemplate'; +import IrisGridUtils from './IrisGridUtils'; +import type { IrisGridThemeType } from './IrisGridTheme'; +import { + getSimplePivotColumnMap, + isColumnMapComplete, + KEY_TABLE_PIVOT_COLUMN, + TOTALS_COLUMN, + type KeyColumnArray, + type KeyTableSubscriptionData, + type SimplePivotColumnMap, + type SimplePivotSchema, +} from './SimplePivotUtils'; + +const log = Log.module('IrisGridSimplePivotModel'); + +function makeModel( + dh: typeof DhType, + table: DhType.Table, + formatter?: Formatter +): IrisGridModel { + return new IrisGridTableModel(dh, table, formatter); +} + +const GRAND_TOTAL_VALUE = 'Grand Total'; + +/** + * Model which proxies calls to IrisGridModel. + * This allows updating the underlying Simple Pivot tables on schema changes. + * The proxy model will call any methods it has implemented and delegate any + * it does not implement to the underlying model. + */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +class IrisGridSimplePivotModel extends IrisGridModel { + private keyTable: DhType.Table; + + private keyTableSubscription: DhType.TableSubscription | null; + + private columnMap: SimplePivotColumnMap; + + private nextColumnMap: SimplePivotColumnMap | null; + + private schema: SimplePivotSchema; + + private pivotWidget: DhType.Widget; + + model: IrisGridModel; + + private schemaPromise: CancelablePromise<[DhType.Table, DhType.Table]> | null; + + private nextModel: IrisGridModel | null; + + private totalsTable: DhType.Table | null; + + private nextTotalsTable: DhType.Table | null; + + private totalsRowMap: Map; + + private _layoutHints: DhType.LayoutHints | null | undefined; + + constructor( + dh: typeof DhType, + table: DhType.Table, + keyTable: DhType.Table, + totalsTable: DhType.Table | null, + columnMap: KeyColumnArray, + schema: SimplePivotSchema, + pivotWidget: DhType.Widget, + formatter = new Formatter(dh) + ) { + super(dh); + + this.addEventListener = this.addEventListener.bind(this); + this.removeEventListener = this.removeEventListener.bind(this); + this.dispatchEvent = this.dispatchEvent.bind(this); + + this.handleModelEvent = this.handleModelEvent.bind(this); + + this.handleKeyTableUpdate = this.handleKeyTableUpdate.bind(this); + this.handleSchemaUpdate = this.handleSchemaUpdate.bind(this); + this.handleTotalsUpdate = this.handleTotalsUpdate.bind(this); + + this.model = makeModel(dh, table, formatter); + this.schemaPromise = null; + this.nextModel = null; + + this.keyTable = keyTable; + this.keyTableSubscription = null; + this.pivotWidget = pivotWidget; + this.totalsTable = null; + this.nextTotalsTable = null; + this.totalsRowMap = new Map(); + + this.columnMap = new Map( + schema.hasTotals ? [[TOTALS_COLUMN, 'Totals'], ...columnMap] : columnMap + ); + this.nextColumnMap = null; + this.pivotWidget = pivotWidget; + this.schema = schema; + + this._layoutHints = { + backColumns: [TOTALS_COLUMN], + hiddenColumns: [], + frozenColumns: [], + columnGroups: [], + areSavedLayoutsAllowed: false, + frontColumns: [], + searchDisplayMode: this.dh.SearchDisplayMode.SEARCH_DISPLAY_HIDE, + }; + + this.startListeningToKeyTable(); + + this.startListeningToSchema(); + + this.setTotalsTable(totalsTable); + + // Proxy everything to the underlying model, unless overridden + // eslint-disable-next-line no-constructor-return + return new Proxy(this, { + // We want to use any properties on the proxy model if defined + // If not, then proxy to the underlying model + get(target, prop, receiver) { + // Does this class have a getter for the prop + // Getter functions are on the prototype + const proxyHasGetter = + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), prop) + ?.get != null; + + if (proxyHasGetter) { + return Reflect.get(target, prop, receiver); + } + + // Does this class implement the property + const proxyHasProp = Object.prototype.hasOwnProperty.call(target, prop); + + // Does the class implement a function for the property + const proxyHasFn = Object.prototype.hasOwnProperty.call( + Object.getPrototypeOf(target), + prop + ); + + const trueTarget = proxyHasProp || proxyHasFn ? target : target.model; + return Reflect.get(trueTarget, prop); + }, + set(target, prop, value) { + const proxyHasSetter = + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), prop) + ?.set != null; + + const proxyHasProp = Object.prototype.hasOwnProperty.call(target, prop); + + if (proxyHasSetter || proxyHasProp) { + return Reflect.set(target, prop, value, target); + } + + return Reflect.set(target.model, prop, value, target.model); + }, + }); + } + + /** + * Add displayName property to the given column + * @param column Column to add displayName to + * @param columnMap Column name map + * @returns Column with the displayName + */ + private createDisplayColumn( + column: DhType.Column, + columnMap: SimplePivotColumnMap + ): DisplayColumn { + return new Proxy(column, { + get: (target, prop) => { + if (prop === 'displayName') { + return columnMap.get(column.name) ?? column.name; + } + return Reflect.get(target, prop); + }, + }); + } + + private getCachedColumnHeaderGroups = memoize( + ( + columns: readonly DisplayColumn[], + schema: SimplePivotSchema + ): readonly ColumnHeaderGroup[] => { + const { rowColNames, columnColNames } = schema; + const columnNamesGroup = columns.filter( + c => !rowColNames.includes(c.name) + ); + return [ + new ColumnHeaderGroup({ + name: schema.pivotDescription, + children: rowColNames, + depth: 1, + childIndexes: rowColNames.map((_, index) => index), + }), + new ColumnHeaderGroup({ + name: columnColNames.join(', '), + children: columnNamesGroup.map(c => c.name), + depth: 1, + childIndexes: columnNamesGroup.map( + (_, index) => rowColNames.length + index + ), + }), + ]; + } + ); + + get initialColumnHeaderGroups(): readonly ColumnHeaderGroup[] { + log.debug('get initialColumnHeaderGroups'); + return this.getCachedColumnHeaderGroups(this.model.columns, this.schema); + } + + get initialMovedColumns(): readonly MoveOperation[] { + log.debug('get initialMovedColumns'); + return this.getCachedMovedColumns( + this.model.columns, + this.schema.hasTotals + ); + } + + get columns(): DhType.Column[] { + return this.getCachedColumns(this.columnMap, this.model.columns); + } + + get isChartBuilderAvailable(): boolean { + return false; + } + + get isFormatColumnsAvailable(): boolean { + return false; + } + + get isOrganizeColumnsAvailable(): boolean { + return false; + } + + get isSeekRowAvailable(): boolean { + return false; + } + + get isSelectDistinctAvailable(): boolean { + return false; + } + + get isReversible(): boolean { + return false; + } + + isFilterable(columnIndex: ModelIndex): boolean { + return columnIndex < this.schema.rowColNames.length; + } + + isColumnSortable(columnIndex: ModelIndex): boolean { + return columnIndex < this.schema.rowColNames.length; + } + + get isTotalsAvailable(): boolean { + // Hide Aggregate Columns option in Table Settings + return false; + } + + get isRollupAvailable(): boolean { + return false; + } + + get isExportAvailable(): boolean { + // table.freeze is available, but exporting requires extra logic for column mapping and totals rows + return false; + } + + get isCustomColumnsAvailable(): boolean { + return false; + } + + get rowCount(): number { + return this.model.rowCount + (this.schema.hasTotals ? 1 : 0); + } + + valueForCell(x: ModelIndex, y: ModelIndex): unknown { + if (this.schema.hasTotals && y === this.rowCount - 1) { + if (x >= this.schema.rowColNames.length) { + return this.totalsRowMap.get(this.columns[x].name); + } + return x === 0 ? GRAND_TOTAL_VALUE : undefined; + } + return this.model.valueForCell(x, y); + } + + textForCell(x: ModelIndex, y: ModelIndex): string { + return this.schema.hasTotals && y === this.rowCount - 1 && x === 0 + ? GRAND_TOTAL_VALUE + : // Pass the context so model.textForCell calls this.valueForCell instead of model.valueForCell + this.model.textForCell.call(this, x, y); + } + + setTotalsTable(totalsTable: DhType.Table | null): void { + log.debug('setTotalsTable', totalsTable); + this.stopListeningToTotals(); + + if (totalsTable == null) { + this.totalsTable = null; + return; + } + + this.totalsTable = totalsTable; + this.startListeningToTotals(); + this.totalsTable.setViewport(0, 0); + } + + startListeningToKeyTable(): void { + const { dh, keyTable } = this; + log.debug('Start Listening to key table'); + this.keyTableSubscription = keyTable.subscribe(keyTable.columns); + this.keyTableSubscription.addEventListener( + dh.Table.EVENT_UPDATED, + this.handleKeyTableUpdate + ); + } + + stopListeningToKeyTable(): void { + log.debug('Stop Listening to key table subscription'); + this.keyTableSubscription?.close(); + this.keyTableSubscription = null; + } + + startListeningToSchema(): void { + const { dh, pivotWidget } = this; + log.debug('Start Listening to schema'); + pivotWidget.addEventListener( + dh.Widget.EVENT_MESSAGE, + this.handleSchemaUpdate + ); + } + + stopListeningToSchema(): void { + const { dh, pivotWidget } = this; + log.debug('Stop Listening to schema'); + pivotWidget.removeEventListener( + dh.Widget.EVENT_MESSAGE, + this.handleSchemaUpdate + ); + } + + startListeningToTotals(): void { + log.debug('Start Listening to totals table'); + this.totalsTable?.addEventListener( + this.dh.Table.EVENT_UPDATED, + this.handleTotalsUpdate + ); + } + + stopListeningToTotals(): void { + log.debug('Stop Listening to totals table'); + this.totalsTable?.removeEventListener( + this.dh.Table.EVENT_UPDATED, + this.handleTotalsUpdate + ); + } + + handleKeyTableUpdate(e: { detail: KeyTableSubscriptionData }): void { + log.debug('Key table updated'); + const pivotIdColumn = this.keyTable.findColumn(KEY_TABLE_PIVOT_COLUMN); + const columns = this.keyTable.columns.filter( + c => c.name !== KEY_TABLE_PIVOT_COLUMN + ); + const keyColumns = getSimplePivotColumnMap( + e.detail, + columns, + pivotIdColumn + ); + if (this.schema.hasTotals) { + keyColumns.push([TOTALS_COLUMN, 'Totals']); + } + const columnMap = new Map(keyColumns); + + if (this.nextModel == null) { + if (isColumnMapComplete(columnMap, this.model.columns)) { + log.debug2( + 'Key table update matches the existing model, update columns' + ); + this.columnMap = columnMap; + this.columnHeaderGroups = this.getCachedColumnHeaderGroups( + this.model.columns, + this.schema + ); + this.dispatchEvent( + new EventShimCustomEvent(IrisGridModel.EVENT.COLUMNS_CHANGED, { + detail: this.columns, + }) + ); + } else { + log.debug2( + 'Key table update does not match the existing model, save column map for the next schema update' + ); + this.nextColumnMap = columnMap; + } + return; + } + if (isColumnMapComplete(columnMap, this.nextModel.columns)) { + log.debug2('Key table update matches the saved model, update the model'); + assertNotNull(this.nextTotalsTable); + this.setModel(this.nextModel, columnMap, this.nextTotalsTable); + this.nextModel = null; + this.nextTotalsTable = null; + } else { + log.debug2( + 'Key table update does not match the saved model, save column map for the next schema update' + ); + this.nextColumnMap = columnMap; + } + } + + async handleSchemaUpdate(e: DhType.Event): Promise { + log.debug('Schema updated'); + const tables = e.detail.exportedObjects; + const tablePromise = tables[0].fetch(); + const totalsTablePromise = tables.length === 2 ? tables[1].fetch() : null; + const pivotTablesPromise = Promise.all([tablePromise, totalsTablePromise]); + this.setNextSchema(pivotTablesPromise); + } + + copyTotalsData(data: DhType.ViewportData): void { + this.totalsRowMap = new Map(); + data.columns.forEach(column => { + this.totalsRowMap.set(column.name, data.getData(0, column)); + }); + } + + handleTotalsUpdate(event: DhType.Event): void { + log.debug('handleTotalsUpdate', event.detail); + + this.copyTotalsData(event.detail); + this.dispatchEvent(new EventShimCustomEvent(IrisGridModel.EVENT.UPDATED)); + } + + getCachedMovedColumns = memoize( + ( + columns: readonly DhType.Column[], + hasTotals: boolean + ): readonly MoveOperation[] => { + if (!hasTotals) { + return EMPTY_ARRAY; + } + + const totalsColumnIndex = columns.findIndex( + c => c.name === TOTALS_COLUMN + ); + if (totalsColumnIndex === -1) { + log.warn('Totals column not found in getCachedMovedColumns'); + return EMPTY_ARRAY; + } + const movedColumns: MoveOperation[] = []; + if (totalsColumnIndex < columns.length - 1) { + movedColumns.push({ + from: totalsColumnIndex, + to: columns.length - 1, + }); + } + return movedColumns; + } + ); + + getCachedColumns = memoize( + (columnMap: SimplePivotColumnMap, tableColumns: readonly DhType.Column[]) => + tableColumns.map(c => this.createDisplayColumn(c, columnMap)) + ); + + get layoutHints(): DhType.LayoutHints | null | undefined { + return this._layoutHints; + } + + set columnHeaderGroups(columnHeaderGroups: readonly ColumnHeaderGroup[]) { + this.model.columnHeaderGroups = columnHeaderGroups; + } + + isColumnMovable(): boolean { + return false; + } + + /** + * Use this as the canonical column index since things like layoutHints could have + * changed the column order. + */ + getColumnIndexByName(name: ColumnName): number | undefined { + return this.getColumnIndicesByNameMap(this.columns).get(name); + } + + getColumnIndicesByNameMap = memoize( + (columns: DhType.Column[]): Map => { + const indices = new Map(); + columns.forEach(({ name }, i) => indices.set(name, i)); + return indices; + } + ); + + updateFrozenColumns(columns: ColumnName[]): void { + if (columns.length > 0) { + throw new Error('Cannot freeze columns on a pivot table'); + } + } + + handleModelEvent(event: CustomEvent): void { + log.debug2('handleModelEvent', event); + + const { detail, type } = event; + this.dispatchEvent(new EventShimCustomEvent(type, { detail })); + } + + setModel( + model: IrisGridModel, + columnMap: SimplePivotColumnMap, + totalsTable: DhType.Table + ): void { + log.debug('setModel', model); + + const oldModel = this.model; + oldModel.close(); + if (this.listenerCount > 0) { + this.removeListeners(oldModel); + } + + this.model = model; + this.setTotalsTable(totalsTable); + this.columnMap = columnMap; + this.columnHeaderGroups = this.getCachedColumnHeaderGroups( + this.model.columns, + this.schema + ); + + if ( + !isIrisGridTableModelTemplate(model) || + !isIrisGridTableModelTemplate(oldModel) + ) { + throw new Error('Invalid model, setModel not available'); + } + if (this.listenerCount > 0) { + this.addListeners(model); + } + + if (isIrisGridTableModelTemplate(model)) { + this.dispatchEvent( + new EventShimCustomEvent(IrisGridModel.EVENT.TABLE_CHANGED, { + detail: model.table, + }) + ); + } + this.dispatchEvent( + new EventShimCustomEvent(IrisGridModel.EVENT.COLUMNS_CHANGED, { + detail: this.columns, + }) + ); + this.dispatchEvent( + new EventShimCustomEvent(IrisGridModel.EVENT.UPDATED, { + detail: this, + }) + ); + } + + setNextSchema( + pivotTablesPromise: Promise<[DhType.Table, DhType.Table]> + ): void { + if (this.schemaPromise) { + this.schemaPromise.cancel(); + } + + this.schemaPromise = PromiseUtils.makeCancelable( + pivotTablesPromise, + ([table, totalsTable]: [DhType.Table, DhType.Table]) => { + table.close(); + totalsTable.close(); + } + ); + this.schemaPromise + .then(([table, totalsTable]) => { + log.debug('Schema updated'); + this.schemaPromise = null; + const model = makeModel(this.dh, table, this.formatter); + if (this.nextColumnMap != null) { + if (isColumnMapComplete(this.nextColumnMap, model.columns)) { + log.debug2( + 'Schema updated, set new model with the saved column map' + ); + this.setModel(model, this.nextColumnMap, totalsTable); + this.nextColumnMap = null; + } else { + log.debug2( + 'Saved column map does not match the new model, save the model for the next key table update' + ); + this.nextModel = model; + this.nextTotalsTable = totalsTable; + } + return; + } + if (isColumnMapComplete(this.columnMap, model.columns)) { + log.debug2('Schema updated, set new model with existing column map'); + this.setModel(model, this.columnMap, totalsTable); + } else { + log.debug2( + 'Existing column map does not match the new model, save the model for the next key table update' + ); + this.nextModel = model; + this.nextTotalsTable = totalsTable; + } + }) + .catch((err: unknown) => { + if (PromiseUtils.isCanceled(err)) { + log.debug2('setNextSchema cancelled'); + return; + } + + log.error('Unable to set next model', err); + this.schemaPromise = null; + + this.dispatchEvent( + new EventShimCustomEvent(IrisGridModel.EVENT.REQUEST_FAILED, { + detail: err, + }) + ); + }); + } + + async snapshot( + ranges: readonly GridRange[], + includeHeaders = false, + formatValue: (value: unknown, column: DhType.Column) => unknown = value => + value, + consolidateRanges = true + ): Promise { + if (!isIrisGridTableModelTemplate(this.model)) { + throw new Error('Invalid model, snapshot not available'); + } + + const consolidated = consolidateRanges + ? GridRange.consolidate(ranges) + : ranges; + if (!IrisGridUtils.isValidSnapshotRanges(consolidated)) { + throw new Error(`Invalid snapshot ranges ${ranges}`); + } + + let hasTotals = false; + const tableRanges: GridRange[] = []; + + const tableSize = this.model.table.size; + + for (let i = 0; i < consolidated.length; i += 1) { + const range = consolidated[i]; + assertNotNull(range.endRow); + assertNotNull(range.startRow); + // Separate out the range that is part of the actual table + if (range.endRow === tableSize) { + hasTotals = true; + if (range.startRow < tableSize) { + tableRanges.push( + new GridRange( + range.startColumn, + range.startRow, + range.endColumn, + range.endRow - 1 + ) + ); + } + } else { + tableRanges.push(range); + } + } + const result = + tableRanges.length === 0 + ? [] + : await this.model.snapshot( + tableRanges, + false, + formatValue, + consolidateRanges + ); + + const columns = IrisGridUtils.columnsFromRanges(consolidated, this.columns); + + if (includeHeaders) { + const headerRow = columns.map( + column => this.columnMap.get(column.name) ?? column.name + ); + result.unshift(headerRow); + } + + if (hasTotals) { + const rowData = columns.map(column => { + const index = this.getColumnIndexByName(column.name); + assertNotNull(index); + return index === 0 + ? GRAND_TOTAL_VALUE + : formatValue(this.valueForCell(index, tableSize), column); + }); + result.push(rowData); + } + + return result; + } + + colorForCell(x: ModelIndex, y: ModelIndex, theme: IrisGridThemeType): string { + if (!isIrisGridTableModelTemplate(this.model)) { + throw new Error('Invalid model, colorForCell not available'); + } + + if (this.schema.hasTotals && y === this.rowCount - 1) { + if (x >= this.schema.rowColNames.length) { + const value = this.valueForCell(x, y); + if (value == null || value === '') { + assertNotNull(theme.nullStringColor); + return theme.nullStringColor; + } + + // Format based on the value/type of the cell + if (value != null) { + const column = this.columns[x]; + if (TableUtils.isDateType(column.type) || column.name === 'Date') { + assertNotNull(theme.dateColor); + return theme.dateColor; + } + if (TableUtils.isNumberType(column.type)) { + if ((value as number) > 0) { + assertNotNull(theme.positiveNumberColor); + return theme.positiveNumberColor; + } + if ((value as number) < 0) { + assertNotNull(theme.negativeNumberColor); + return theme.negativeNumberColor; + } + assertNotNull(theme.zeroNumberColor); + return theme.zeroNumberColor; + } + } + } + + return theme.textColor; + } + + return this.model.colorForCell(x, y, theme); + } + + startListening(): void { + super.startListening(); + + this.addListeners(this.model); + } + + stopListening(): void { + super.stopListening(); + + this.removeListeners(this.model); + } + + addListeners(model: IrisGridModel): void { + const events = Object.keys(IrisGridModel.EVENT); + for (let i = 0; i < events.length; i += 1) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + model.addEventListener(events[i], this.handleModelEvent); + } + } + + removeListeners(model: IrisGridModel): void { + const events = Object.keys(IrisGridModel.EVENT); + for (let i = 0; i < events.length; i += 1) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + model.removeEventListener(events[i], this.handleModelEvent); + } + } + + close(): void { + log.debug('close'); + this.stopListeningToTotals(); + this.stopListeningToKeyTable(); + this.stopListeningToSchema(); + this.model.close(); + } +} + +export default IrisGridSimplePivotModel; diff --git a/packages/iris-grid/src/SimplePivotUtils.test.ts b/packages/iris-grid/src/SimplePivotUtils.test.ts new file mode 100644 index 0000000000..2a9a0e70f9 --- /dev/null +++ b/packages/iris-grid/src/SimplePivotUtils.test.ts @@ -0,0 +1,48 @@ +import type { dh as DhType } from '@deephaven/jsapi-types'; +import { + PIVOT_COLUMN_PREFIX, + isColumnMapComplete, + type SimplePivotColumnMap, +} from './SimplePivotUtils'; + +describe('isColumnMapComplete', () => { + it('returns true if all PIVOT_C_ columns exist in the map', () => { + const mockColumns = [ + { name: `${PIVOT_COLUMN_PREFIX}A` } as DhType.Column, + { name: `${PIVOT_COLUMN_PREFIX}B` } as DhType.Column, + ]; + const mockColumnMap: SimplePivotColumnMap = new Map([ + [`${PIVOT_COLUMN_PREFIX}A`, 'ValA'], + [`${PIVOT_COLUMN_PREFIX}B`, 'ValB'], + ]); + + const result = isColumnMapComplete(mockColumnMap, mockColumns); + expect(result).toBe(true); + }); + + it('returns false if a pivot column is missing in the map', () => { + const mockColumns = [ + { name: `${PIVOT_COLUMN_PREFIX}A` } as DhType.Column, + { name: `${PIVOT_COLUMN_PREFIX}B` } as DhType.Column, + ]; + const mockColumnMap: SimplePivotColumnMap = new Map([ + [`${PIVOT_COLUMN_PREFIX}A`, 'ValA'], + ]); + + const result = isColumnMapComplete(mockColumnMap, mockColumns); + expect(result).toBe(false); + }); + + it('ignores non-pivot columns when checking completeness', () => { + const mockColumns = [ + { name: 'RandomColumn' } as DhType.Column, + { name: `${PIVOT_COLUMN_PREFIX}A` } as DhType.Column, + ]; + const mockColumnMap: SimplePivotColumnMap = new Map([ + [`${PIVOT_COLUMN_PREFIX}A`, 'ValA'], + ]); + + const result = isColumnMapComplete(mockColumnMap, mockColumns); + expect(result).toBe(true); + }); +}); diff --git a/packages/iris-grid/src/SimplePivotUtils.ts b/packages/iris-grid/src/SimplePivotUtils.ts new file mode 100644 index 0000000000..0e1340e350 --- /dev/null +++ b/packages/iris-grid/src/SimplePivotUtils.ts @@ -0,0 +1,66 @@ +import type { dh as DhType, Iterator } from '@deephaven/jsapi-types'; + +export const KEY_TABLE_PIVOT_COLUMN = '__PIVOT_COLUMN'; + +export const PIVOT_COLUMN_PREFIX = 'PIVOT_C_'; + +export const TOTALS_COLUMN = '__TOTALS_COLUMN'; + +export interface SimplePivotSchema { + columnColNames: string[]; + rowColNames: string[]; + hasTotals: boolean; + pivotDescription: string; +} + +export type KeyColumnArray = (readonly [string, string])[]; + +export type SimplePivotColumnMap = ReadonlyMap; + +export interface KeyTableSubscriptionData { + fullIndex: { iterator: () => Iterator }; + getData: (rowKey: DhType.Row, column: DhType.Column) => string; +} + +/** + * Get a column map for a simple pivot table based on the key table data + * @param data Data from the key table + * @param columns Columns to include in the column map + * @param pivotIdColumn Key table column containing display names for the pivot columns + * @returns Column map for the simple pivot table + */ +export function getSimplePivotColumnMap( + data: KeyTableSubscriptionData, + columns: DhType.Column[], + pivotIdColumn: DhType.Column +): KeyColumnArray { + const columnMap: KeyColumnArray = []; + const rowIter = data.fullIndex.iterator(); + while (rowIter.hasNext()) { + const rowKey = rowIter.next().value; + const value = []; + for (let i = 0; i < columns.length; i += 1) { + value.push(data.getData(rowKey, columns[i])); + } + columnMap.push([ + `${PIVOT_COLUMN_PREFIX}${data.getData(rowKey, pivotIdColumn)}`, + value.join(', '), + ]); + } + return columnMap; +} + +/** + * Check if the column map has entries for all pivot columns + * @param columnMap Column map to check + * @param columns Columns to check against + * @returns True if the column map has entries for all pivot columns + */ +export function isColumnMapComplete( + columnMap: SimplePivotColumnMap, + columns: readonly DhType.Column[] +): boolean { + return !columns.some( + c => c.name.startsWith(PIVOT_COLUMN_PREFIX) && !columnMap.has(c.name) + ); +} diff --git a/packages/iris-grid/src/index.ts b/packages/iris-grid/src/index.ts index 3a01c0e2ea..411e8511b3 100644 --- a/packages/iris-grid/src/index.ts +++ b/packages/iris-grid/src/index.ts @@ -12,6 +12,7 @@ export * from './IrisGrid'; export type { default as IrisGridType } from './IrisGrid'; export { default as SHORTCUTS } from './IrisGridShortcuts'; export { default as IrisGridModel } from './IrisGridModel'; +export * from './IrisGridModel'; export { default as IrisGridTableModel } from './IrisGridTableModel'; export * from './IrisGridTableModel'; export { default as IrisGridPartitionedTableModel } from './IrisGridPartitionedTableModel'; @@ -20,6 +21,9 @@ export { default as IrisGridTableModelTemplate } from './IrisGridTableModelTempl export * from './IrisGridTreeTableModel'; export * from './IrisGridTableModelTemplate'; export { default as IrisGridModelFactory } from './IrisGridModelFactory'; +export { default as IrisGridSimplePivotModel } from './IrisGridSimplePivotModel'; +export * from './IrisGridSimplePivotModel'; +export * from './SimplePivotUtils'; export { createDefaultIrisGridTheme } from './IrisGridTheme'; export type { IrisGridThemeType } from './IrisGridTheme'; export * from './IrisGridThemeProvider'; diff --git a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx index 368ab3a62c..92bc1c6aff 100644 --- a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx +++ b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx @@ -278,6 +278,7 @@ class IrisGridContextMenuHandler extends GridMouseHandler { action: () => { this.irisGrid.handleAdvancedMenuOpened(visibleIndex); }, + disabled: !model.isFilterable(modelIndex), }); actions.push({ title: 'Clear Table Filters',