From db219ca66bd087d4b5ddb58b667de96deee97760 Mon Sep 17 00:00:00 2001 From: George Wan Date: Fri, 19 Jan 2024 06:13:37 +0800 Subject: [PATCH] feat: Create UI to Display Partitioned Tables (#1663) - `PartitionedTable` objects can now be opened and displayed with a new UI - Supports switching between partitions and viewing the merged or key table - Partition aware parquet tables will also share this new UI - The new UI will no longer allow users to enter invalid partitions - Closes #1143 - Depends on the following changes to core: - https://github.com/deephaven/deephaven-core/pull/4789 - https://github.com/deephaven/deephaven-core/pull/4931 - https://github.com/deephaven/deephaven-core/pull/4940 ### Testing Instructions #### PartitionedTable 1. Run the code and open the table `pt` ```py from deephaven import empty_table _t = empty_table(100).update(["verylongcolumn=(int)Math.floor(i/5)", "veryveryverylongcolumn=i"]) pt = _t.partition_by(["verylongcolumn", "veryveryverylongcolumn"]) ``` 2. Check that the features specified in the [spec](https://user-images.githubusercontent.com/1576283/268390627-d427b993-1d09-43a5-960f-4e6cd0848f36.png) are present: - Resizing the panel horizontally wraps the dropdown without wrapping the '>' - Hovering over the 'Key' and 'Merge' buttons displays the correct labels - The initial partition should be the first valid partition available when all columns are sorted in descending order - Options in the dropdown are displayed in descending order 3. Clicking the 'Key' and 'Merge' tables should correctly display the respective table and the button should visually indicate if one of them is being displayed. All the dropdowns should show empty values while one of the buttons is active. - While one of the toggle buttons is active, only the leftmost dropdown should be enabled 4. After clearing the dropdowns by clicking either the 'Key' or 'Merge' button, selecting any value on any dropdown should automatically set the remaining dropdowns and display a valid partition. 5. Dropdowns should only contain values that are valid with respect to the selected values of all the dropdowns left of it 6. Changing the value of a dropdown should try to preserve dropdowns to the right of it. If this is not possible, the values of the dropdowns right of it should be changed so that a valid partition can be displayed. #### Parquet Tables 1. Run the following code and verify that all tables display and function correctly for every data type ```py from deephaven import empty_table part = empty_table(4).update("II=ii") from deephaven.parquet import write, read write(part, "/tmp/pt-test/intCol=0/part.parquet") write(part, "/tmp/pt-test/intCol=1/part.parquet") int_partition = read("/tmp/pt-test") write(part, "/tmp/string-test/stringCol=hello/part.parquet") write(part, "/tmp/string-test/stringCol=world/part.parquet") string_partition = read("/tmp/string-test") write(part, "/tmp/double-test/doubleCol=1.5/part.parquet") write(part, "/tmp/double-test/doubleCol=2.5/part.parquet") double_partition = read("/tmp/double-test") write(part, "/tmp/char-test/charCol=a/part.parquet") write(part, "/tmp/char-test/charCol=b/part.parquet") char_partition = read("/tmp/char-test") write(part, "/tmp/long-test/longCol=2147483648/part.parquet") write(part, "/tmp/long-test/longCol=2147483650/part.parquet") long_partition = read("/tmp/long-test") write(part, "/tmp/bool-test/boolCol=true/part.parquet") write(part, "/tmp/bool-test/boolCol=false/part.parquet") bool_partition = read("/tmp/bool-test") write(part, "/tmp/multi_test/x=0/y=0/part.parquet") write(part, "/tmp/multi_test/x=0/y=1/part.parquet") write(part, "/tmp/multi_test/x=1/y=0/part.parquet") write(part, "/tmp/multi_test/x=1/y=1/part.parquet") write(part, "/tmp/multi_test/x=1/y=2/part.parquet") multi_partition = read("/tmp/multi_test") ``` --------- Co-authored-by: georgecwan Co-authored-by: mikebender --- package-lock.json | 2 + .../src/styleguide/MockIrisGridTreeModel.ts | 16 + packages/components/src/Option.tsx | 20 +- packages/console/src/common/ConsoleUtils.ts | 3 +- packages/console/src/common/ObjectIcon.tsx | 1 + .../src/GridPluginConfig.ts | 7 +- .../src/panels/IrisGridPanel.tsx | 33 +- packages/embed-grid/src/App.tsx | 1 + packages/grid/src/DataBarGridModel.ts | 3 +- packages/iris-grid/package.json | 1 + packages/iris-grid/src/EmptyIrisGridModel.ts | 263 +++++++++ packages/iris-grid/src/IrisGrid.tsx | 250 ++++----- packages/iris-grid/src/IrisGridModel.ts | 11 +- .../iris-grid/src/IrisGridModelFactory.ts | 15 +- .../iris-grid/src/IrisGridModelUpdater.tsx | 14 + .../src/IrisGridPartitionSelector.scss | 54 +- .../src/IrisGridPartitionSelector.test.tsx | 81 ++- .../src/IrisGridPartitionSelector.tsx | 520 ++++++++++-------- .../src/IrisGridPartitionedTableModel.ts | 90 +++ packages/iris-grid/src/IrisGridProxyModel.ts | 122 +++- packages/iris-grid/src/IrisGridTableModel.ts | 115 +++- .../src/IrisGridTableModelTemplate.ts | 16 +- .../iris-grid/src/IrisGridTreeTableModel.ts | 2 +- packages/iris-grid/src/IrisGridUtils.test.ts | 61 -- packages/iris-grid/src/IrisGridUtils.ts | 119 ++-- .../iris-grid/src/MissingPartitionError.ts | 11 + .../src/PartitionSelectorSearch.scss | 25 - .../src/PartitionSelectorSearch.test.tsx | 78 --- .../iris-grid/src/PartitionSelectorSearch.tsx | 366 ------------ .../iris-grid/src/PartitionedGridModel.ts | 65 +++ packages/iris-grid/src/index.ts | 4 +- packages/iris-grid/tsconfig.json | 1 + .../jsapi-components/src/TableDropdown.tsx | 144 +++++ packages/jsapi-components/src/index.ts | 1 + packages/jsapi-types/src/dh.types.ts | 27 + packages/jsapi-utils/src/TableUtils.ts | 15 + 36 files changed, 1441 insertions(+), 1116 deletions(-) create mode 100644 packages/iris-grid/src/EmptyIrisGridModel.ts create mode 100644 packages/iris-grid/src/IrisGridPartitionedTableModel.ts create mode 100644 packages/iris-grid/src/MissingPartitionError.ts delete mode 100644 packages/iris-grid/src/PartitionSelectorSearch.scss delete mode 100644 packages/iris-grid/src/PartitionSelectorSearch.test.tsx delete mode 100644 packages/iris-grid/src/PartitionSelectorSearch.tsx create mode 100644 packages/iris-grid/src/PartitionedGridModel.ts create mode 100644 packages/jsapi-components/src/TableDropdown.tsx 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); }