From 7e57503aa6e40950bbe61bc1fad7953698cc86fc Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Mon, 9 May 2022 15:53:22 -0700 Subject: [PATCH] Adding ability to expand and columns slickgrid table rows (#19168) * Adding ability to expand and columns tables * Bolding icons Fixing variable names * Adding helper function Making css more target * Adding keyboard navigation and parsing treegrid data * Adding attributes, data transformations and key events to the treegrid * Expanded * changing var name * FIxing formatter name * Adding back cell styling * Removing comments * Making a new TreeGrid component. Separating treegrid logic from tableDataView * Fixing comments * changing method name * Modifying only visible row dom attributes * Removing white space, moving role attribute setter to constructor. * Fixing some more PR comments * Adding comments and renaming functions * Fixing comments * Fixing comments * Fixing comments * Fixing some logic and removing unused attributes from element * Adding expandable formatter to the first column * Making the formatter generic * Reverting formatter code * Adding doc comments * Fixing comments * Removing duplicated code * Adding comments * Setting columns only when the table is initialized * Letting users set expanded state instead of forcing it to false --- src/sql/base/browser/ui/table/formatters.ts | 21 ++ src/sql/base/browser/ui/table/media/table.css | 5 + src/sql/base/browser/ui/table/table.ts | 24 +-- .../base/browser/ui/table/tableDataView.ts | 4 +- src/sql/base/browser/ui/table/treeGrid.ts | 183 ++++++++++++++++++ .../browser/executionPlanPropertiesView.ts | 7 +- .../executionPlanPropertiesViewBase.ts | 20 +- 7 files changed, 233 insertions(+), 31 deletions(-) create mode 100644 src/sql/base/browser/ui/table/treeGrid.ts diff --git a/src/sql/base/browser/ui/table/formatters.ts b/src/sql/base/browser/ui/table/formatters.ts index 5575660e15..a1596b303b 100644 --- a/src/sql/base/browser/ui/table/formatters.ts +++ b/src/sql/base/browser/ui/table/formatters.ts @@ -170,6 +170,27 @@ export function slickGridDataItemColumnValueWithNoData(value: any, columnDef: an }; } +/** + * Creates a formatter for the first column of the treegrid. The created formatter will wrap the output of the provided formatter with a level based indentation and a chevron icon for tree grid parents that indicates their expand/collapse state. + */ +export function createTreeGridExpandableColumnFormatter(formattingFunction: Slick.Formatter): Slick.Formatter { + return (row: number | undefined, cell: any | undefined, value: any, columnDef: any | undefined, dataContext: any | undefined): string => { + const spacer = ``; + + const innerCellContent = formattingFunction(row, cell, value, columnDef, dataContext); + + if (dataContext['isParent']) { + if (dataContext.expanded) { + return `
${spacer}  ${innerCellContent}
`; + } else { + return `
${spacer}  ${innerCellContent}
`; + } + } else { + return `${spacer}${innerCellContent}`; + } + }; +} + /** The following code is a rewrite over the both formatter function using dom builder * rather than string manipulation, which is a safer and easier method of achieving the same goal. * However, when electron is in "Run as node" mode, dom creation acts differently than normal and therefore diff --git a/src/sql/base/browser/ui/table/media/table.css b/src/sql/base/browser/ui/table/media/table.css index 5e6bf85f5a..8eff94c95a 100644 --- a/src/sql/base/browser/ui/table/media/table.css +++ b/src/sql/base/browser/ui/table/media/table.css @@ -234,3 +234,8 @@ display: flex; align-items: center; } + +.slick-cell .toggle { + font-size: 12px !important; + margin-right: -5px; +} diff --git a/src/sql/base/browser/ui/table/table.ts b/src/sql/base/browser/ui/table/table.ts index 9fcc98d3f4..3ae7fc8286 100644 --- a/src/sql/base/browser/ui/table/table.ts +++ b/src/sql/base/browser/ui/table/table.ts @@ -33,14 +33,14 @@ export class Table extends Widget implements IDisposa private styleElement: HTMLStyleElement; private idPrefix: string; - private _grid: Slick.Grid; - private _columns: Slick.Column[]; - private _data: IDisposableDataProvider; + protected _grid: Slick.Grid; + protected _columns: Slick.Column[]; + protected _data: IDisposableDataProvider; private _sorter?: ITableSorter; private _autoscroll?: boolean; private _container: HTMLElement; - private _tableContainer: HTMLElement; + protected _tableContainer: HTMLElement; private _classChangeTimeout: any; @@ -69,12 +69,6 @@ export class Table extends Widget implements IDisposa this._register(this._data); - if (configuration && configuration.columns) { - this._columns = configuration.columns; - } else { - this._columns = new Array>(); - } - let newOptions = mixin(options || {}, getDefaultOptions(), false); this._container = document.createElement('div'); @@ -98,7 +92,14 @@ export class Table extends Widget implements IDisposa this._tableContainer = document.createElement('div'); this._container.appendChild(this._tableContainer); this.styleElement = DOM.createStyleSheet(this._container); - this._grid = new Slick.Grid(this._tableContainer, this._data, this._columns, newOptions); + this._grid = new Slick.Grid(this._tableContainer, this._data, [], newOptions); + + if (configuration && configuration.columns) { + this.columns = configuration.columns; + } else { + this.columns = new Array>(); + } + this.idPrefix = this._tableContainer.classList[0]; this._container.classList.add(this.idPrefix); if (configuration && configuration.sorter) { @@ -175,6 +176,7 @@ export class Table extends Widget implements IDisposa this._data = new TableDataView(data); } this._grid.setData(this._data, true); + this._data.filter(this._grid.getColumns()); } getData(): IDisposableDataProvider { diff --git a/src/sql/base/browser/ui/table/tableDataView.ts b/src/sql/base/browser/ui/table/tableDataView.ts index f77899ae8a..b958554ff9 100644 --- a/src/sql/base/browser/ui/table/tableDataView.ts +++ b/src/sql/base/browser/ui/table/tableDataView.ts @@ -20,7 +20,7 @@ export type TableFilterFunc = (data: Array, column export type TableSortFunc = (args: Slick.OnSortEventArgs, data: Array) => Array; export type TableFindFunc = (val: T, exp: string) => Array; -function defaultCellValueGetter(data: any): any { +export function defaultCellValueGetter(data: any): any { return data; } @@ -54,7 +54,7 @@ export function defaultSort(args: Slick.OnSortEventAr return data.sort((a, b) => comparer(a, b) * sign); } -function defaultFilter(data: T[], columns: FilterableColumn[], cellValueGetter: CellValueGetter = defaultCellValueGetter): T[] { +export function defaultFilter(data: T[], columns: FilterableColumn[], cellValueGetter: CellValueGetter = defaultCellValueGetter): T[] { let filteredData = data; columns?.forEach(column => { if (column.filterValues?.length > 0 && column.field) { diff --git a/src/sql/base/browser/ui/table/treeGrid.ts b/src/sql/base/browser/ui/table/treeGrid.ts new file mode 100644 index 0000000000..9172c8f6a7 --- /dev/null +++ b/src/sql/base/browser/ui/table/treeGrid.ts @@ -0,0 +1,183 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./media/slick.grid'; + +import { FilterableColumn, ITableConfiguration } from 'sql/base/browser/ui/table/interfaces'; +import { Table } from 'sql/base/browser/ui/table/table'; +import { IDisposableDataProvider } from 'sql/base/common/dataProvider'; +import { generateUuid } from 'vs/base/common/uuid'; +import { CellValueGetter, defaultCellValueGetter, defaultFilter, TableDataView } from 'sql/base/browser/ui/table/tableDataView'; +import { AsyncDataProvider } from 'sql/base/browser/ui/table/asyncDataView'; +import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import { createTreeGridExpandableColumnFormatter, textFormatter } from 'sql/base/browser/ui/table/formatters'; + +function defaultTreeGridFilter(data: T[], columns: FilterableColumn[], cellValueGetter: CellValueGetter = defaultCellValueGetter): T[] { + let filteredData = defaultFilter(data, columns, cellValueGetter); + + // filtering out rows which have parent/grandparents collapsed. + filteredData = filteredData.filter((item) => { + let parent = data[item.parent]; + while (parent) { + if (!parent.expanded) { + return false; + } + parent = data[parent.parent]; + } + return true; + }); + + return filteredData; +} + +/** + * TreeGrid component displays a hierarchical table data grouped into expandable and collapsible nodes. + */ +export class TreeGrid extends Table { + constructor(parent: HTMLElement, configuration?: ITableConfiguration, options?: Slick.GridOptions) { + super(parent, configuration, options); + this._tableContainer.setAttribute('role', 'treegrid'); + if (configuration?.dataProvider && configuration.dataProvider instanceof TableDataView) { + this._data = configuration.dataProvider; + } else { + this._data = new TableDataView(configuration && configuration.dataProvider as Array, + undefined, + undefined, + defaultTreeGridFilter, + undefined); + } + + this._grid.onClick.subscribe((e, data) => { + this.setRowExpandedState(data.row); + return false; + }); + + // The events returned by grid are Jquery events. These events can be handled by returning false which executes preventDefault and stopPropagation + this._grid.onKeyDown.subscribe((e, data) => { + const keyboardEvent = (e).originalEvent; + if (keyboardEvent instanceof KeyboardEvent) { + let event = new StandardKeyboardEvent(keyboardEvent); + if (event.keyCode === KeyCode.Enter) { + // toggle the collapsed state of the row + this.setRowExpandedState(data.row); + return false; + } else if (event.keyCode === KeyCode.LeftArrow) { + // Left arrow on first cell of the expanded row collapses it + if (data.cell === 0) { + this.setRowExpandedState(data.row, false); // Collapsing state + return false; + } + } else if (event.keyCode === KeyCode.RightArrow) { + // Right arrow on last cell of the collapsed row expands it. + if (data.cell === (this._grid.getColumns().length - 1)) { + this.setRowExpandedState(data.row, true); + return false; + } + } + } + return true; + }); + + this._grid.onRendered.subscribe((e, data) => { + const visibleRows = this._grid.getViewport(); + for (let i = visibleRows.top; i <= visibleRows.bottom; i++) { + const rowData = this._data.getItem(i); + // Getting the row div that corresponds to the data row + const rowElement = this._tableContainer.querySelector(`div [role="row"][aria-rowindex="${(i + 1)}"]`); + // If the row element is found in the dom, we are setting the required aria attributes for it. + if (rowElement) { + if (rowData.expanded !== undefined) { + rowElement.ariaExpanded = rowData.expanded; + } else { + rowElement.removeAttribute('aria-expanded'); + } + if (rowData.setSize !== undefined) { + rowElement.ariaSetSize = rowData.setSize; + } else { + rowElement.removeAttribute('aria-setsize'); + } + if (rowData.posInSet !== undefined) { + rowElement.ariaPosInSet = rowData.posInSet; + } else { + rowElement.removeAttribute('aria-posinset'); + } + if (rowData.level !== undefined) { + rowElement.ariaLevel = rowData.level; + } + } + } + return false; + }); + } + + override setData(data: Array): void; + override setData(data: TableDataView): void; + override setData(data: AsyncDataProvider): void; + override setData(data: Array | TableDataView | AsyncDataProvider): void { + if (data instanceof TableDataView || data instanceof AsyncDataProvider) { + this._data = data; + } else { + this._data = new TableDataView(data, undefined, undefined, defaultTreeGridFilter); + } + this.addTreeGridDataAttributes(this._data); + this._grid.setData(this._data, true); + this._data.filter(this._grid.getColumns()); + } + + public override set columns(columns: Slick.Column[]) { + if (columns[0]) { + // Create a new formatter for the first column that adds level based indentation and a chevron icon. + columns[0].formatter = createTreeGridExpandableColumnFormatter(columns[0].formatter ?? textFormatter); + } + super.columns = columns; + } + + /** + * Sets the expanded state to the specified value, or if undefined toggles the current state of the cell + */ + private setRowExpandedState(row: number, expanded?: boolean): void { + const rowData = this._data.getItem(row); + if (rowData['isParent']) { + if (expanded === undefined) { + (rowData).expanded = !rowData.expanded; + } else { + (rowData).expanded = expanded; + } + this._data.filter(this._grid.getColumns()); + this.rerenderGrid(); + this.focus(); + } + } + + /** + * Adds additional properties to data rows necessary for displaying as part of the tree grid structure. + */ + private addTreeGridDataAttributes(data: IDisposableDataProvider): void { + for (let i = 0; i < data.getLength(); i++) { + const dataRow = data.getItem(i); + if (dataRow.parent === undefined || dataRow.parent === -1) { + dataRow.level = 1; + } else { + const parentRow = data.getItem(dataRow.parent); + dataRow.level = parentRow.level + 1; + if (parentRow.setSize === undefined) { + parentRow.setSize = 1; + } else { + parentRow.setSize += 1; + } + dataRow.posInSet = parentRow.setSize; + if (parentRow.expanded === undefined) { + parentRow.expanded = false; + } + parentRow.isParent = true; + if (!parentRow._guid) { + parentRow._guid = generateUuid(); + } + dataRow.parentGuid = parentRow._guid; + } + } + } +} diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesView.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesView.ts index bd7a36c526..449930dad3 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesView.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesView.ts @@ -131,7 +131,7 @@ export class ExecutionPlanPropertiesView extends ExecutionPlanPropertiesViewBase props.forEach((p, i) => { let row = {}; rows.push(row); - row['name'] = ' '.repeat(indent) + p.name; + row['name'] = p.name; row['parent'] = parentIndex; if (!isString(p.value)) { // Styling values in the parent row differently to make them more apparent and standout compared to the rest of the cells. @@ -143,10 +143,11 @@ export class ExecutionPlanPropertiesView extends ExecutionPlanPropertiesViewBase text: removeLineBreaks(p.displayValue, ' '), style: parentRowCellStyling }; + row['tootltip'] = p.displayValue; this.convertModelToTableRows(p.value, rows.length - 1, indent + 2, rows); } else { - row['value'] = removeLineBreaks(p.value, ' '); - row['tooltip'] = p.value; + row['value'] = removeLineBreaks(p.displayValue, ' '); + row['tooltip'] = p.displayValue; } }); return rows; diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesViewBase.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesViewBase.ts index d815bda1ff..5c523f0233 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesViewBase.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesViewBase.ts @@ -4,8 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from 'vs/base/browser/dom'; -import { Table } from 'sql/base/browser/ui/table/table'; -import { TableDataView } from 'sql/base/browser/ui/table/tableDataView'; import { ActionBar } from 'sql/base/browser/ui/taskbar/actionbar'; import { IColorTheme, ICssStyleCollector, IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; import { localize } from 'vs/nls'; @@ -16,6 +14,7 @@ import { sortAlphabeticallyIconClassNames, sortByDisplayOrderIconClassNames, sor import { attachTableStyler } from 'sql/platform/theme/common/styler'; import { RESULTS_GRID_DEFAULTS } from 'sql/workbench/common/constants'; import { contrastBorder, listHoverBackground } from 'vs/platform/theme/common/colorRegistry'; +import { TreeGrid } from 'sql/base/browser/ui/table/treeGrid'; export abstract class ExecutionPlanPropertiesViewBase { // Title bar with close button action @@ -32,9 +31,7 @@ export abstract class ExecutionPlanPropertiesViewBase { private _headerActions: ActionBar; // Properties table - private _tableComponent: Table; - private _tableComponentDataView: TableDataView; - private _tableComponentDataModel: { [key: string]: string }[]; + private _tableComponent: TreeGrid; private _tableContainer!: HTMLElement; private _tableWidth; @@ -83,10 +80,8 @@ export abstract class ExecutionPlanPropertiesViewBase { const table = DOM.$('.table'); this._tableContainer.appendChild(table); - this._tableComponentDataView = new TableDataView(); - this._tableComponentDataModel = []; - this._tableComponent = new Table(table, { - dataProvider: this._tableComponentDataView, columns: [] + this._tableComponent = new TreeGrid(table, { + columns: [] }, { rowHeight: RESULTS_GRID_DEFAULTS.rowHeight, forceFitColumns: true, @@ -140,12 +135,7 @@ export abstract class ExecutionPlanPropertiesViewBase { public populateTable(columns: Slick.Column[], data: { [key: string]: string }[]) { this._tableComponent.columns = columns; this._tableContainer.scrollTo(0, 0); - this._tableComponentDataView.clear(); - this._tableComponentDataModel = data; - this._tableComponentDataView.push(this._tableComponentDataModel); - this._tableComponent.setData(this._tableComponentDataView); - this._tableComponent.autosizeColumns(); - this._tableComponent.updateRowCount(); + this._tableComponent.setData(data); this.resizeTable(); }