diff --git a/package-lock.json b/package-lock.json index 54341481a4..d93a43dd90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28366,6 +28366,7 @@ "@deephaven/filters": "file:../filters", "@deephaven/grid": "file:../grid", "@deephaven/icons": "file:../icons", + "@deephaven/jsapi-components": "file:../jsapi-components", "@deephaven/jsapi-types": "file:../jsapi-types", "@deephaven/jsapi-utils": "file:../jsapi-utils", "@deephaven/log": "file:../log", @@ -30329,6 +30330,7 @@ "@deephaven/filters": "file:../filters", "@deephaven/grid": "file:../grid", "@deephaven/icons": "file:../icons", + "@deephaven/jsapi-components": "file:../jsapi-components", "@deephaven/jsapi-shim": "file:../jsapi-shim", "@deephaven/jsapi-types": "file:../jsapi-types", "@deephaven/jsapi-utils": "file:../jsapi-utils", diff --git a/packages/code-studio/src/styleguide/MockIrisGridTreeModel.ts b/packages/code-studio/src/styleguide/MockIrisGridTreeModel.ts index b279b8823f..c1afd485f8 100644 --- a/packages/code-studio/src/styleguide/MockIrisGridTreeModel.ts +++ b/packages/code-studio/src/styleguide/MockIrisGridTreeModel.ts @@ -222,6 +222,22 @@ class MockIrisGridTreeModel // Ignore for mock } + get partition(): never[] { + return []; + } + + set partition(partition: never[]) { + // Ignore for mock + } + + get partitionColumns(): never[] { + return []; + } + + set partitionColumns(partitionColumns: never[]) { + // Ignore for mock + } + set formatter(formatter: Formatter) { // Ignore for mock } diff --git a/packages/components/src/Option.tsx b/packages/components/src/Option.tsx index 34527b6452..0d85d7048f 100644 --- a/packages/components/src/Option.tsx +++ b/packages/components/src/Option.tsx @@ -1,23 +1,13 @@ -import React from 'react'; +import React, { OptionHTMLAttributes } from 'react'; -export type OptionProps = { +export type OptionProps = OptionHTMLAttributes & { children: React.ReactNode; - disabled?: boolean; - value: string; 'data-testid'?: string; }; -function Option({ - children, - disabled, - value, - 'data-testid': dataTestId, -}: OptionProps): JSX.Element { - return ( - - ); +function Option({ children, ...props }: OptionProps): JSX.Element { + // eslint-disable-next-line react/jsx-props-no-spreading + return ; } export default Option; diff --git a/packages/console/src/common/ConsoleUtils.ts b/packages/console/src/common/ConsoleUtils.ts index 4f679c1451..bebd6816ed 100644 --- a/packages/console/src/common/ConsoleUtils.ts +++ b/packages/console/src/common/ConsoleUtils.ts @@ -56,7 +56,8 @@ class ConsoleUtils { return ( type === dh.VariableType.TABLE || type === dh.VariableType.TREETABLE || - type === dh.VariableType.HIERARCHICALTABLE + type === dh.VariableType.HIERARCHICALTABLE || + type === dh.VariableType.PARTITIONEDTABLE ); } diff --git a/packages/console/src/common/ObjectIcon.tsx b/packages/console/src/common/ObjectIcon.tsx index 3143947fa4..35f93f6484 100644 --- a/packages/console/src/common/ObjectIcon.tsx +++ b/packages/console/src/common/ObjectIcon.tsx @@ -14,6 +14,7 @@ function ObjectIcon({ type }: ObjectIconProps): JSX.Element { case dh.VariableType.TABLEMAP: case dh.VariableType.TREETABLE: case dh.VariableType.HIERARCHICALTABLE: + case dh.VariableType.PARTITIONEDTABLE: return ; case dh.VariableType.FIGURE: return ; diff --git a/packages/dashboard-core-plugins/src/GridPluginConfig.ts b/packages/dashboard-core-plugins/src/GridPluginConfig.ts index 6e18aebe1b..0627464a82 100644 --- a/packages/dashboard-core-plugins/src/GridPluginConfig.ts +++ b/packages/dashboard-core-plugins/src/GridPluginConfig.ts @@ -9,7 +9,12 @@ const GridPluginConfig: WidgetPlugin = { type: PluginType.WIDGET_PLUGIN, component: GridWidgetPlugin, panelComponent: GridPanelPlugin, - supportedTypes: ['Table', 'TreeTable', 'HierarchicalTable'], + supportedTypes: [ + 'Table', + 'TreeTable', + 'HierarchicalTable', + 'PartitionedTable', + ], icon: dhTable, }; diff --git a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx index 1b895ab508..2ef7cb1995 100644 --- a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx @@ -43,6 +43,7 @@ import { ColumnHeaderGroup, IrisGridContextMenuData, IrisGridTableModel, + PartitionConfig, } from '@deephaven/iris-grid'; import { AdvancedFilterOptions, @@ -126,9 +127,7 @@ export interface PanelState { type LoadedPanelState = PanelState & { irisGridPanelState: PanelState['irisGridPanelState'] & { partitions?: (string | null)[]; - partitionColumns?: ColumnName[]; partition?: string | null; - partitionColumn?: ColumnName | null; }; }; @@ -190,7 +189,7 @@ interface IrisGridPanelState { movedRows: readonly MoveOperation[]; isSelectingPartition: boolean; partitions: (string | null)[]; - partitionColumns: Column[]; + partitionConfig?: PartitionConfig; rollupConfig?: UIRollupConfig; showSearchBar: boolean; searchValue: string; @@ -296,7 +295,6 @@ export class IrisGridPanel extends PureComponent< movedRows: [], isSelectingPartition: false, partitions: [], - partitionColumns: [], rollupConfig: undefined, showSearchBar: false, searchValue: '', @@ -466,13 +464,11 @@ export class IrisGridPanel extends PureComponent< model: IrisGridModel, isSelectingPartition: boolean, partitions: (string | null)[], - partitionColumns: Column[], advancedSettings: Map ) => IrisGridUtils.dehydrateIrisGridPanelState(model, { isSelectingPartition, partitions, - partitionColumns, advancedSettings, }) ); @@ -499,7 +495,8 @@ export class IrisGridPanel extends PureComponent< pendingDataMap: PendingDataMap, frozenColumns: readonly ColumnName[], conditionalFormats: readonly SidebarFormattingRule[], - columnHeaderGroups: readonly ColumnHeaderGroup[] + columnHeaderGroups: readonly ColumnHeaderGroup[], + partitionConfig: PartitionConfig | undefined ) => { assertNotNull(this.irisGridUtils); return this.irisGridUtils.dehydrateIrisGridState(model, { @@ -525,6 +522,7 @@ export class IrisGridPanel extends PureComponent< frozenColumns, conditionalFormats, columnHeaderGroups, + partitionConfig, }); } ); @@ -1035,12 +1033,8 @@ export class IrisGridPanel extends PureComponent< }[] ); } - const { - isSelectingPartition, - partitions, - partitionColumns, - advancedSettings, - } = IrisGridUtils.hydrateIrisGridPanelState(model, irisGridPanelState); + const { isSelectingPartition, partitions, advancedSettings } = + IrisGridUtils.hydrateIrisGridPanelState(model, irisGridPanelState); assertNotNull(this.irisGridUtils); const { advancedFilters, @@ -1063,6 +1057,7 @@ export class IrisGridPanel extends PureComponent< frozenColumns, conditionalFormats, columnHeaderGroups, + partitionConfig, } = this.irisGridUtils.hydrateIrisGridState(model, { ...irisGridState, ...irisGridStateOverrides, @@ -1084,7 +1079,6 @@ export class IrisGridPanel extends PureComponent< movedColumns, movedRows, partitions, - partitionColumns, quickFilters, reverseType, rollupConfig, @@ -1102,6 +1096,7 @@ export class IrisGridPanel extends PureComponent< isStuckToBottom, isStuckToRight, columnHeaderGroups, + partitionConfig, }); } catch (error) { log.error('loadPanelState failed to load panelState', panelState, error); @@ -1117,7 +1112,6 @@ export class IrisGridPanel extends PureComponent< panelState: oldPanelState, isSelectingPartition, partitions, - partitionColumns, advancedSettings, } = this.state; const { @@ -1140,6 +1134,7 @@ export class IrisGridPanel extends PureComponent< frozenColumns, conditionalFormats, columnHeaderGroups, + partitionConfig, } = irisGridState; assertNotNull(model); assertNotNull(metrics); @@ -1153,7 +1148,6 @@ export class IrisGridPanel extends PureComponent< model, isSelectingPartition, partitions, - partitionColumns, advancedSettings ), this.getDehydratedIrisGridState( @@ -1177,7 +1171,8 @@ export class IrisGridPanel extends PureComponent< pendingDataMap, frozenColumns, conditionalFormats, - columnHeaderGroups + columnHeaderGroups, + partitionConfig ), this.getDehydratedGridState( model, @@ -1241,7 +1236,7 @@ export class IrisGridPanel extends PureComponent< movedColumns, movedRows, partitions, - partitionColumns, + partitionConfig, quickFilters, reverseType, rollupConfig, @@ -1323,7 +1318,7 @@ export class IrisGridPanel extends PureComponent< movedColumns={movedColumns} movedRows={movedRows} partitions={partitions} - partitionColumns={partitionColumns} + partitionConfig={partitionConfig} quickFilters={quickFilters} reverseType={reverseType} rollupConfig={rollupConfig} diff --git a/packages/embed-grid/src/App.tsx b/packages/embed-grid/src/App.tsx index c3a41fcd83..446bd0ac4b 100644 --- a/packages/embed-grid/src/App.tsx +++ b/packages/embed-grid/src/App.tsx @@ -23,6 +23,7 @@ const SUPPORTED_TYPES: string[] = [ dh.VariableType.TREETABLE, dh.VariableType.HIERARCHICALTABLE, dh.VariableType.PANDAS, + dh.VariableType.PARTITIONEDTABLE, ]; export type Command = 'filter' | 'sort'; diff --git a/packages/grid/src/DataBarGridModel.ts b/packages/grid/src/DataBarGridModel.ts index 1e96c909eb..0bad337fe7 100644 --- a/packages/grid/src/DataBarGridModel.ts +++ b/packages/grid/src/DataBarGridModel.ts @@ -1,7 +1,6 @@ -import { GridThemeType } from '.'; import { ModelIndex } from './GridMetrics'; import GridModel from './GridModel'; -import { GridColor } from './GridTheme'; +import { GridColor, type GridTheme as GridThemeType } from './GridTheme'; export type Marker = { value: number; color: string }; export type AxisOption = 'proportional' | 'middle' | 'directional'; diff --git a/packages/iris-grid/package.json b/packages/iris-grid/package.json index d1f372ad69..67808c9f4a 100644 --- a/packages/iris-grid/package.json +++ b/packages/iris-grid/package.json @@ -36,6 +36,7 @@ "@deephaven/filters": "file:../filters", "@deephaven/grid": "file:../grid", "@deephaven/icons": "file:../icons", + "@deephaven/jsapi-components": "file:../jsapi-components", "@deephaven/jsapi-types": "file:../jsapi-types", "@deephaven/jsapi-utils": "file:../jsapi-utils", "@deephaven/log": "file:../log", diff --git a/packages/iris-grid/src/EmptyIrisGridModel.ts b/packages/iris-grid/src/EmptyIrisGridModel.ts new file mode 100644 index 0000000000..cc86d2a246 --- /dev/null +++ b/packages/iris-grid/src/EmptyIrisGridModel.ts @@ -0,0 +1,263 @@ +/* eslint class-methods-use-this: "off" */ +import { + GridRange, + ModelIndex, + MoveOperation, + VisibleIndex, +} from '@deephaven/grid'; +import { + Column, + ColumnStatistics, + CustomColumn, + dh as DhType, + FilterCondition, + Format, + RollupConfig, + Row, + Sort, + Table, +} from '@deephaven/jsapi-types'; +import { ColumnName, Formatter } from '@deephaven/jsapi-utils'; +import { EMPTY_ARRAY, EMPTY_MAP } from '@deephaven/utils'; +import IrisGridModel from './IrisGridModel'; +import ColumnHeaderGroup from './ColumnHeaderGroup'; +import { + PendingDataErrorMap, + PendingDataMap, + UITotalsTableConfig, +} from './CommonTypes'; + +class EmptyIrisGridModel extends IrisGridModel { + constructor(dh: DhType, formatter = new Formatter(dh)) { + super(dh); + + this.modelFormatter = formatter; + } + + modelFormatter: Formatter; + + get rowCount(): number { + return 0; + } + + get columnCount(): number { + return 0; + } + + textForCell(column: number, row: number): string { + return ''; + } + + textForColumnHeader(column: ModelIndex, depth?: number): string | undefined { + return undefined; + } + + get columns(): readonly Column[] { + return EMPTY_ARRAY; + } + + getColumnIndexByName(name: string): ModelIndex | undefined { + return undefined; + } + + get initialMovedColumns(): readonly MoveOperation[] { + return EMPTY_ARRAY; + } + + get initialMovedRows(): readonly MoveOperation[] { + return EMPTY_ARRAY; + } + + get initialColumnHeaderGroups(): readonly ColumnHeaderGroup[] { + return EMPTY_ARRAY; + } + + get groupedColumns(): readonly Column[] { + return EMPTY_ARRAY; + } + + formatForCell(column: ModelIndex, row: ModelIndex): Format | undefined { + return undefined; + } + + valueForCell(column: ModelIndex, row: ModelIndex): unknown { + return undefined; + } + + get filter(): readonly FilterCondition[] { + return EMPTY_ARRAY; + } + + set filter(filter: readonly FilterCondition[]) { + // No-op + } + + get partition(): readonly unknown[] { + return EMPTY_ARRAY; + } + + set partition(partition: readonly unknown[]) { + // No-op + } + + get partitionColumns(): readonly Column[] { + return EMPTY_ARRAY; + } + + get formatter(): Formatter { + return this.modelFormatter; + } + + set formatter(formatter: Formatter) { + this.modelFormatter = formatter; + } + + displayString( + value: unknown, + columnType: string, + columnName?: ColumnName + ): string { + return ''; + } + + get sort(): readonly Sort[] { + return EMPTY_ARRAY; + } + + set sort(sort: readonly Sort[]) { + // No-op + } + + get customColumns(): readonly ColumnName[] { + return EMPTY_ARRAY; + } + + set customColumns(customColumns: readonly ColumnName[]) { + // No-op + } + + get formatColumns(): readonly CustomColumn[] { + return EMPTY_ARRAY; + } + + updateFrozenColumns(columns: readonly ColumnName[]): void { + // Do nothing + } + + get rollupConfig(): RollupConfig | null { + return null; + } + + set rollupConfig(rollupConfig: RollupConfig | null) { + // No-op + } + + get totalsConfig(): UITotalsTableConfig | null { + return null; + } + + set totalsConfig(totalsConfig: UITotalsTableConfig | null) { + // No-op + } + + export(): Promise { + throw new Error('Method not implemented.'); + } + + columnStatistics(column: Column): Promise { + throw new Error('Method not implemented.'); + } + + get selectDistinctColumns(): readonly ColumnName[] { + return EMPTY_ARRAY; + } + + set selectDistinctColumns(selectDistinctColumns: readonly ColumnName[]) { + // No-op + } + + get pendingDataMap(): PendingDataMap { + return EMPTY_MAP; + } + + set pendingDataMap(map: PendingDataMap) { + // No-op + } + + get pendingRowCount(): number { + return 0; + } + + set pendingRowCount(count: number) { + // No-op + } + + get pendingDataErrors(): PendingDataErrorMap { + return EMPTY_MAP; + } + + commitPending(): Promise { + return Promise.resolve(); + } + + setViewport( + top: VisibleIndex, + bottom: VisibleIndex, + columns?: Column[] + ): void { + // No-op + } + + snapshot(ranges: readonly GridRange[]): Promise { + return Promise.resolve([]); + } + + textSnapshot( + ranges: readonly GridRange[], + includeHeaders?: boolean, + formatValue?: (value: unknown, column: Column, row?: Row) => string + ): Promise { + return Promise.resolve(''); + } + + valuesTable(columns: Column | readonly Column[]): Promise
{ + throw new Error('Method not implemented.'); + } + + delete(ranges: readonly GridRange[]): Promise { + return Promise.resolve(); + } + + seekRow( + startRow: number, + column: Column, + valueType: unknown, + value: unknown, + insensitive?: boolean, + contains?: boolean, + isBackwards?: boolean + ): Promise { + return Promise.resolve(0); + } + + get columnHeaderGroups(): readonly ColumnHeaderGroup[] { + return EMPTY_ARRAY; + } + + set columnHeaderGroups(groups: readonly ColumnHeaderGroup[]) { + // No-op + } + + get columnHeaderGroupMap(): ReadonlyMap { + return EMPTY_MAP; + } + + getColumnHeaderParentGroup( + modelIndex: ModelIndex, + depth: number + ): ColumnHeaderGroup | undefined { + return undefined; + } +} + +export default EmptyIrisGridModel; diff --git a/packages/iris-grid/src/IrisGrid.tsx b/packages/iris-grid/src/IrisGrid.tsx index 3d387c75d2..e8a392ba39 100644 --- a/packages/iris-grid/src/IrisGrid.tsx +++ b/packages/iris-grid/src/IrisGrid.tsx @@ -72,6 +72,7 @@ import type { Sort, Table, TableViewportSubscription, + ViewportData, } from '@deephaven/jsapi-types'; import { DateUtils, @@ -152,6 +153,11 @@ import { import IrisGridUtils from './IrisGridUtils'; import CrossColumnSearch from './CrossColumnSearch'; import IrisGridModel from './IrisGridModel'; +import { + isPartitionedGridModel, + PartitionConfig, + PartitionedGridModel, +} from './PartitionedGridModel'; import IrisGridPartitionSelector from './IrisGridPartitionSelector'; import SelectDistinctBuilder from './sidebar/SelectDistinctBuilder'; import AdvancedSettingsType from './sidebar/AdvancedSettingsType'; @@ -190,6 +196,7 @@ import { } from './CommonTypes'; import ColumnHeaderGroup from './ColumnHeaderGroup'; import { IrisGridThemeContext } from './IrisGridThemeProvider'; +import { isMissingPartitionError } from './MissingPartitionError'; const log = Log.module('IrisGrid'); @@ -287,8 +294,10 @@ export interface IrisGridProps { onDataSelected: (index: ModelIndex, map: Record) => void; onStateChange: (irisGridState: IrisGridState, gridState: GridState) => void; onAdvancedSettingsChange: AdvancedSettingsMenuCallback; - partitions: (string | null)[]; - partitionColumns: Column[]; + + /** @deprecated use `partitionConfig` instead */ + partitions?: (string | null)[]; + partitionConfig?: PartitionConfig; sorts: readonly Sort[]; reverseType: ReverseType; quickFilters: ReadonlyQuickFilterMap | null; @@ -352,10 +361,8 @@ export interface IrisGridState { keyHandlers: readonly KeyHandler[]; mouseHandlers: readonly GridMouseHandler[]; - partitions: (string | null)[]; - partitionColumns: Column[]; - partitionTable: Table | null; - partitionFilters: readonly FilterCondition[]; + partitionConfig?: PartitionConfig; + // setAdvancedFilter and setQuickFilter mutate the arguments // so we want to always use map copies from the state instead of props quickFilters: ReadonlyQuickFilterMap; @@ -470,8 +477,8 @@ export class IrisGrid extends Component { onError: (): void => undefined, onStateChange: (): void => undefined, onAdvancedSettingsChange: (): void => undefined, - partitions: [], - partitionColumns: [], + partitions: undefined, + partitionConfig: undefined, quickFilters: EMPTY_MAP, selectDistinctColumns: EMPTY_ARRAY, sorts: EMPTY_ARRAY, @@ -586,8 +593,6 @@ export class IrisGrid extends Component { this.handleDownloadCanceled = this.handleDownloadCanceled.bind(this); this.handleDownloadCompleted = this.handleDownloadCompleted.bind(this); this.handlePartitionChange = this.handlePartitionChange.bind(this); - this.handlePartitionFetchAll = this.handlePartitionFetchAll.bind(this); - this.handlePartitionDone = this.handlePartitionDone.bind(this); this.handleColumnVisibilityChanged = this.handleColumnVisibilityChanged.bind(this); this.handleColumnVisibilityReset = @@ -680,11 +685,11 @@ export class IrisGrid extends Component { customColumnFormatMap, isFilterBarShown, isSelectingPartition, + partitions, + partitionConfig, model, movedColumns: movedColumnsProp, movedRows: movedRowsProp, - partitions, - partitionColumns, rollupConfig, userColumnWidths, userRowHeights, @@ -760,10 +765,12 @@ export class IrisGrid extends Component { keyHandlers, mouseHandlers, - partitions, - partitionColumns, - partitionTable: null, - partitionFilters: [], + partitionConfig: + partitionConfig ?? + (partitions && partitions.length + ? { partitions, mode: 'partition' } + : undefined), + // setAdvancedFilter and setQuickFilter mutate the arguments // so we want to always use map copies from the state instead of props quickFilters: quickFilters ? new Map(quickFilters) : new Map(), @@ -849,19 +856,8 @@ export class IrisGrid extends Component { } componentDidMount(): void { - const { partitionColumns, model } = this.props; - const columns = partitionColumns.length - ? partitionColumns - : model.columns.filter(c => c.isPartitionColumn); - if ( - model.isFilterRequired && - model.isValuesTableAvailable && - columns.length - ) { - this.loadPartitionsTable(columns); - } else { - this.initState(); - } + const { model } = this.props; + this.initState(); this.startListening(model); } @@ -1358,11 +1354,9 @@ export class IrisGrid extends Component { customFilters: readonly FilterCondition[], quickFilters: ReadonlyQuickFilterMap, advancedFilters: ReadonlyAdvancedFilterMap, - partitionFilters: readonly FilterCondition[], searchFilter: FilterCondition | undefined ) => [ ...(customFilters ?? []), - ...(partitionFilters ?? []), ...IrisGridUtils.getFiltersFromFilterMap(quickFilters), ...IrisGridUtils.getFiltersFromFilterMap(advancedFilters), ...(searchFilter !== undefined ? [searchFilter] : []), @@ -1909,6 +1903,19 @@ export class IrisGrid extends Component { } initState(): void { + const { model } = this.props; + try { + if (isPartitionedGridModel(model) && model.isPartitionRequired) { + this.loadPartitionsTable(model); + } else { + this.loadTableState(); + } + } catch (error) { + this.handleTableLoadError(error); + } + } + + loadTableState(): void { const { applyInputFiltersOnInit, inputFilters, @@ -1949,78 +1956,70 @@ export class IrisGrid extends Component { this.initFormatter(); } - async loadPartitionsTable(partitionColumns: Column[]): Promise { - const { model } = this.props; - this.setState({ isSelectingPartition: true }); - + async loadPartitionsTable(model: PartitionedGridModel): Promise { try { - const partitionTable = await this.pending.add( - model.valuesTable(partitionColumns), - resolved => resolved.close() + const partitionConfig = await this.getInitialPartitionConfig(model); + this.setState( + { isSelectingPartition: true, partitionConfig }, + this.loadTableState ); - - const columns = partitionTable.columns.slice(0, partitionColumns.length); - const sorts = columns.map(column => column.sort().desc()); - partitionTable.applySort(sorts); - partitionTable.setViewport(0, 0, columns); - - const data = await this.pending.add(partitionTable.getViewportData()); - if (data.rows.length > 0) { - const row = data.rows[0]; - const values = columns.map(column => row.get(column)); - - this.updatePartition(values, partitionColumns); - - this.setState({ isSelectingPartition: true }); - } else { - log.info('Table does not have any data, just fetching all'); - this.setState({ isSelectingPartition: false }); - this.handlePartitionFetchAll(); - } - this.setState({ partitionTable, partitionColumns }, () => { - this.initState(); - }); } catch (error) { - this.handleTableLoadError(error); + if (!PromiseUtils.isCanceled(error)) { + this.handleTableLoadError(error); + } } } - updatePartition( - partitions: (string | null)[], - partitionColumns: Column[] - ): void { - const partitionFilters = []; - - for (let i = 0; i < partitionColumns.length; i += 1) { - const partition = partitions[i]; - const partitionColumn = partitionColumns[i]; + /** + * Gets the initial partition config for the currently set model. + * Sorts the key table and gets the first key. + * If the table is ticking, it will wait for the first tick. + */ + async getInitialPartitionConfig( + model: PartitionedGridModel + ): Promise { + const { partitionConfig } = this.state; + if (partitionConfig !== undefined) { + // User already has a partition selected, just use that + return partitionConfig; + } - if ( - partition !== null && - !(TableUtils.isCharType(partitionColumn.type) && partition === '') - ) { - const { model } = this.props; + const keyTable = await this.pending.add( + model.partitionKeysTable(), + resolved => resolved.close() + ); + const { dh } = model; - const partitionText = TableUtils.isCharType(partitionColumn.type) - ? model.displayString( - partition, - partitionColumn.type, - partitionColumn.name - ) - : partition; - const partitionFilter = this.tableUtils.makeQuickFilterFromComponent( - partitionColumn, - partitionText - ); - if (partitionFilter !== null) { - partitionFilters.push(partitionFilter); + const sorts = keyTable.columns.map(column => column.sort().desc()); + keyTable.applySort(sorts); + keyTable.setViewport(0, 0); + + return new Promise((resolve, reject) => { + // We want to wait for the first UPDATED event instead of just getting viewport data here + // It's possible that the key table does not have any rows of data yet, so just wait until it does have one + keyTable.addEventListener( + dh.Table.EVENT_UPDATED, + (event: CustomEvent) => { + try { + const { detail: data } = event; + if (data.rows.length === 0) { + // Table is empty, wait for the next updated event + return; + } + const row = data.rows[0]; + const values = keyTable.columns.map(column => row.get(column)); + const newPartition: PartitionConfig = { + partitions: values, + mode: 'partition', + }; + keyTable.close(); + resolve(newPartition); + } catch (e) { + keyTable.close(); + reject(e); + } } - } - } - - this.setState({ - partitions, - partitionFilters, + ); }); } @@ -2444,23 +2443,9 @@ export class IrisGrid extends Component { this.isAnimating = false; } - handlePartitionChange(partitions: (string | null)[]): void { - const { partitionColumns } = this.state; - if (partitionColumns.length === 0) { - return; - } - this.updatePartition(partitions, partitionColumns); - } - - handlePartitionFetchAll(): void { - this.setState({ - partitionFilters: [], - isSelectingPartition: false, - }); - } - - handlePartitionDone(): void { - this.setState({ isSelectingPartition: false }); + handlePartitionChange(partitionConfig: PartitionConfig): void { + this.startLoading('Partitioning...'); + this.setState({ partitionConfig }); } handleTableLoadError(error: unknown): void { @@ -2930,16 +2915,23 @@ export class IrisGrid extends Component { } handleRequestFailed(event: Event): void { - const customEvent = event as CustomEvent; - log.error('request failed:', customEvent.detail); + const { detail: error } = event as CustomEvent; + log.error('request failed:', error); this.stopLoading(); - if (this.canRollback()) { + const { partitionConfig } = this.state; + if (isMissingPartitionError(error) && partitionConfig != null) { + // We'll try loading the initial partition again + this.startLoading('Reloading partition...', true); + this.setState({ partitionConfig: undefined }, () => { + this.initState(); + }); + } else if (this.canRollback()) { this.startLoading('Rolling back changes...', true); this.rollback(); } else { log.error('Table failed and unable to rollback'); const { onError } = this.props; - onError(new Error(`Error displaying table: ${customEvent.detail}`)); + onError(new Error(`Error displaying table: ${error}`)); } } @@ -3221,7 +3213,7 @@ export class IrisGrid extends Component { this.stopLoading(); this.grid?.forceUpdate(); } else { - this.initState(); + this.loadTableState(); } } @@ -4019,10 +4011,6 @@ export class IrisGrid extends Component { hoverSelectColumn, quickFilters, advancedFilters, - partitions, - partitionFilters, - partitionTable, - partitionColumns, searchFilter, selectDistinctColumns, @@ -4073,6 +4061,7 @@ export class IrisGrid extends Component { gotoValueSelectedColumnName, gotoValue, gotoValueSelectedFilter, + partitionConfig, } = this.state; if (!isReady) { return null; @@ -4084,7 +4073,6 @@ export class IrisGrid extends Component { customFilters, quickFilters, advancedFilters, - partitionFilters, searchFilter ); @@ -4541,22 +4529,13 @@ export class IrisGrid extends Component { unmountOnExit >
- {partitionTable && - partitionColumns.length && - partitions.length && ( + {isPartitionedGridModel(model) && + model.isPartitionRequired && + partitionConfig && ( model.displayString(value, type, stringName)} - columns={partitionColumns} - partitions={partitions} + model={model} + partitionConfig={partitionConfig} onChange={this.handlePartitionChange} - onFetchAll={this.handlePartitionFetchAll} - onDone={this.handlePartitionDone} /> )}
@@ -4658,6 +4637,7 @@ export class IrisGrid extends Component { pendingDataMap={pendingDataMap} frozenColumns={frozenColumns} columnHeaderGroups={columnHeaderGroups} + partitionConfig={partitionConfig} /> )} {!isMenuShown && ( diff --git a/packages/iris-grid/src/IrisGridModel.ts b/packages/iris-grid/src/IrisGridModel.ts index ef47b703bd..c9c26c74e8 100644 --- a/packages/iris-grid/src/IrisGridModel.ts +++ b/packages/iris-grid/src/IrisGridModel.ts @@ -356,6 +356,7 @@ abstract class IrisGridModel< } /** + * @deprecated Replaced with isPartitionRequired() * @returns True if this model requires a filter to be set */ get isFilterRequired(): boolean { @@ -507,12 +508,12 @@ abstract class IrisGridModel< * Set the indices of the viewport * @param top Top of viewport * @param bottom Bottom of viewport - * @param columns The columns in the viewport. `null` for all columns + * @param columns The columns in the viewport. `undefined` for all columns */ abstract setViewport( top: VisibleIndex, bottom: VisibleIndex, - columns: Column[] | null + columns?: Column[] ): void; /** @@ -540,7 +541,7 @@ abstract class IrisGridModel< * @param column The columns to get the distinct values for * @returns A table partitioned on the specified columns in the order given in */ - abstract valuesTable(columns: Column | Column[]): Promise
; + abstract valuesTable(columns: Column | readonly Column[]): Promise
; /** * Close this model. It can no longer be used after being closed @@ -579,10 +580,10 @@ abstract class IrisGridModel< abstract get columnHeaderGroups(): readonly ColumnHeaderGroup[]; - abstract get columnHeaderGroupMap(): ReadonlyMap; - abstract set columnHeaderGroups(groups: readonly ColumnHeaderGroup[]); + abstract get columnHeaderGroupMap(): ReadonlyMap; + abstract getColumnHeaderParentGroup( modelIndex: ModelIndex, depth: number diff --git a/packages/iris-grid/src/IrisGridModelFactory.ts b/packages/iris-grid/src/IrisGridModelFactory.ts index 455cb51f0c..1421b04962 100644 --- a/packages/iris-grid/src/IrisGridModelFactory.ts +++ b/packages/iris-grid/src/IrisGridModelFactory.ts @@ -1,4 +1,9 @@ -import type { dh as DhType, Table, TreeTable } from '@deephaven/jsapi-types'; +import type { + dh as DhType, + Table, + TreeTable, + PartitionedTable, +} from '@deephaven/jsapi-types'; import { Formatter, TableUtils } from '@deephaven/jsapi-utils'; import IrisGridModel from './IrisGridModel'; import IrisGridProxyModel from './IrisGridProxyModel'; @@ -14,11 +19,15 @@ class IrisGridModelFactory { */ static async makeModel( dh: DhType, - table: Table | TreeTable, + table: Table | TreeTable | PartitionedTable, formatter = new Formatter(dh) ): Promise { let inputTable = null; - if (!TableUtils.isTreeTable(table) && table.hasInputTable) { + if ( + !TableUtils.isTreeTable(table) && + !TableUtils.isPartitionedTable(table) && + table.hasInputTable + ) { inputTable = await table.inputTable(); } return new IrisGridProxyModel(dh, table, formatter, inputTable); diff --git a/packages/iris-grid/src/IrisGridModelUpdater.tsx b/packages/iris-grid/src/IrisGridModelUpdater.tsx index 96b36473f8..8c02d6be6f 100644 --- a/packages/iris-grid/src/IrisGridModelUpdater.tsx +++ b/packages/iris-grid/src/IrisGridModelUpdater.tsx @@ -15,6 +15,10 @@ import IrisGridUtils from './IrisGridUtils'; import { ColumnName, UITotalsTableConfig, PendingDataMap } from './CommonTypes'; import IrisGridModel from './IrisGridModel'; import type ColumnHeaderGroup from './ColumnHeaderGroup'; +import { + PartitionConfig, + isPartitionedGridModel, +} from './PartitionedGridModel'; const COLUMN_BUFFER_PAGES = 1; @@ -41,6 +45,7 @@ interface IrisGridModelUpdaterProps { selectDistinctColumns?: readonly ColumnName[]; pendingRowCount?: number; pendingDataMap?: PendingDataMap; + partitionConfig?: PartitionConfig; } /** @@ -70,6 +75,7 @@ const IrisGridModelUpdater = React.memo( frozenColumns, formatColumns, columnHeaderGroups, + partitionConfig, }: IrisGridModelUpdaterProps) => { const columns = useMemo( () => @@ -186,6 +192,14 @@ const IrisGridModelUpdater = React.memo( }, [model, columnHeaderGroups] ); + useEffect( + function updatePartitionConfig() { + if (partitionConfig && isPartitionedGridModel(model)) { + model.partitionConfig = partitionConfig; + } + }, + [model, partitionConfig] + ); return null; } diff --git a/packages/iris-grid/src/IrisGridPartitionSelector.scss b/packages/iris-grid/src/IrisGridPartitionSelector.scss index 7534447219..ce46a1a98d 100644 --- a/packages/iris-grid/src/IrisGridPartitionSelector.scss +++ b/packages/iris-grid/src/IrisGridPartitionSelector.scss @@ -1,48 +1,26 @@ @import '@deephaven/components/scss/custom.scss'; -$partition-selector-color: $white; -$partition-selector-input-height: 2.25em; .iris-grid-partition-selector { display: flex; flex-wrap: wrap; - background: transparent; - vertical-align: middle; - .status-message { - text-overflow: ellipsis; + background: var(--dh-color-surface-bg); + align-items: center; + padding: $spacer-2; + gap: $spacer-2; + + .column-selector { display: flex; - flex-direction: row; align-items: center; - padding: 0.5em; - white-space: nowrap; - } - div { - flex-grow: 0; - } - .input-group { - width: auto; - align-items: center; - .form-control { - background-color: transparent; - border: 2px solid $partition-selector-color; - border-right: none; - height: $partition-selector-input-height; - } - .btn { - background-color: transparent; - margin: 0; + gap: $spacer-2; + + .custom-select { + min-width: 4rem; } } - .iris-grid-partition-selector-spacer { - flex-grow: 1; - } - .btn-outline-primary { - color: $white; - border-color: $white; - margin: 0.35em 0.3em; - padding: 0.25em 0.5em; - } - .btn-close { - padding: 0 0.5em; - margin: 0.25em 0.25em 0.25em 0; - min-width: 0; + .partition-button-group { + display: flex; + border: 1px var(--dh-color-hr); + border-style: none solid; + padding: 0 $spacer-2; + gap: $spacer-2; } } diff --git a/packages/iris-grid/src/IrisGridPartitionSelector.test.tsx b/packages/iris-grid/src/IrisGridPartitionSelector.test.tsx index 0c70cfea6a..0d26ca3579 100644 --- a/packages/iris-grid/src/IrisGridPartitionSelector.test.tsx +++ b/packages/iris-grid/src/IrisGridPartitionSelector.test.tsx @@ -1,27 +1,39 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render } from '@testing-library/react'; +import { ApiContext } from '@deephaven/jsapi-bootstrap'; import dh from '@deephaven/jsapi-shim'; import IrisGridPartitionSelector from './IrisGridPartitionSelector'; import IrisGridTestUtils from './IrisGridTestUtils'; +import { PartitionConfig, PartitionedGridModel } from './PartitionedGridModel'; + +const irisGridTestUtils = new IrisGridTestUtils(dh); + +function makeModel( + columns = irisGridTestUtils.makeColumns() +): PartitionedGridModel { + const model = { + ...irisGridTestUtils.makeModel(), + partitionKeysTable: jest.fn(() => + Promise.resolve(irisGridTestUtils.makeTable()) + ), + partitionColumns: columns, + } as unknown as PartitionedGridModel; + return model; +} function makeIrisGridPartitionSelector( - table = new IrisGridTestUtils(dh).makeTable(), - columns = [new IrisGridTestUtils(dh).makeColumn()], + model = makeModel(), onChange = jest.fn(), - onDone = jest.fn(), - getFormattedString = jest.fn(value => `${value}`), - onAppend = undefined + partitionConfig = { partitions: [], mode: 'merged' } ) { return render( - + + + ); } @@ -29,44 +41,13 @@ it('unmounts successfully without crashing', () => { makeIrisGridPartitionSelector(); }); -it('calls onDone when close button is clicked', () => { - const onDone = jest.fn(); - const component = makeIrisGridPartitionSelector( - undefined, - undefined, - undefined, - onDone - ); - - const closeButton = component.getAllByRole('button')[2]; - fireEvent.click(closeButton); - expect(onDone).toHaveBeenCalled(); -}); - it('should display multiple selectors to match columns', () => { const columns = [ - new IrisGridTestUtils(dh).makeColumn(), - new IrisGridTestUtils(dh).makeColumn(), + irisGridTestUtils.makeColumn('a'), + irisGridTestUtils.makeColumn('b'), ]; - const component = makeIrisGridPartitionSelector(undefined, columns); + const component = makeIrisGridPartitionSelector(makeModel(columns)); - const selectors = component.getAllByRole('textbox'); + const selectors = component.getAllByRole('combobox'); expect(selectors).toHaveLength(2); }); - -it('calls handlePartitionChange when PartitionSelectorSearch value changes', () => { - const handlePartitionChange = jest.spyOn( - IrisGridPartitionSelector.prototype, - 'handlePartitionChange' - ); - const component = makeIrisGridPartitionSelector(); - - const partitionSelectorSearch = component.getByRole('textbox'); - fireEvent.change(partitionSelectorSearch, { target: { value: 'test' } }); - expect(handlePartitionChange).toHaveBeenCalledWith( - 0, - expect.objectContaining({ - target: expect.objectContaining({ value: 'test' }), - }) - ); -}); diff --git a/packages/iris-grid/src/IrisGridPartitionSelector.tsx b/packages/iris-grid/src/IrisGridPartitionSelector.tsx index 7b9b3cd77e..4b1dc380e8 100644 --- a/packages/iris-grid/src/IrisGridPartitionSelector.tsx +++ b/packages/iris-grid/src/IrisGridPartitionSelector.tsx @@ -1,316 +1,350 @@ import React, { Component } from 'react'; +import memoizee from 'memoizee'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { DropdownMenu, Tooltip } from '@deephaven/components'; -import { vsTriangleDown, vsClose } from '@deephaven/icons'; +import { Button } from '@deephaven/components'; +import { vsChevronRight, vsMerge, vsKey } from '@deephaven/icons'; import Log from '@deephaven/log'; -import debounce from 'lodash.debounce'; -import type { Column, dh as DhType, Table } from '@deephaven/jsapi-types'; +import { TableDropdown } from '@deephaven/jsapi-components'; +import type { FilterCondition, Table } from '@deephaven/jsapi-types'; import { TableUtils } from '@deephaven/jsapi-utils'; -import PartitionSelectorSearch from './PartitionSelectorSearch'; +import { assertNotNull, Pending, PromiseUtils } from '@deephaven/utils'; import './IrisGridPartitionSelector.scss'; -import IrisGridUtils from './IrisGridUtils'; +import { PartitionConfig, PartitionedGridModel } from './PartitionedGridModel'; const log = Log.module('IrisGridPartitionSelector'); -const PARTITION_CHANGE_DEBOUNCE_MS = 250; -interface IrisGridPartitionSelectorProps { - dh: DhType; - getFormattedString: (value: T, type: string, name: string) => string; - table: Table; - columns: Column[]; - partitions: (string | null)[]; - onFetchAll: () => void; - onDone: (event?: React.MouseEvent) => void; - onChange: (partitions: (string | null)[]) => void; +interface IrisGridPartitionSelectorProps { + model: PartitionedGridModel; + partitionConfig: PartitionConfig; + onChange: (partitionConfig: PartitionConfig) => void; } interface IrisGridPartitionSelectorState { - partitions: (string | null)[]; + isLoading: boolean; + + keysTable: Table | null; + partitionTables: Table[] | null; + + /** The filters to apply to each partition table */ + partitionFilters: FilterCondition[][] | null; } -class IrisGridPartitionSelector extends Component< - IrisGridPartitionSelectorProps, +class IrisGridPartitionSelector extends Component< + IrisGridPartitionSelectorProps, IrisGridPartitionSelectorState > { - static defaultProps = { - onChange: (): void => undefined, - onFetchAll: (): void => undefined, - onDone: (): void => undefined, - partitions: [], - }; - - constructor(props: IrisGridPartitionSelectorProps) { + constructor(props: IrisGridPartitionSelectorProps) { super(props); - this.handleCloseClick = this.handleCloseClick.bind(this); - this.handleIgnoreClick = this.handleIgnoreClick.bind(this); - this.handlePartitionChange = this.handlePartitionChange.bind(this); + this.handleKeyTableClick = this.handleKeyTableClick.bind(this); + this.handleMergeClick = this.handleMergeClick.bind(this); this.handlePartitionSelect = this.handlePartitionSelect.bind(this); - this.handlePartitionListResized = - this.handlePartitionListResized.bind(this); - this.handleSearchOpened = this.handleSearchOpened.bind(this); - this.handleSearchClosed = this.handleSearchClosed.bind(this); - const { dh, columns, partitions } = props; - this.tableUtils = new TableUtils(dh); - this.searchMenu = columns.map(() => null); - this.selectorSearch = columns.map(() => null); + const { model } = props; + this.tableUtils = new TableUtils(model.dh); + this.pending = new Pending(); this.state = { - partitions, + // We start be loading the partition tables, so we should be in a loading state + isLoading: true, + + keysTable: null, + partitionFilters: null, partitionTables: null, }; } async componentDidMount(): Promise { - const { columns, table } = this.props; - const { partitions } = this.state; + const { model } = this.props; + + try { + const keysTable = await this.pending.add( + model.partitionKeysTable().then(keyTable => { + const sorts = model.partitionColumns.map(column => + column.sort().desc() + ); + keyTable.applySort(sorts); + return keyTable; + }), + t => t.close() + ); + + const partitionTables = await Promise.all( + model.partitionColumns.map(async (_, i) => + this.pending.add( + keysTable.selectDistinct(model.partitionColumns.slice(0, i + 1)), + t => t.close() + ) + ) + ); + + const partitionFilters = this.getPartitionFilters(partitionTables); + this.setState({ + isLoading: false, + keysTable, + partitionFilters, + partitionTables, + }); + } catch (e) { + if (!PromiseUtils.isCanceled(e)) { + // Just re-throw the error if it's not a cancel + throw e; + } + } + } - const partitionTables = await Promise.all( - columns.map(async (_, i) => table.selectDistinct(columns.slice(0, i + 1))) - ); - this.updatePartitionFilters(partitions, partitionTables); + componentDidUpdate(prevProps: IrisGridPartitionSelectorProps): void { + const { partitionConfig: prevConfig } = prevProps; + + const { partitionConfig } = this.props; + + if (prevConfig !== partitionConfig) { + this.updatePartitionFilters(); + } } componentWillUnmount(): void { - const { partitionTables } = this.state; + this.pending.cancel(); + + const { keysTable, partitionTables } = this.state; + keysTable?.close(); partitionTables?.forEach(table => table.close()); - this.debounceUpdate.cancel(); } - tableUtils: TableUtils; - - searchMenu: (DropdownMenu | null)[]; + pending: Pending; - selectorSearch: (PartitionSelectorSearch | null)[]; + tableUtils: TableUtils; - handleCloseClick(): void { - log.debug2('handleCloseClick'); + handleKeyTableClick(): void { + log.debug2('handleKeyTableClick'); - this.sendDone(); + const { partitionConfig } = this.props; + const newPartitionConfig = { ...partitionConfig }; + // Toggle between Keys and Partition mode + newPartitionConfig.mode = + partitionConfig.mode === 'keys' ? 'partition' : 'keys'; + this.sendUpdate(newPartitionConfig); } - handleIgnoreClick(): void { - log.debug2('handleIgnoreClick'); + handleMergeClick(): void { + log.debug2('handleMergeClick'); - this.sendFetchAll(); + const { partitionConfig } = this.props; + const newPartitionConfig = { ...partitionConfig }; + // Toggle between Merged and Partition mode + newPartitionConfig.mode = + partitionConfig.mode === 'merged' ? 'partition' : 'merged'; + this.sendUpdate(newPartitionConfig); } - handlePartitionChange( + /** + * Handles when a partition dropdown selection is changed. Will send an update with the new partition config + * @param index Index of the partition column that was changed + * @param selectedValue Selected value of the partition column + */ + async handlePartitionSelect( index: number, - event: React.ChangeEvent - ): void { - log.debug2('handlePartitionChange'); - - const { columns } = this.props; - const { partitions, partitionTables } = this.state; - const { value: partition } = event.target; - - const newPartitions = [...partitions]; - newPartitions[index] = - TableUtils.isCharType(columns[index].type) && partition.length > 0 - ? partition.charCodeAt(0).toString() - : partition; - if (partitionTables) { - this.updatePartitionFilters(newPartitions, partitionTables); - } + selectedValue: unknown + ): Promise { + const { model, partitionConfig: prevConfig } = this.props; - this.setState({ - partitions: newPartitions, - }); + log.debug('handlePartitionSelect', index, selectedValue, prevConfig); - this.debounceUpdate(); - } + const newPartitions = [...prevConfig.partitions]; + newPartitions[index] = selectedValue; - handlePartitionSelect(index: number, partition: string): void { - const { partitions, partitionTables } = this.state; - const selectedMenu = this.searchMenu[index]; - if (selectedMenu) { - selectedMenu.closeMenu(); + // If it's the last partition changed, we know it's already a valid value, just emit it + if (index === model.partitionColumns.length - 1) { + this.sendUpdate({ partitions: newPartitions, mode: 'partition' }); + return; } - const newPartitions = [...partitions]; - newPartitions[index] = partition; - if (partitionTables) { - this.updatePartitionFilters(newPartitions, partitionTables); - } - - this.setState({ partitions: newPartitions }, () => { - this.sendUpdate(); - }); - } - - handlePartitionListResized(index: number): void { - const selectedMenu = this.searchMenu[index]; - if (selectedMenu) { - selectedMenu.scheduleUpdate(); + const { keysTable } = this.state; + // Otherwise, we need to get the value from a filtered key table + assertNotNull(keysTable); + try { + this.setState({ isLoading: true }); + const t = await this.pending.add(keysTable.copy(), tCopy => + tCopy.close() + ); + + // Apply our partition filters, and just get the first value + const partitionFilters = newPartitions + .slice(0, index + 1) + .map((partition, i) => { + const partitionColumn = t.columns[i]; + return partitionColumn + .filter() + .eq( + this.tableUtils.makeFilterRawValue( + partitionColumn.type, + partition + ) + ); + }); + t.applyFilter(partitionFilters); + t.setViewport(0, 0, t.columns); + const data = await this.pending.add(t.getViewportData()); + const newConfig: PartitionConfig = { + partitions: t.columns.map(column => data.rows[0].get(column)), + mode: 'partition', + }; + t.close(); + this.sendUpdate(newConfig); + } catch (e) { + if (!PromiseUtils.isCanceled(e)) { + log.error('Unable to get partition tables', e); + } + } finally { + this.setState({ isLoading: false }); } } - handleSearchClosed(): void { - // Reset the table filter so it's ready next time user opens search - const { table } = this.props; - table.applyFilter([]); - } + sendUpdate(partitionConfig: PartitionConfig): void { + log.debug2('sendUpdate', partitionConfig); - handleSearchOpened(index: number): void { - const selectedSearch = this.selectorSearch[index]; - if (selectedSearch) { - selectedSearch.focus(); - } + const { onChange } = this.props; + onChange(partitionConfig); } - debounceUpdate = debounce((): void => { - this.sendUpdate(); - }, PARTITION_CHANGE_DEBOUNCE_MS); + /** + * Calls model.displayString with a special character case + * @param index The index of the partition column to get the display value for + * @param value The partition value to get the display value for + */ + getDisplayValue(index: number, value: unknown): string { + const { model } = this.props; - sendDone(): void { - this.debounceUpdate.flush(); - - const { onDone } = this.props; - onDone(); + if (value == null || value === '') { + return ''; + } + const column = model.partitionColumns[index]; + if (TableUtils.isCharType(column.type) && value.toString().length > 0) { + return String.fromCharCode(parseInt(value.toString(), 10)); + } + return model.displayString(value, column.type, column.name); } - sendUpdate(): void { - log.debug2('sendUpdate'); + /** + * Update the filters on the partition dropdown tables + */ + updatePartitionFilters(): void { + const { partitionTables } = this.state; + assertNotNull(partitionTables); + + const { partitionConfig } = this.props; + const { mode } = partitionConfig; + log.debug('updatePartitionFilters', partitionConfig); + if (mode !== 'partition') { + // We only need to update the filters if the mode is `partitions` + // In the other modes, we disable the dropdowns anyway + return; + } - const { onChange } = this.props; - const { partitions } = this.state; - onChange(partitions); + const partitionFilters = this.getPartitionFilters(partitionTables); + this.setState({ partitionFilters }); } - sendFetchAll(): void { - log.debug2('sendFetchAll'); - - this.debounceUpdate.cancel(); + getPartitionFilters(partitionTables: Table[]): FilterCondition[][] { + const { model, partitionConfig } = this.props; + const { partitions } = partitionConfig; + log.debug('getPartitionFilters', partitionConfig); - const { onFetchAll } = this.props; - onFetchAll(); - } - - getDisplayValue(column: Column, index: number): string { - const { partitions } = this.state; - const partition = partitions[index]; - if (partition == null) { - return ''; + if (partitions.length !== partitionTables.length) { + throw new Error( + `Invalid partition config set. Expected ${partitionTables.length} partitions, but got ${partitions.length}` + ); } - if (TableUtils.isCharType(column.type) && partition.toString().length > 0) { - return String.fromCharCode(parseInt(partition, 10)); - } - return IrisGridUtils.convertValueToText(partition, column.type); - } - async updatePartitionFilters( - partitions: (string | null)[], - partitionTables: Table[] - ): Promise { - const { columns, getFormattedString } = this.props; - - const partitionFilters = []; - for (let i = 0; i < columns.length - 1; i += 1) { - const partition = partitions[i]; - const partitionColumn = columns[i]; - - partitionTables[i]?.applyFilter(partitionFilters); - if ( - partition !== null && - !(TableUtils.isCharType(partitionColumn.type) && partition === '') - ) { - const partitionText = TableUtils.isCharType(partitionColumn.type) - ? getFormattedString( - partition as T, - partitionColumn.type, - partitionColumn.name - ) - : partition; - const partitionFilter = this.tableUtils.makeQuickFilterFromComponent( - partitionColumn, - partitionText - ); - if (partitionFilter !== null) { - partitionFilters.push(partitionFilter); - } + // The filters are applied in order, so we need to build up the filters for each partition + const partitionFilters: FilterCondition[][] = []; + for (let i = 0; i < partitions.length; i += 1) { + if (i === 0) { + // There's no reason to ever filter the first table + partitionFilters.push([]); + } else { + const previousFilter = partitionFilters[i - 1]; + const previousPartition = partitions[i - 1]; + const previousColumn = model.partitionColumns[i - 1]; + const partitionFilter = [ + ...previousFilter, + previousColumn + .filter() + .eq( + this.tableUtils.makeFilterRawValue( + previousColumn.type, + previousPartition + ) + ), + ]; + partitionFilters.push(partitionFilter); } } - partitionTables[partitionTables.length - 1]?.applyFilter(partitionFilters); - this.setState({ partitionTables }); + return partitionFilters; } - render(): JSX.Element { - const { columns, dh, getFormattedString, onDone } = this.props; - const { partitionTables } = this.state; + getCachedChangeCallback = memoizee( + (index: number) => (value: unknown) => + this.handlePartitionSelect(index, value) + ); - const partitionSelectorSearch = columns.map( - (column, index) => - partitionTables && ( - - this.handlePartitionSelect(index, partition) - } - onListResized={() => this.handlePartitionListResized(index)} - ref={selectorSearch => { - this.selectorSearch[index] = selectorSearch; - }} - /> - ) - ); - const partitionSelectors = columns.map((column, index) => ( - <> -
- {column.name}: -
-
- { - this.handlePartitionChange(index, e); - }} - className="form-control input-partition" - /> -
- -
-
-
- + getCachedFormatValueCallback = memoizee( + (index: number) => (value: unknown) => this.getDisplayValue(index, value) + ); + + render(): JSX.Element { + const { model, partitionConfig } = this.props; + const { isLoading, partitionFilters, partitionTables } = this.state; + + const { mode, partitions } = partitionConfig; + + const partitionSelectors = model.partitionColumns.map((column, index) => ( +
+
{column.name}
+ 0 && partitionConfig.mode !== 'partition') || isLoading + } + formatValue={this.getCachedFormatValueCallback(index)} + /> + {model.partitionColumns.length - 1 === index || ( + + )} +
)); return (
+
Partitioned Table
+
+ + +
{partitionSelectors} - -
); } diff --git a/packages/iris-grid/src/IrisGridPartitionedTableModel.ts b/packages/iris-grid/src/IrisGridPartitionedTableModel.ts new file mode 100644 index 0000000000..2eb88a34bc --- /dev/null +++ b/packages/iris-grid/src/IrisGridPartitionedTableModel.ts @@ -0,0 +1,90 @@ +/* eslint class-methods-use-this: "off" */ +import type { + Column, + dh as DhType, + PartitionedTable, + Table, +} from '@deephaven/jsapi-types'; +import { Formatter } from '@deephaven/jsapi-utils'; +import { ColumnName } from './CommonTypes'; +import EmptyIrisGridModel from './EmptyIrisGridModel'; +import MissingPartitionError, { + isMissingPartitionError, +} from './MissingPartitionError'; +import { PartitionedGridModelProvider } from './PartitionedGridModel'; + +class IrisGridPartitionedTableModel + extends EmptyIrisGridModel + implements PartitionedGridModelProvider +{ + readonly partitionedTable: PartitionedTable; + + /** + * @param dh JSAPI instance + * @param table Partitioned table to be used in the model + * @param formatter The formatter to use when getting formats + */ + constructor( + dh: DhType, + partitionedTable: PartitionedTable, + formatter = new Formatter(dh) + ) { + super(dh, formatter); + this.partitionedTable = partitionedTable; + } + + get isPartitionRequired(): boolean { + return true; + } + + get isReversible(): boolean { + return false; + } + + displayString( + value: unknown, + columnType: string, + columnName?: ColumnName + ): string { + return ''; + } + + close(): void { + this.partitionedTable.close(); + } + + get columns(): readonly Column[] { + return this.partitionedTable.columns; + } + + get partitionColumns(): readonly Column[] { + return this.partitionedTable.keyColumns; + } + + async partitionKeysTable(): Promise
{ + return this.partitionedTable.getKeyTable(); + } + + async partitionMergedTable(): Promise
{ + return this.partitionedTable.getMergedTable(); + } + + async partitionTable(partitions: unknown[]): Promise
{ + try { + const table = await this.partitionedTable.getTable(partitions); + if (table == null) { + // TODO: Will be unnecessary with https://github.com/deephaven/deephaven-core/pull/5050 + throw new MissingPartitionError('Partition not found'); + } + return table; + } catch (e) { + if (!isMissingPartitionError(e)) { + throw new MissingPartitionError('Unable to retrieve partition'); + } else { + throw e; + } + } + } +} + +export default IrisGridPartitionedTableModel; diff --git a/packages/iris-grid/src/IrisGridProxyModel.ts b/packages/iris-grid/src/IrisGridProxyModel.ts index 49b6ff8405..9bc0cf6c1d 100644 --- a/packages/iris-grid/src/IrisGridProxyModel.ts +++ b/packages/iris-grid/src/IrisGridProxyModel.ts @@ -19,6 +19,7 @@ import type { Sort, Table, TreeTable, + PartitionedTable, ValueTypeUnion, } from '@deephaven/jsapi-types'; import { @@ -29,6 +30,7 @@ import { MoveOperation, } from '@deephaven/grid'; import IrisGridTableModel from './IrisGridTableModel'; +import IrisGridPartitionedTableModel from './IrisGridPartitionedTableModel'; import IrisGridTreeTableModel from './IrisGridTreeTableModel'; import IrisGridModel from './IrisGridModel'; import { @@ -40,18 +42,26 @@ import { } from './CommonTypes'; import { isIrisGridTableModelTemplate } from './IrisGridTableModelTemplate'; import type ColumnHeaderGroup from './ColumnHeaderGroup'; +import { + PartitionConfig, + PartitionedGridModel, + isPartitionedGridModelProvider, +} from './PartitionedGridModel'; const log = Log.module('IrisGridProxyModel'); function makeModel( dh: DhType, - table: Table | TreeTable, + table: Table | TreeTable | PartitionedTable, formatter?: Formatter, inputTable?: InputTable | null ): IrisGridModel { if (TableUtils.isTreeTable(table)) { return new IrisGridTreeTableModel(dh, table, formatter); } + if (TableUtils.isPartitionedTable(table)) { + return new IrisGridPartitionedTableModel(dh, table, formatter); + } return new IrisGridTableModel(dh, table, formatter, inputTable); } @@ -59,7 +69,7 @@ function makeModel( * Model which proxies calls to other IrisGridModels. * This allows for operations that generate new tables, like rollups. */ -class IrisGridProxyModel extends IrisGridModel { +class IrisGridProxyModel extends IrisGridModel implements PartitionedGridModel { /** * @param dh JSAPI instance * @param table Iris data table to be used in the model @@ -77,11 +87,19 @@ class IrisGridProxyModel extends IrisGridModel { rollup: RollupConfig | null; + partition: PartitionConfig | null; + selectDistinct: ColumnName[]; + currentViewport?: { + top: number; + bottom: number; + columns?: Column[]; + }; + constructor( dh: DhType, - table: Table | TreeTable, + table: Table | TreeTable | PartitionedTable, formatter = new Formatter(dh), inputTable: InputTable | null = null ) { @@ -95,6 +113,7 @@ class IrisGridProxyModel extends IrisGridModel { this.model = model; this.modelPromise = null; this.rollup = null; + this.partition = null; this.selectDistinct = []; } @@ -119,6 +138,7 @@ class IrisGridProxyModel extends IrisGridModel { log.debug('setModel', model); const oldModel = this.model; + const { columns: oldColumns } = oldModel; if (oldModel !== this.originalModel) { oldModel.close(); @@ -130,11 +150,17 @@ class IrisGridProxyModel extends IrisGridModel { this.addListeners(model); } - this.dispatchEvent( - new EventShimCustomEvent(IrisGridModel.EVENT.COLUMNS_CHANGED, { - detail: model.columns, - }) - ); + if (oldColumns !== model.columns) { + this.dispatchEvent( + new EventShimCustomEvent(IrisGridModel.EVENT.COLUMNS_CHANGED, { + detail: model.columns, + }) + ); + } else if (this.currentViewport != null) { + // If the columns haven't changed, the current viewport should still valid, and needs to be set on the new model + const { top, bottom, columns } = this.currentViewport; + model.setViewport(top, bottom, columns); + } if (isIrisGridTableModelTemplate(model)) { this.dispatchEvent( @@ -452,6 +478,13 @@ class IrisGridProxyModel extends IrisGridModel { return this.model.groupedColumns; } + get partitionColumns(): readonly Column[] { + if (!isPartitionedGridModelProvider(this.originalModel)) { + return []; + } + return this.originalModel.partitionColumns; + } + sourceForCell: IrisGridModel['sourceForCell'] = (...args) => this.model.sourceForCell(...args); @@ -482,6 +515,67 @@ class IrisGridProxyModel extends IrisGridModel { this.model.filter = filter; } + get partitionConfig(): PartitionConfig | null { + if ( + !isPartitionedGridModelProvider(this.originalModel) || + !this.originalModel.isPartitionRequired + ) { + return null; + } + return this.partition; + } + + set partitionConfig(partitionConfig: PartitionConfig | null) { + if (!this.isPartitionRequired) { + throw new Error('Partitions are not available'); + } + log.debug('set partitionConfig', partitionConfig); + this.partition = partitionConfig; + + let modelPromise = Promise.resolve(this.originalModel); + if ( + partitionConfig != null && + isPartitionedGridModelProvider(this.originalModel) + ) { + if (partitionConfig.mode === 'keys') { + modelPromise = this.originalModel + .partitionKeysTable() + .then(table => makeModel(this.dh, table, this.formatter)); + } else if (partitionConfig.mode === 'merged') { + modelPromise = this.originalModel + .partitionMergedTable() + .then(table => makeModel(this.dh, table, this.formatter)); + } else { + modelPromise = this.originalModel + .partitionTable(partitionConfig.partitions) + .then(table => makeModel(this.dh, table, this.formatter)); + } + } + + this.setNextModel(modelPromise); + } + + partitionKeysTable(): Promise
{ + if (!isPartitionedGridModelProvider(this.originalModel)) { + throw new Error('Partitions are not available'); + } + return this.originalModel.partitionKeysTable(); + } + + partitionMergedTable(): Promise
{ + if (!isPartitionedGridModelProvider(this.originalModel)) { + throw new Error('Partitions are not available'); + } + return this.originalModel.partitionMergedTable(); + } + + partitionTable(partitions: unknown[]): Promise
{ + if (!isPartitionedGridModelProvider(this.originalModel)) { + throw new Error('Partitions are not available'); + } + return this.originalModel.partitionTable(partitions); + } + get formatter(): Formatter { return this.model.formatter; } @@ -604,7 +698,13 @@ class IrisGridProxyModel extends IrisGridModel { } get isFilterRequired(): boolean { - return this.model.isFilterRequired; + return this.originalModel.isFilterRequired; + } + + get isPartitionRequired(): boolean { + return isPartitionedGridModelProvider(this.originalModel) + ? this.originalModel.isPartitionRequired + : false; } get isEditable(): boolean { @@ -623,8 +723,10 @@ class IrisGridProxyModel extends IrisGridModel { isFilterable: IrisGridTableModel['isFilterable'] = (...args) => this.model.isFilterable(...args); - setViewport = (top: number, bottom: number, columns: Column[]): void => + setViewport = (top: number, bottom: number, columns?: Column[]): void => { + this.currentViewport = { top, bottom, columns }; this.model.setViewport(top, bottom, columns); + }; snapshot: IrisGridModel['snapshot'] = (...args) => this.model.snapshot(...args); diff --git a/packages/iris-grid/src/IrisGridTableModel.ts b/packages/iris-grid/src/IrisGridTableModel.ts index 1d99694591..80be071b2d 100644 --- a/packages/iris-grid/src/IrisGridTableModel.ts +++ b/packages/iris-grid/src/IrisGridTableModel.ts @@ -6,6 +6,7 @@ import type { ColumnStatistics, CustomColumn, dh as DhType, + FilterCondition, InputTable, LayoutHints, Table, @@ -13,10 +14,15 @@ import type { } from '@deephaven/jsapi-types'; import Log from '@deephaven/log'; import { Formatter } from '@deephaven/jsapi-utils'; -import { EventShimCustomEvent, assertNotNull } from '@deephaven/utils'; +import { + EventShimCustomEvent, + PromiseUtils, + assertNotNull, +} from '@deephaven/utils'; import IrisGridModel from './IrisGridModel'; -import { ColumnName, UIRow } from './CommonTypes'; +import { ColumnName, UIRow, UITotalsTableConfig } from './CommonTypes'; import IrisGridTableModelTemplate from './IrisGridTableModelTemplate'; +import { PartitionedGridModelProvider } from './PartitionedGridModel'; const log = Log.module('IrisGridTableModel'); @@ -24,13 +30,18 @@ const log = Log.module('IrisGridTableModel'); * Model for a grid showing an iris data table */ -class IrisGridTableModel extends IrisGridTableModelTemplate { +class IrisGridTableModel + extends IrisGridTableModelTemplate + implements PartitionedGridModelProvider +{ userFrozenColumns?: ColumnName[]; customColumnList: string[]; formatColumnList: CustomColumn[]; + initialFilters: FilterCondition[] = []; + /** * @param dh JSAPI instance * @param table Iris data table to be used in the model @@ -46,6 +57,7 @@ class IrisGridTableModel extends IrisGridTableModelTemplate { super(dh, table, formatter, inputTable); this.customColumnList = []; this.formatColumnList = []; + this.initialFilters = table.filter; } get isExportAvailable(): boolean { @@ -181,10 +193,101 @@ class IrisGridTableModel extends IrisGridTableModelTemplate { ); } + get partitionColumns(): readonly Column[] { + return this.getCachedPartitionColumns(this.columns); + } + + async partitionKeysTable(): Promise
{ + return this.valuesTable(this.partitionColumns); + } + + async partitionMergedTable(): Promise
{ + const t = await this.table.copy(); + t.applyFilter([]); + return t; + } + + async partitionTable(partitions: unknown[]): Promise
{ + log.debug('getting partition table for partitions', partitions); + + const partitionFilters: FilterCondition[] = []; + for (let i = 0; i < this.partitionColumns.length; i += 1) { + const partition = partitions[i]; + const partitionColumn = this.partitionColumns[i]; + + const partitionFilter = this.tableUtils.makeFilterRawValue( + partitionColumn.type, + partition + ); + partitionFilters.push(partitionColumn.filter().eq(partitionFilter)); + } + + const t = await this.table.copy(); + t.applyFilter([...this.initialFilters, ...partitionFilters]); + return t; + } + + set filter(filter: FilterCondition[]) { + this.closeSubscription(); + this.table.applyFilter([...this.initialFilters, ...filter]); + this.applyViewport(); + } + + set totalsConfig(totalsConfig: UITotalsTableConfig | null) { + log.debug('set totalsConfig', totalsConfig); + + if (totalsConfig === this.totals) { + // Totals already set, or it will be set when the next model actually gets set + return; + } + + this.totals = totalsConfig; + this.formattedStringData = []; + + if (this.totalsTablePromise != null) { + this.totalsTablePromise.cancel(); + } + + this.setTotalsTable(null); + + if (totalsConfig == null) { + this.dispatchEvent(new EventShimCustomEvent(IrisGridModel.EVENT.UPDATED)); + return; + } + + this.totalsTablePromise = PromiseUtils.makeCancelable( + this.table.getTotalsTable(totalsConfig), + table => table.close() + ); + this.totalsTablePromise + .then(totalsTable => { + this.totalsTablePromise = null; + this.setTotalsTable(totalsTable); + }) + .catch(err => { + if (PromiseUtils.isCanceled(err)) { + return; + } + + log.error('Unable to set next totalsTable', err); + this.totalsTablePromise = null; + + this.dispatchEvent( + new EventShimCustomEvent(IrisGridModel.EVENT.REQUEST_FAILED, { + detail: err, + }) + ); + }); + } + get isFilterRequired(): boolean { return this.table.isUncoalesced; } + get isPartitionRequired(): boolean { + return this.table.isUncoalesced && this.isValuesTableAvailable; + } + isFilterable(columnIndex: ModelIndex): boolean { return this.getCachedFilterableColumnSet(this.columns).has(columnIndex); } @@ -202,6 +305,10 @@ class IrisGridTableModel extends IrisGridTableModelTemplate { new Set(columns.map((_: Column, index: ModelIndex) => index)) ); + getCachedPartitionColumns = memoize((columns: readonly Column[]) => + columns.filter(column => column.isPartitionColumn) + ); + isColumnMovable(modelIndex: ModelIndex): boolean { const columnName = this.columns[modelIndex].name; if ( @@ -218,7 +325,7 @@ class IrisGridTableModel extends IrisGridTableModelTemplate { return this.frozenColumns.includes(this.columns[modelIndex].name); } - async delete(ranges: GridRange[]): Promise { + async delete(ranges: readonly GridRange[]): Promise { if (!this.isDeletableRanges(ranges)) { throw new Error(`Undeletable ranges ${ranges}`); } diff --git a/packages/iris-grid/src/IrisGridTableModelTemplate.ts b/packages/iris-grid/src/IrisGridTableModelTemplate.ts index 2b64e196bf..608149c19e 100644 --- a/packages/iris-grid/src/IrisGridTableModelTemplate.ts +++ b/packages/iris-grid/src/IrisGridTableModelTemplate.ts @@ -62,7 +62,7 @@ import { import { IrisGridThemeType } from './IrisGridTheme'; import ColumnHeaderGroup, { isColumnHeaderGroup } from './ColumnHeaderGroup'; -const log = Log.module('IrisGridTableModel'); +const log = Log.module('IrisGridTableModelTemplate'); const SET_VIEWPORT_THROTTLE = 150; const APPLY_VIEWPORT_THROTTLE = 0; @@ -164,7 +164,7 @@ class IrisGridTableModelTemplate< viewport: { top: VisibleIndex; bottom: VisibleIndex; - columns: Column[]; + columns?: Column[]; } | null; viewportData: UIViewportData | null; @@ -1328,7 +1328,7 @@ class IrisGridTableModelTemplate< } setViewport = throttle( - (top: VisibleIndex, bottom: VisibleIndex, columns: Column[]) => { + (top: VisibleIndex, bottom: VisibleIndex, columns?: Column[]) => { if (bottom < top) { log.error('Invalid viewport', top, bottom); return; @@ -1381,7 +1381,7 @@ class IrisGridTableModelTemplate< applyBufferedViewport( viewportTop: number, viewportBottom: number, - columns: Column[] + columns?: Column[] ): void { log.debug2('applyBufferedViewport', viewportTop, viewportBottom, columns); if (this.subscription == null) { @@ -1398,7 +1398,7 @@ class IrisGridTableModelTemplate< } async snapshot( - ranges: GridRange[], + ranges: readonly GridRange[], includeHeaders = false, formatValue: (value: unknown, column: Column) => unknown = value => value, consolidateRanges = true @@ -1519,7 +1519,7 @@ class IrisGridTableModelTemplate< * @returns A formatted string of all the data, columns separated by `\t` and rows separated by `\n` */ async textSnapshot( - ranges: GridRange[], + ranges: readonly GridRange[], includeHeaders = false, formatValue: ( value: unknown, @@ -1538,7 +1538,7 @@ class IrisGridTableModelTemplate< return data.map(row => row.join('\t')).join('\n'); } - async valuesTable(columns: Column | Column[]): Promise
{ + async valuesTable(columns: Column | readonly Column[]): Promise
{ let table = null; try { table = await this.table.copy(); @@ -1699,7 +1699,7 @@ class IrisGridTableModelTemplate< return ranges.every(range => this.isEditableRange(range)); } - isDeletableRanges(ranges: GridRange[]): boolean { + isDeletableRanges(ranges: readonly GridRange[]): boolean { return ranges.every(range => this.isDeletableRange(range)); } diff --git a/packages/iris-grid/src/IrisGridTreeTableModel.ts b/packages/iris-grid/src/IrisGridTreeTableModel.ts index f26218c4dc..65d2800804 100644 --- a/packages/iris-grid/src/IrisGridTreeTableModel.ts +++ b/packages/iris-grid/src/IrisGridTreeTableModel.ts @@ -138,7 +138,7 @@ class IrisGridTreeTableModel extends IrisGridTableModelTemplate< ): Promise { assertNotNull(this.viewport); assertNotNull(this.viewportData); - const { columns } = this.viewport; + const columns = this.viewport.columns ?? this.columns; const result = []; if (includeHeaders != null && includeHeaders) { diff --git a/packages/iris-grid/src/IrisGridUtils.test.ts b/packages/iris-grid/src/IrisGridUtils.test.ts index a9507c8952..d332f1c24e 100644 --- a/packages/iris-grid/src/IrisGridUtils.test.ts +++ b/packages/iris-grid/src/IrisGridUtils.test.ts @@ -646,7 +646,6 @@ describe('dehydration methods', () => { IrisGridUtils.dehydrateIrisGridPanelState(irisGridTestUtils.makeModel(), { isSelectingPartition: false, partitions: [], - partitionColumns: [], advancedSettings: new Map(), }), ], @@ -676,55 +675,11 @@ describe('hydration methods', () => { ); it.each([ - [ - 'hydrateIrisGridPanelStateV1', - { - isSelectingPartition: false, - partition: null, - partitionColumn: 'INVALID', - advancedSettings: [], - }, - ], - [ - 'hydrateIrisGridPanelStateV2', - { - isSelectingPartition: false, - partitions: [null], - partitionColumns: ['INVALID'], - advancedSettings: [], - }, - ], - ])('%s invalid column error', (_label, panelState) => { - expect(() => - IrisGridUtils.hydrateIrisGridPanelState(model, panelState) - ).toThrow('Invalid partition column INVALID'); - }); - - it.each([ - [ - 'hydrateIrisGridPanelStateV1 null partition column', - { - isSelectingPartition: false, - partition: null, - partitionColumn: null, - advancedSettings: [], - }, - ], [ 'hydrateIrisGridPanelStateV1 null partition', { isSelectingPartition: false, partition: null, - partitionColumn: 'name_0', - advancedSettings: [], - }, - ], - [ - 'hydrateIrisGridPanelStateV1 unselected partition', - { - isSelectingPartition: false, - partition: 'a', - partitionColumn: 'name_0', advancedSettings: [], }, ], @@ -733,7 +688,6 @@ describe('hydration methods', () => { { isSelectingPartition: true, partition: 'a', - partitionColumn: 'name_0', advancedSettings: [], }, ], @@ -742,7 +696,6 @@ describe('hydration methods', () => { { isSelectingPartition: false, partitions: [], - partitionColumns: [], advancedSettings: [], }, ], @@ -751,7 +704,6 @@ describe('hydration methods', () => { { isSelectingPartition: true, partitions: [null, null], - partitionColumns: ['name_0', 'name_1'], advancedSettings: [], }, ], @@ -760,7 +712,6 @@ describe('hydration methods', () => { { isSelectingPartition: true, partitions: ['a', 'b'], - partitionColumns: ['name_0', 'name_1'], advancedSettings: [], }, ], @@ -769,7 +720,6 @@ describe('hydration methods', () => { { isSelectingPartition: true, partitions: [null, 'b', null], - partitionColumns: ['name_0', 'name_1', 'name_2'], advancedSettings: [], }, ], @@ -778,7 +728,6 @@ describe('hydration methods', () => { { isSelectingPartition: true, partitions: ['a', null, 'b'], - partitionColumns: ['name_0', 'name_1', 'name_2'], advancedSettings: [], }, ], @@ -787,18 +736,8 @@ describe('hydration methods', () => { expect(result.isSelectingPartition).toBe(panelState.isSelectingPartition); if (isPanelStateV1(panelState)) { expect(result.partitions).toEqual([panelState.partition]); - if (panelState.partitionColumn !== null) { - expect(result.partitionColumns[0].name).toBe( - panelState.partitionColumn - ); - } else { - expect(result.partitionColumns).toEqual([]); - } } else { expect(result.partitions).toEqual(panelState.partitions); - panelState.partitionColumns.forEach((partition, index) => { - expect(result.partitionColumns[index].name === partition).toBeTruthy(); - }); } }); }); diff --git a/packages/iris-grid/src/IrisGridUtils.ts b/packages/iris-grid/src/IrisGridUtils.ts index 325728346c..7ac9f4dba5 100644 --- a/packages/iris-grid/src/IrisGridUtils.ts +++ b/packages/iris-grid/src/IrisGridUtils.ts @@ -49,6 +49,10 @@ import IrisGridModel from './IrisGridModel'; import type AdvancedSettingsType from './sidebar/AdvancedSettingsType'; import AdvancedSettings from './sidebar/AdvancedSettings'; import ColumnHeaderGroup from './ColumnHeaderGroup'; +import { + isPartitionedGridModelProvider, + PartitionConfig, +} from './PartitionedGridModel'; const log = Log.module('IrisGridUtils'); @@ -72,6 +76,7 @@ type HydratedIrisGridState = Pick< | 'frozenColumns' | 'conditionalFormats' | 'columnHeaderGroups' + | 'partitionConfig' > & { metrics: Pick; }; @@ -98,6 +103,8 @@ export type DehydratedUserColumnWidth = [ColumnName, number]; export type DehydratedUserRowHeight = [number, number]; +export type DehydratedPartitionConfig = PartitionConfig; + /** @deprecated Use `DehydratedSort` instead */ export interface LegacyDehydratedSort { column: ModelIndex; @@ -141,19 +148,18 @@ export interface DehydratedIrisGridState { pendingDataMap: DehydratedPendingDataMap; frozenColumns: readonly ColumnName[]; columnHeaderGroups?: readonly ColumnGroup[]; + partitionConfig?: DehydratedPartitionConfig; } export interface DehydratedIrisGridPanelStateV1 { isSelectingPartition: boolean; partition: string | null; - partitionColumn: ColumnName | null; advancedSettings: [AdvancedSettingsType, boolean][]; } export interface DehydratedIrisGridPanelStateV2 { isSelectingPartition: boolean; partitions: (string | null)[]; - partitionColumns: ColumnName[]; advancedSettings: [AdvancedSettingsType, boolean][]; } @@ -164,17 +170,13 @@ export type DehydratedIrisGridPanelState = export function isPanelStateV1( state: DehydratedIrisGridPanelState ): state is DehydratedIrisGridPanelStateV1 { - return ( - (state as DehydratedIrisGridPanelStateV1).partitionColumn !== undefined - ); + return (state as DehydratedIrisGridPanelStateV1).partition !== undefined; } export function isPanelStateV2( state: DehydratedIrisGridPanelState ): state is DehydratedIrisGridPanelStateV2 { - return Array.isArray( - (state as DehydratedIrisGridPanelStateV2).partitionColumns - ); + return Array.isArray((state as DehydratedIrisGridPanelStateV2).partitions); } /** @@ -310,24 +312,16 @@ class IrisGridUtils { // This needs to be changed after IrisGridPanel is done isSelectingPartition: boolean; partitions: (string | null)[]; - partitionColumns: Column[]; advancedSettings: Map; } ): DehydratedIrisGridPanelState { - const { - isSelectingPartition, - partitions, - partitionColumns, - advancedSettings, - } = irisGridPanelState; + const { isSelectingPartition, partitions, advancedSettings } = + irisGridPanelState; // Return value will be serialized, should not contain undefined return { isSelectingPartition, partitions, - partitionColumns: partitionColumns.map( - partitionColumn => partitionColumn.name - ), advancedSettings: [...advancedSettings], }; } @@ -344,32 +338,17 @@ class IrisGridUtils { ): { isSelectingPartition: boolean; partitions: (string | null)[]; - partitionColumns: Column[]; advancedSettings: Map; } { const { isSelectingPartition, advancedSettings } = irisGridPanelState; - const { partitionColumns, partitions } = isPanelStateV2(irisGridPanelState) - ? irisGridPanelState - : { - partitionColumns: - irisGridPanelState.partitionColumn !== null - ? [irisGridPanelState.partitionColumn] - : [], - partitions: [irisGridPanelState.partition], - }; + const partitions = isPanelStateV2(irisGridPanelState) + ? irisGridPanelState.partitions + : [irisGridPanelState.partition]; - const { columns } = model; return { isSelectingPartition, partitions, - partitionColumns: partitionColumns.map(partitionColumn => { - const column = IrisGridUtils.getColumnByName(columns, partitionColumn); - if (column === undefined) { - throw new Error(`Invalid partition column ${partitionColumn}`); - } - return column; - }), advancedSettings: new Map([ ...AdvancedSettings.DEFAULTS, ...advancedSettings, @@ -423,7 +402,6 @@ class IrisGridUtils { }, inputFilters: InputFilter[] = [] ): { - partitionColumns: ColumnName[]; partitions: unknown[]; advancedFilters: AF; inputFilters: InputFilter[]; @@ -431,22 +409,15 @@ class IrisGridUtils { sorts: S; } { const { irisGridPanelState, irisGridState } = panelState; - const { partitionColumns, partitions } = isPanelStateV2(irisGridPanelState) - ? irisGridPanelState - : { - partitionColumns: - irisGridPanelState.partitionColumn !== null - ? [irisGridPanelState.partitionColumn] - : [], - partitions: [irisGridPanelState.partition], - }; + const partitions = isPanelStateV2(irisGridPanelState) + ? irisGridPanelState.partitions + : [irisGridPanelState.partition]; const { advancedFilters, quickFilters, sorts } = irisGridState; return { advancedFilters, inputFilters, partitions, - partitionColumns, quickFilters, sorts, }; @@ -690,9 +661,9 @@ class IrisGridUtils { hiddenColumns: readonly VisibleIndex[] = [], alwaysFetchColumnNames: readonly ColumnName[] = [], bufferPages = 0 - ): Column[] | null { + ): Column[] | undefined { if (left == null || right == null) { - return null; + return undefined; } const columnsCenter = IrisGridUtils.getVisibleColumnsInRange( @@ -1204,10 +1175,15 @@ class IrisGridUtils { pendingDataMap = EMPTY_MAP, frozenColumns, columnHeaderGroups, + partitionConfig = undefined, } = irisGridState; assertNotNull(metrics); const { userColumnWidths, userRowHeights } = metrics; const { columns } = model; + const partitionColumns = isPartitionedGridModelProvider(model) + ? model.partitionColumns + : []; + // Return value will be serialized, should not contain undefined return { advancedFilters: this.dehydrateAdvancedFilters(columns, advancedFilters), @@ -1241,6 +1217,10 @@ class IrisGridUtils { children: item.children, color: item.color, })), + partitionConfig: this.dehydratePartitionConfig( + partitionColumns, + partitionConfig + ), }; } @@ -1277,9 +1257,12 @@ class IrisGridUtils { pendingDataMap = [], frozenColumns, columnHeaderGroups, + partitionConfig, } = irisGridState; const { columns, formatter } = model; - + const partitionColumns = isPartitionedGridModelProvider(model) + ? model.partitionColumns + : []; return { advancedFilters: this.hydrateAdvancedFilters( columns, @@ -1335,6 +1318,10 @@ class IrisGridUtils { model, columnHeaderGroups ?? model.layoutHints?.columnGroups ?? [] ).groups, + partitionConfig: this.hydratePartitionConfig( + partitionColumns, + partitionConfig + ), }; } @@ -1528,6 +1515,38 @@ class IrisGridUtils { ); } + dehydratePartitionConfig( + partitionColumns: readonly Column[], + partitionConfig: PartitionConfig | undefined + ): PartitionConfig | undefined { + if (partitionConfig == null) { + return partitionConfig; + } + + return { + ...partitionConfig, + partitions: partitionConfig.partitions.map((partition, index) => + this.dehydrateValue(partition, partitionColumns[index].type) + ), + }; + } + + hydratePartitionConfig( + partitionColumns: readonly Column[], + partitionConfig: PartitionConfig | undefined + ): PartitionConfig | undefined { + if (partitionConfig == null) { + return partitionConfig; + } + + return { + ...partitionConfig, + partitions: partitionConfig.partitions.map((partition, index) => + this.hydrateValue(partition, partitionColumns[index].type) + ), + }; + } + /** * Dehydrates/serializes a value for storage. * @param value The value to dehydrate diff --git a/packages/iris-grid/src/MissingPartitionError.ts b/packages/iris-grid/src/MissingPartitionError.ts new file mode 100644 index 0000000000..2c9c17b61d --- /dev/null +++ b/packages/iris-grid/src/MissingPartitionError.ts @@ -0,0 +1,11 @@ +class MissingPartitionError extends Error { + isMissingPartitionError = true; +} + +export function isMissingPartitionError( + err: unknown +): err is MissingPartitionError { + return (err as MissingPartitionError)?.isMissingPartitionError === true; +} + +export default MissingPartitionError; diff --git a/packages/iris-grid/src/PartitionSelectorSearch.scss b/packages/iris-grid/src/PartitionSelectorSearch.scss deleted file mode 100644 index cd5efe8cd9..0000000000 --- a/packages/iris-grid/src/PartitionSelectorSearch.scss +++ /dev/null @@ -1,25 +0,0 @@ -@import '@deephaven/components/scss/custom.scss'; -$partition-selector-search-input-bg: $gray-500; -.iris-grid-partition-selector-search { - .iris-grid-partition-selector-search-list { - .item-list-scroll-pane { - border: none; - } - } - .iris-grid-partition-selector-search-empty { - color: $text-muted; - padding: $input-padding-y $input-btn-padding-x; - text-align: center; - } - .iris-grid-partition-selector-loading { - color: $text-muted; - padding: $input-padding-y $input-btn-padding-x; - text-align: center; - } - .search-container { - padding: $tooltip-padding-y $tooltip-padding-x; - .form-control { - background-color: $partition-selector-search-input-bg; - } - } -} diff --git a/packages/iris-grid/src/PartitionSelectorSearch.test.tsx b/packages/iris-grid/src/PartitionSelectorSearch.test.tsx deleted file mode 100644 index 4a3c889cb9..0000000000 --- a/packages/iris-grid/src/PartitionSelectorSearch.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import dh from '@deephaven/jsapi-shim'; -import type { Table } from '@deephaven/jsapi-types'; -import PartitionSelectorSearch from './PartitionSelectorSearch'; -import IrisGridTestUtils from './IrisGridTestUtils'; - -const irisGridTestUtils = new IrisGridTestUtils(dh); - -function makePartitionSelectorSearch({ - table = irisGridTestUtils.makeTable(), - onSelect = jest.fn(), - getFormattedString = jest.fn(value => `${value}`), -} = {}) { - return render( - - ); -} - -beforeEach(() => { - jest.useFakeTimers(); -}); - -afterEach(() => { - jest.useRealTimers(); -}); - -it('mounts and unmounts properly', () => { - makePartitionSelectorSearch(); -}); - -it('updates filters when input is changed', async () => { - const user = userEvent.setup({ delay: null }); - const table = irisGridTestUtils.makeTable(); - table.applyFilter = jest.fn(); - - const component = makePartitionSelectorSearch({ table }); - - const input = screen.getByRole('textbox'); - await user.type(input, 'abc'); - - jest.runAllTimers(); - - expect(table.applyFilter).toHaveBeenCalledWith([ - expect.any(dh.FilterCondition), - ]); - - await user.type(input, '{Backspace}{Backspace}{Backspace}'); - - jest.runAllTimers(); - - expect(table.applyFilter).toHaveBeenCalledWith([]); - - component.unmount(); -}); - -it('selects the first item when enter is pressed', async () => { - const user = userEvent.setup({ delay: null }); - const onSelect = jest.fn(); - const table = irisGridTestUtils.makeTable(); - const component = makePartitionSelectorSearch({ onSelect, table }); - - (table as Table).fireViewportUpdate(); - - const input = screen.getByRole('textbox'); - await user.type(input, '{Enter}'); - - expect(onSelect).toHaveBeenCalledWith('AAPL'); - - component.unmount(); -}); diff --git a/packages/iris-grid/src/PartitionSelectorSearch.tsx b/packages/iris-grid/src/PartitionSelectorSearch.tsx deleted file mode 100644 index e168a72dea..0000000000 --- a/packages/iris-grid/src/PartitionSelectorSearch.tsx +++ /dev/null @@ -1,366 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import debounce from 'lodash.debounce'; -import { TableUtils } from '@deephaven/jsapi-utils'; -import type { Column, dh as DhType, Table } from '@deephaven/jsapi-types'; -import { ItemList, LoadingSpinner } from '@deephaven/components'; -import Log from '@deephaven/log'; -import { CanceledPromiseError } from '@deephaven/utils'; -import './PartitionSelectorSearch.scss'; -import { ModelIndex } from '@deephaven/grid'; - -const log = Log.module('PartitionSelectorSearch'); -const DEBOUNCE_UPDATE_FILTER = 150; - -interface Item { - value: string; - displayValue: string; -} - -interface PartitionSelectorSearchProps { - column: Column; - dh: DhType; - getFormattedString: (value: T, type: string, name: string) => string; - table: Table; - initialPageSize: number; - onSelect: (value: string) => void; - onListResized: () => void; -} -interface PartitionSelectorSearchState { - offset: number; - itemCount: number; - items: Item[]; - text: string; - isLoading: boolean; -} -class PartitionSelectorSearch extends Component< - PartitionSelectorSearchProps, - PartitionSelectorSearchState -> { - static MAX_VISIBLE_ITEMS = 12; - - static defaultProps = { - initialPageSize: 100, - onSelect: (): void => undefined, - onListResized: (): void => undefined, - }; - - static propTypes = { - getFormattedString: PropTypes.func.isRequired, - table: PropTypes.shape({ - addEventListener: PropTypes.func.isRequired, - removeEventListener: PropTypes.func.isRequired, - columns: PropTypes.arrayOf( - PropTypes.shape({ - name: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, - filter: PropTypes.func.isRequired, - }) - ), - size: PropTypes.number.isRequired, - applyFilter: PropTypes.func.isRequired, - setViewport: PropTypes.func.isRequired, - }).isRequired, - initialPageSize: PropTypes.number, - onSelect: PropTypes.func, - onListResized: PropTypes.func, - }; - - static handleError(error: unknown): void { - if (!(error instanceof CanceledPromiseError)) { - log.error(error); - } - } - - constructor(props: PartitionSelectorSearchProps) { - super(props); - - this.handleFilterChange = this.handleFilterChange.bind(this); - this.handleKeyDown = this.handleKeyDown.bind(this); - this.handleListKeydown = this.handleListKeydown.bind(this); - this.handleSelect = this.handleSelect.bind(this); - this.handleTableUpdate = this.handleTableUpdate.bind(this); - this.handleTextChange = this.handleTextChange.bind(this); - this.handleViewportChange = this.handleViewportChange.bind(this); - - this.itemList = null; - this.searchInput = null; - this.timer = null; - - const { dh } = props; - this.tableUtils = new TableUtils(dh); - - this.state = { - offset: 0, - itemCount: 0, - items: [], - text: '', - isLoading: true, - }; - } - - componentDidMount(): void { - this.startListening(); - } - - componentDidUpdate( - prevProps: PartitionSelectorSearchProps, - prevState: PartitionSelectorSearchState - ): void { - const { isLoading, itemCount } = this.state; - const { onListResized } = this.props; - if ( - itemCount !== prevState.itemCount || - isLoading !== prevState.isLoading - ) { - onListResized(); - } - } - - componentWillUnmount(): void { - this.debounceUpdateFilter.cancel(); - - this.stopListening(); - } - - itemList: ItemList | null; - - searchInput: HTMLInputElement | null; - - timer: null; - - tableUtils: TableUtils; - - handleKeyDown(event: React.KeyboardEvent): boolean { - if (this.itemList == null) { - return false; - } - - const { items, itemCount } = this.state; - switch (event.key) { - case 'Enter': { - let selectedValue = null; - if (items.length > 0) { - selectedValue = items[0].value; - } else { - const { text } = this.state; - selectedValue = text.trim(); - } - - if (selectedValue.length > 0) { - const { onSelect } = this.props; - onSelect(selectedValue); - } - - event.stopPropagation(); - event.preventDefault(); - return true; - } - case 'ArrowDown': - if (itemCount > 0) { - this.itemList.focusItem(1); - } - event.stopPropagation(); - event.preventDefault(); - return true; - default: - return false; - } - } - - handleListKeydown(event: React.KeyboardEvent): void { - switch (event.key) { - case 'Escape': - // Do nothing - break; - default: - this.focus(); - break; - } - } - - handleFilterChange(): void { - log.debug2('handleFilterChange'); - - const { table } = this.props; - const itemCount = table.size; - this.setState({ itemCount, isLoading: true }); - } - - handleSelect(itemIndex: ModelIndex): void { - log.debug2('handleSelect', itemIndex); - - const { onSelect } = this.props; - const { offset, items } = this.state; - const offsetIndex = itemIndex - offset; - if (offsetIndex < 0 || items.length <= offsetIndex) { - log.error('No data for item', itemIndex); - return; - } - - const { value } = items[offsetIndex]; - onSelect(value); - } - - handleTableUpdate(event: CustomEvent): void { - const { column } = this.props; - const data = event.detail; - const { offset, rows } = data; - - const items = [] as Item[]; - const { getFormattedString, table } = this.props; - for (let r = 0; r < rows.length; r += 1) { - const row = rows[r]; - const value = row.get(column); - const displayValue = getFormattedString(value, column.type, column.name); - items.push({ - displayValue, - value, - }); - } - - const itemCount = table.size; - log.debug2('handleTableUpdate', itemCount, offset, items.length); - this.setState({ itemCount, items, offset, isLoading: false }); - } - - handleTextChange(event: React.ChangeEvent): void { - log.debug2('handleTextChange'); - - const { column } = this.props; - const { value: text } = event.target; - - if (text !== '' && TableUtils.isIntegerType(column.type)) { - this.setState({ text: parseInt(text, 10).toString() }); - } else { - this.setState({ text }); - } - - this.debounceUpdateFilter(); - } - - handleViewportChange(topRow: number, bottomRow: number): void { - log.debug2('handleViewportChange', topRow, bottomRow); - - const delta = Math.max(1, bottomRow - topRow); - const top = Math.max(0, topRow - delta); - const bottom = bottomRow + delta; - - const { table } = this.props; - table.setViewport(top, bottom); - } - - debounceUpdateFilter = debounce((): void => { - this.updateFilter(); - }, DEBOUNCE_UPDATE_FILTER); - - focus(): void { - if (this.searchInput) { - this.searchInput.focus(); - } - } - - startListening(): void { - const { dh, initialPageSize, table } = this.props; - table.addEventListener(dh.Table.EVENT_UPDATED, this.handleTableUpdate); - table.addEventListener( - dh.Table.EVENT_FILTERCHANGED, - this.handleFilterChange - ); - table.setViewport(0, initialPageSize); - } - - stopListening(): void { - const { dh, table } = this.props; - table.removeEventListener(dh.Table.EVENT_UPDATED, this.handleTableUpdate); - table.removeEventListener( - dh.Table.EVENT_FILTERCHANGED, - this.handleFilterChange - ); - } - - updateFilter(): void { - const { column, initialPageSize, table } = this.props; - const { text } = this.state; - const filterText = text.trim(); - const filters = []; - if (filterText.length > 0) { - const filter = this.tableUtils.makeQuickFilterFromComponent( - column, - TableUtils.isStringType(column.type) ? `~${filterText}` : filterText - ); - if (!filter) { - throw new Error( - 'Unable to create column filter for partition selector' - ); - } - filters.push(filter); - } - - log.debug2('updateFilter', filters); - - table.applyFilter(filters); - table.setViewport(0, initialPageSize); - } - - render(): JSX.Element { - const { column } = this.props; - const { isLoading, itemCount, items, offset, text } = this.state; - - const listHeight = - Math.min(itemCount, PartitionSelectorSearch.MAX_VISIBLE_ITEMS) * - ItemList.DEFAULT_ROW_HEIGHT + - // Adjust for ListItem vertical padding - .375rem ~ 5.25px - 11; - const inputType = TableUtils.isNumberType(column.type) ? 'number' : 'text'; - return ( -
-
- { - this.searchInput = searchInput; - }} - value={text} - placeholder="Available Partitions" - onChange={this.handleTextChange} - onKeyDown={this.handleKeyDown} - className="form-control input-partition" - /> -
- {!isLoading && itemCount > 0 && ( -
- { - this.itemList = itemList; - }} - itemCount={itemCount} - items={items} - offset={offset} - onSelect={this.handleSelect} - onViewportChange={this.handleViewportChange} - /> -
- )} - {!isLoading && itemCount === 0 && ( -
- No results -
- )} - {isLoading && ( -
- -  Loading... -
- )} -
- ); - } -} - -export default PartitionSelectorSearch; diff --git a/packages/iris-grid/src/PartitionedGridModel.ts b/packages/iris-grid/src/PartitionedGridModel.ts new file mode 100644 index 0000000000..6f9f70ab5f --- /dev/null +++ b/packages/iris-grid/src/PartitionedGridModel.ts @@ -0,0 +1,65 @@ +import type { Column, Table } from '@deephaven/jsapi-types'; +import IrisGridModel from './IrisGridModel'; + +export function isPartitionedGridModelProvider( + model: IrisGridModel +): model is PartitionedGridModelProvider { + return ( + (model as PartitionedGridModel)?.isPartitionRequired !== undefined && + (model as PartitionedGridModel)?.partitionColumns !== undefined && + (model as PartitionedGridModel)?.partitionKeysTable !== undefined && + (model as PartitionedGridModel)?.partitionMergedTable !== undefined && + (model as PartitionedGridModel)?.partitionTable !== undefined + ); +} + +export function isPartitionedGridModel( + model: IrisGridModel +): model is PartitionedGridModel { + return ( + isPartitionedGridModelProvider(model) && + (model as PartitionedGridModel).partitionConfig !== undefined + ); +} + +export interface PartitionConfig { + /** The partition values to set on the model */ + partitions: unknown[]; + + /** What data to display - the keys table, the merged table, or the selected partition */ + mode: 'keys' | 'merged' | 'partition'; +} + +/** + * A grid model that provides key tables and partitions, cannot accept a `PartitionConfig` being set + */ +export interface PartitionedGridModelProvider extends IrisGridModel { + /** + * Retrieve the columns this model is partitioned on + * @returns All columns to partition on + */ + get partitionColumns(): readonly Column[]; + + /** Whether a partition is required to be set on this model */ + get isPartitionRequired(): boolean; + + /** Get a keys table for the partitions */ + partitionKeysTable: () => Promise
; + + /** Get a merged table containing all partitions */ + partitionMergedTable: () => Promise
; + + /** Get a table containing the selected partition */ + partitionTable: (partitions: unknown[]) => Promise
; +} + +/** + * A grid model that can be partitioned on a column + */ +export interface PartitionedGridModel extends PartitionedGridModelProvider { + /** Retrieve the currently set partition config */ + get partitionConfig(): PartitionConfig | null; + + /** Set the partition config */ + set partitionConfig(partitionConfig: PartitionConfig | null); +} diff --git a/packages/iris-grid/src/index.ts b/packages/iris-grid/src/index.ts index 0f9df3920d..046dc7d501 100644 --- a/packages/iris-grid/src/index.ts +++ b/packages/iris-grid/src/index.ts @@ -6,15 +6,17 @@ export * from './sidebar'; export * from './AdvancedFilterCreator'; export * from './CommonTypes'; export { default as ColumnHeaderGroup } from './ColumnHeaderGroup'; +export * from './PartitionedGridModel'; export * from './IrisGrid'; export { default as SHORTCUTS } from './IrisGridShortcuts'; export { default as IrisGridModel } from './IrisGridModel'; export { default as IrisGridTableModel } from './IrisGridTableModel'; export * from './IrisGridTableModel'; +export { default as IrisGridPartitionedTableModel } from './IrisGridPartitionedTableModel'; export { default as IrisGridTreeTableModel } from './IrisGridTreeTableModel'; export { default as IrisGridTableModelTemplate } from './IrisGridTableModelTemplate'; -export * from './IrisGridTableModelTemplate'; export * from './IrisGridTreeTableModel'; +export * from './IrisGridTableModelTemplate'; export { default as IrisGridModelFactory } from './IrisGridModelFactory'; export { createDefaultIrisGridTheme } from './IrisGridTheme'; export type { IrisGridThemeType } from './IrisGridTheme'; diff --git a/packages/iris-grid/tsconfig.json b/packages/iris-grid/tsconfig.json index 3cf2d5dfd7..d2e5247dcd 100644 --- a/packages/iris-grid/tsconfig.json +++ b/packages/iris-grid/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../components" }, { "path": "../filters" }, { "path": "../grid" }, + { "path": "../jsapi-components" }, { "path": "../jsapi-shim" }, { "path": "../jsapi-types" }, { "path": "../jsapi-utils" }, diff --git a/packages/jsapi-components/src/TableDropdown.tsx b/packages/jsapi-components/src/TableDropdown.tsx new file mode 100644 index 0000000000..67cbe0a390 --- /dev/null +++ b/packages/jsapi-components/src/TableDropdown.tsx @@ -0,0 +1,144 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Option, Select } from '@deephaven/components'; +import { useApi } from '@deephaven/jsapi-bootstrap'; +import { + Column, + FilterCondition, + Table, + ViewportData, +} from '@deephaven/jsapi-types'; +import { EMPTY_ARRAY } from '@deephaven/utils'; + +type JavaObject = { + equals: (other: unknown) => boolean; +}; + +function isJavaObject(value: unknown): value is JavaObject { + return ( + typeof value === 'object' && + value != null && + 'equals' in value && + typeof value.equals === 'function' + ); +} + +function defaultFormatValue(value: unknown): string { + return `${value}`; +} + +export type TableDropdownProps = { + /** Table to use as the source of data. Does not own the table, does not close it on unmount. */ + table?: Table; + + /** Column to read data from the table. Defaults to the first column in the table if it's not provided. */ + column?: Column; + + /** Triggered when the dropdown selection has changed */ + onChange: (value: unknown) => void; + + /** Filter to apply on the table */ + filter?: readonly FilterCondition[]; + + /** The currently selected value */ + selectedValue?: unknown; + + /** Whether the control is disabled */ + disabled?: boolean; + + /** Class to apply to the select element */ + className?: string; + + /** Optional function to format the value for display */ + formatValue?: (value: unknown) => string; + + /** Maximum number of elements to load */ + maxSize?: number; +}; + +/** + * Dropdown that displays the values of a column in a table. + */ +export function TableDropdown({ + column, + table, + filter = EMPTY_ARRAY, + onChange, + selectedValue, + disabled, + className, + formatValue = defaultFormatValue, + maxSize = 1000, +}: TableDropdownProps): JSX.Element { + const dh = useApi(); + const [values, setValues] = useState([]); + + useEffect(() => { + if (table == null) { + setValues([]); + return undefined; + } + + const tableColumn = column ?? table.columns[0]; + // Need to set a viewport on the table and start listening to get the values to populate the dropdown + table.applyFilter(filter as FilterCondition[]); + const subscription = table.setViewport(0, maxSize, [tableColumn]); + + subscription.addEventListener( + dh.Table.EVENT_UPDATED, + (event: CustomEvent) => { + const { detail } = event; + const newValues = detail.rows.map(row => row.get(tableColumn)); + setValues(newValues); + } + ); + + return () => { + subscription.close(); + }; + }, [column, dh, filter, maxSize, table]); + + // If the selected value is undefined, add a placeholder item + const allValues = useMemo(() => { + if (selectedValue === undefined) { + return [undefined, ...values]; + } + return values; + }, [selectedValue, values]); + + // Since values could be anything, not just strings, track the selected index based on the current data + const selectedIndex = useMemo( + // eslint-disable-next-line eqeqeq + () => + allValues.findIndex( + value => + value === selectedValue || + (isJavaObject(value) && value.equals(selectedValue)) + ), + [selectedValue, allValues] + ); + + const handleChange = useCallback( + newSelectedIndex => { + onChange(allValues[newSelectedIndex]); + }, + [onChange, allValues] + ); + + return ( + + ); +} + +export default TableDropdown; diff --git a/packages/jsapi-components/src/index.ts b/packages/jsapi-components/src/index.ts index 206a7e7639..d8b233c9ff 100644 --- a/packages/jsapi-components/src/index.ts +++ b/packages/jsapi-components/src/index.ts @@ -3,6 +3,7 @@ export * from './HookTestUtils'; export { default as TableInput } from './TableInput'; export * from './RefreshTokenBootstrap'; export * from './RefreshTokenUtils'; +export * from './TableDropdown'; export { default as useBroadcastChannel } from './useBroadcastChannel'; export { default as useBroadcastLoginListener } from './useBroadcastLoginListener'; export * from './useCheckIfExistsValue'; diff --git a/packages/jsapi-types/src/dh.types.ts b/packages/jsapi-types/src/dh.types.ts index ccb020395a..40d0ff1c9a 100644 --- a/packages/jsapi-types/src/dh.types.ts +++ b/packages/jsapi-types/src/dh.types.ts @@ -165,6 +165,7 @@ export interface IdeSession extends Evented { getTable: (name: string) => Promise
; getFigure: (name: string) => Promise
; getTreeTable: (name: string) => Promise; + getPartitionedTable: (name: string) => Promise; getObject: (( definition: VariableDefinition ) => Promise
) & @@ -177,6 +178,9 @@ export interface IdeSession extends Evented { (( definition: VariableDefinition ) => Promise) & + (( + definition: VariableDefinition + ) => Promise) & ((definition: VariableDefinition) => Promise); onLogMessage: (logHandler: (logItem: LogItem) => void) => () => void; runCode: (code: string) => Promise; @@ -907,6 +911,13 @@ export interface ColumnStatistics { getType: (name: string) => string; } +export interface PartitionedTableStatic { + readonly EVENT_KEYADDED: string; + readonly EVENT_DISCONNECT: string; + readonly EVENT_RECONNECT: string; + readonly EVENT_RECONNECTFAILED: string; +} + export interface TreeTableStatic { readonly EVENT_UPDATED: string; readonly EVENT_DISCONNECT: string; @@ -942,6 +953,19 @@ export interface TableTemplate extends Evented { close: () => void; } +export interface PartitionedTable extends Evented, PartitionedTableStatic { + readonly size: number; + readonly columns: Column[]; + readonly keyColumns: Column[]; + + getTable: (key: unknown) => Promise
; + getMergedTable: () => Promise
; + getKeys: () => Set; + getKeyTable: () => Promise
; + + close: () => void; +} + export interface TreeTable extends TableTemplate, TreeTableStatic { readonly isIncludeConstituents: boolean; readonly groupedColumns: Column[]; @@ -1067,6 +1091,9 @@ export interface IdeConnection (( definition: VariableDefinition ) => Promise) & + (( + definition: VariableDefinition + ) => Promise) & ((definition: VariableDefinition) => Promise); subscribeToFieldUpdates: ( param: (changes: VariableChanges) => void diff --git a/packages/jsapi-utils/src/TableUtils.ts b/packages/jsapi-utils/src/TableUtils.ts index 6edc4dfc26..c439eccef3 100644 --- a/packages/jsapi-utils/src/TableUtils.ts +++ b/packages/jsapi-utils/src/TableUtils.ts @@ -13,6 +13,7 @@ import type { FilterValue, LongWrapper, RemoverFn, + PartitionedTable, Sort, Table, TreeTable, @@ -746,6 +747,15 @@ export class TableUtils { } } + static isPartitionedTable(table: unknown): table is PartitionedTable { + return ( + table != null && + (table as PartitionedTable).getMergedTable !== undefined && + (table as PartitionedTable).getKeyTable !== undefined && + (table as PartitionedTable).getKeys !== undefined + ); + } + static isTreeTable(table: unknown): table is TreeTable { return ( table != null && @@ -1778,6 +1788,11 @@ export class TableUtils { */ makeFilterRawValue(columnType: string, rawValue: unknown): FilterValue { const { dh } = this; + if (TableUtils.isCharType(columnType)) { + return dh.FilterValue.ofString( + typeof rawValue === 'number' ? String.fromCharCode(rawValue) : rawValue + ); + } if (TableUtils.isTextType(columnType)) { return dh.FilterValue.ofString(rawValue); }