UNPKG

@finos/legend-data-cube

Version:
891 lines (890 loc) 52.3 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; /** * 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. */ /*************************************************************************************** * [GRID] * * These are utilities used to build the configuration for the grid client, * AG Grid, from the snapshot. ***************************************************************************************/ import {} from '../../core/DataCubeSnapshot.js'; import { _findCol, _toCol, } from '../../core/model/DataCubeColumn.js'; import { DataCubeGridClientSortDirection, INTERNAL__GRID_CLIENT_COLUMN_MIN_WIDTH, INTERNAL__GridClientUtilityCssClassName, generateFontFamilyUtilityClassName, generateFontSizeUtilityClassName, generateFontUnderlineUtilityClassName, generateTextAlignUtilityClassName, generateTextColorUtilityClassName, generateBackgroundColorUtilityClassName, generateFontCaseUtilityClassName, DataCubeGridClientPinnedAlignement, INTERNAL__GRID_CLIENT_ROW_HEIGHT, INTERNAL__GRID_CLIENT_AUTO_RESIZE_PADDING, INTERNAL__GRID_CLIENT_HEADER_HEIGHT, INTERNAL__GRID_CLIENT_TOOLTIP_SHOW_DELAY, INTERNAL__GRID_CLIENT_SIDE_BAR_WIDTH, INTERNAL__GRID_CLIENT_ROW_GROUPING_COUNT_AGG_COLUMN_ID, INTERNAL__GRID_CLIENT_MISSING_VALUE, INTERNAL__GRID_CLIENT_DATA_FETCH_MANUAL_TRIGGER_COLUMN_ID, INTERNAL__GRID_CLIENT_PIVOT_COLUMN_GROUP_COLOR_ROTATION_SIZE, INTERNAL__GRID_CLIENT_TREE_COLUMN_ID, INTERNAL__GRID_CLIENT_ROOT_AGGREGATION_COLUMN_ID, } from './DataCubeGridClientEngine.js'; import { at, getQueryParameters, getQueryParameterValue, isNonNullable, isNullable, isNumber, isValidUrl, assertTrue, guaranteeNonNullable, } from '@finos/legend-shared'; import { DataCubeColumnConfiguration, } from '../../core/model/DataCubeConfiguration.js'; import { DataCubeColumnDataType, DataCubeColumnPinPlacement, DataCubeNumberScale, DEFAULT_URL_LABEL_QUERY_PARAM, getDataType, DataCubeQuerySortDirection, DataCubeColumnKind, DEFAULT_MISSING_VALUE_DISPLAY_TEXT, PIVOT_COLUMN_NAME_VALUE_SEPARATOR, isPivotResultColumnName, TREE_COLUMN_VALUE_SEPARATOR, DEFAULT_ROOT_AGGREGATION_COLUMN_VALUE, } from '../../core/DataCubeQueryEngine.js'; import { DataCubeIcon } from '@finos/legend-art'; // --------------------------------- UTILITIES --------------------------------- function scaleNumber(value, type) { switch (type) { case DataCubeNumberScale.PERCENT: return { value: value * 1e2, unit: '%' }; case DataCubeNumberScale.BASIS_POINT: return { value: value * 1e4, unit: 'bp' }; case DataCubeNumberScale.THOUSANDS: return { value: value / 1e3, unit: 'k' }; case DataCubeNumberScale.MILLIONS: return { value: value / 1e6, unit: 'm' }; case DataCubeNumberScale.BILLIONS: return { value: value / 1e9, unit: 'b' }; case DataCubeNumberScale.TRILLIONS: return { value: value / 1e12, unit: 't' }; case DataCubeNumberScale.AUTO: return scaleNumber(value, value >= 1e12 ? DataCubeNumberScale.TRILLIONS : value >= 1e9 ? DataCubeNumberScale.BILLIONS : value >= 1e6 ? DataCubeNumberScale.MILLIONS : value >= 1e3 ? DataCubeNumberScale.THOUSANDS : undefined); default: return { value, unit: undefined }; } } function DataCubeGridLoadingCellRenderer(props) { if (props.node.failedLoad) { return _jsx("span", { className: "inline-flex items-center", children: "#ERR" }); } return (_jsxs("span", { className: "inline-flex items-center", children: [_jsx(DataCubeIcon.Loader, { className: "mr-1 animate-spin stroke-2" }), "Loading"] })); } function getCellRenderer(columnData) { const { column } = columnData; const dataType = getDataType(column.type); if (dataType === DataCubeColumnDataType.TEXT && column.displayAsLink) { return function LinkRenderer(params) { const isUrl = isValidUrl(params.value); if (!isUrl) { return params.value; } const url = params.value; const label = getQueryParameterValue(column.linkLabelParameter ?? DEFAULT_URL_LABEL_QUERY_PARAM, getQueryParameters(url, true)); return (_jsx("a", { href: url, target: "_blank", rel: "noreferrer", className: "text-blue-600 underline", children: label ?? url })); }; } return null; } function getDimensionCellRenderer(columnData, dimensions) { const { column } = columnData; const dataType = getDataType(column.type); if (dataType === DataCubeColumnDataType.TEXT && dimensions.filter((dim) => dim.name === column.name).length === 1) { return function ValueRenderer(params) { const level = guaranteeNonNullable(params.data.metadata.get(column.name)?.level); const indent = '\u00A0'.repeat((level + 1) * 2); // 4 non-breaking spaces per level return `${indent}${params.value}`; }; } else if (dataType === DataCubeColumnDataType.TEXT && column.displayAsLink) { return function LinkRenderer(params) { const isUrl = isValidUrl(params.value); if (!isUrl) { return params.value; } const url = params.value; const label = getQueryParameterValue(column.linkLabelParameter ?? DEFAULT_URL_LABEL_QUERY_PARAM, getQueryParameters(url, true)); return (_jsx("a", { href: url, target: "_blank", rel: "noreferrer", className: "text-blue-600 underline", children: label ?? url })); }; } return null; } function _displaySpec(columnData) { const { name, snapshot, column, configuration } = columnData; const dataType = getDataType(column.type); const fontFamily = column.fontFamily ?? configuration.fontFamily; const fontSize = column.fontSize ?? configuration.fontSize; const fontBold = column.fontBold ?? configuration.fontBold; const fontItalic = column.fontItalic ?? configuration.fontItalic; const fontStrikethrough = column.fontStrikethrough ?? configuration.fontStrikethrough; const fontUnderline = column.fontUnderline ?? configuration.fontUnderline; const fontCase = column.fontCase ?? configuration.fontCase; const textAlign = column.textAlign ?? configuration.textAlign; const normalForegroundColor = column.normalForegroundColor ?? configuration.normalForegroundColor; const normalBackgroundColor = column.normalBackgroundColor ?? configuration.normalBackgroundColor; const negativeForegroundColor = column.negativeForegroundColor ?? configuration.negativeForegroundColor; const negativeBackgroundColor = column.negativeBackgroundColor ?? configuration.negativeBackgroundColor; const zeroForegroundColor = column.zeroForegroundColor ?? configuration.zeroForegroundColor; const zeroBackgroundColor = column.zeroBackgroundColor ?? configuration.zeroBackgroundColor; const errorForegroundColor = column.errorForegroundColor ?? configuration.errorForegroundColor; const errorBackgroundColor = column.errorBackgroundColor ?? configuration.errorBackgroundColor; return { // disabling cell data type inference can grid performance // especially when this information is only necessary for cell value editor cellDataType: false, hide: column.hideFromView || !column.isSelected || (Boolean(snapshot.data.pivot && !_findCol(snapshot.data.pivot.castColumns, name)) && !_findCol(snapshot.data.groupExtendedColumns, name)), lockVisible: !column.isSelected || (Boolean(snapshot.data.pivot && !_findCol(snapshot.data.pivot.castColumns, name)) && !_findCol(snapshot.data.groupExtendedColumns, name)), pinned: column.pinned !== undefined ? column.pinned === DataCubeColumnPinPlacement.RIGHT ? DataCubeGridClientPinnedAlignement.RIGHT : DataCubeGridClientPinnedAlignement.LEFT : null, headerClass: isPivotResultColumnName(name) ? 'pl-1 border border-neutral-300' : 'pl-1 border border-neutral-200', cellClassRules: { [generateFontFamilyUtilityClassName(fontFamily)]: () => true, [generateFontSizeUtilityClassName(fontSize)]: () => true, [INTERNAL__GridClientUtilityCssClassName.FONT_BOLD]: () => fontBold, [INTERNAL__GridClientUtilityCssClassName.FONT_ITALIC]: () => fontItalic, [INTERNAL__GridClientUtilityCssClassName.FONT_STRIKETHROUGH]: () => fontStrikethrough, [generateFontUnderlineUtilityClassName(fontUnderline)]: () => Boolean(fontUnderline), [generateFontCaseUtilityClassName(fontCase)]: (params) => dataType === DataCubeColumnDataType.TEXT && Boolean(fontCase), [generateTextAlignUtilityClassName(textAlign)]: () => true, [generateTextColorUtilityClassName(normalForegroundColor, 'normal')]: () => true, [generateBackgroundColorUtilityClassName(normalBackgroundColor, 'normal')]: () => true, [generateTextColorUtilityClassName(zeroForegroundColor, 'zero')]: (params) => dataType === DataCubeColumnDataType.NUMBER && isNumber(params.value) && params.value === 0, [generateBackgroundColorUtilityClassName(zeroBackgroundColor, 'zero')]: (params) => dataType === DataCubeColumnDataType.NUMBER && isNumber(params.value) && params.value === 0, [generateTextColorUtilityClassName(negativeForegroundColor, 'negative')]: (params) => dataType === DataCubeColumnDataType.NUMBER && isNumber(params.value) && params.value < 0, [generateBackgroundColorUtilityClassName(negativeBackgroundColor, 'negative')]: (params) => dataType === DataCubeColumnDataType.NUMBER && isNumber(params.value) && params.value < 0, [generateTextColorUtilityClassName(errorForegroundColor, 'error')]: (params) => Boolean(params.node.failedLoad), [generateBackgroundColorUtilityClassName(errorBackgroundColor, 'error')]: (params) => Boolean(params.node.failedLoad), [INTERNAL__GridClientUtilityCssClassName.BLUR]: () => column.blur, }, valueFormatter: dataType === DataCubeColumnDataType.NUMBER ? (params) => { const value = params.value; if (isNullable(value) || value === INTERNAL__GRID_CLIENT_MISSING_VALUE) { return (column.missingValueDisplayText ?? DEFAULT_MISSING_VALUE_DISPLAY_TEXT); } const showNegativeNumberInParens = column.negativeNumberInParens && value < 0; // 1. apply the number scale const scaledNumber = scaleNumber(value, column.numberScale); // 2. apply the number formatter const formattedValue = (showNegativeNumberInParens ? Math.abs(scaledNumber.value) : scaledNumber.value).toLocaleString(undefined, { useGrouping: column.displayCommas, ...(column.decimals !== undefined ? { minimumFractionDigits: column.decimals, maximumFractionDigits: column.decimals, } : {}), }); // 3. add the parentheses, scale unit, unit, etc. let displayValue = (showNegativeNumberInParens ? `(${formattedValue})` : formattedValue) + (scaledNumber.unit ? ` ${scaledNumber.unit}` : ''); if (column.unit) { displayValue = !column.unit.startsWith('_') ? `${displayValue}${column.unit}` : `${column.unit.substring(1)}${displayValue}`; } return displayValue; } : (params) => params.value === INTERNAL__GRID_CLIENT_MISSING_VALUE ? (column.missingValueDisplayText ?? DEFAULT_MISSING_VALUE_DISPLAY_TEXT) : params.value, tooltipValueGetter: (params) => isNonNullable(params.value) && params.value !== INTERNAL__GRID_CLIENT_MISSING_VALUE ? `Value = ${params.value === '' ? "''" : params.value === true ? 'TRUE' : params.value === false ? 'FALSE' : params.value}` : `Missing Value`, cellRenderer: configuration.dimensions.dimensions.length > 0 ? getDimensionCellRenderer(columnData, configuration.dimensions.dimensions) : getCellRenderer(columnData), }; } function _groupDisplaySpec(snapshot, configuration) { // TODO?: we can technically alternate the styling based on the column at various drilldown level // but for now,we will simply use the same styling as the (default) grid styling const fontFamily = configuration.fontFamily; const fontSize = configuration.fontSize; const fontBold = configuration.fontBold; const fontItalic = configuration.fontItalic; const fontStrikethrough = configuration.fontStrikethrough; const fontUnderline = configuration.fontUnderline; const fontCase = configuration.fontCase; const textAlign = configuration.textAlign; const normalForegroundColor = configuration.normalForegroundColor; const normalBackgroundColor = configuration.normalBackgroundColor; return { cellDataType: false, // no point in specifying a type here since it can be of multiple types hide: !snapshot.data.groupBy, lockPosition: true, lockPinned: true, pinned: DataCubeGridClientPinnedAlignement.LEFT, cellClassRules: { [generateFontFamilyUtilityClassName(fontFamily)]: () => true, [generateFontSizeUtilityClassName(fontSize)]: () => true, [INTERNAL__GridClientUtilityCssClassName.FONT_BOLD]: () => fontBold, [INTERNAL__GridClientUtilityCssClassName.FONT_ITALIC]: () => fontItalic, [INTERNAL__GridClientUtilityCssClassName.FONT_STRIKETHROUGH]: () => fontStrikethrough, [generateFontUnderlineUtilityClassName(fontUnderline)]: () => Boolean(fontUnderline), [generateFontCaseUtilityClassName(fontCase)]: (params) => Boolean(fontCase), [generateTextAlignUtilityClassName(textAlign)]: () => true, [generateTextColorUtilityClassName(normalForegroundColor, 'normal')]: () => true, [generateBackgroundColorUtilityClassName(normalBackgroundColor, 'normal')]: () => true, }, tooltipValueGetter: (params) => { if (isNonNullable(params.value) && params.value !== INTERNAL__GRID_CLIENT_MISSING_VALUE) { return (`Group Value = ${params.value === '' ? "''" : params.value === true ? 'TRUE' : params.value === false ? 'FALSE' : params.value}` + `${params.data[INTERNAL__GRID_CLIENT_ROW_GROUPING_COUNT_AGG_COLUMN_ID] !== undefined ? ` (${params.data[INTERNAL__GRID_CLIENT_ROW_GROUPING_COUNT_AGG_COLUMN_ID]})` : ''}`); } return null; }, }; } function _sizeSpec(columnData) { const { column } = columnData; return { // NOTE: there is a problem with ag-grid when scrolling horizontally, the header row // lags behind the data, it seems to be caused by synchronizing scroll not working properly // There is currently, no way around this // See https://github.com/ag-grid/ag-grid/issues/5233 // See https://github.com/ag-grid/ag-grid/issues/7620 // See https://github.com/ag-grid/ag-grid/issues/6292 // See https://issues.chromium.org/issues/40890343#comment11 // // TODO: if we support column resize to fit content, should we disable this behavior? resizable: column.fixedWidth === undefined, suppressAutoSize: column.fixedWidth !== undefined, suppressSizeToFit: column.fixedWidth !== undefined, width: column.fixedWidth, minWidth: Math.max(column.minWidth ?? INTERNAL__GRID_CLIENT_COLUMN_MIN_WIDTH, INTERNAL__GRID_CLIENT_COLUMN_MIN_WIDTH), maxWidth: column.maxWidth, }; } function _sortSpec(columnData) { const { name, snapshot } = columnData; const sortColumns = snapshot.data.sortColumns; const sortCol = _findCol(sortColumns, name); return { sortable: true, // if this is pivot column, no sorting is supported yet sort: sortCol ? sortCol.direction === DataCubeQuerySortDirection.ASCENDING ? DataCubeGridClientSortDirection.ASCENDING : DataCubeGridClientSortDirection.DESCENDING : null, sortIndex: sortCol ? sortColumns.indexOf(sortCol) : null, }; } function _aggregationSpec(columnData) { const { name, snapshot, column, configuration } = columnData; const data = snapshot.data; const pivotCol = _findCol(data.pivot?.columns, name); const groupByCol = _findCol(data.groupBy?.columns, name); const isGroupExtendedColumn = Boolean(_findCol(data.groupExtendedColumns, name)); const rowGroupIndex = !isGroupExtendedColumn && groupByCol ? (data.groupBy?.columns.indexOf(groupByCol) ?? null) : null; return { enableRowGroup: !isGroupExtendedColumn && column.kind === DataCubeColumnKind.DIMENSION, rowGroup: !isGroupExtendedColumn && Boolean(groupByCol), rowGroupIndex: rowGroupIndex !== null ? configuration.showRootAggregation ? rowGroupIndex + 1 : rowGroupIndex : null, enablePivot: !isGroupExtendedColumn && column.kind === DataCubeColumnKind.DIMENSION, pivot: !isGroupExtendedColumn && Boolean(pivotCol), pivotIndex: !isGroupExtendedColumn && pivotCol ? (data.pivot?.columns.indexOf(pivotCol) ?? null) : null, // NOTE: we don't quite care about populating these accurately // since ag-grid aggregation does not support parameters, so // its set of supported aggregators will never match that specified // in the editor. But we MUST set this to make sure sorting works // when row grouping is used, so we need to set a non-null value here. aggFunc: !isGroupExtendedColumn ? column.aggregateOperator : null, enableValue: false, // disable GUI interactions to modify this column's aggregate function allowedAggFuncs: [], // disable GUI for options of the agg functions }; } // --------------------------------- MAIN --------------------------------- export function generateBaseGridOptions(view) { const grid = view.grid; return { // -------------------------------------- README -------------------------------------- // NOTE: we observe performance degradataion when configuring the grid via React component // props when the options is non-static, i.e. changed when the query configuration changes. // As such, we must ONLY ADD STATIC CONFIGURATION HERE, and dynamic configuration should be // programatically updated when the query is modified. // // // -------------------------------------- ROW GROUPING -------------------------------------- rowGroupPanelShow: 'always', // use the auto-generated group column to make it work with pivot mode // See https://github.com/ag-grid/ag-grid/issues/8088 groupDisplayType: 'singleColumn', suppressGroupChangesColumnVisibility: true, // keeps the column set stable when row grouping is used suppressAggFuncInHeader: true, // keeps the columns stable when aggregation is used getChildCount: (data) => data[INTERNAL__GRID_CLIENT_ROW_GROUPING_COUNT_AGG_COLUMN_ID], // -------------------------------------- PIVOT -------------------------------------- // NOTE: when enabled, pivot mode will show the pivot panel (allowing drag and drop) // and pivot section in column tools panel, but it comes with many restrictions/opinionated // behaviors on column grouping: i.e. it disallow full control of column definitions, so we // couldn't display dimension columns which are not part of pivot while pivoting. // // Even setting flag pivotSuppressAutoColumn=true does not seem to remove the column // auto-grouping behavior // // As such, we will just make use of column pivot settings to trigger server-side row // model data-fetching when pivot changes, and would opt out from all GUI features // that pivot mode offers by disabling pivot mode and will re-assess its usage in the future. // // pivotMode: Boolean(snapshot.data.pivot), // pivotPanelShow: 'always', // pivotSuppressAutoColumn: true, // -------------------------------------- SORT -------------------------------------- // Force multi-sorting since this is what the query supports anyway alwaysMultiSort: true, // -------------------------------------- DISPLAY -------------------------------------- rowHeight: INTERNAL__GRID_CLIENT_ROW_HEIGHT, headerHeight: INTERNAL__GRID_CLIENT_HEADER_HEIGHT, noRowsOverlayComponent: () => (_jsxs("div", { className: "flex items-center border-[1.5px] border-neutral-300 p-2 font-medium text-neutral-400", children: [_jsx("div", { children: _jsx(DataCubeIcon.WarningCircle, { className: "mr-1 stroke-2 text-lg" }) }), "0 rows"] })), loadingOverlayComponent: () => (_jsxs("div", { className: "flex items-center border-[1.5px] border-neutral-300 p-2 font-medium text-neutral-400", children: [_jsx("div", { children: _jsx(DataCubeIcon.Loader, { className: "mr-1 animate-spin stroke-2 text-lg" }) }), "Loading..."] })), // Show cursor position when scrolling onBodyScroll: (event) => { const rowCount = event.api.getDisplayedRowCount(); const range = event.api.getVerticalPixelRange(); const start = Math.max(1, Math.ceil(range.top / INTERNAL__GRID_CLIENT_ROW_HEIGHT) + 1); const end = Math.min(rowCount, Math.floor(range.bottom / INTERNAL__GRID_CLIENT_ROW_HEIGHT)); grid.setScrollHintText(`${start}-${end}/${rowCount}`); event.api.hidePopupMenu(); // hide context-menu while scrolling }, onBodyScrollEnd: () => grid.setScrollHintText(undefined), // -------------------------------------- CONTEXT MENU -------------------------------------- preventDefaultOnContextMenu: true, // prevent showing the browser's context menu columnMenu: 'new', // ensure context menu works on header // NOTE: dynamically generate the content of the context menu to make sure the items are not stale getContextMenuItems: (params) => grid.controller.menuBuilder?.(params, false) ?? [], getMainMenuItems: (params) => grid.controller.menuBuilder?.(params, true) ?? [], // NOTE: when right-clicking empty space in the header, a menu will show up // with 2 default options: 'Choose Columns` and `Reset Columns`, which is not // a desired behavior, so we hide the popup menu immediately onColumnMenuVisibleChanged: (event) => { if (!event.column) { const menuElement = document.querySelector(`.${INTERNAL__GridClientUtilityCssClassName.ROOT} .ag-popup .ag-menu`); if (menuElement) { menuElement.style.display = 'none'; } event.api.hidePopupMenu(); } }, // -------------------------------------- COLUMN SIZING -------------------------------------- autoSizePadding: INTERNAL__GRID_CLIENT_AUTO_RESIZE_PADDING, autoSizeStrategy: { type: 'fitCellContents', }, // -------------------------------------- TOOLTIP -------------------------------------- tooltipShowDelay: INTERNAL__GRID_CLIENT_TOOLTIP_SHOW_DELAY, // though this is a nice behavior to have enabled, ag-grid not dismissing tooltip // when context-menu is triggered makes it an undesirable interaction. tooltipInteraction: false, // -------------------------------------- COLUMN MOVING -------------------------------------- suppressDragLeaveHidesColumns: true, // disable this since it's quite easy to accidentally hide columns while moving // -------------------------------------- SERVER SIDE ROW MODEL -------------------------------------- suppressScrollOnNewData: true, // NOTE: use row loader instead of showing loader in each cell to improve performance // otherwise, when we have many columns (i.e. when pivoting), the app could freeze. // // This would render the loading cell as the full-width row, which, in combination with // fit cell content auto-sizing strategy creates an unwanted row flashing effect while loading. // To compensate for that, we modify the styling to make sure the full-width row has a blank background loadingCellRenderer: DataCubeGridLoadingCellRenderer, // By default, when row-grouping is active, ag-grid's caching mechanism causes sort // to not work properly for pivot result columns, so we must disable this mechanism. serverSideSortAllLevels: true, // -------------------------------------- SELECTION -------------------------------------- cellSelection: true, // -------------------------------------- SIDEBAR -------------------------------------- sideBar: { toolPanels: [ { id: 'columns', labelDefault: 'Columns', labelKey: 'columns', iconKey: 'columns', toolPanel: 'agColumnsToolPanel', minWidth: INTERNAL__GRID_CLIENT_SIDE_BAR_WIDTH, width: INTERNAL__GRID_CLIENT_SIDE_BAR_WIDTH, toolPanelParams: { suppressValues: true, suppressPivotMode: true, }, }, ], position: 'right', }, allowDragFromColumnsToolPanel: true, // -------------------------------------- PERFORMANCE -------------------------------------- animateRows: false, // improve performance suppressColumnMoveAnimation: true, // improve performance }; } export function generateDimensionalBaseGridOptions(view) { const grid = view.grid; return { // -------------------------------------- README -------------------------------------- // NOTE: we observe performance degradataion when configuring the grid via React component // props when the options is non-static, i.e. changed when the query configuration changes. // As such, we must ONLY ADD STATIC CONFIGURATION HERE, and dynamic configuration should be // programatically updated when the query is modified. // // // -------------------------------------- DISPLAY -------------------------------------- rowHeight: INTERNAL__GRID_CLIENT_ROW_HEIGHT, headerHeight: INTERNAL__GRID_CLIENT_HEADER_HEIGHT, noRowsOverlayComponent: () => (_jsxs("div", { className: "flex items-center border-[1.5px] border-neutral-300 p-2 font-medium text-neutral-400", children: [_jsx("div", { children: _jsx(DataCubeIcon.WarningCircle, { className: "mr-1 stroke-2 text-lg" }) }), "0 rows"] })), loadingOverlayComponent: () => (_jsxs("div", { className: "flex items-center border-[1.5px] border-neutral-300 p-2 font-medium text-neutral-400", children: [_jsx("div", { children: _jsx(DataCubeIcon.Loader, { className: "mr-1 animate-spin stroke-2 text-lg" }) }), "Loading..."] })), // Show cursor position when scrolling onBodyScroll: (event) => { const rowCount = event.api.getDisplayedRowCount(); const range = event.api.getVerticalPixelRange(); const start = Math.max(1, Math.ceil(range.top / INTERNAL__GRID_CLIENT_ROW_HEIGHT) + 1); const end = Math.min(rowCount, Math.floor(range.bottom / INTERNAL__GRID_CLIENT_ROW_HEIGHT)); grid.setScrollHintText(`${start}-${end}/${rowCount}`); event.api.hidePopupMenu(); // hide context-menu while scrolling }, onBodyScrollEnd: () => grid.setScrollHintText(undefined), // -------------------------------------- CONTEXT MENU -------------------------------------- // figure out the context menu preventDefaultOnContextMenu: true, // prevent showing the browser's context menu columnMenu: 'new', // ensure context menu works on header // NOTE: dynamically generate the content of the context menu to make sure the items are not stale getContextMenuItems: (params) => grid.controller.menuBuilder?.(params, false) ?? [], getMainMenuItems: (params) => grid.controller.menuBuilder?.(params, true) ?? [], // NOTE: when right-clicking empty space in the header, a menu will show up // with 2 default options: 'Choose Columns` and `Reset Columns`, which is not // a desired behavior, so we hide the popup menu immediately onColumnMenuVisibleChanged: (event) => { if (!event.column) { const menuElement = document.querySelector(`.${INTERNAL__GridClientUtilityCssClassName.ROOT} .ag-popup .ag-menu`); if (menuElement) { menuElement.style.display = 'none'; } event.api.hidePopupMenu(); } }, // -------------------------------------- COLUMN SIZING -------------------------------------- autoSizePadding: INTERNAL__GRID_CLIENT_AUTO_RESIZE_PADDING, autoSizeStrategy: { type: 'fitCellContents', }, defaultColDef: { sortable: false, }, // -------------------------------------- TOOLTIP -------------------------------------- tooltipShowDelay: INTERNAL__GRID_CLIENT_TOOLTIP_SHOW_DELAY, // though this is a nice behavior to have enabled, ag-grid not dismissing tooltip // when context-menu is triggered makes it an undesirable interaction. tooltipInteraction: false, // -------------------------------------- COLUMN MOVING -------------------------------------- suppressDragLeaveHidesColumns: true, // disable this since it's quite easy to accidentally hide columns while moving // -------------------------------------- SELECTION -------------------------------------- cellSelection: true, // -------------------------------------- SIDEBAR -------------------------------------- sideBar: { toolPanels: [ { id: 'columns', labelDefault: 'Columns', labelKey: 'columns', iconKey: 'columns', toolPanel: 'agColumnsToolPanel', minWidth: INTERNAL__GRID_CLIENT_SIDE_BAR_WIDTH, width: INTERNAL__GRID_CLIENT_SIDE_BAR_WIDTH, toolPanelParams: { suppressValues: true, suppressPivotMode: true, suppressRowGroups: true, }, }, ], position: 'right', }, allowDragFromColumnsToolPanel: true, // -------------------------------------- PERFORMANCE -------------------------------------- animateRows: false, // improve performance suppressColumnMoveAnimation: true, // improve performance }; } function generatePivotResultColumnHeaderTooltip(id, snapshot, configuration) { const values = id.split(PIVOT_COLUMN_NAME_VALUE_SEPARATOR); if (!snapshot.data.pivot || values.length > snapshot.data.pivot.columns.length + 1) { return values.join(' / '); } if (values.length === snapshot.data.pivot.columns.length + 1) { const baseColumnName = at(values, values.length - 1); const columnConfiguration = _findCol(configuration.columns, baseColumnName); return `Column = ${columnConfiguration ? columnConfiguration.displayName ? `${columnConfiguration.displayName} (${columnConfiguration.name})` : columnConfiguration.name : baseColumnName} ~ [ ${snapshot.data.pivot.columns.map((col, i) => `${_findCol(configuration.columns, col.name)?.displayName ?? col.name} = ${values[i]}`).join(', ')} ]`; } return `[ ${snapshot.data.pivot.columns .slice(0, values.length) .map((col, i) => `${_findCol(configuration.columns, col.name)?.displayName ?? col.name} = ${values[i]}`) .join(', ')} ]`; } function generateDefinitionForPivotResultColumns(pivotResultColumns, snapshot, configuration) { const columns = pivotResultColumns .map((col) => ({ ...col, values: col.name.split(PIVOT_COLUMN_NAME_VALUE_SEPARATOR), })) .filter((col) => col.values.length > 1); const columnDefs = []; columns.forEach((col) => { const groups = []; let leaf = undefined; let id = ''; for (let i = 0; i < col.values.length; i++) { const value = at(col.values, i); id = id === '' ? at(col.values, i) : `${id}${PIVOT_COLUMN_NAME_VALUE_SEPARATOR}${value}`; if (i !== col.values.length - 1) { groups.push({ groupId: id, children: [], suppressColumnsToolPanel: true, headerName: value, headerTooltip: generatePivotResultColumnHeaderTooltip(id, snapshot, configuration), }); } else { const column = _findCol(configuration.columns, value); if (column) { const columnData = { name: col.name, snapshot, column, configuration, }; leaf = { headerName: column.displayName ?? column.name, colId: col.name, field: col.name, menuTabs: [], ..._displaySpec(columnData), ..._sortSpec(columnData), ..._sizeSpec(columnData), // disallow pinning and moving pivot result columns pinned: false, lockPinned: true, lockPosition: true, suppressColumnsToolPanel: true, // hide from column tool panel headerTooltip: generatePivotResultColumnHeaderTooltip(id, snapshot, configuration), }; } } } let currentCollection = columnDefs; groups.forEach((group) => { const existingGroup = currentCollection.find((collection) => 'groupId' in collection && collection.groupId === group.groupId); if (existingGroup) { currentCollection = existingGroup.children; } else { const newGroup = { ...group, headerClass: `${INTERNAL__GridClientUtilityCssClassName.PIVOT_COLUMN_GROUP} ${INTERNAL__GridClientUtilityCssClassName.PIVOT_COLUMN_GROUP_PREFIX}${currentCollection.length % INTERNAL__GRID_CLIENT_PIVOT_COLUMN_GROUP_COLOR_ROTATION_SIZE}`, }; currentCollection.push(newGroup); currentCollection = newGroup.children; } }); if (leaf) { currentCollection.push(leaf); // sort the leaf level columns based on the order of selected/configuration columns currentCollection.sort((a, b) => { const colAName = (a.colId?.split(PIVOT_COLUMN_NAME_VALUE_SEPARATOR) ?? []).at(-1); const colAConf = colAName ? _findCol(configuration.columns, colAName) : undefined; const colBName = (b.colId?.split(PIVOT_COLUMN_NAME_VALUE_SEPARATOR) ?? []).at(-1); const colBConf = colBName ? _findCol(configuration.columns, colBName) : undefined; return ((colAConf ? configuration.columns.indexOf(colAConf) : Number.MAX_VALUE) - (colBConf ? configuration.columns.indexOf(colBConf) : Number.MAX_VALUE)); }); } }); return columnDefs; } /** * We tried to push-down to the DB level to ensure a particular order * for the pivot result columns, we do so by preceding pivot() with a sort(). * * Implementations of pivot() is highly non-standard across different DBs, so * this rearranging needs to be done client-side. */ function rearrangePivotResultColumns(pivotResultColumns, pivotData, configuration) { try { const columns = []; for (const pivotResultColumn of pivotResultColumns) { const column = { ...pivotResultColumn, values: pivotResultColumn.name .split(PIVOT_COLUMN_NAME_VALUE_SEPARATOR) .slice(0, -1), // remove the last entry }; assertTrue(column.values.length === pivotData.columns.length); columns.push(column); } const columnConfigs = pivotData.columns .map((col) => configuration.getColumn(col.name)) .filter(isNonNullable); assertTrue(columnConfigs.length === pivotData.columns.length); // apply multi dimensional sorts by starting from the last pivot column to the first for (let i = pivotData.columns.length - 1; i >= 0; i--) { const direction = columnConfigs[i]?.pivotSortDirection ?? DataCubeQuerySortDirection.ASCENDING; columns.sort((a, b) => direction === DataCubeQuerySortDirection.ASCENDING ? at(a.values, i).localeCompare(at(b.values, i)) : at(b.values, i).localeCompare(at(a.values, i))); } return columns.map((col) => _toCol(col)); } catch { return pivotResultColumns; } } export function generateColumnDefs(snapshot, configuration) { // NOTE: only show columns which are fetched in select() as we // can't solely rely on column selection because of certain restrictions // from ag-grid, e.g. in the case of row grouping tree column: the columns // which are grouped must be present in the column definitions, so even // when some of these might not be selected explicitly by the users, they // must still be included in the column definitions, and made hidden instead. const columns = configuration.columns.filter((col) => _findCol(snapshot.data.selectColumns, col.name) ?? _findCol(snapshot.data.groupExtendedColumns, col.name)); let pivotResultColumns = []; if (snapshot.data.pivot) { const castColumns = snapshot.data.pivot.castColumns; pivotResultColumns = rearrangePivotResultColumns(castColumns.filter((col) => isPivotResultColumnName(col.name)), snapshot.data.pivot, configuration); } const columnDefs = [ // NOTE: Internal column used for programatically trigger data fetch when filter is modified { colId: INTERNAL__GRID_CLIENT_DATA_FETCH_MANUAL_TRIGGER_COLUMN_ID, hide: true, enableValue: false, // disable GUI interactions to modify this column's aggregate function allowedAggFuncs: [], // disable GUI for options of the agg functions enablePivot: false, enableRowGroup: false, filter: 'agTextColumnFilter', suppressColumnsToolPanel: true, }, ...(configuration.showRootAggregation ? [ { colId: INTERNAL__GRID_CLIENT_ROOT_AGGREGATION_COLUMN_ID, headerName: 'Root', field: INTERNAL__GRID_CLIENT_ROOT_AGGREGATION_COLUMN_ID, hide: true, enableValue: false, // disable GUI interactions to modify this column's aggregate function allowedAggFuncs: [], // disable GUI for options of the agg functions enablePivot: false, enableRowGroup: false, suppressColumnsToolPanel: true, rowGroup: true, rowGroupIndex: 0, }, ] : []), ...generateDefinitionForPivotResultColumns(pivotResultColumns, snapshot, configuration), ...columns.map((column) => { const columnData = { name: column.name, snapshot, column, configuration, }; return { headerName: column.displayName ?? column.name, headerTooltip: `Column = ${column.displayName ? `${column.displayName} (${column.name})` : column.name}`, suppressSpanHeaderHeight: true, colId: column.name, field: column.name, menuTabs: [], ..._displaySpec(columnData), ..._sizeSpec(columnData), ..._sortSpec(columnData), ..._aggregationSpec(columnData), }; }), ]; return columnDefs; } export function generateColumnDefsForDimensions(snapshot, configuration) { // NOTE: only show columns which are fetched in select() or are dimensions as we // can't solely rely on column selection because of certain restrictions // from ag-grid, e.g. in the case of row grouping tree column: the columns // which are grouped must be present in the column definitions, so even // when some of these might not be selected explicitly by the users, they // must still be included in the column definitions, and made hidden instead. const dimensionColNames = configuration.dimensions.dimensions.flatMap((col) => col.columns); const dimCols = configuration.dimensions.dimensions.map((col) => new DataCubeColumnConfiguration(col.name, 'String')); let columns = configuration.columns.filter((col) => _findCol(snapshot.data.selectColumns, col.name) ?? _findCol(snapshot.data.groupExtendedColumns, col.name)); columns = columns.filter((col) => !dimensionColNames.includes(col.name)); columns.unshift(...dimCols); const columnDefs = [ ...(configuration.showRootAggregation ? [ { colId: INTERNAL__GRID_CLIENT_ROOT_AGGREGATION_COLUMN_ID, headerName: 'Root', field: INTERNAL__GRID_CLIENT_ROOT_AGGREGATION_COLUMN_ID, hide: true, enableValue: false, // disable GUI interactions to modify this column's aggregate function allowedAggFuncs: [], // disable GUI for options of the agg functions enablePivot: false, enableRowGroup: false, suppressColumnsToolPanel: true, rowGroup: true, rowGroupIndex: 0, }, ] : []), ...columns.map((column) => { const columnData = { name: column.name, snapshot, column, configuration, }; return { headerName: column.displayName ?? column.name, headerTooltip: `Column = ${column.displayName ? `${column.displayName} (${column.name})` : column.name}`, suppressSpanHeaderHeight: true, colId: column.name, field: column.name, menuTabs: [], ..._displaySpec(columnData), ..._sizeSpec(columnData), }; }), ]; return columnDefs; } export function generateGridOptionsFromSnapshot(snapshot, configuration, view) { return { isServerSideGroupOpenByDefault: (params) => { if (configuration.initialExpandLevel !== undefined && configuration.initialExpandLevel > 0 && params.rowNode.level <= // root aggregation (if enabled) should not be counted when applying initial expand level (configuration.showRootAggregation ? configuration.initialExpandLevel + 1 : configuration.initialExpandLevel) - 1) { return true; } const routes = params.rowNode.getRoute() ?? []; if (!routes.length) { return false; } // when root aggregation is enabled, the root node should be // expanded automatically by default if (configuration.showRootAggregation && routes.length === 1 && routes[0] === DEFAULT_ROOT_AGGREGATION_COLUMN_VALUE) { return true; } // when root aggregation is enabled, the root node should not be removed // from path when matching against all expanded paths if (configuration.showRootAggregation) { routes.shift(); } const path = routes.join(TREE_COLUMN_VALUE_SEPARATOR); if (configuration.pivotLayout.expandedPaths.includes(path)) { return true; } return false; }, /** * NOTE: there is a strange issue where if we put dynamic configuration directly * such as rowClassRules which depends on some changing state (e.g. alternateRows) * as the grid component's props, the grid performance will be heavily compromised * while if we programatically set it like this, it does not seem so taxing to the * performance; perhaps something to do with React component rendering on props change * so in general for grid options which are not static, we must configure them here */ rowClassRules: configuration.alternateRows ? { [INTERNAL__GridClientUtilityCssClassName.HIGHLIGHT_ROW]: (params) => params.rowIndex % (configuration.alternateRowsCount * 2) >= configuration.alternateRowsCount, } : {}, // -------------------------------------- EVENT HANDLERS -------------------------------------- // NOTE: make sure the event source must not be 'api' since these handlers are meant for direct // user interaction with the grid. Actions through context menu (i.e. grid controller) or programatic // update of the grid options due to change in snapshot should not trigger these handlers. onColumnPinned: (event) => { if (event.source !== 'api' && event.column) { const column = event.column; const pinned = column.getPinned(); view.grid.controller.pinColumn(column.getColId(), pinned === null ? undefined : pinned === DataCubeGridClientPinnedAlignement.LEFT ? DataCubeColumnPinPlacement.LEFT : DataCubeColumnPinPlacement.RIGHT); } }, onColumnMoved: (event) => { // make sure the move event is finished before syncing the changes if (event.source !== 'api' && event.column && event.finished) { view.grid.controller.rearrangeColumns((event.api.getColumnDefs() ?? []) .filter((col) => !('children' in col)) .map((col) => col.colId ?? '')); } }, onColumnVisible: (event) => { if (event.source !== 'api' && event.column) { const column = event.column; const isVisible = column.isVisible(); view.grid.controller.showColumn(column.getColId(), isVisible); } }, onRowGroupOpened: (event) => { // NOTE: only update the pivot layout expanded paths when the user manually expands/collapses // a path. If the path is expanded/collapsed programmatically, such as when tree column initially- // expanded-to-level is specified, causing the groups to be automatically drilled down, resultant