UNPKG

@finos/legend-data-cube

Version:
529 lines (466 loc) 17.1 kB
/** * Copyright (c) 2020-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { guaranteeNonNullable, isNonNullable, uniq, uniqBy, } from '@finos/legend-shared'; import { DataCubeConfiguration } from '../../core/model/DataCubeConfiguration.js'; import { type DataCubeSnapshot, type DataCubeSnapshotSortColumn, } from '../../core/DataCubeSnapshot.js'; import { _findCol, _toCol, type DataCubeColumn, } from '../../core/model/DataCubeColumn.js'; import { DataCubeSnapshotController } from '../../services/DataCubeSnapshotService.js'; import { type DataCubeColumnPinPlacement, DataCubeColumnKind, DataCubeQueryFilterGroupOperator, DataCubeQuerySortDirection, isPivotResultColumnName, getPivotResultColumnBaseColumnName, isDimensionalGridMode, } from '../../core/DataCubeQueryEngine.js'; import type { DefaultMenuItem, GetContextMenuItemsParams, GetMainMenuItemsParams, MenuItemDef, } from 'ag-grid-community'; import type { DataCubeViewState } from '../DataCubeViewState.js'; import { generateDimensionalMenuBuilder, generateMenuBuilder, } from './DataCubeGridMenuBuilder.js'; import { buildFilterEditorTree, buildFilterSnapshot, DataCubeFilterEditorConditionGroupTreeNode, type DataCubeFilterEditorConditionTreeNode, type DataCubeFilterEditorTree, type DataCubeFilterEditorTreeNode, } from '../../core/filter/DataCubeQueryFilterEditorState.js'; import { _pruneExpandedPaths } from '../../core/DataCubeSnapshotBuilderUtils.js'; /** * This query editor state is responsible for capturing updates to the data cube query * caused by interactions with the grid which are either not captured by the server-side row model * datasource, e.g. column pinning, column visibility changes, etc or done programatically via grid * context menu. Think of this as a companion state for grid editor which bridges the gap between * ag-grid state and data cube query state. * * More technically, this handles interactions that result in instant (not batched) change to the query. * For example, in the editor, users can make changes to multiple parts of the query, but until they are * explicit applied, these changes will not impact the query; whereas here a change immediately take effect. * * NOTE: since typically, each grid action causes a new snapshot to be created, * we MUST NEVER use the editor here, as it could potentially create illegal state * while the editor is still in the middle of a modification that has not been applied. */ export class DataCubeGridControllerState extends DataCubeSnapshotController { readonly view: DataCubeViewState; constructor(view: DataCubeViewState) { super(view.engine, view.settingService, view.snapshotService); this.view = view; } configuration = new DataCubeConfiguration(); menuBuilder?: | (( params: | GetContextMenuItemsParams<unknown, { view: DataCubeViewState }> | GetMainMenuItemsParams<unknown, { view: DataCubeViewState }>, fromHeader: boolean, ) => (DefaultMenuItem | MenuItemDef)[]) | undefined; getColumnConfiguration(colName: string | undefined) { return _findCol(this.configuration.columns, colName); } // --------------------------------- FILTER --------------------------------- filterTree: DataCubeFilterEditorTree = { nodes: new Map<string, DataCubeFilterEditorTreeNode>(), }; /** * Add a new filter condition to the root of the filter tree. * 1. If the root is empty, add a new AND group with the condition as the root * 2. If the root is an AND group, add the condition to the root * 3. If the root is an OR group, create a new AND group with the condition and * wrapping the current root and set that as the new root */ addNewFilterCondition(condition: DataCubeFilterEditorConditionTreeNode) { if (!this.filterTree.root) { const root = new DataCubeFilterEditorConditionGroupTreeNode( undefined, DataCubeQueryFilterGroupOperator.AND, undefined, ); this.filterTree.nodes.set(root.uuid, root); this.filterTree.root = root; root.addChild(condition); this.filterTree.nodes.set(condition.uuid, condition); } else if ( this.filterTree.root.operation === DataCubeQueryFilterGroupOperator.AND ) { this.filterTree.root.addChild(condition); this.filterTree.nodes.set(condition.uuid, condition); } else { // Normally, for this case, we just wrap the current root with a new AND group // but if the current (OR group) root has only 1 condition (this is only allowed // if the group is root), we can just simply change the group operator to AND const currentRoot = this.filterTree.root; if (currentRoot.children.length === 1) { currentRoot.operation = DataCubeQueryFilterGroupOperator.AND; currentRoot.addChild(condition); this.filterTree.nodes.set(condition.uuid, condition); } else { const newRoot = new DataCubeFilterEditorConditionGroupTreeNode( undefined, DataCubeQueryFilterGroupOperator.AND, undefined, ); this.filterTree.nodes.set(newRoot.uuid, newRoot); this.filterTree.root = newRoot; newRoot.addChild(currentRoot); newRoot.addChild(condition); this.filterTree.nodes.set(condition.uuid, condition); } } this.applyChanges(); } clearFilters() { this.filterTree.root = undefined; this.filterTree.nodes = new Map<string, DataCubeFilterEditorTreeNode>(); this.applyChanges(); } // --------------------------------- COLUMNS --------------------------------- selectColumns: DataCubeColumn[] = []; leafExtendedColumns: DataCubeColumn[] = []; groupExtendedColumns: DataCubeColumn[] = []; pinColumn( colName: string | undefined, placement: DataCubeColumnPinPlacement | undefined, ) { const columnConfiguration = this.getColumnConfiguration(colName); if (columnConfiguration) { columnConfiguration.pinned = placement; this.applyChanges(); } } rearrangeColumns(columns: string[]) { const rearrangedColumnConfigurations = columns .map((colName) => this.getColumnConfiguration(colName)) .filter(isNonNullable); this.configuration.columns = [ ...rearrangedColumnConfigurations, ...this.configuration.columns.filter( (col) => !rearrangedColumnConfigurations.includes(col), ), ]; const rearrangedSelectColumns = columns .map((colName) => _findCol(this.selectColumns, colName)) .filter(isNonNullable); this.selectColumns = [ ...rearrangedSelectColumns, ...rearrangedSelectColumns.filter( (col) => !rearrangedSelectColumns.includes(col), ), ]; this.applyChanges(); } removeAllPins() { this.configuration.columns.forEach((col) => (col.pinned = undefined)); this.applyChanges(); } showColumn(colName: string | undefined, isVisible: boolean) { const columnConfiguration = this.getColumnConfiguration(colName); if (columnConfiguration) { columnConfiguration.hideFromView = !isVisible; this.applyChanges(); } } // --------------------------------- PIVOT --------------------------------- horizontalPivotColumns: DataCubeColumn[] = []; horizontalPivotCastColumns: DataCubeColumn[] = []; private get horizontalPivotResultColumns() { return this.horizontalPivotCastColumns .filter((col) => isPivotResultColumnName(col.name)) .map(_toCol); } getHorizontalPivotableColumn(colName: string) { return _findCol( this.configuration.columns.filter( (col) => col.kind === DataCubeColumnKind.DIMENSION && // exclude group-level extended columns !_findCol(this.groupExtendedColumns, col.name), ), colName, ); } setHorizontalPivotOnColumn(colName: string) { const column = this.getHorizontalPivotableColumn(colName); if (column) { this.horizontalPivotColumns = [column]; this.applyChanges(); } } addHorizontalPivotOnColumn(colName: string) { const column = this.getHorizontalPivotableColumn(colName); if (column) { this.horizontalPivotColumns = [...this.horizontalPivotColumns, column]; this.applyChanges(); } } clearAllHorizontalPivots() { this.horizontalPivotColumns = []; this.horizontalPivotCastColumns = []; this.applyChanges(); } excludeColumnFromHorizontalPivot(colName: string) { if (isPivotResultColumnName(colName)) { const baseColumnName = getPivotResultColumnBaseColumnName(colName); const columnConfiguration = this.getColumnConfiguration(baseColumnName); if (columnConfiguration && !columnConfiguration.excludedFromPivot) { columnConfiguration.excludedFromPivot = true; this.applyChanges(); } } } includeColumnInHorizontalPivot(colName: string) { const columnConfiguration = this.getColumnConfiguration(colName); if (columnConfiguration?.excludedFromPivot) { columnConfiguration.excludedFromPivot = false; this.applyChanges(); } } // --------------------------------- GROUP BY --------------------------------- verticalPivotColumns: DataCubeColumn[] = []; getVerticalPivotableColumn(colName: string) { return _findCol( this.configuration.columns.filter( (col) => col.kind === DataCubeColumnKind.DIMENSION && // exclude group-level extended columns !_findCol(this.groupExtendedColumns, col.name) && // exclude pivot columns !_findCol(this.horizontalPivotColumns, col.name), ), colName, ); } setVerticalPivotOnColumn(colName: string) { const column = this.getVerticalPivotableColumn(colName); if (column) { this.verticalPivotColumns = [column]; this.applyChanges(); } } addVerticalPivotOnColumn(colName: string) { const column = this.getVerticalPivotableColumn(colName); if (column) { this.verticalPivotColumns = [...this.verticalPivotColumns, column]; this.applyChanges(); } } removeVerticalPivotOnColumn(colName: string) { this.verticalPivotColumns = this.verticalPivotColumns.filter( (col) => col.name !== colName, ); this.applyChanges(); } clearAllVerticalPivots() { this.verticalPivotColumns = []; this.applyChanges(); } collapseAllPaths() { this.view.grid.client.collapseAll(); this.configuration.pivotLayout.expandedPaths = []; this.applyChanges(); } expandPath(path: string) { this.configuration.pivotLayout.expandedPaths = uniq([ ...this.configuration.pivotLayout.expandedPaths, path, ]).sort(); this.applyChanges(); } collapsePath(path: string) { this.configuration.pivotLayout.expandedPaths = this.configuration.pivotLayout.expandedPaths.filter((p) => p !== path); this.applyChanges(); } // --------------------------------- SORT --------------------------------- sortColumns: DataCubeSnapshotSortColumn[] = []; getSortableColumn(colName: string | undefined) { if (!colName) { return undefined; } return _findCol( [ ...(this.horizontalPivotCastColumns.length ? this.horizontalPivotCastColumns : this.selectColumns), ...this.groupExtendedColumns, ], colName, ); } setSortByColumn(colName: string, direction: DataCubeQuerySortDirection) { const column = this.getSortableColumn(colName); if (!column) { return; } this.sortColumns = [ { ...column, direction, }, ]; this.applyChanges(); } addSortByColumn(colName: string, direction: DataCubeQuerySortDirection) { const column = this.getSortableColumn(colName); if (!column) { return; } this.sortColumns = [...this.sortColumns, { ...column, direction }]; this.applyChanges(); } clearSortByColumn(colName: string) { this.sortColumns = this.sortColumns.filter((col) => col.name !== colName); this.applyChanges(); } clearAllSorts() { this.sortColumns = []; this.applyChanges(); } // --------------------------------- MAIN --------------------------------- override getSnapshotSubscriberName() { return 'grid-controller'; } override async applySnapshot( snapshot: DataCubeSnapshot, previousSnapshot: DataCubeSnapshot | undefined, ) { this.configuration = DataCubeConfiguration.serialization.fromJson( snapshot.data.configuration, ); this.filterTree.nodes = new Map<string, DataCubeFilterEditorTreeNode>(); this.filterTree.root = snapshot.data.filter ? buildFilterEditorTree( snapshot.data.filter, undefined, this.filterTree.nodes, (operator) => this.view.engine.getFilterOperation(operator), ) : undefined; this.selectColumns = snapshot.data.selectColumns; this.leafExtendedColumns = snapshot.data.leafExtendedColumns; this.groupExtendedColumns = snapshot.data.groupExtendedColumns; this.horizontalPivotColumns = snapshot.data.pivot?.columns ?? []; this.horizontalPivotCastColumns = snapshot.data.pivot?.castColumns ?? []; this.verticalPivotColumns = snapshot.data.groupBy?.columns ?? []; this.sortColumns = snapshot.data.sortColumns; this.menuBuilder = isDimensionalGridMode( this.view.info.configuration.gridMode, ) ? generateDimensionalMenuBuilder(this) : generateMenuBuilder(this); } private propagateChanges(baseSnapshot: DataCubeSnapshot) { this.verticalPivotColumns = this.verticalPivotColumns.filter( (col) => !_findCol(this.horizontalPivotColumns, col.name), ); this.configuration.pivotLayout.expandedPaths = _pruneExpandedPaths( baseSnapshot.data.groupBy?.columns ?? [], this.verticalPivotColumns, this.configuration.pivotLayout.expandedPaths, ); this.configuration.columns.forEach((col) => { col.pivotSortDirection = _findCol(this.horizontalPivotColumns, col.name) ? (col.pivotSortDirection ?? DataCubeQuerySortDirection.ASCENDING) : undefined; }); this.selectColumns = uniqBy( [ ...this.configuration.columns.filter( (col) => col.isSelected && !_findCol(this.groupExtendedColumns, col.name), ), ...this.horizontalPivotColumns, ...this.verticalPivotColumns, ], (col) => col.name, ).map(_toCol); const sortableColumns = uniqBy( [ // if pivot is active, take the pivot result columns and include // selected dimension columns which are not part of pivot columns ...(this.horizontalPivotColumns.length ? [ ...this.horizontalPivotResultColumns, ...[ ...this.configuration.columns.filter((col) => col.isSelected), ...this.verticalPivotColumns, ].filter( (column) => this.getColumnConfiguration(column.name)?.kind === DataCubeColumnKind.DIMENSION && !_findCol(this.horizontalPivotColumns, column.name), ), ] : [ ...this.configuration.columns.filter((col) => col.isSelected), ...this.verticalPivotColumns, ]), ...this.groupExtendedColumns, ], (col) => col.name, ); this.sortColumns = this.sortColumns.filter((col) => _findCol(sortableColumns, col.name), ); } private applyChanges() { const baseSnapshot = guaranteeNonNullable(this.getLatestSnapshot()); const snapshot = baseSnapshot.clone(); this.propagateChanges(baseSnapshot); snapshot.data.configuration = this.configuration.serialize(); snapshot.data.filter = this.filterTree.root ? buildFilterSnapshot(this.filterTree.root) : undefined; snapshot.data.selectColumns = this.selectColumns; snapshot.data.pivot = this.horizontalPivotColumns.length ? { columns: this.horizontalPivotColumns, castColumns: this.horizontalPivotCastColumns, } : undefined; snapshot.data.groupBy = this.verticalPivotColumns.length ? { columns: this.verticalPivotColumns, } : undefined; snapshot.data.sortColumns = this.sortColumns; snapshot.finalize(); if (snapshot.hashCode !== baseSnapshot.hashCode) { this.publishSnapshot(snapshot); } } }