From 228a5d8e20468f4e812fc64ff9199f48bc1b13f8 Mon Sep 17 00:00:00 2001 From: Alan Ren Date: Thu, 22 Apr 2021 12:03:17 -0700 Subject: [PATCH] Table Filter improvements (#15207) * use virutualized list * comments --- .../base/browser/ui/table/asyncDataView.ts | 4 - .../browser/ui/table/hybridDataProvider.ts | 5 - src/sql/base/browser/ui/table/media/table.css | 59 ++- .../ui/table/plugins/headerFilter.plugin.ts | 481 +++++++++++------- .../browser/ui/table/plugins/rowDetailView.ts | 4 +- .../base/browser/ui/table/tableDataView.ts | 10 +- src/sql/base/common/dataProvider.ts | 6 - src/sql/platform/theme/common/styler.ts | 8 +- .../browser/asmtResultsView.component.ts | 47 -- 9 files changed, 346 insertions(+), 278 deletions(-) diff --git a/src/sql/base/browser/ui/table/asyncDataView.ts b/src/sql/base/browser/ui/table/asyncDataView.ts index fca027f62b..c8238e9783 100644 --- a/src/sql/base/browser/ui/table/asyncDataView.ts +++ b/src/sql/base/browser/ui/table/asyncDataView.ts @@ -218,10 +218,6 @@ export class AsyncDataProvider implements IDisposable throw new Error('Method not implemented.'); } - getFilteredColumnValues(column: Slick.Column): Promise { - throw new Error('Method not implemented.'); - } - getColumnValues(column: Slick.Column): Promise { throw new Error('Method not implemented.'); } diff --git a/src/sql/base/browser/ui/table/hybridDataProvider.ts b/src/sql/base/browser/ui/table/hybridDataProvider.ts index 1f2258b6c1..ed88b2987a 100644 --- a/src/sql/base/browser/ui/table/hybridDataProvider.ts +++ b/src/sql/base/browser/ui/table/hybridDataProvider.ts @@ -68,11 +68,6 @@ export class HybridDataProvider implements IDisposabl return this.provider.getColumnValues(column); } - public async getFilteredColumnValues(column: Slick.Column): Promise { - await this.initializeCacheIfNeeded(); - return this.provider.getFilteredColumnValues(column); - } - public get dataRows(): IObservableCollection { return this._asyncDataProvider.dataRows; } diff --git a/src/sql/base/browser/ui/table/media/table.css b/src/sql/base/browser/ui/table/media/table.css index de1c86c9ed..7dcf906bbd 100644 --- a/src/sql/base/browser/ui/table/media/table.css +++ b/src/sql/base/browser/ui/table/media/table.css @@ -88,16 +88,55 @@ padding: 4px; } +.slick-header-menu .searchbox-row +{ + display: flex; + align-items: center; + padding-left: 5px; + margin-top: 5px; +} + +.slick-header-menu .searchbox-row .select-all-checkbox +{ + flex: 0; +} + +.slick-header-menu .searchbox-row .search-input +{ + flex: 1 1 auto; +} + +.slick-header-menu .searchbox-row .search-input input +{ + padding-right: 70px; +} + +.slick-header-menu .searchbox-row .selected-count +{ + position: absolute; + right: 8px; + align-self: center; + align-items: center; + display: flex; +} + +.slick-header-menu .searchbox-row .visible-count +{ + position: absolute; + /* visible count badge is not visible but will be read by the screen reader */ + left: -10000px; +} + + .slick-header-menu .filter { border: 1px solid #BFBDBD; font-size: 8pt; height: 250px; margin-top: 6px; - overflow: scroll; - padding: 4px; + overflow: hidden; + padding: 1px; white-space: nowrap; - width: 200px; align-content: flex-start; display: flex; flex-direction: column; @@ -106,6 +145,7 @@ .slick-header-menu .filter .filter-option { display: flex; align-items: center; + padding-left: 3px; } .slick-header-menu .filter-menu-button-container { @@ -167,19 +207,6 @@ z-index: 999; } -.vs-dark .slick-header-menu > input.input { - color: #4a4a4a; -} - -.hc-black .slick-header-menu > input.input { - color: #000000; -} - -.vs-dark .slick-header-menu, -.hc-black .slick-header-menu { - color: #FFFFFF; -} - .slick-icon-cell-content { background-position: 5px center !important; background-repeat: no-repeat; diff --git a/src/sql/base/browser/ui/table/plugins/headerFilter.plugin.ts b/src/sql/base/browser/ui/table/plugins/headerFilter.plugin.ts index f299a0634d..06ae5f5b3a 100644 --- a/src/sql/base/browser/ui/table/plugins/headerFilter.plugin.ts +++ b/src/sql/base/browser/ui/table/plugins/headerFilter.plugin.ts @@ -1,19 +1,25 @@ // Adopted and converted to typescript from https://github.com/danny-sg/slickgrid-spreadsheet-plugins/blob/master/ext.headerfilter.js // heavily modified -import { IButtonStyles } from 'vs/base/browser/ui/button/button'; +import { IButtonOptions, IButtonStyles } from 'vs/base/browser/ui/button/button'; import { localize } from 'vs/nls'; import { Button } from 'sql/base/browser/ui/button/button'; import { FilterableColumn } from 'sql/base/browser/ui/table/interfaces'; -import { escape } from 'sql/base/common/strings'; -import { addDisposableListener } from 'vs/base/browser/dom'; -import { DisposableStore } from 'vs/base/common/lifecycle'; +import { addDisposableListener, EventType, EventHelper, $, isAncestor, clearNode, append } from 'vs/base/browser/dom'; +import { DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle'; import { withNullAsUndefined } from 'vs/base/common/types'; import { instanceOfIDisposableDataProvider } from 'sql/base/common/dataProvider'; import { IContextViewProvider } from 'vs/base/browser/ui/contextview/contextview'; import { IInputBoxStyles, InputBox } from 'sql/base/browser/ui/inputBox/inputBox'; import { trapKeyboardNavigation } from 'sql/base/browser/dom'; +import { IListAccessibilityProvider, IListStyles, List } from 'vs/base/browser/ui/list/listWidget'; +import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; +import { Checkbox } from 'sql/base/browser/ui/checkbox/checkbox'; +import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import { Emitter } from 'vs/base/common/event'; +import { CountBadge, ICountBadgetyles } from 'vs/base/browser/ui/countBadge/countBadge'; export type HeaderFilterCommands = 'sort-asc' | 'sort-desc'; @@ -23,7 +29,8 @@ export interface CommandEventArgs { command: HeaderFilterCommands } -export interface ITableFilterStyle extends IButtonStyles, IInputBoxStyles { } +export interface ITableFilterStyles extends IButtonStyles, IInputBoxStyles, IListStyles, ICountBadgetyles { +} const ShowFilterText: string = localize('headerFilter.showFilter', "Show Filter"); @@ -35,16 +42,22 @@ export class HeaderFilter { private grid!: Slick.Grid; private handler = new Slick.EventHandler(); - private $menu?: JQuery; + private menu?: HTMLElement; private okButton?: Button; private clearButton?: Button; private cancelButton?: Button; private sortAscButton?: Button; private sortDescButton?: Button; + private selectAllCheckBox?: Checkbox; private searchInputBox?: InputBox; - private workingFilters!: Array; + private countBadge?: CountBadge; + private visibleCountBadge?: CountBadge; + private list?: List; + private listData?: TableFilterListElement[]; + private filteredListData?: TableFilterListElement[]; + private elementDisposables?: IDisposable[]; private columnDef!: FilterableColumn; - private filterStyles?: ITableFilterStyle; + private filterStyles?: ITableFilterStyles; private disposableStore = new DisposableStore(); private _enabled: boolean = true; @@ -70,15 +83,15 @@ export class HeaderFilter { } private handleKeyDown(e: KeyboardEvent): void { - if (this.$menu && (e.key === 'Escape' || e.keyCode === 27)) { + const event = new StandardKeyboardEvent(e); + if (this.menu && event.keyCode === KeyCode.Escape) { this.hideMenu(); - e.preventDefault(); - e.stopPropagation(); + EventHelper.stop(e, true); } } private handleBodyMouseDown(e: MouseEvent): void { - if (this.$menu && this.$menu[0] !== e.target && !jQuery.contains(this.$menu[0], e.target as Element)) { + if (this.menu && this.menu !== e.target && !isAncestor(e.target as Element, this.menu)) { this.hideMenu(); } } @@ -92,9 +105,6 @@ export class HeaderFilter { return; } const column = args.column as FilterableColumn; - if (column.id === '_detail_selector') { - return; - } if ((>column).filterable === false) { return; } @@ -122,69 +132,167 @@ export class HeaderFilter { .remove(); } - private createButtonMenuItem(menu: HTMLElement, columnDef: Slick.Column, title: string, command: HeaderFilterCommands, iconClass: string): Button { - const buttonContainer = document.createElement('div'); - menu.appendChild(buttonContainer); + private createButtonMenuItem(title: string, command: HeaderFilterCommands, iconClass: string): Button { + const buttonContainer = append(this.menu, $('')); const button = new Button(buttonContainer); button.icon = { classNames: `slick-header-menuicon ${iconClass}` }; button.label = title; button.onDidClick(async () => { - await this.handleMenuItemClick(command, columnDef); + await this.handleMenuItemClick(command, this.columnDef); }); return button; } - private createSearchInput(menu: JQuery, columnDef: Slick.Column): InputBox { - const inputContainer = document.createElement('div'); - inputContainer.style.width = '206px'; - inputContainer.style.marginTop = '5px'; - menu[0].appendChild(inputContainer); - const input = new InputBox(inputContainer, this.contextViewProvider, { + private createSearchInputRow(): void { + const searchRow = append(this.menu, $('.searchbox-row')); + this.selectAllCheckBox = new Checkbox(append(searchRow, $('.select-all-checkbox')), { + onChange: (val) => { + this.filteredListData.forEach(item => { + item.checked = val; + }); + }, + label: '', + ariaLabel: localize('table.selectAll', "Select All") + }); + + + this.searchInputBox = new InputBox(append(searchRow, $('.search-input')), this.contextViewProvider, { placeholder: localize('table.searchPlaceHolder', "Search") }); - input.onDidChange(async (newString) => { - const filterVals = await this.getFilterValuesByInput(columnDef, newString); - this.updateFilterInputs(menu, columnDef, filterVals); + const visibleCountContainer = append(searchRow, $('.visible-count')); + visibleCountContainer.setAttribute('aria-live', 'polite'); + visibleCountContainer.setAttribute('aria-atomic', 'true'); + this.visibleCountBadge = new CountBadge(visibleCountContainer, { + countFormat: localize({ key: 'tableFilter.visibleCount', comment: ['This tells the user how many items are shown in the list. Currently not visible, but read by screen readers.'] }, "{0} Results") + }); + + const selectedCountBadgeContainer = append(searchRow, $('.selected-count')); + selectedCountBadgeContainer.setAttribute('aria-live', 'polite'); + this.countBadge = new CountBadge(selectedCountBadgeContainer, { + countFormat: localize({ key: 'tableFilter.selectedCount', comment: ['This tells the user how many items are selected in the list'] }, "{0} Selected") + }); + + this.searchInputBox.onDidChange(async (newString) => { + this.filteredListData = this.listData.filter(element => element.value?.toUpperCase().indexOf(newString.toUpperCase()) !== -1); + this.list.splice(0, this.list.length, this.filteredListData); + this.updateSelectionState(); }); - return input; } - private updateFilterInputs(menu: JQuery, columnDef: FilterableColumn, filterItems: Array) { - let filterOptions = ''; - columnDef.filterValues = columnDef.filterValues || []; + private async createFilterList(): Promise { + this.columnDef.filterValues = this.columnDef.filterValues || []; // WorkingFilters is a copy of the filters to enable apply/cancel behaviour - this.workingFilters = columnDef.filterValues.slice(0); - - for (let i = 0; i < filterItems.length; i++) { - const filtered = this.workingFilters.some(x => x === filterItems[i]); - - filterOptions += ''; + const workingFilters = this.columnDef.filterValues.slice(0); + let filterItems: Array; + const dataView = this.grid.getData() as Slick.DataProvider; + if (instanceOfIDisposableDataProvider(dataView)) { + filterItems = await dataView.getColumnValues(this.columnDef); + } else { + const filterApplied = this.grid.getColumns().findIndex((col) => { + const filterableColumn = col as FilterableColumn; + return filterableColumn.filterValues?.length > 0; + }) !== -1; + if (!filterApplied) { + // Filter based all available values + filterItems = this.getFilterValues(this.grid.getData() as Slick.DataProvider, this.columnDef); + } + else { + // Filter based on current dataView subset + filterItems = this.getAllFilterValues((this.grid.getData() as Slick.Data.DataView).getFilteredItems(), this.columnDef); + } } - const $filter = menu.find('.filter'); - $filter.empty().append(jQuery(filterOptions)); - jQuery(':checkbox', $filter).bind('click', (e) => { - this.workingFilters = this.changeWorkingFilter(filterItems, this.workingFilters, jQuery(e.target)); + this.listData = []; + for (let i = 0; i < filterItems.length; i++) { + const filtered = workingFilters.some(x => x === filterItems[i]); + // work item to remove the 'Error:' string check: https://github.com/microsoft/azuredatastudio/issues/15206 + if (filterItems[i] && filterItems[i].indexOf('Error:') < 0) { + this.listData.push(new TableFilterListElement(filterItems[i], filtered)); + } + } + + this.elementDisposables = this.listData.map(element => { + return element.onCheckStateChanged((e) => { + this.updateSelectionState(); + }); }); + + this.filteredListData = this.listData; + + const filter = append(this.menu, $('.filter')); + this.list = new List('TableFilter', filter, new TableFilterListDelegate(), [new TableFilterListRenderer()], { + multipleSelectionSupport: false, + keyboardSupport: true, + mouseSupport: true, + accessibilityProvider: new TableFilterListAccessibilityProvider() + }); + this.list.onKeyDown((e) => { + const event = new StandardKeyboardEvent(e); + switch (event.keyCode) { + case KeyCode.Home: + if (this.filteredListData.length > 0) { + this.list.focusFirst(); + this.list.reveal(0); + EventHelper.stop(e, true); + } + break; + case KeyCode.End: + if (this.filteredListData.length > 0) { + this.list.focusLast(); + this.list.reveal(this.filteredListData.length - 1); + EventHelper.stop(e, true); + } + break; + case KeyCode.Space: + if (this.list.getFocus().length !== 0) { + this.list.setSelection(this.list.getFocus()); + this.toggleCheckbox(); + EventHelper.stop(e, true); + } + break; + } + }); + this.list.splice(0, this.filteredListData.length, this.filteredListData); + this.updateSelectionState(); + } + + private createButton(container: HTMLElement, id: string, text: string, options?: IButtonOptions): Button { + const buttonContainer = append(container, $('.filter-menu-button')); + const button = new Button(buttonContainer, options); + button.label = button.title = text; + button.element.id = id; + return button; + } + + private toggleCheckbox(): void { + if (this.list.getFocus().length !== 0) { + const element = this.list.getFocusedElements()[0]; + element.checked = !element.checked; + this.updateSelectionState(); + } + } + + private updateSelectionState() { + const checkedElements = this.filteredListData.filter(element => element.checked); + this.selectAllCheckBox.checked = this.filteredListData.length > 0 && checkedElements.length === this.filteredListData.length; + this.countBadge.setCount(checkedElements.length); + this.visibleCountBadge.setCount(this.filteredListData.length); } private async showFilter(filterButton: HTMLElement): Promise { await this.createFilterMenu(filterButton); - const menuElement = this.$menu[0]; // Get the absolute coordinates of the filter button const offset = jQuery(filterButton).offset(); // Calculate the position of menu item - let menuleft = offset.left - menuElement.offsetWidth + filterButton.offsetWidth; + let menuleft = offset.left - this.menu.offsetWidth + filterButton.offsetWidth; let menutop = offset.top + filterButton.offsetHeight; // Make sure the entire menu is on screen. // If there is not enough vertical space under the filter button, we will move up the menu. // If the left of the menu is off screen (negative value), we will show the menu next to the left edge of window. // We don't really consider the case when there is not enough space to show the entire menu since in that case the application is not usable already. - if (menutop + menuElement.offsetHeight > window.innerHeight) { - menutop = window.innerHeight - menuElement.offsetHeight; + if (menutop + this.menu.offsetHeight > window.innerHeight) { + menutop = window.innerHeight - this.menu.offsetHeight; } menuleft = menuleft > 0 ? menuleft : 0; @@ -196,13 +304,10 @@ export class HeaderFilter { }; }, render: (container: HTMLElement) => { - container.appendChild(menuElement); + container.appendChild(this.menu); return { dispose: () => { - if (this.$menu) { - this.$menu.remove(); - this.$menu = undefined; - } + this.disposeMenu(); } }; }, @@ -212,106 +317,56 @@ export class HeaderFilter { }); } + private disposeMenu(): void { + if (this.menu) { + clearNode(this.menu); + this.menu.remove(); + this.menu = undefined; + dispose(this.elementDisposables); + } + } + private async createFilterMenu(filterButton: HTMLElement) { const target = withNullAsUndefined(filterButton); const $menuButton = jQuery(target); this.columnDef = $menuButton.data('column'); - this.columnDef.filterValues = this.columnDef.filterValues || []; + this.disposeMenu(); - // WorkingFilters is a copy of the filters to enable apply/cancel behaviour - this.workingFilters = this.columnDef.filterValues.slice(0); + // first add it to the document so that we can get the actual size of the menu + // later, it will be added to the correct container + this.menu = append(document.body, $('.slick-header-menu')); - let filterItems: Array; + this.sortAscButton = this.createButtonMenuItem(localize('table.sortAscending', "Sort Ascending"), 'sort-asc', 'ascending'); + this.sortDescButton = this.createButtonMenuItem(localize('table.sortDescending', "Sort Descending"), 'sort-desc', 'descending'); + this.createSearchInputRow(); + await this.createFilterList(); - const dataView = this.grid.getData() as Slick.DataProvider; - - if (instanceOfIDisposableDataProvider(dataView)) { - if (this.workingFilters.length === 0) { - filterItems = await dataView.getColumnValues(this.columnDef); - } else { - filterItems = await dataView.getFilteredColumnValues(this.columnDef); - } - } else { - if (this.workingFilters.length === 0) { - // Filter based all available values - filterItems = this.getFilterValues(this.grid.getData() as Slick.DataProvider, this.columnDef); - } - else { - // Filter based on current dataView subset - filterItems = this.getAllFilterValues((this.grid.getData() as Slick.DataProvider).getItems(), this.columnDef); - } - } - - if (!this.$menu) { - // first add it to the document so that we can get the actual size of the menu - // later, it will be added to the correct container - this.$menu = jQuery('
').appendTo(document.body); - } - - this.$menu.empty(); - - this.sortAscButton = this.createButtonMenuItem(this.$menu[0], this.columnDef, localize('table.sortAscending', "Sort Ascending"), 'sort-asc', 'ascending'); - this.sortDescButton = this.createButtonMenuItem(this.$menu[0], this.columnDef, localize('table.sortDescending', "Sort Descending"), 'sort-desc', 'descending'); - this.searchInputBox = this.createSearchInput(this.$menu, this.columnDef); - - let filterOptions = ''; - - for (let i = 0; i < filterItems.length; i++) { - const filtered = this.workingFilters.some(x => x === filterItems[i]); - if (filterItems[i] && filterItems[i].indexOf('Error:') < 0) { - filterOptions += ''; - } - } - const $filter = jQuery('
') - .append(jQuery(filterOptions)) - .appendTo(this.$menu); - - const $buttonContainer = jQuery('
').appendTo(this.$menu); - const $okButtonDiv = jQuery('
').appendTo($buttonContainer); - const $clearButtonDiv = jQuery('
').appendTo($buttonContainer); - const $cancelButtonDiv = jQuery('
').appendTo($buttonContainer); - this.okButton = new Button($okButtonDiv.get(0)); - this.okButton.label = localize('headerFilter.ok', "OK"); - this.okButton.title = localize('headerFilter.ok', "OK"); - this.okButton.element.id = 'filter-ok-button'; + const buttonGroupContainer = append(this.menu, $('.filter-menu-button-container')); + this.okButton = this.createButton(buttonGroupContainer, 'filter-ok-button', localize('headerFilter.ok', "OK")); this.okButton.onDidClick(() => { - this.columnDef.filterValues = this.workingFilters.splice(0); + this.columnDef.filterValues = this.listData.filter(element => element.checked).map(element => element.value); this.setButtonImage($menuButton, this.columnDef.filterValues.length > 0); this.handleApply(this.columnDef); }); - this.clearButton = new Button($clearButtonDiv.get(0), { secondary: true }); - this.clearButton.label = localize('headerFilter.clear', "Clear"); - this.clearButton.title = localize('headerFilter.clear', "Clear"); - this.clearButton.element.id = 'filter-clear-button'; + this.clearButton = this.createButton(buttonGroupContainer, 'filter-clear-button', localize('headerFilter.clear', "Clear"), { secondary: true }); this.clearButton.onDidClick(() => { this.columnDef.filterValues!.length = 0; this.setButtonImage($menuButton, false); this.handleApply(this.columnDef); }); - this.cancelButton = new Button($cancelButtonDiv.get(0), { secondary: true }); - this.cancelButton.label = localize('headerFilter.cancel', "Cancel"); - this.cancelButton.title = localize('headerFilter.cancel', "Cancel"); - this.cancelButton.element.id = 'filter-cancel-button'; + this.cancelButton = this.createButton(buttonGroupContainer, 'filter-cancel-button', localize('headerFilter.cancel', "Cancel"), { secondary: true }); this.cancelButton.onDidClick(() => { this.hideMenu(); }); - this.applyStyles(); - - jQuery(':checkbox', $filter).bind('click', (e) => { - this.workingFilters = this.changeWorkingFilter(filterItems, this.workingFilters, jQuery(e.target)); - }); - // No need to add this to disposable store, it will be disposed when the menu is closed. - trapKeyboardNavigation(this.$menu[0]); + trapKeyboardNavigation(this.menu); } - public style(styles: ITableFilterStyle): void { + public style(styles: ITableFilterStyles): void { this.filterStyles = styles; this.applyStyles(); } @@ -324,6 +379,9 @@ export class HeaderFilter { this.sortAscButton?.style(this.filterStyles); this.sortDescButton?.style(this.filterStyles); this.searchInputBox?.style(this.filterStyles); + this.countBadge?.style(this.filterStyles); + this.visibleCountBadge?.style(this.filterStyles); + this.list?.style(this.filterStyles); } } @@ -331,39 +389,6 @@ export class HeaderFilter { this.hideMenu(); } - private changeWorkingFilter(filterItems: Array, workingFilters: Array, $checkbox: JQuery) { - const value = $checkbox.val() as number; - const $filter = $checkbox.parent().parent(); - - if ($checkbox.val() as number < 0) { - // Select All - if ($checkbox.prop('checked')) { - jQuery(':checkbox', $filter).prop('checked', true); - workingFilters = filterItems.slice(0); - } else { - jQuery(':checkbox', $filter).prop('checked', false); - workingFilters.length = 0; - } - } else { - const index = workingFilters.indexOf(filterItems[value]); - - if ($checkbox.prop('checked') && index < 0) { - workingFilters.push(filterItems[value]); - const nextRow = filterItems[Number((parseInt(value) + 1).toString())]; // for some reason parseInt is defined as only supporting strings even though it works fine for numbers - if (nextRow && nextRow.indexOf('Error:') >= 0) { - workingFilters.push(nextRow); - } - } - else { - if (index > -1) { - workingFilters.splice(index, 1); - } - } - } - - return workingFilters; - } - private setButtonImage($el: JQuery, filtered: boolean) { const element: HTMLElement = $el.get(0); if (filtered) { @@ -399,34 +424,6 @@ export class HeaderFilter { return Array.from(seen); } - private async getFilterValuesByInput(column: Slick.Column, filter: string): Promise> { - const dataView = this.grid.getData() as Slick.DataProvider, - seen: Set = new Set(); - - let columnValues: any[]; - if (instanceOfIDisposableDataProvider(dataView)) { - columnValues = await dataView.getColumnValues(this.columnDef); - } else { - columnValues = dataView.getItems().map(item => item[column.field]); - } - - columnValues.forEach(value => { - const valueArr = value instanceof Array ? value : [(!value ? '' : value)]; - if (filter.length > 0) { - const lowercaseFilter = filter.toString().toLowerCase(); - valueArr.map(v => v.toLowerCase()).forEach((lowerVal, index) => { - if (lowerVal.indexOf(lowercaseFilter) > -1) { - seen.add(valueArr[index]); - } - }); - } else { - valueArr.forEach(v => seen.add(v)); - } - }); - - return Array.from(seen).sort((v) => { return v; }); - } - private getAllFilterValues(data: Array, column: Slick.Column) { const seen: Set = new Set(); @@ -475,3 +472,111 @@ export class HeaderFilter { } } } + +class TableFilterListElement { + private readonly _onCheckStateChanged = new Emitter(); + private _checked: boolean; + + constructor(val: string, checked: boolean) { + this.value = val; + this._checked = checked; + } + + public value: string; + + public onCheckStateChanged = this._onCheckStateChanged.event; + + public get checked(): boolean { + return this._checked; + } + public set checked(val: boolean) { + if (this._checked !== val) { + this._checked = val; + this._onCheckStateChanged.fire(val); + } + } +} + +const TableFilterTemplateId = 'TableFilterListTemplate'; +class TableFilterListDelegate implements IListVirtualDelegate { + getHeight(element: TableFilterListElement): number { + return 22; + } + + getTemplateId(element: TableFilterListElement): string { + return TableFilterTemplateId; + } +} + +interface TableFilterListItemTemplate { + checkbox: HTMLInputElement; + text: HTMLDivElement; + label: HTMLLabelElement; + element: TableFilterListElement; + elementDisposables: IDisposable[]; + templateDisposables: IDisposable[]; +} + +class TableFilterListRenderer implements IListRenderer { + renderTemplate(container: HTMLElement): TableFilterListItemTemplate { + const data: TableFilterListItemTemplate = Object.create(null); + data.templateDisposables = []; + data.elementDisposables = []; + data.label = append(container, $('label.filter-option')); + data.checkbox = append(data.label, $('input', { + 'type': 'checkbox', + 'tabIndex': -1 + })); + data.text = append(data.label, $('div')); + data.text.style.flex = '1 1 auto'; + data.templateDisposables.push(addDisposableListener(data.checkbox, EventType.CHANGE, (event) => { + data.element.checked = data.checkbox.checked; + })); + return data; + } + + renderElement(element: TableFilterListElement, index: number, templateData: TableFilterListItemTemplate, height: number): void { + templateData.element = element; + templateData.elementDisposables = dispose(templateData.elementDisposables); + templateData.elementDisposables.push(templateData.element.onCheckStateChanged((e) => { + templateData.checkbox.checked = e; + })); + templateData.checkbox.checked = element.checked; + templateData.checkbox.setAttribute('aria-label', element.value); + templateData.text.innerText = element.value; + templateData.label.title = element.value; + } + + disposeElement?(element: TableFilterListElement, index: number, templateData: TableFilterListItemTemplate, height: number): void { + templateData.elementDisposables = dispose(templateData.elementDisposables); + } + + public disposeTemplate(templateData: TableFilterListItemTemplate): void { + templateData.elementDisposables = dispose(templateData.elementDisposables); + templateData.templateDisposables = dispose(templateData.templateDisposables); + } + + public get templateId(): string { + return TableFilterTemplateId; + } +} + +class TableFilterListAccessibilityProvider implements IListAccessibilityProvider { + getAriaLabel(element: TableFilterListElement): string { + return element.value; + } + + getWidgetAriaLabel(): string { + return localize('table.filterOptions', "Filter Options"); + } + + getWidgetRole() { + return 'listbox'; + } + + getRole(element: TableFilterListElement): string { + return 'option'; + } +} + + diff --git a/src/sql/base/browser/ui/table/plugins/rowDetailView.ts b/src/sql/base/browser/ui/table/plugins/rowDetailView.ts index 5f648a6669..e7f12405e7 100644 --- a/src/sql/base/browser/ui/table/plugins/rowDetailView.ts +++ b/src/sql/base/browser/ui/table/plugins/rowDetailView.ts @@ -1,5 +1,6 @@ // Adopted and converted to typescript from https://github.com/6pac/SlickGrid/blob/master/plugins/slick.rowdetailview.js // heavily modified +import { FilterableColumn } from 'sql/base/browser/ui/table/interfaces'; import { escape } from 'sql/base/common/strings'; import { mixin } from 'vs/base/common/objects'; import * as nls from 'vs/nls'; @@ -338,9 +339,10 @@ export class RowDetailView { } } - public getColumnDefinition(): Slick.Column { + public getColumnDefinition(): FilterableColumn { return { id: this._options.columnId, + filterable: false, name: '', toolTip: this._options.toolTip, field: 'sel', diff --git a/src/sql/base/browser/ui/table/tableDataView.ts b/src/sql/base/browser/ui/table/tableDataView.ts index ac9e7bae83..4d97a69d71 100644 --- a/src/sql/base/browser/ui/table/tableDataView.ts +++ b/src/sql/base/browser/ui/table/tableDataView.ts @@ -113,17 +113,9 @@ export class TableDataView implements IDisposableData return this._data.slice(startIndex, startIndex + length); } - public async getFilteredColumnValues(column: Slick.Column): Promise { - return this.getDistinctColumnValues(this._data, column); - } - public async getColumnValues(column: Slick.Column): Promise { - return this.getDistinctColumnValues(this.filterEnabled ? this._allData : this._data, column); - } - - private getDistinctColumnValues(source: T[], column: Slick.Column): string[] { const distinctValues: Set = new Set(); - source.forEach(items => { + this._data.forEach(items => { const value = items[column.field!]; const valueArr = value instanceof Array ? value : [value]; valueArr.forEach(v => distinctValues.add(this._cellValueGetter(v))); diff --git a/src/sql/base/common/dataProvider.ts b/src/sql/base/common/dataProvider.ts index cd1bda3dc4..d7a0a502ec 100644 --- a/src/sql/base/common/dataProvider.ts +++ b/src/sql/base/common/dataProvider.ts @@ -26,12 +26,6 @@ export interface IDisposableDataProvider extends Slick.DataProvider { */ getColumnValues(column: Slick.Column): Promise; - /** - * Gets the unique values of the filtered cells in the given column - * @param column - */ - getFilteredColumnValues(column: Slick.Column): Promise; - /** * Filters the data * @param columns columns to be filtered, the diff --git a/src/sql/platform/theme/common/styler.ts b/src/sql/platform/theme/common/styler.ts index aa7fb394c4..cc31394c52 100644 --- a/src/sql/platform/theme/common/styler.ts +++ b/src/sql/platform/theme/common/styler.ts @@ -8,7 +8,7 @@ import * as colors from './colors'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import * as cr from 'vs/platform/theme/common/colorRegistry'; import * as sqlcr from 'sql/platform/theme/common/colorRegistry'; -import { attachStyler, IColorMapping, IStyleOverrides } from 'vs/platform/theme/common/styler'; +import { attachStyler, defaultListStyles, IColorMapping, IStyleOverrides } from 'vs/platform/theme/common/styler'; import { IDisposable } from 'vs/base/common/lifecycle'; import { IThemable } from 'vs/base/common/styler'; @@ -342,6 +342,10 @@ export function attachTableFilterStyler(widget: IThemable, themeService: IThemeS buttonSecondaryBorder: cr.buttonSecondaryBorder, buttonDisabledBorder: cr.buttonDisabledBorder, buttonDisabledBackground: cr.buttonDisabledBackground, - buttonDisabledForeground: cr.buttonDisabledForeground + buttonDisabledForeground: cr.buttonDisabledForeground, + badgeBackground: cr.badgeBackground, + badgeForeground: cr.badgeForeground, + badgeBorder: cr.contrastBorder, + ...defaultListStyles, }, widget); } diff --git a/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.ts b/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.ts index a9eed1e5d1..39f1ca9578 100644 --- a/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.ts +++ b/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.ts @@ -335,7 +335,6 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom // we need to be able to show distinct array values in filter dialog for columns with array data filterPlugin['getFilterValues'] = this.getFilterValues; filterPlugin['getAllFilterValues'] = this.getAllFilterValues; - filterPlugin['getFilterValuesByInput'] = this.getFilterValuesByInput; dom.clearNode(this._gridEl.nativeElement); dom.clearNode(this.actionBarContainer.nativeElement); @@ -588,52 +587,6 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom return seen.sort((v) => { return v; }); } - private async getFilterValuesByInput(column: Slick.Column, filter: string): Promise> { - const dataView = this['grid'].getData() as Slick.DataProvider, - seen: Array = []; - - for (let i = 0; i < dataView.getLength(); i++) { - const value = dataView.getItem(i)[column.field]; - if (value instanceof Array) { - if (filter.length > 0) { - const itemValue = !value ? [] : value; - const lowercaseFilter = filter.toString().toLowerCase(); - const lowercaseVals = itemValue.map(v => v.toLowerCase()); - for (let valIdx = 0; valIdx < value.length; valIdx++) { - if (!seen.some(x => x === value[valIdx]) && lowercaseVals[valIdx].indexOf(lowercaseFilter) > -1) { - seen.push(value[valIdx]); - } - } - } - else { - for (let item = 0; item < value.length; item++) { - if (!seen.some(x => x === value[item])) { - seen.push(value[item]); - } - } - } - - } else { - if (filter.length > 0) { - const itemValue = !value ? '' : value; - const lowercaseFilter = filter.toString().toLowerCase(); - const lowercaseVal = itemValue.toString().toLowerCase(); - - if (!seen.some(x => x === value) && lowercaseVal.indexOf(lowercaseFilter) > -1) { - seen.push(value); - } - } - else { - if (!seen.some(x => x === value)) { - seen.push(value); - } - } - } - } - - return seen.sort((v) => { return v; }); - } - private _updateStyles(theme: IColorTheme): void { this.actionBarContainer.nativeElement.style.borderTopColor = theme.getColor(themeColors.DASHBOARD_BORDER, true).toString(); let tableStyle: ITableStyles = {