diff --git a/extensions/mssql/config.json b/extensions/mssql/config.json index 2bdc628751..1efef7cdc0 100644 --- a/extensions/mssql/config.json +++ b/extensions/mssql/config.json @@ -1,6 +1,6 @@ { "downloadUrl": "https://github.com/Microsoft/sqltoolsservice/releases/download/{#version#}/microsoft.sqltools.servicelayer-{#fileName#}", - "version": "4.7.0.29", + "version": "4.7.0.31", "downloadFileNames": { "Windows_86": "win-x86-net7.0.zip", "Windows_64": "win-x64-net7.0.zip", diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index 9095714868..6fb4d8f400 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -1818,14 +1818,27 @@ declare module 'azdata' { /** * The list of choices for the filter property if the type is choice */ - choices: string[]; + choices: NodeFilterChoicePropertyValue[]; + } + + export interface NodeFilterChoicePropertyValue { + /** + * The value of the choice + */ + value: string; + /** + * The display name of the choice + * If not specified, the value will be used as the display name + * If specified, the display name will be used in the dropdown + */ + displayName?: string; } export interface NodeFilter { /** * The name of the filter property */ - name: string; + displayName: string; /** * The operator of the filter property */ @@ -1833,7 +1846,7 @@ declare module 'azdata' { /** * The applied values of the filter property */ - value: string | string[] | number | boolean | undefined; + value: string | string[] | number | number[] | boolean | undefined; } export enum NodeFilterPropertyDataType { diff --git a/src/sql/base/browser/ui/table/tableCellEditorFactory.ts b/src/sql/base/browser/ui/table/tableCellEditorFactory.ts index 5fd48cd336..0b0e7d9219 100644 --- a/src/sql/base/browser/ui/table/tableCellEditorFactory.ts +++ b/src/sql/base/browser/ui/table/tableCellEditorFactory.ts @@ -41,7 +41,7 @@ export class TableCellEditorFactory { }; } - public getTextEditorClass(context: any, inputType: 'text' | 'number' = 'text'): any { + public getTextEditorClass(context: any, inputType: 'text' | 'number' | 'date' = 'text', presetValue?: string): any { const self = this; class TextEditor extends Disposable { private _originalValue: string; @@ -76,6 +76,8 @@ export class TableCellEditorFactory { this._register(self._options.onStyleChange(() => { self._options.editorStyler(this._input); })); + + this._input.value = presetValue ?? ''; } private async commitEdit(): Promise { @@ -96,11 +98,21 @@ export class TableCellEditorFactory { public loadValue(item: Slick.SlickData): void { this._originalValue = self._options.valueGetter(item, this._args.column) ?? ''; - this._input.value = this._originalValue; + if (inputType === 'date') { + this._input.inputElement.valueAsDate = new Date(this._originalValue); + } else { + this._input.value = this._originalValue; + } } public applyValue(item: Slick.SlickData, state: string): void { const activeCell = this._args.grid.getActiveCell(); + if (inputType === 'date') { + // Usually, the date picker will return the date in the local time zone and change the date to the previous day. + // We need to convert the date to UTC time zone to avoid this behavior so that the date will be the same as the + // date picked in the date picker. + state = new Date(state).toLocaleDateString(window.navigator.language, { timeZone: 'UTC' }); + } self._options.valueSetter(context, activeCell.row, item, this._args.column, state); } diff --git a/src/sql/workbench/contrib/objectExplorer/browser/serverTreeView.ts b/src/sql/workbench/contrib/objectExplorer/browser/serverTreeView.ts index 962d51e812..ecf8385fe2 100644 --- a/src/sql/workbench/contrib/objectExplorer/browser/serverTreeView.ts +++ b/src/sql/workbench/contrib/objectExplorer/browser/serverTreeView.ts @@ -47,6 +47,7 @@ import { ActionRunner } from 'vs/base/common/actions'; import { IHostService } from 'vs/workbench/services/host/browser/host'; import { USE_ASYNC_SERVER_TREE_CONFIG } from 'sql/workbench/contrib/objectExplorer/common/serverGroup.contribution'; import { INotificationService } from 'vs/platform/notification/common/notification'; +import { FilterDialog } from 'sql/workbench/services/objectExplorer/browser/filterDialog/filterDialog'; export const CONTEXT_SERVER_TREE_VIEW = new RawContextKey('serverTreeView.view', ServerTreeViewView.all); export const CONTEXT_SERVER_TREE_HAS_CONNECTIONS = new RawContextKey('serverTreeView.hasConnections', false); @@ -588,6 +589,43 @@ export class ServerTreeView extends Disposable implements IServerTreeView { } } + public async filterElementChildren(node: TreeNode): Promise { + await FilterDialog.getFiltersForProperties( + node.filterProperties, + localize('objectExplorer.filterDialogTitle', "(Preview) Filter Settings: {0}", node.getConnectionProfile().title), + localize('objectExplorer.nodePath', "Node Path: {0}", node.nodePath), + node.filters, + async (filters) => { + let errorListener; + try { + let expansionError = undefined; + errorListener = this._objectExplorerService.onUpdateObjectExplorerNodes(e => { + if (e.errorMessage) { + expansionError = e.errorMessage; + } + errorListener.dispose(); + }); + node.forceRefresh = true; + node.filters = filters || []; + if (this._tree instanceof AsyncServerTree) { + await this._tree.rerender(node); + } + await this.refreshElement(node); + await this._tree.expand(node); + if (expansionError) { + throw new Error(expansionError); + } + } finally { + if (errorListener) { + errorListener.dispose(); + } + } + return; + }, + this._instantiationService + ); + } + /** * Filter connections based on view (recent/active) */ diff --git a/src/sql/workbench/services/objectExplorer/browser/connectionTreeAction.ts b/src/sql/workbench/services/objectExplorer/browser/connectionTreeAction.ts index b957017840..64dff96523 100644 --- a/src/sql/workbench/services/objectExplorer/browser/connectionTreeAction.ts +++ b/src/sql/workbench/services/objectExplorer/browser/connectionTreeAction.ts @@ -310,3 +310,57 @@ export class DeleteConnectionAction extends Action { } } } + +export class FilterChildrenAction extends Action { + public static ID = 'objectExplorer.filterChildren'; + public static LABEL = localize('objectExplorer.filterChildren', "Filter (Preview)"); + + constructor( + id: string, + label: string, + private _node: TreeNode, + @IObjectExplorerService private _objectExplorerService: IObjectExplorerService) { + super(id, label); + } + + public override async run(): Promise { + await this._objectExplorerService.getServerTreeView().filterElementChildren(this._node); + } +} + +export class RemoveFilterAction extends Action { + public static ID = 'objectExplorer.removeFilter'; + public static LABEL = localize('objectExplorer.removeFilter', "Remove Filter"); + + constructor( + id: string, + label: string, + private _node: TreeNode, + private _tree: AsyncServerTree | ITree, + private _profile: ConnectionProfile | undefined, + @IObjectExplorerService private _objectExplorerService: IObjectExplorerService + ) { + super(id, label); + } + + public override async run(): Promise { + let node = this._node; + let nodeToRefresh: ServerTreeElement = this._node; + if (this._profile) { + node = this._objectExplorerService.getObjectExplorerNode(this._profile); + nodeToRefresh = this._profile; + } + node.filters = []; + if (nodeToRefresh instanceof TreeNode) { + nodeToRefresh.forceRefresh = true; + } + if (this._tree instanceof AsyncServerTree) { + await this._tree.rerender(nodeToRefresh); + await this._tree.updateChildren(nodeToRefresh); + await this._tree.expand(nodeToRefresh); + } else { + await this._tree.refresh(nodeToRefresh); + await this._tree.expand(nodeToRefresh); + } + } +} diff --git a/src/sql/workbench/services/objectExplorer/browser/filterDialog/filterDialog.ts b/src/sql/workbench/services/objectExplorer/browser/filterDialog/filterDialog.ts new file mode 100644 index 0000000000..8d194d2052 --- /dev/null +++ b/src/sql/workbench/services/objectExplorer/browser/filterDialog/filterDialog.ts @@ -0,0 +1,726 @@ +/*--------------------------------------------------------------------------------------------- + * 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/filterDialog'; +import { Button } from 'sql/base/browser/ui/button/button'; +import { IClipboardService } from 'sql/platform/clipboard/common/clipboardService'; +import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry'; +import { Modal } from 'sql/workbench/browser/modal/modal' +import { ITextResourcePropertiesService } from 'vs/editor/common/services/textResourceConfiguration'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { localize } from 'vs/nls'; +import { attachModalDialogStyler } from 'sql/workbench/common/styler'; +import { attachButtonStyler, attachInputBoxStyler, attachSelectBoxStyler } from 'vs/platform/theme/common/styler'; +import * as DOM from 'vs/base/browser/dom'; +import * as azdata from 'azdata'; +import { InputBox } from 'sql/base/browser/ui/inputBox/inputBox'; +import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { NodeFilterPropertyDataType, NodeFilterOperator } from 'sql/workbench/api/common/sqlExtHostTypes'; +import { SelectBox } from 'sql/base/browser/ui/selectBox/selectBox'; +import { Table } from 'sql/base/browser/ui/table/table'; +import { TableCellEditorFactory } from 'sql/base/browser/ui/table/tableCellEditorFactory'; +import { Emitter } from 'vs/base/common/event'; +import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; +import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; +import { TableDataView } from 'sql/base/browser/ui/table/tableDataView'; +import { TableHeaderRowHeight, TableRowHeight } from 'sql/workbench/browser/designer/designerTableUtil'; +import { textFormatter } from 'sql/base/browser/ui/table/formatters'; +import { Dropdown } from 'sql/base/browser/ui/editableDropdown/browser/dropdown'; +import { Checkbox } from 'sql/base/browser/ui/checkbox/checkbox'; +import { TabbedPanel } from 'sql/base/browser/ui/panel/panel'; +import { attachTableStyler } from 'sql/platform/theme/common/styler'; +import { ButtonColumn } from 'sql/base/browser/ui/table/plugins/buttonColumn.plugin'; +import Severity from 'vs/base/common/severity'; +import { status } from 'vs/base/browser/ui/aria/aria'; +import { IErrorMessageService } from 'sql/platform/errorMessage/common/errorMessageService'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; + + +// strings for filter dialog +const OkButtonText = localize('objectExplorer.okButtonText', "OK"); +const CancelButtonText = localize('objectExplorer.cancelButtonText', "Cancel"); +const ClearAllButtonText = localize('objectExplorer.clearAllButtonText', "Clear All"); +const TitleIconClass: string = 'icon filterLabel'; + +// strings for filter operator select box +const EQUALS_SELECT_BOX = localize('objectExplorer.equalsSelectBox', "Equals"); +const NOT_EQUALS_SELECT_BOX = localize('objectExplorer.notEqualsSelectBox', "Not Equals"); +const LESS_THAN_SELECT_BOX = localize('objectExplorer.lessThanSelectBox', "Less Than"); +const LESS_THAN_OR_EQUALS_SELECT_BOX = localize('objectExplorer.lessThanOrEqualsSelectBox', "Less Than Or Equals"); +const GREATER_THAN_SELECT_BOX = localize('objectExplorer.greaterThanSelectBox', "Greater Than"); +const GREATER_THAN_OR_EQUALS_SELECT_BOX = localize('objectExplorer.greaterThanOrEqualsSelectBox', "Greater Than Or Equals"); +const BETWEEN_SELECT_BOX = localize('objectExplorer.betweenSelectBox', "Between"); +const NOT_BETWEEN_SELECT_BOX = localize('objectExplorer.notBetweenSelectBox', "Not Between"); +const CONTAINS_SELECT_BOX = localize('objectExplorer.containsSelectBox', "Contains"); +const NOT_CONTAINS_SELECT_BOX = localize('objectExplorer.notContainsSelectBox', "Not Contains"); +const AND_SELECT_BOX = localize('objectExplorer.andSelectBox', "And"); +const IS_NULL_SELECT_BOX = localize('objectExplorer.isNullSelectBox', "Is Null"); +const IS_NOT_NULL_SELECT_BOX = localize('objectExplorer.isNotNullSelectBox', "Is Not Null"); + +// strings for filter table column headers +const PROPERTY_NAME_COLUMN_HEADER = localize('objectExplorer.propertyNameColumnHeader', "Property"); +const OPERATOR_COLUMN_HEADER = localize('objectExplorer.operatorColumnHeader', "Operator"); +const VALUE_COLUMN_HEADER = localize('objectExplorer.valueColumnHeader', "Value"); +const CLEAR_COLUMN_HEADER = localize('objectExplorer.clearColumnHeader', "Clear"); + + +// strings for value select box for boolean type filters +const TRUE_SELECT_BOX = localize('objectExplorer.trueSelectBox', "True"); +const FALSE_SELECT_BOX = localize('objectExplorer.falseSelectBox', "False"); + +function nodePathDisplayString(nodepath: string): string { return localize('objectExplorer.nodePath', "Node Path: {0}", nodepath) } + +const PROPERTY_COLUMN_ID = 'property'; +const OPERATOR_COLUMN_ID = 'operator'; +const VALUE_COLUMN_ID = 'value'; +const CLEAR_COLUMN_ID = 'clear'; + +export class FilterDialog extends Modal { + + private _okButton?: Button; + private _cancelButton?: Button; + private _clearAllButton?: Button; + + private filterTable: Table; + private _tableCellEditorFactory: TableCellEditorFactory; + private _onStyleChangeEventEmitter = new Emitter(); + private _description: HTMLElement; + private _onFilterApplied = new Emitter(); + public readonly onFilterApplied = this._onFilterApplied.event; + private _onCloseEvent = new Emitter(); + public readonly onDialogClose = this._onCloseEvent.event; + + constructor( + private _properties: azdata.NodeFilterProperty[], + private _filterDialogTitle: string, + private _filterDialogSubtitle: string, + private _appliedFilters: azdata.NodeFilter[], + private applyFilterAction: (filters: azdata.NodeFilter[]) => Promise | undefined, + @IThemeService themeService: IThemeService, + @IAdsTelemetryService telemetryService: IAdsTelemetryService, + @ILayoutService layoutService: ILayoutService, + @IClipboardService clipboardService: IClipboardService, + @ILogService logService: ILogService, + @ITextResourcePropertiesService textResourcePropertiesService: ITextResourcePropertiesService, + @IContextKeyService contextKeyService: IContextKeyService, + @IContextViewService private readonly _contextViewProvider: IContextViewService, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, + @IQuickInputService private readonly _quickInputService: IQuickInputService, + @IErrorMessageService private _errorMessageService: IErrorMessageService, + ) { + super( + 'ObjectExplorerServiceDialog', + 'Object Explorer Service Dialog', + telemetryService, + layoutService, + clipboardService, + themeService, + logService, + textResourcePropertiesService, + contextKeyService, + { + dialogStyle: 'normal', + hasTitleIcon: true, + hasSpinner: true + } + ); + } + + public open(): void { + this.render(); + this.show(); + this._okButton.focus(); + } + + public override render() { + super.render(); + this.title = this._filterDialogTitle; + this.titleIconClassName = TitleIconClass; + this._register(attachModalDialogStyler(this, this._themeService)); + this._okButton = this.addFooterButton(OkButtonText, async () => { await this.onApply() }); + this._cancelButton = this.addFooterButton(CancelButtonText, () => { this.onClose() }); + this._clearAllButton = this.addFooterButton(ClearAllButtonText, () => { this.onClearAll() }, 'left', true); + this._register(attachButtonStyler(this._okButton, this._themeService)); + this._register(attachButtonStyler(this._cancelButton, this._themeService)); + this._register(attachButtonStyler(this._clearAllButton, this._themeService)); + } + + protected renderBody(container: HTMLElement): void { + const body = DOM.append(container, DOM.$('.filter-dialog-body')); + const subtitle = DOM.append(body, DOM.$('.filter-dialog-node-path')); + subtitle.innerText = nodePathDisplayString(this._filterDialogSubtitle); + const clauseTableContainer = DOM.append(body, DOM.$('.filter-table-container')); + const filter = DOM.append(clauseTableContainer, DOM.$('.filter-table')); + this._tableCellEditorFactory = new TableCellEditorFactory( + { + valueGetter: (item, column): string => { + + // if the operator is And and the operator is date, we need to get the date from the previous + // row to make it more user friendly for the user to enter the next value. + if (column.field === VALUE_COLUMN_ID && item[OPERATOR_COLUMN_ID].value === AND_SELECT_BOX) { + const index = item.filterPropertyIndex; + const tableData = this.filterTable.getData().getItems(); + if (this._properties[index].type === NodeFilterPropertyDataType.Date) { + let value1 = ''; + for (let i = 0; i < tableData.length; i++) { + if (tableData[i].filterPropertyIndex === index) { + value1 = tableData[i].value.value; + break; + } + } + const value2 = item[column.field].value; + return value2 === '' ? value1 : value2; + } + } + return item[column.field].value; + }, + valueSetter: (context: any, row: number, item: any, column: Slick.Column, value: string): void => { + item[column.field].value = value; + if (column.field === 'operator') { + const index = item.filterPropertyIndex; + const nodeOperator = this._properties[index].type; + if (nodeOperator === NodeFilterPropertyDataType.Date || nodeOperator === NodeFilterPropertyDataType.Number) { + if (value === BETWEEN_SELECT_BOX || value === NOT_BETWEEN_SELECT_BOX) { + + const tableData = this.filterTable.getData().getItems(); + if (tableData.length > row + 1) { + if (tableData[row + 1].operator.value === AND_SELECT_BOX) { + return; + } + } + const newRow: Slick.SlickData = { + property: { + value: '' + }, + operator: { + value: AND_SELECT_BOX, + values: [AND_SELECT_BOX] + }, + value: { + value: '', + values: [] + }, + filterPropertyIndex: tableData[row].filterPropertyIndex + }; + const activeElement = this.filterTable.activeCell; + tableData.splice(row + 1, 0, newRow); + dataProvider.clear(); + dataProvider.push(tableData); + this.filterTable.rerenderGrid(); + this.filterTable.layout(new DOM.Dimension(600, (dataProvider.getItems().length + 2) * TableRowHeight)); + this.filterTable.setActiveCell(activeElement.row, activeElement.cell); + } else { + const tableData = this.filterTable.getData().getItems(); + if (tableData.length > row + 1) { + if (tableData[row + 1].operator.value === AND_SELECT_BOX) { + const activeElement = this.filterTable.activeCell; + tableData.splice(row + 1, 1); + dataProvider.clear(); + dataProvider.push(tableData); + this.filterTable.rerenderGrid(); + this.filterTable.layout(new DOM.Dimension(600, (dataProvider.getItems().length + 2) * TableRowHeight)); + this.filterTable.setActiveCell(activeElement.row, activeElement.cell); + } + } + } + } + } + }, + optionsGetter: (item, column): string[] => { + return item[column.field].values; + }, + editorStyler: (component) => { + this.styleComponent(component); + }, + onStyleChange: this._onStyleChangeEventEmitter.event + }, this._contextViewProvider + ); + const columns: Slick.Column[] = [ + { + id: PROPERTY_COLUMN_ID, + name: PROPERTY_NAME_COLUMN_HEADER, + field: PROPERTY_COLUMN_ID, + formatter: textFormatter, + width: 180, + }, + { + id: OPERATOR_COLUMN_ID, + name: OPERATOR_COLUMN_HEADER, + editor: this._tableCellEditorFactory.getDropdownEditorClass(this, [], false), + field: OPERATOR_COLUMN_ID, + formatter: textFormatter, + width: 180 + }, + { + id: VALUE_COLUMN_ID, + name: VALUE_COLUMN_HEADER, + width: 180, + formatter: textFormatter, + field: VALUE_COLUMN_ID + } + ]; + + const clearValueColumn = new ButtonColumn({ + id: CLEAR_COLUMN_ID, + iconCssClass: 'icon erase', + name: CLEAR_COLUMN_HEADER, + title: CLEAR_COLUMN_HEADER, + width: 60, + resizable: true, + isFontIcon: true + }); + this._register(clearValueColumn.onClick(e => { + const row = e.row; + const data = this.filterTable.getData().getItems(); + data[row][VALUE_COLUMN_ID].value = ''; + dataProvider.clear(); + dataProvider.push(data); + this.filterTable.rerenderGrid(); + })); + columns.push(clearValueColumn.definition); + + + const tableData: Slick.SlickData[] = []; + if (!this._appliedFilters) { + this._appliedFilters = []; + } + this._properties.forEach((f, i) => { + const appliedFilter = this._appliedFilters.find(filter => filter.displayName === f.displayName); + const filterOperators = this.getOperatorsForType(f.type); + const row: Slick.SlickData = { + property: { + value: f.displayName + }, + operator: { + value: appliedFilter ? this.getFilterOperatorString(appliedFilter.operator) : filterOperators[0], + values: filterOperators + }, + value: { + value: appliedFilter ? this.getStringValueForFilter(f, appliedFilter.value) : '', + values: this.getChoiceValuesForFilterProperties(f) + }, + filterPropertyIndex: i + }; + tableData.push(row); + + if (appliedFilter?.operator === NodeFilterOperator.Between || appliedFilter?.operator === NodeFilterOperator.NotBetween) { + row.value.value = this.getStringValueForFilter(f, appliedFilter.value[0]); + const andRow: Slick.SlickData = { + property: { + value: '' + }, + operator: { + value: AND_SELECT_BOX, + values: [AND_SELECT_BOX] + }, + value: { + value: this.getStringValueForFilter(f, appliedFilter.value[1]), + values: [] + }, + datatype: f.type, + filterPropertyIndex: i + }; + tableData.push(andRow); + } + }); + + const dataProvider = new TableDataView(); + dataProvider.push(tableData); + + + // Sets up the editor for the value column + (dataProvider).getItemMetadata = (row: number) => { + const rowData = dataProvider.getItem(row); + + const filterProperty = this._properties[rowData.filterPropertyIndex]; + let editor; + if (rowData.operator.value === AND_SELECT_BOX) { + if (filterProperty.type === NodeFilterPropertyDataType.Number) { + editor = this._tableCellEditorFactory.getTextEditorClass(this, 'number'); + } else if (filterProperty.type === NodeFilterPropertyDataType.Date) { + editor = this._tableCellEditorFactory.getTextEditorClass(this, 'date'); + } + } else { + + if (filterProperty.type === NodeFilterPropertyDataType.String) { + editor = this._tableCellEditorFactory.getTextEditorClass(this, 'text'); + } else if (filterProperty.type === NodeFilterPropertyDataType.Date) { + editor = this._tableCellEditorFactory.getTextEditorClass(this, 'date'); + } else if (filterProperty.type === NodeFilterPropertyDataType.Boolean) { + editor = this._tableCellEditorFactory.getDropdownEditorClass(this, [TRUE_SELECT_BOX, FALSE_SELECT_BOX], false); + } else if (filterProperty.type === NodeFilterPropertyDataType.Number) { + editor = this._tableCellEditorFactory.getTextEditorClass(this, 'number'); + } else if (filterProperty.type === NodeFilterPropertyDataType.Choice) { + editor = this._tableCellEditorFactory.getDropdownEditorClass(this, this.getDropdownOptionsForChoiceProperty(filterProperty), false); + } + + } + return { + columns: { + value: { + editor: editor + } + } + }; + } + + this.filterTable = new Table(filter, this._accessibilityService, this._quickInputService, { + dataProvider: dataProvider!, + columns: columns, + }, { + editable: true, + autoEdit: true, + dataItemColumnValueExtractor: (data: any, column: Slick.Column): string => { + if (column.field) { + return data[column.field]?.value; + } else { + return undefined; + } + }, + rowHeight: TableRowHeight, + headerRowHeight: TableHeaderRowHeight, + editorLock: new Slick.EditorLock(), + autoHeight: true, + }); + + this.filterTable.grid.onActiveCellChanged.subscribe((e, any) => { + if (this.filterTable.grid.getActiveCell()) { + const row = this.filterTable.grid.getActiveCell().row; + const data = this.filterTable.getData().getItems()[row]; + let index = data.filterPropertyIndex; + const filterPropertyDescription = this._properties[index].description; + this._description.innerText = filterPropertyDescription; + // Announcing the filter property description for screen reader users + status(filterPropertyDescription); + } + }); + + this.filterTable.registerPlugin(clearValueColumn); + this.filterTable.layout(new DOM.Dimension(600, (tableData.length + 2) * TableRowHeight)); + this._register(attachTableStyler(this.filterTable, this._themeService)); + + this._description = DOM.append(body, DOM.$('.filter-dialog-description')); + this._description.innerHTML = this._properties[0].description; + } + + protected layout(height?: number): void { + // noop + } + + protected override onClose() { + this.hide('close'); + this._onCloseEvent.fire(); + } + + protected onClearAll() { + const tableAllData = this.filterTable.getData().getItems(); + tableAllData.forEach((row) => { + row.value.value = ''; + + }); + this.filterTable.rerenderGrid(); + } + + // This method is called when the ok button is pressed + private async onApply(): Promise { + const tableData = this.filterTable.getData().getItems(); + + this._appliedFilters = []; + + for (let i = 0; i < tableData.length; i++) { + const row = tableData[i]; + let filterProperty = this._properties[row.filterPropertyIndex] + let filter: azdata.NodeFilter = { + displayName: row.property.value, + operator: this.getFilterOperatorEnum(row.operator.value), + value: this.getFilterValue(filterProperty.type, row.value.value, filterProperty), + }; + + const isMultipleValueFilter = filter.operator === NodeFilterOperator.Between || filter.operator === NodeFilterOperator.NotBetween; + if (isMultipleValueFilter) { + i++; + const row2 = tableData[i]; + var value1 = this.getFilterValue(filterProperty.type, row.value.value, filterProperty); + var value2 = this.getFilterValue(filterProperty.type, row2.value.value, filterProperty); + filter.value = [value1, value2]; + if (filterProperty.type === NodeFilterPropertyDataType.Date) { + if (filter.value[0] === '' && filter.value[1] !== '') { + // start date not specified. + this._errorMessageService.showDialog(Severity.Error, '', localize('filterDialog.errorStartDate', "Start date is not specified.")); + return; + } else if (filter.value[0] !== '' && filter.value[1] === '') { + // end date not specified. + this._errorMessageService.showDialog(Severity.Error, '', localize('filterDialog.errorEndDate', "End date is not specified.")); + return; + } else if (new Date(filter.value[0]) > new Date(filter.value[1])) { + // start date is greater than end date. + this._errorMessageService.showDialog(Severity.Error, '', localize('filterDialog.errorDateRange', "Start date cannot be greater than end date.")); + return; + } + } else if (filterProperty.type === NodeFilterPropertyDataType.Number) { + if (filter.value[0] === '' && filter.value[1] !== '') { + // start number not specified. + this._errorMessageService.showDialog(Severity.Error, '', localize('filterDialog.errorStartNumber', "Start number is not specified.")); + return; + } else if (filter.value[0] !== '' && filter.value[1] === '') { + // end number not specified. + this._errorMessageService.showDialog(Severity.Error, '', localize('filterDialog.errorEndNumber', "End number is not specified.")); + return; + } else if (Number(filter.value[0]) > Number(filter.value[1])) { + // start number is greater than end number. + this._errorMessageService.showDialog(Severity.Error, '', localize('filterDialog.errorNumberRange', "Start number cannot be greater than end number.")); + return; + } + + } + if (value1 !== '' && value2 !== '') { + this._appliedFilters.push(filter); + } + } else { + if (filter.value !== '') { + this._appliedFilters.push(filter); + } + } + } + this.spinner = true; + + try { + if (this.applyFilterAction) { + await this.applyFilterAction(this._appliedFilters); + } + this._onFilterApplied.fire(this._appliedFilters); + this.hide('ok'); + } + catch (e) { + this.spinner = false; + throw e; + } + } + + // This method is called by modal when the enter button is pressed + // We override it to do nothing so that the enter button doesn't close the dialog + protected override async onAccept() { + // noop + } + + private getFilterValue( + filterType: NodeFilterPropertyDataType, + value: string, + filterProperty: azdata.NodeFilterProperty + ): string | number | boolean { + if (value === '') { + return ''; + } + switch (filterType) { + case NodeFilterPropertyDataType.Boolean: + if (value === TRUE_SELECT_BOX) { + return true; + } else if (value === FALSE_SELECT_BOX) { + return false; + } + case NodeFilterPropertyDataType.Number: + return Number(value); + case NodeFilterPropertyDataType.Choice: + const choice = ((filterProperty).choices.find(c => c.displayName === value)); + if (choice) { + return choice.value; + } else { + return value; + } + case NodeFilterPropertyDataType.Date: + case NodeFilterPropertyDataType.String: + return value; + } + } + + private getStringValueForFilter(filter: azdata.NodeFilterProperty, value: string | number | boolean | number[] | string[]): string { + switch (filter.type) { + case NodeFilterPropertyDataType.Boolean: + if (value === true) { + return TRUE_SELECT_BOX; + } else if (value === false) { + return FALSE_SELECT_BOX; + } + break; + case NodeFilterPropertyDataType.Number: + return value.toString(); + case NodeFilterPropertyDataType.Choice: + return (filter).choices.find(c => c.value === value).displayName; + case NodeFilterPropertyDataType.Date: + case NodeFilterPropertyDataType.String: + return value as string; + } + return ''; + } + + private getOperatorsForType(type: NodeFilterPropertyDataType): string[] { + switch (type) { + case NodeFilterPropertyDataType.String: + return [ + CONTAINS_SELECT_BOX, + NOT_CONTAINS_SELECT_BOX, + EQUALS_SELECT_BOX, + NOT_EQUALS_SELECT_BOX + ]; + case NodeFilterPropertyDataType.Number: + return [ + EQUALS_SELECT_BOX, + NOT_EQUALS_SELECT_BOX, + GREATER_THAN_SELECT_BOX, + GREATER_THAN_OR_EQUALS_SELECT_BOX, + LESS_THAN_SELECT_BOX, + LESS_THAN_OR_EQUALS_SELECT_BOX, + BETWEEN_SELECT_BOX, + NOT_BETWEEN_SELECT_BOX + ]; + case NodeFilterPropertyDataType.Boolean: + return [ + EQUALS_SELECT_BOX, + NOT_EQUALS_SELECT_BOX + ]; + case NodeFilterPropertyDataType.Choice: + return [ + EQUALS_SELECT_BOX, + NOT_EQUALS_SELECT_BOX + ]; + case NodeFilterPropertyDataType.Date: + return [ + EQUALS_SELECT_BOX, + NOT_EQUALS_SELECT_BOX, + GREATER_THAN_SELECT_BOX, + GREATER_THAN_OR_EQUALS_SELECT_BOX, + LESS_THAN_SELECT_BOX, + LESS_THAN_OR_EQUALS_SELECT_BOX, + BETWEEN_SELECT_BOX, + NOT_BETWEEN_SELECT_BOX + ]; + } + } + + private getFilterOperatorString(operator: NodeFilterOperator): string { + switch (operator) { + case NodeFilterOperator.Contains: + return CONTAINS_SELECT_BOX; + case NodeFilterOperator.NotContains: + return NOT_CONTAINS_SELECT_BOX; + case NodeFilterOperator.Equals: + return EQUALS_SELECT_BOX; + case NodeFilterOperator.NotEquals: + return NOT_EQUALS_SELECT_BOX; + case NodeFilterOperator.GreaterThan: + return GREATER_THAN_SELECT_BOX; + case NodeFilterOperator.GreaterThanOrEquals: + return GREATER_THAN_OR_EQUALS_SELECT_BOX; + case NodeFilterOperator.LessThan: + return LESS_THAN_SELECT_BOX; + case NodeFilterOperator.LessThanOrEquals: + return LESS_THAN_OR_EQUALS_SELECT_BOX; + case NodeFilterOperator.Between: + return BETWEEN_SELECT_BOX; + case NodeFilterOperator.NotBetween: + return NOT_BETWEEN_SELECT_BOX; + case NodeFilterOperator.IsNull: + return IS_NULL_SELECT_BOX; + case NodeFilterOperator.IsNotNull: + return IS_NOT_NULL_SELECT_BOX; + default: + return ''; + } + } + + private getFilterOperatorEnum(operator: string): NodeFilterOperator { + switch (operator) { + case CONTAINS_SELECT_BOX: + return NodeFilterOperator.Contains; + case NOT_CONTAINS_SELECT_BOX: + return NodeFilterOperator.NotContains; + case EQUALS_SELECT_BOX: + return NodeFilterOperator.Equals; + case NOT_EQUALS_SELECT_BOX: + return NodeFilterOperator.NotEquals; + case GREATER_THAN_SELECT_BOX: + return NodeFilterOperator.GreaterThan; + case GREATER_THAN_OR_EQUALS_SELECT_BOX: + return NodeFilterOperator.GreaterThanOrEquals; + case LESS_THAN_SELECT_BOX: + return NodeFilterOperator.LessThan; + case LESS_THAN_OR_EQUALS_SELECT_BOX: + return NodeFilterOperator.LessThanOrEquals; + case BETWEEN_SELECT_BOX: + return NodeFilterOperator.Between; + case NOT_BETWEEN_SELECT_BOX: + return NodeFilterOperator.NotBetween; + case TRUE_SELECT_BOX: + return NodeFilterOperator.Equals; + case FALSE_SELECT_BOX: + return NodeFilterOperator.NotEquals; + default: + return undefined; + } + } + + private getChoiceValuesForFilterProperties(f: azdata.NodeFilterProperty): string[] { + switch (f.type) { + case NodeFilterPropertyDataType.Boolean: + return ['', TRUE_SELECT_BOX, FALSE_SELECT_BOX]; + case NodeFilterPropertyDataType.Choice: + return ['', ...this.getDropdownOptionsForChoiceProperty(f)]; + default: + return []; + } + } + + private getDropdownOptionsForChoiceProperty(f: azdata.NodeFilterChoiceProperty): string[] { + return f.choices.map(choice => { + return choice.displayName ?? choice.value; + }); + } + + private styleComponent(component: TabbedPanel | InputBox | Checkbox | Table | SelectBox | Button | Dropdown): void { + if (component instanceof InputBox) { + this._register(attachInputBoxStyler(component, this._themeService)); + } else if (component instanceof SelectBox) { + this._register(attachSelectBoxStyler(component, this._themeService)); + } else if (component instanceof Table) { + this._register(attachTableStyler(component, this._themeService)); + } + } + + + /** + * This method is used to let user apply filters on the given filters properties. + * @param properties Properties on which user can apply filters. + * @param filterDialogTitle Title of the filter dialog. + * @param filterDialogSubtile Subtitle of the filter dialog. + * @param appliedFilters Filters that are already applied so that we can prepopulate the filter dialog values. + * @param applyFilterAction Action to be performed when user clicks on apply button. We should pass this so that we can handle the spinner and error message within the dialog. + * @param instantiationService Instantiation service to create the filter dialog. + * @returns + */ + public static async getFiltersForProperties( + properties: azdata.NodeFilterProperty[], + filterDialogTitle: string, + filterDialogSubtile: string, + appliedFilters: azdata.NodeFilter[] | undefined, + applyFilterAction: (filters: azdata.NodeFilter[]) => Promise | undefined, + instantiationService: IInstantiationService, + ): Promise { + + const dialog = instantiationService.createInstance(FilterDialog, properties, filterDialogTitle, filterDialogSubtile, appliedFilters, applyFilterAction); + dialog.open(); + return new Promise((resolve, reject) => { + dialog.onFilterApplied(filters => { + resolve(filters); + }); + dialog.onDialogClose(() => { + reject(); + }); + }); + } +} + diff --git a/src/sql/workbench/services/objectExplorer/browser/media/eraseDark.svg b/src/sql/workbench/services/objectExplorer/browser/media/eraseDark.svg new file mode 100644 index 0000000000..ba39e79fdc --- /dev/null +++ b/src/sql/workbench/services/objectExplorer/browser/media/eraseDark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/sql/workbench/services/objectExplorer/browser/media/eraseLight.svg b/src/sql/workbench/services/objectExplorer/browser/media/eraseLight.svg new file mode 100644 index 0000000000..227a413fe5 --- /dev/null +++ b/src/sql/workbench/services/objectExplorer/browser/media/eraseLight.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/sql/workbench/services/objectExplorer/browser/media/filterDialog.css b/src/sql/workbench/services/objectExplorer/browser/media/filterDialog.css new file mode 100644 index 0000000000..96c3ab0397 --- /dev/null +++ b/src/sql/workbench/services/objectExplorer/browser/media/filterDialog.css @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.filter-dialog-body { + height: 400px; + padding: 10px; + display: flex; + flex-direction: column; +} + +.filter-table-container .filter-table { + width: 100%; + margin-bottom: 10px; +} + +.filter-dialog-body .filter-dialog-node-path { + padding: 10px 0px; + font-size: 13px; + font-weight: 600; +} + +.filter-table-container { + max-height: 270px; + overflow-y: scroll; + flex: 1; +} + +.filter-table-container table.filter-table-element { + width: 97.2%; + border-collapse: collapse; + table-layout: fixed; +} + +.filter-table-container table.filter-table-element td { + width: calc((100% - 36px) / (4 - 1)); + /* equal width for all but last column */ +} + +.filter-table-container table.filter-table-element td.delete-cell { + width: 36px; +} + +.filter-action-button-container div { + padding: 5px; +} + +.filter-table-element .monaco-inputbox { + height: 25px; + font-size: 11px; +} + + +.filter-dialog-body .filter-dialog-description { + height: 50px; + border: 1px solid; + padding: 5px; +} + +.vs .icon.erase, +.hc-light .icon.erase { + background-image: url("eraseLight.svg"); + width: 16px; + height: 100%; + background-repeat: no-repeat; + background-position: center; +} + +.vs-dark .icon.erase, +.hc-black .icon.erase { + background-image: url("eraseDark.svg"); + width: 16px; + height: 100%; + background-repeat: no-repeat; + background-position: center; +} diff --git a/src/sql/workbench/services/objectExplorer/browser/objectExplorerService.ts b/src/sql/workbench/services/objectExplorer/browser/objectExplorerService.ts index b693b1a9aa..1c3008c181 100644 --- a/src/sql/workbench/services/objectExplorer/browser/objectExplorerService.ts +++ b/src/sql/workbench/services/objectExplorer/browser/objectExplorerService.ts @@ -55,6 +55,7 @@ export interface IServerTreeView { renderBody(container: HTMLElement): Promise; layout(size: number): void; showFilteredTree(view: ServerTreeViewView): void; + filterElementChildren(node: TreeNode): Promise; view: ServerTreeViewView; } @@ -484,7 +485,14 @@ export class ObjectExplorerService implements IObjectExplorerService { this.logService.trace(`${session.sessionId}: got providers for node expansion: ${allProviders.map(p => p.providerId).join(', ')}`); const resolveExpansion = () => { - resolve(self.mergeResults(allProviders, resultMap, node.nodePath)); + const expansionResult = self.mergeResults(allProviders, resultMap, node.nodePath); + if (expansionResult.errorMessage || expansionResult.nodes.some(n => n.errorMessage)) { + this._onUpdateObjectExplorerNodes.fire({ + connection: node.getConnectionProfile(), + errorMessage: expansionResult.errorMessage + }); + } + resolve(expansionResult); // Have to delete it after get all responses otherwise couldn't find session for not the first response if (newRequest) { delete self._sessions[session.sessionId!].nodes[node.nodePath]; @@ -531,12 +539,15 @@ export class ObjectExplorerService implements IObjectExplorerService { }); if (newRequest) { allProviders.forEach(provider => { - self.callExpandOrRefreshFromProvider(provider, { + let expandRequest: azdata.ExpandNodeInfo = { sessionId: session.sessionId!, nodePath: node.nodePath, securityToken: session.securityToken, - filters: node.filters - }, refresh).then(isExpanding => { + }; + if (node?.filters?.length > 0) { + expandRequest.filters = node.filters; + } + self.callExpandOrRefreshFromProvider(provider, expandRequest, refresh).then(isExpanding => { if (!isExpanding) { // The provider stated it's not going to expand the node, therefore do not need to track when merging results let emptyResult: azdata.ObjectExplorerExpandInfo = { @@ -613,6 +624,7 @@ export class ObjectExplorerService implements IObjectExplorerService { }); } finalResult.nodes = allNodes; + finalResult.errorMessage = errorMessages.join('\n'); } return finalResult; } diff --git a/src/sql/workbench/services/objectExplorer/browser/serverTreeActionProvider.ts b/src/sql/workbench/services/objectExplorer/browser/serverTreeActionProvider.ts index 187754904a..728d4cfba9 100644 --- a/src/sql/workbench/services/objectExplorer/browser/serverTreeActionProvider.ts +++ b/src/sql/workbench/services/objectExplorer/browser/serverTreeActionProvider.ts @@ -10,7 +10,7 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { DisconnectConnectionAction, EditConnectionAction, - DeleteConnectionAction, RefreshAction, EditServerGroupAction, AddServerAction + DeleteConnectionAction, RefreshAction, EditServerGroupAction, AddServerAction, FilterChildrenAction, RemoveFilterAction } from 'sql/workbench/services/objectExplorer/browser/connectionTreeAction'; import { TreeNode } from 'sql/workbench/services/objectExplorer/common/treeNode'; import { NodeType } from 'sql/workbench/services/objectExplorer/common/nodeType'; @@ -27,6 +27,8 @@ import { fillInActions } from 'vs/platform/actions/browser/menuEntryActionViewIt import { AsyncServerTree, ServerTreeElement } from 'sql/workbench/services/objectExplorer/browser/asyncServerTree'; import { ICapabilitiesService } from 'sql/platform/capabilities/common/capabilitiesService'; import { ILogService } from 'vs/platform/log/common/log'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { CONFIG_WORKBENCH_ENABLEPREVIEWFEATURES } from 'sql/workbench/common/constants'; /** * Provides actions for the server tree elements @@ -42,7 +44,8 @@ export class ServerTreeActionProvider { @IMenuService private menuService: IMenuService, @IContextKeyService private _contextKeyService: IContextKeyService, @ICapabilitiesService private _capabilitiesService: ICapabilitiesService, - @ILogService private _logService: ILogService + @ILogService private _logService: ILogService, + @IConfigurationService private _configurationService: IConfigurationService ) { } @@ -224,8 +227,16 @@ export class ServerTreeActionProvider { // Contribute refresh action for scriptable objects via contribution if (!this.isScriptableObject(context)) { actions.push(this._instantiationService.createInstance(RefreshAction, RefreshAction.ID, RefreshAction.LABEL, context.tree, context.treeNode || context.profile)); - } + // Adding filter action if the node has filter properties + if (treeNode?.filterProperties?.length > 0 && this._configurationService.getValue(CONFIG_WORKBENCH_ENABLEPREVIEWFEATURES)) { + actions.push(this._instantiationService.createInstance(FilterChildrenAction, FilterChildrenAction.ID, FilterChildrenAction.LABEL, context.treeNode)); + } + // Adding remove filter action if the node has filters applied to it. + if (treeNode?.filters?.length > 0) { + actions.push(this._instantiationService.createInstance(RemoveFilterAction, RemoveFilterAction.ID, RemoveFilterAction.LABEL, context.treeNode, context.tree, undefined)); + } + } return actions; } diff --git a/src/sql/workbench/services/objectExplorer/test/browser/testObjectExplorerService.ts b/src/sql/workbench/services/objectExplorer/test/browser/testObjectExplorerService.ts index 46d910a712..aeb2f8bc8f 100644 --- a/src/sql/workbench/services/objectExplorer/test/browser/testObjectExplorerService.ts +++ b/src/sql/workbench/services/objectExplorer/test/browser/testObjectExplorerService.ts @@ -55,6 +55,8 @@ export class TestObjectExplorerService implements IObjectExplorerService { public get onUpdateObjectExplorerNodes(): Event { throw new Error('Method not implemented'); } + public get onNodeExpandedError(): Event { throw new Error('Method not implemented'); } + public get onSelectionOrFocusChange(): Event { throw new Error('Method not implemented'); } public async updateObjectExplorerNodes(connection: IConnectionProfile): Promise { }