/*--------------------------------------------------------------------------------------------- * 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/table'; import { Component, Input, Inject, ChangeDetectorRef, forwardRef, ViewChild, ElementRef, OnDestroy, AfterViewInit } from '@angular/core'; import * as azdata from 'azdata'; import { ComponentBase } from 'sql/workbench/browser/modelComponents/componentBase'; import { Table } from 'sql/base/browser/ui/table/table'; import { TableDataView } from 'sql/base/browser/ui/table/tableDataView'; import { attachTableFilterStyler, attachTableStyler } from 'sql/platform/theme/common/styler'; import { IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; import { getContentHeight, getContentWidth, Dimension, isAncestor } from 'vs/base/browser/dom'; import { RowSelectionModel } from 'sql/base/browser/ui/table/plugins/rowSelectionModel.plugin'; import { ActionOnCheck, CheckboxSelectColumn, ICheckboxCellActionEventArgs } from 'sql/base/browser/ui/table/plugins/checkboxSelectColumn.plugin'; import { Emitter, Event as vsEvent } from 'vs/base/common/event'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { KeyMod, KeyCode } from 'vs/base/common/keyCodes'; import { slickGridDataItemColumnValueWithNoData, textFormatter, iconCssFormatter, CssIconCellValue } from 'sql/base/browser/ui/table/formatters'; import { isUndefinedOrNull } from 'vs/base/common/types'; import { IComponent, IComponentDescriptor, IModelStore, ComponentEventType, ModelViewAction } from 'sql/platform/dashboard/browser/interfaces'; import { convertSizeToNumber } from 'sql/base/browser/dom'; import { ButtonCellValue, ButtonColumn } from 'sql/base/browser/ui/table/plugins/buttonColumn.plugin'; import { IconPath, createIconCssClass, getIconKey } from 'sql/workbench/browser/modelComponents/iconUtils'; import { HeaderFilter } from 'sql/base/browser/ui/table/plugins/headerFilter.plugin'; import { onUnexpectedError } from 'vs/base/common/errors'; import { ILogService } from 'vs/platform/log/common/log'; import { TableCellClickEventArgs } from 'sql/base/browser/ui/table/plugins/tableColumn'; import { HyperlinkCellValue, HyperlinkColumn } from 'sql/base/browser/ui/table/plugins/hyperlinkColumn.plugin'; import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; export enum ColumnSizingMode { ForceFit = 0, // all columns will be sized to fit in viewable space, no horiz scroll bar AutoFit = 1, // columns will be ForceFit up to a certain number; currently 3. At 4 or more the behavior will switch to NO force fit DataFit = 2 // columns use sizing based on cell data, horiz scroll bar present if more cells than visible in view area } enum ColumnType { text = 0, checkBox = 1, button = 2, icon = 3, hyperlink = 4 } type TableCellInputDataType = string | azdata.IconColumnCellValue | azdata.ButtonColumnCellValue | azdata.HyperlinkColumnCellValue | undefined; type TableCellDataType = string | CssIconCellValue | ButtonCellValue | HyperlinkCellValue | undefined; @Component({ selector: 'modelview-table', template: `
` }) export default class TableComponent extends ComponentBase implements IComponent, OnDestroy, AfterViewInit { @Input() descriptor: IComponentDescriptor; @Input() modelStore: IModelStore; private _table: Table; private _tableData: TableDataView; private _tableColumns; private _checkboxColumns: CheckboxSelectColumn<{}>[] = []; private _buttonColumns: ButtonColumn<{}>[] = []; private _hyperlinkColumns: HyperlinkColumn<{}>[] = []; private _pluginsRegisterStatus: boolean[] = []; private _filterPlugin: HeaderFilter; private _onCheckBoxChanged = new Emitter(); private _onButtonClicked = new Emitter>(); public readonly onCheckBoxChanged: vsEvent = this._onCheckBoxChanged.event; public readonly onButtonClicked: vsEvent> = this._onButtonClicked.event; private _iconCssMap: { [iconKey: string]: string } = {}; @ViewChild('table', { read: ElementRef }) private _inputContainer: ElementRef; constructor( @Inject(forwardRef(() => ChangeDetectorRef)) changeRef: ChangeDetectorRef, @Inject(IWorkbenchThemeService) private themeService: IWorkbenchThemeService, @Inject(forwardRef(() => ElementRef)) el: ElementRef, @Inject(ILogService) logService: ILogService, @Inject(IContextViewService) private contextViewService: IContextViewService) { super(changeRef, el, logService); } transformColumns(columns: string[] | azdata.TableColumn[]): Slick.Column[] { let tableColumns: any[] = columns; if (tableColumns) { const mycolumns: Slick.Column[] = []; let index: number = 0; (columns).map(col => { if (col.type === ColumnType.checkBox) { this.createCheckBoxPlugin(col, index); } else if (col.type === ColumnType.button) { this.createButtonPlugin(col); } else if (col.type === ColumnType.icon) { mycolumns.push(TableComponent.createIconColumn(col)); } else if (col.type === ColumnType.hyperlink) { this.createHyperlinkPlugin(col); } else if (col.value) { mycolumns.push(TableComponent.createTextColumn(col as azdata.TableColumn)); } else { mycolumns.push(>{ name: col, id: col, field: col, formatter: textFormatter }); } index++; }); return mycolumns; } else { return (columns).map(col => { return >{ name: col, id: col, field: col }; }); } } private static createIconColumn(col: azdata.TableColumn): Slick.Column { return >{ name: col.name ?? col.value, id: col.value, field: col.value, width: col.width, cssClass: col.cssClass, headerCssClass: col.headerCssClass, toolTip: col.toolTip, formatter: iconCssFormatter, filterable: false, resizable: col.resizable }; } private static createTextColumn(col: azdata.TableColumn): Slick.Column { return { name: col.name ?? col.value, id: col.value, field: col.value, width: col.width, cssClass: col.cssClass, headerCssClass: col.headerCssClass, toolTip: col.toolTip, formatter: textFormatter, resizable: col.resizable }; } public transformData(rows: (TableCellInputDataType)[][], columns: string[] | azdata.TableColumn[]): { [key: string]: TableCellDataType }[] { if (rows && columns) { return rows.map(row => { const object: { [key: string]: TableCellDataType } = {}; if (!Array.isArray(row)) { return object; } row.forEach((val, index) => { const column = columns[index]; if (typeof column === 'string') { object[column] = val; } else { const columnType = column.type; let cellValue = undefined; switch (columnType) { case ColumnType.icon: const iconValue = val; cellValue = { iconCssClass: this.createIconCssClassInternal(iconValue.icon), title: iconValue.title }; break; case ColumnType.button: if (val) { const buttonValue = val; cellValue = { iconCssClass: buttonValue.icon ? this.createIconCssClassInternal(buttonValue.icon) : undefined, title: buttonValue.title }; } break; case ColumnType.hyperlink: if (val) { const hyperlinkValue = val; cellValue = { iconCssClass: hyperlinkValue.icon ? this.createIconCssClassInternal(hyperlinkValue.icon) : undefined, title: hyperlinkValue.title, url: hyperlinkValue.url }; break; } break; default: cellValue = val; } object[column.value] = cellValue; } }); return object; }); } else { return []; } } private createIconCssClassInternal(icon: IconPath): string { const iconKey: string = getIconKey(icon); const iconCssClass = this._iconCssMap[iconKey] ?? createIconCssClass(icon); if (!this._iconCssMap[iconKey]) { this._iconCssMap[iconKey] = iconCssClass; } return iconCssClass; } ngAfterViewInit(): void { if (this._inputContainer) { this._tableData = new TableDataView( null, null, null, (data: Slick.SlickData[]) => { let columns = this._table.grid.getColumns(); for (let i = 0; i < columns.length; i++) { let col: any = columns[i]; let filterValues: Array = col.filterValues; if (filterValues && filterValues.length > 0) { return data.filter(item => { let colValue = item[col.field]; if (colValue instanceof Array) { return filterValues.find(x => colValue.indexOf(x) >= 0); } return filterValues.find(x => x === colValue); }); } } return data; } ); let options = >{ syncColumnCellResize: true, enableColumnReorder: false, enableCellNavigation: true, forceFitColumns: true, // default to true during init, actual value will be updated when setProperties() is called dataItemColumnValueExtractor: slickGridDataItemColumnValueWithNoData // must change formatter if you are changing explicit column value extractor }; this._table = new Table(this._inputContainer.nativeElement, { dataProvider: this._tableData, columns: this._tableColumns }, options); this._table.setData(this._tableData); this._table.setSelectionModel(new RowSelectionModel({ selectActiveRow: true })); this._register(this._table); this._register(attachTableStyler(this._table, this.themeService)); this._register(this._table.onSelectedRowsChanged((e, data) => { this.selectedRows = data.rows; this.fireEvent({ eventType: ComponentEventType.onSelectedRowChanged, args: e }); })); this._table.grid.onKeyDown.subscribe((e: DOMEvent) => { if (this.moveFocusOutWithTab) { let event = new StandardKeyboardEvent(e as KeyboardEvent); if (event.equals(KeyMod.Shift | KeyCode.Tab)) { e.stopImmediatePropagation(); ((this._inputContainer.nativeElement).previousElementSibling).focus(); } else if (event.equals(KeyCode.Tab)) { e.stopImmediatePropagation(); ((this._inputContainer.nativeElement).nextElementSibling).focus(); } } }); } this.baseInit(); } override ngOnDestroy(): void { this.baseDestroy(); } /// IComponent implementation public override layout(): void { this.layoutTable(); super.layout(); } private layoutTable(): void { let width: number = convertSizeToNumber(this.width); let height: number = convertSizeToNumber(this.height); let forceFit: boolean = true; // convert the tri-state viewmodel columnSizingMode to be either true or false for SlickGrid switch (this.forceFitColumns) { case ColumnSizingMode.DataFit: { forceFit = false; break; } case ColumnSizingMode.AutoFit: { // determine if force fit should be on or off based on the number of columns // this can be made more sophisticated if need be in the future. a simple // check for 3 or less force fits causes the small number of columns to fill the // screen better. 4 or more, slickgrid seems to do a good job filling the view and having forceFit // false enables the scroll bar and avoids the over-packing should there be a very large // number of columns forceFit = (this._table.columns.length <= 3); break; } case ColumnSizingMode.ForceFit: default: { // default behavior for the table component (used primarily in wizards) is to forcefit the columns forceFit = true; break; } } let updateOptions = >{ forceFitColumns: forceFit }; this._table.setOptions(updateOptions); this._table.layout(new Dimension( width && width > 0 ? width : getContentWidth(this._inputContainer.nativeElement), height && height > 0 ? height : getContentHeight(this._inputContainer.nativeElement))); this._table.resizeCanvas(); } public setLayout(): void { // TODO allow configuring the look and feel this.layout(); } public override setProperties(properties: { [key: string]: any; }): void { super.setProperties(properties); this._tableData.clear(); this._tableData.push(this.transformData(this.data, this.columns)); this._tableColumns = this.transformColumns(this.columns); this._table.columns = this._tableColumns; this._table.setData(this._tableData); this._table.setTableTitle(this.title); if (this.selectedRows) { this._table.setSelectedRows(this.selectedRows); } Object.keys(this._checkboxColumns).forEach(col => this.registerPlugins(col, this._checkboxColumns[col])); Object.keys(this._buttonColumns).forEach(col => this.registerPlugins(col, this._buttonColumns[col])); Object.keys(this._hyperlinkColumns).forEach(col => this.registerPlugins(col, this._hyperlinkColumns[col])); if (this.headerFilter === true) { this.registerFilterPlugin(); this._tableData.clearFilter(); } if (this.ariaRowCount === -1) { this._table.removeAriaRowCount(); } else { this._table.ariaRowCount = this.ariaRowCount; } if (this.ariaColumnCount === -1) { this._table.removeAriaColumnCount(); } else { this._table.ariaColumnCount = this.ariaColumnCount; } if (this.ariaRole) { this._table.ariaRole = this.ariaRole; } if (this.ariaLabel) { this._table.ariaLabel = this.ariaLabel; } if (this.updateCells !== undefined) { this.updateTableCells(this.updateCells); } this.layoutTable(); this.validate().catch(onUnexpectedError); } private updateTableCells(cellInfos): void { cellInfos.forEach((cellInfo) => { if (isUndefinedOrNull(cellInfo.column) || isUndefinedOrNull(cellInfo.row) || cellInfo.row < 0 || cellInfo.row > this.data.length) { return; } const checkInfo: azdata.CheckBoxCell = cellInfo as azdata.CheckBoxCell; if (checkInfo) { this._checkboxColumns[checkInfo.columnName].reactiveCheckboxCheck(checkInfo.row, checkInfo.checked); } }); } private createCheckBoxPlugin(col: azdata.CheckboxColumn, index: number) { let name = col.value; if (!this._checkboxColumns[col.value]) { const checkboxAction = (col.options ? (col.options).actionOnCheckbox : col.action); this._checkboxColumns[col.value] = new CheckboxSelectColumn({ title: col.value, toolTip: col.toolTip, width: col.width, cssClass: col.cssClass, headerCssClass: col.headerCssClass, actionOnCheck: checkboxAction }, index); this._register(this._checkboxColumns[col.value].onChange((state) => { this.fireEvent({ eventType: ComponentEventType.onCellAction, args: { row: state.row, column: state.column, checked: state.checked, name: name } }); })); } } private createButtonPlugin(col: azdata.ButtonColumn) { let name = col.value; if (!this._buttonColumns[col.value]) { const icon = (col.options ? (col.options).icon : col.icon); this._buttonColumns[col.value] = new ButtonColumn({ title: col.value, iconCssClass: icon ? this.createIconCssClassInternal(icon) : undefined, field: col.value, showText: col.showText, name: col.name, resizable: col.resizable }); this._register(this._buttonColumns[col.value].onClick((state) => { this.fireEvent({ eventType: ComponentEventType.onCellAction, args: { row: state.row, column: state.column, name: name } }); })); } } private createHyperlinkPlugin(col: azdata.HyperlinkColumn) { const name = col.value; if (!this._hyperlinkColumns[col.value]) { const hyperlinkColumn = new HyperlinkColumn({ title: col.value, width: col.width, iconCssClass: col.icon ? this.createIconCssClassInternal(col.icon) : undefined, field: col.value, name: col.name, resizable: col.resizable }); this._hyperlinkColumns[col.value] = hyperlinkColumn; this._register(hyperlinkColumn.onClick((state) => { this.fireEvent({ eventType: ComponentEventType.onCellAction, args: { row: state.row, column: state.column, name: name } }); })); } } private registerPlugins(col: string, plugin: CheckboxSelectColumn<{}> | ButtonColumn<{}> | HyperlinkColumn<{}>): void { const index = 'index' in plugin ? plugin.index : this.columns?.findIndex(x => x === col || ('value' in x && x['value'] === col)); if (index >= 0) { this._tableColumns.splice(index, 0, plugin.definition); if (!(col in this._pluginsRegisterStatus) || !this._pluginsRegisterStatus[col]) { this._table.registerPlugin(plugin); this._pluginsRegisterStatus[col] = true; } } this._table.columns = this._tableColumns; this._table.autosizeColumns(); } private registerFilterPlugin() { const filterPlugin = new HeaderFilter(this.contextViewService); this._register(attachTableFilterStyler(filterPlugin, this.themeService)); this._filterPlugin = filterPlugin; this._filterPlugin.onFilterApplied.subscribe((e, args) => { let filterValues = (args).column.filterValues; if (filterValues) { this._tableData.filter(); this._table.grid.resetActiveCell(); this.data = this._tableData.getItems().map(dataObject => Object.values(dataObject)); this.layoutTable(); } else { this._tableData.clearFilter(); } }); this._filterPlugin.onCommand.subscribe((e, args: any) => { this._tableData.sort({ sortAsc: args.command === 'sort-asc', sortCol: args.column, multiColumnSort: false, grid: this._table.grid }); this.layoutTable(); }); this._table.registerPlugin(filterPlugin); } public override focus(): void { if (this._table.grid.getDataLength() > 0) { if (!this._table.grid.getActiveCell()) { this._table.grid.setActiveCell(0, 0); } this._table.grid.getActiveCellNode().focus(); } } // CSS-bound properties public get data(): any[][] { return this.getPropertyOrDefault((props) => props.data, []); } public set data(newValue: any[][]) { this.setPropertyFromUI((props, value) => props.data = value, newValue); } public get columns(): string[] | azdata.TableColumn[] { return this.getPropertyOrDefault((props) => props.columns, []); } public get fontSize(): number | string { return this.getPropertyOrDefault((props) => props.fontSize, ''); } public set columns(newValue: string[] | azdata.TableColumn[]) { this.setPropertyFromUI((props, value) => props.columns = value, newValue); } public get selectedRows(): number[] { return this.getPropertyOrDefault((props) => props.selectedRows, []); } public set selectedRows(newValue: number[]) { this.setPropertyFromUI((props, value) => props.selectedRows = value, newValue); } public get forceFitColumns() { return this.getPropertyOrDefault((props) => props.forceFitColumns, ColumnSizingMode.ForceFit); } public get title() { return this.getPropertyOrDefault((props) => props.title, ''); } public get ariaRowCount(): number { return this.getPropertyOrDefault((props) => props.ariaRowCount, -1); } public get ariaColumnCount(): number { return this.getPropertyOrDefault((props) => props.ariaColumnCount, -1); } public set moveFocusOutWithTab(newValue: boolean) { this.setPropertyFromUI((props, value) => props.moveFocusOutWithTab = value, newValue); } public get moveFocusOutWithTab(): boolean { return this.getPropertyOrDefault((props) => props.moveFocusOutWithTab, false); } public get updateCells(): azdata.TableCell[] { return this.getPropertyOrDefault((props) => props.updateCells, undefined); } public set updateCells(newValue: azdata.TableCell[]) { this.setPropertyFromUI((properties, value) => { properties.updateCells = value; }, newValue); } public get headerFilter(): boolean { return this.getPropertyOrDefault((props) => props.headerFilter, false); } public override doAction(action: string, ...args: any[]): void { switch (action) { case ModelViewAction.AppendData: this.appendData(args[0]); } } private appendData(data: any[][]) { const tableHasFocus = isAncestor(document.activeElement, this._inputContainer.nativeElement); const currentActiveCell = this._table.grid.getActiveCell(); const wasFocused = tableHasFocus && this._table.grid.getDataLength() > 0 && currentActiveCell; this._tableData.push(this.transformData(data, this.columns)); this.data = this._tableData.getItems().map(dataObject => Object.values(dataObject)); this.layoutTable(); if (wasFocused) { if (!this._table.grid.getActiveCell()) { this._table.grid.setActiveCell(currentActiveCell.row, currentActiveCell.cell); } this._table.grid.getActiveCellNode().focus(); } } public override get CSSStyles(): azdata.CssStyles { return this.mergeCss(super.CSSStyles, { 'width': this.getWidth(), 'height': '100%', 'font-size': this.fontSize }); } }