diff --git a/extensions/arc/package.json b/extensions/arc/package.json index 38721410ea..3eb07f4a9e 100644 --- a/extensions/arc/package.json +++ b/extensions/arc/package.json @@ -140,6 +140,7 @@ "light": "./images/data_controller.svg", "dark": "./images/data_controller.svg" }, + "tags": ["Hybrid", "SQL Server", "PostgreSQL"], "providers": [ { "notebookWizard": { @@ -515,6 +516,7 @@ "light": "./images/miaa.svg", "dark": "./images/miaa.svg" }, + "tags": ["Hybrid", "SQL Server"], "providers": [ { "notebookWizard": { @@ -680,6 +682,7 @@ "light": "./images/postgres.svg", "dark": "./images/postgres.svg" }, + "tags": ["Hybrid", "PostgreSQL"], "providers": [ { "notebookWizard": { diff --git a/extensions/asde-deployment/package.json b/extensions/asde-deployment/package.json index 518d2409d6..8bcf81f5d4 100644 --- a/extensions/asde-deployment/package.json +++ b/extensions/asde-deployment/package.json @@ -28,6 +28,7 @@ "light": "./images/sqldb_edge.svg", "dark": "./images/sqldb_edge_inverse.svg" }, + "tags": ["Hybrid", "SQL Server"], "options": [ { "name": "type", diff --git a/extensions/dacpac/src/test/testContext.ts b/extensions/dacpac/src/test/testContext.ts index 523b226b8a..cb69336e23 100644 --- a/extensions/dacpac/src/test/testContext.ts +++ b/extensions/dacpac/src/test/testContext.ts @@ -278,6 +278,7 @@ export function createViewContext(): ViewTestContext { validate: undefined!, initializeModel: () => { return Promise.resolve(); }, modelBuilder: { + listView: undefined!, radioCardGroup: undefined!, navContainer: undefined!, divContainer: () => divBuilder, diff --git a/extensions/machine-learning/src/test/views/utils.ts b/extensions/machine-learning/src/test/views/utils.ts index 58aa31785b..42539b8dd7 100644 --- a/extensions/machine-learning/src/test/views/utils.ts +++ b/extensions/machine-learning/src/test/views/utils.ts @@ -227,6 +227,7 @@ export function createViewContext(): ViewTestContext { validate: undefined!, initializeModel: () => { return Promise.resolve(); }, modelBuilder: { + listView: undefined!, radioCardGroup: undefined!, navContainer: undefined!, divContainer: () => divBuilder, diff --git a/extensions/notebook/src/test/managePackages/managePackagesDialog.test.ts b/extensions/notebook/src/test/managePackages/managePackagesDialog.test.ts index db625f57ff..e4d5973dc8 100644 --- a/extensions/notebook/src/test/managePackages/managePackagesDialog.test.ts +++ b/extensions/notebook/src/test/managePackages/managePackagesDialog.test.ts @@ -265,6 +265,7 @@ describe('Manage Package Dialog', () => { validate: undefined!, initializeModel: () => { return Promise.resolve(); }, modelBuilder: { + listView: undefined!, radioCardGroup: undefined!, navContainer: undefined!, divContainer: undefined!, diff --git a/extensions/resource-deployment/package.json b/extensions/resource-deployment/package.json index 12712d5307..9e42a78c91 100644 --- a/extensions/resource-deployment/package.json +++ b/extensions/resource-deployment/package.json @@ -74,6 +74,7 @@ "light": "./images/sql_server_container.svg", "dark": "./images/sql_server_container_inverse.svg" }, + "tags": ["On-premises", "SQL Server"], "options": [ { "name": "version", @@ -205,6 +206,7 @@ "light": "./images/sql_bdc.svg", "dark": "./images/sql_bdc_inverse.svg" }, + "tags": ["On-premises", "SQL Server"], "options": [ { "name": "version", @@ -363,6 +365,7 @@ "light": "./images/sql_server_on_windows.svg", "dark": "./images/sql_server_on_windows_inverse.svg" }, + "tags": ["On-premises", "SQL Server"], "options": [ { "name": "version", @@ -401,6 +404,7 @@ "light": "./images/azure-sql-db.svg", "dark": "./images/azure-sql-db.svg" }, + "tags": ["SQL Server", "Cloud"], "okButtonText": "%azure-sqldb-ok-button-text%", "options": [ { @@ -466,6 +470,7 @@ "light": "./images/azure-sql-vm.svg", "dark": "./images/azure-sql-vm.svg" }, + "tags": ["SQL Server", "Cloud"], "providers": [ { "azureSQLVMWizard":{ diff --git a/extensions/resource-deployment/src/constants.ts b/extensions/resource-deployment/src/constants.ts index f6d33ae592..b3c5354451 100644 --- a/extensions/resource-deployment/src/constants.ts +++ b/extensions/resource-deployment/src/constants.ts @@ -6,3 +6,13 @@ export const DeploymentConfigurationKey: string = 'deployment'; export const AzdataInstallLocationKey: string = 'azdataInstallLocation'; export const ToolsInstallPath = 'AZDATA_NB_VAR_TOOLS_INSTALLATION_PATH'; + +export const enum ResourceTypeCategories { + All = 'All', + OnPrem = 'On-premises', + SqlServer = 'SQL Server', + Hybrid = 'Hybrid', + PostgreSql = 'PostgreSQL', + Cloud = 'Cloud', +} + diff --git a/extensions/resource-deployment/src/interfaces.ts b/extensions/resource-deployment/src/interfaces.ts index 05d748c72d..f69a7832c4 100644 --- a/extensions/resource-deployment/src/interfaces.ts +++ b/extensions/resource-deployment/src/interfaces.ts @@ -21,6 +21,7 @@ export interface ResourceType { displayIndex?: number; okButtonText?: string; getProvider(selectedOptions: { option: string, value: string }[]): DeploymentProvider | undefined; + tags?: string[]; } export interface AgreementInfo { diff --git a/extensions/resource-deployment/src/localizedConstants.ts b/extensions/resource-deployment/src/localizedConstants.ts index d20e184c65..2f530ad8c6 100644 --- a/extensions/resource-deployment/src/localizedConstants.ts +++ b/extensions/resource-deployment/src/localizedConstants.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as nls from 'vscode-nls'; +import { ResourceTypeCategories } from './constants'; import { FieldType, OptionsType } from './interfaces'; const localize = nls.loadMessageBundle(); @@ -34,5 +35,29 @@ export const optionsTypeRadioOrDropdown = localize('optionsTypeRadioOrDropdown', export const azdataEulaNotAccepted = localize('azdataEulaNotAccepted', "Deployment cannot continue. Azure Data CLI license terms have not yet been accepted. Please accept the EULA to enable the features that requires Azure Data CLI."); export const azdataEulaDeclined = localize('azdataEulaDeclined', "Deployment cannot continue. Azure Data CLI license terms were declined.You can either Accept EULA to continue or Cancel this operation"); export const acceptEulaAndSelect = localize('deploymentDialog.RecheckEulaButton', "Accept EULA & Select"); + +export const resourceTypePickerDialogTitle = localize('resourceTypePickerDialog.title', "Select the deployment options"); +export const resourceTypeSearchBoxDescription = localize('resourceTypePickerDialog.resourceSearchPlaceholder', "Filter resources..."); +export const resoucrceTypeCategoryListViewTitle = localize('resourceTypePickerDialog.tagsListViewTitle', 'Categories'); + export const scriptToNotebook = localize('ui.ScriptToNotebookButton', "Script"); export const deployNotebook = localize('ui.DeployButton', "Run"); + +export function getResourceTypeCategoryLocalizedString(resourceTypeCategory: string): string { + switch (resourceTypeCategory) { + case ResourceTypeCategories.All: + return localize('resourceTypePickerDialog.resourceTypeCategoryAll', "All"); + case ResourceTypeCategories.OnPrem: + return localize('resourceTypePickerDialog.resourceTypeCategoryOnPrem', "On-premises"); + case ResourceTypeCategories.SqlServer: + return localize('resourceTypePickerDialog.resourceTypeCategoriesSqlServer', "SQL Server"); + case ResourceTypeCategories.Hybrid: + return localize('resourceTypePickerDialog.resourceTypeCategoryOnHybrid', "Hybrid"); + case ResourceTypeCategories.PostgreSql: + return localize('resourceTypePickerDialog.resourceTypeCategoryOnPostgreSql', "PostgreSQL"); + case ResourceTypeCategories.Cloud: + return localize('resourceTypePickerDialog.resourceTypeCategoryOnCloud', "Cloud"); + default: + return resourceTypeCategory; + } +} diff --git a/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts b/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts index e68a25a697..3cade3461b 100644 --- a/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts +++ b/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts @@ -2,26 +2,30 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; import * as azdata from 'azdata'; import { EOL } from 'os'; import * as nls from 'vscode-nls'; import { AgreementInfo, DeploymentProvider, ITool, ResourceType, ToolStatus } from '../interfaces'; -import { select } from '../localizedConstants'; import { IResourceTypeService } from '../services/resourceTypeService'; import { IToolsService } from '../services/toolsService'; import { getErrorMessage } from '../common/utils'; import * as loc from './../localizedConstants'; import { DialogBase } from './dialogBase'; import { createFlexContainer } from './modelViewUtils'; +import * as constants from '../constants'; const localize = nls.loadMessageBundle(); export class ResourceTypePickerDialog extends DialogBase { private toolRefreshTimestamp: number = 0; + private _resourceTypes!: ResourceType[]; private _selectedResourceType: ResourceType; private _view!: azdata.ModelView; private _optionsContainer!: azdata.FlexContainer; private _toolsTable!: azdata.TableComponent; + private _resourceTagsListView!: azdata.ListViewComponent; + private _resourceSearchBox!: azdata.InputBoxComponent; private _cardGroup!: azdata.RadioCardGroupComponent; private _optionDropDownMap: Map = new Map(); private _toolsLoadingComponent!: azdata.LoadingComponent; @@ -31,13 +35,16 @@ export class ResourceTypePickerDialog extends DialogBase { private _installationInProgress: boolean = false; private _tools: ITool[] = []; private _eulaValidationSucceeded: boolean = false; + // array to store listners that are specific to the selected resource. To be cleared after change in selected resource. + private _currentResourceTypeDisposables: vscode.Disposable[] = []; + private _cardsCache: Map = new Map(); constructor( private toolsService: IToolsService, private resourceTypeService: IResourceTypeService, defaultResourceType: ResourceType, private _resourceTypeNameFilters?: string[]) { - super(localize('resourceTypePickerDialog.title', "Select the deployment options"), 'ResourceTypePickerDialog', true); + super(loc.resourceTypePickerDialogTitle, 'ResourceTypePickerDialog', true); this._selectedResourceType = defaultResourceType; this._installToolButton = azdata.window.createButton(localize('deploymentDialog.InstallToolsButton', "Install tools")); this._toDispose.push(this._installToolButton.onClick(() => { @@ -68,44 +75,29 @@ export class ResourceTypePickerDialog extends DialogBase { tab.registerContent((view: azdata.ModelView) => { const tableWidth = 1126; this._view = view; - const resourceTypes = this.resourceTypeService + this._resourceTypes = this.resourceTypeService .getResourceTypes() .filter(rt => !this._resourceTypeNameFilters || this._resourceTypeNameFilters.find(rtn => rt.name === rtn)) .sort((a: ResourceType, b: ResourceType) => { return (a.displayIndex || Number.MAX_VALUE) - (b.displayIndex || Number.MAX_VALUE); }); this._cardGroup = view.modelBuilder.radioCardGroup().withProperties({ - cards: resourceTypes.map((resourceType) => { - return { - id: resourceType.name, - label: resourceType.displayName, - icon: resourceType.icon, - descriptions: [ - { - textValue: resourceType.displayName, - textStyles: { - 'font-size': '14px', - 'font-weight': 'bold' - } - }, - { - textValue: resourceType.description, - } - ] - }; + cards: this._resourceTypes.map((resourceType) => { + return this.createOrGetCard(resourceType); }), iconHeight: '35px', iconWidth: '35px', cardWidth: '300px', cardHeight: '150px', ariaLabel: localize('deploymentDialog.deploymentOptions', "Deployment options"), - width: '1100px', + width: '1000px', + height: '550px', iconPosition: 'left' }).component(); this._toDispose.push(this._cardGroup.onSelectionChanged(({ cardId }) => { this._dialogObject.message = { text: '' }; this._dialogObject.okButton.label = loc.select; - const resourceType = resourceTypes.find(rt => { return rt.name === cardId; }); + const resourceType = this._resourceTypes.find(rt => { return rt.name === cardId; }); if (resourceType) { this.selectResourceType(resourceType); } @@ -150,10 +142,35 @@ export class ResourceTypePickerDialog extends DialogBase { loadingText: localize('deploymentDialog.loadingRequiredTools', "Loading required tools information"), showText: true }).component(); + + const resourceComponents: azdata.Component[] = []; + if (this.getAllResourceTags().length !== 0) { + this._resourceTagsListView = this.createTagsListView(); + resourceComponents.push(this._resourceTagsListView); + } + this._resourceSearchBox = view.modelBuilder.inputBox().withProperties({ + placeHolder: loc.resourceTypeSearchBoxDescription, + ariaLabel: loc.resourceTypeSearchBoxDescription + }).component(); + this._toDispose.push(this._resourceSearchBox.onTextChanged((value: string) => { + this.filterResources(); + this._resourceSearchBox.focus(); + })); + const searchContainer = view.modelBuilder.divContainer().withItems([this._resourceSearchBox]).withProps({ + CSSStyles: { + 'margin-left': '15px', + 'width': '300px' + }, + }).component(); + const cardsContainer = this._view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column' + }).withItems([searchContainer, this._cardGroup]).component(); + resourceComponents.push(cardsContainer); + const formBuilder = view.modelBuilder.formContainer().withFormItems( [ { - component: this._cardGroup, + component: this._view.modelBuilder.flexContainer().withLayout({ flexFlow: 'row' }).withItems(resourceComponents).component(), title: '' }, { component: this._agreementContainer, @@ -175,18 +192,89 @@ export class ResourceTypePickerDialog extends DialogBase { const form = formBuilder.withLayout({ width: '100%' }).component(); return view.initializeModel(form).then(() => { + this.selectResourceType(this._resourceTypes[0]); if (this._selectedResourceType) { this._cardGroup.selectedCardId = this._selectedResourceType.name; } + this._resourceTagsListView.focus(); }); }); this._dialogObject.content = [tab]; + + } + + private createTagsListView(): azdata.ListViewComponent { + + const tags = this.getAllResourceTags(); + if (!tags.includes('All')) { + tags.splice(0, 0, 'All'); + } + const items: azdata.ListViewOption[] = []; + tags.forEach((t: string, idx: number) => { + items.push({ + label: loc.getResourceTypeCategoryLocalizedString(t), + id: t + }); + }); + const listView = this._view.modelBuilder.listView().withProps({ + title: { + text: loc.resoucrceTypeCategoryListViewTitle + }, + CSSStyles: { + 'width': '140px', + 'margin-top': '35px' + }, + options: items, + selectedOptionId: items[0].id + }).component(); + this._toDispose.push(listView.onDidClick((e) => { + this._resourceSearchBox.value = ''; + this.filterResources(); + listView.focus(); + })); + + return listView; + } + + private filterResources(): void { + const tag = this._resourceTagsListView.selectedOptionId!; + const search = this._resourceSearchBox.value?.toLowerCase() ?? ''; + + // Getting resourceType based on the selected tag + let filteredResourceTypes = (tag !== 'All') ? this._resourceTypes.filter(element => element.tags?.includes(tag) ?? false) : this._resourceTypes; + + // Filtering resourceTypes based on their names. + const filteredResourceTypesOnSearch: ResourceType[] = filteredResourceTypes.filter((element) => element.displayName.toLowerCase().includes(search!)); + // Adding resourceTypes with descriptions matching the search text to the result at the end as they might be less relevant. + filteredResourceTypesOnSearch.push(...filteredResourceTypes.filter((element) => !element.displayName.toLowerCase().includes(search!) && element.description.toLowerCase().includes(search!))); + + const cards = filteredResourceTypesOnSearch.map((resourceType) => this.createOrGetCard(resourceType)); + + if (filteredResourceTypesOnSearch.length > 0) { + this._cardGroup.updateProperties({ + selectedCardId: cards[0].id, + cards: cards + }); + + this.selectResourceType(filteredResourceTypesOnSearch[0]); + } + else { + this._cardGroup.updateProperties({ + selectedCardId: '', + cards: [] + }); + this._agreementCheckboxChecked = false; + this._agreementContainer.clearItems(); + this._optionsContainer.clearItems(); + this.updateToolsDisplayTable(); + } } private selectResourceType(resourceType: ResourceType): void { + this._currentResourceTypeDisposables.forEach(disposable => disposable.dispose()); this._selectedResourceType = resourceType; //handle special case when resource type has different OK button. - this._dialogObject.okButton.label = this._selectedResourceType.okButtonText || select; + this._dialogObject.okButton.label = this._selectedResourceType.okButtonText || loc.select; this._agreementCheckboxChecked = false; this._agreementContainer.clearItems(); @@ -210,7 +298,7 @@ export class ResourceTypePickerDialog extends DialogBase { ariaLabel: option.displayName }).component(); - this._toDispose.push(optionSelectBox.onValueChanged(() => { this.updateToolsDisplayTable(); })); + this._currentResourceTypeDisposables.push(optionSelectBox.onValueChanged(() => { this.updateToolsDisplayTable(); })); this._optionDropDownMap.set(option.name, optionSelectBox); const row = this._view.modelBuilder.flexContainer().withItems([optionLabel, optionSelectBox], { flex: '0 0 auto', CSSStyles: { 'margin-right': '20px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component(); this._optionsContainer.addItem(row); @@ -262,7 +350,7 @@ export class ResourceTypePickerDialog extends DialogBase { this._toolsTable.data = this.toolRequirements.map(toolRequirement => { const tool = this.toolsService.getToolByName(toolRequirement.name)!; // subscribe to onUpdateData event of the tool. - this._toDispose.push(tool.onDidUpdateData((t: ITool) => { + this._currentResourceTypeDisposables.push(tool.onDidUpdateData((t: ITool) => { this.updateToolsDisplayTableData(t); })); @@ -361,7 +449,7 @@ export class ResourceTypePickerDialog extends DialogBase { required: true }).component(); checkbox.checked = false; - this._toDispose.push(checkbox.onChanged(() => { + this._currentResourceTypeDisposables.push(checkbox.onChanged(() => { this._agreementCheckboxChecked = !!checkbox.checked; })); const text = this._view.modelBuilder.text().withProperties({ @@ -476,4 +564,49 @@ export class ResourceTypePickerDialog extends DialogBase { this._installationInProgress = false; } } + + private getAllResourceTags(): string[] { + const supportedTags = [ + constants.ResourceTypeCategories.All, + constants.ResourceTypeCategories.OnPrem, + constants.ResourceTypeCategories.Hybrid, + constants.ResourceTypeCategories.Cloud, + constants.ResourceTypeCategories.SqlServer, + constants.ResourceTypeCategories.PostgreSql + ]; + + const tagsWithResourceTypes = supportedTags.filter(tag => { + return (tag === constants.ResourceTypeCategories.All) || this._resourceTypes.find(resourceType => resourceType.tags?.includes(tag)) !== undefined; + }); + + return tagsWithResourceTypes; + } + + private createOrGetCard(resourceType: ResourceType): azdata.RadioCard { + if (this._cardsCache.has(resourceType.name)) { + return this._cardsCache.get(resourceType.name)!; + } + + const newCard = { + id: resourceType.name, + label: resourceType.displayName, + icon: resourceType.icon, + descriptions: [ + { + textValue: resourceType.displayName, + textStyles: { + 'font-size': '14px', + 'font-weight': 'bold' + } + }, + { + textValue: resourceType.description, + } + ] + }; + + this._cardsCache.set(resourceType.name, newCard); + return newCard; + } + } diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index b1841d7539..11d53ab390 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -248,6 +248,7 @@ declare module 'azdata' { export interface ModelBuilder { radioCardGroup(): ComponentBuilder; + listView(): ComponentBuilder; tabbedPanel(): TabbedPanelComponentBuilder; separator(): ComponentBuilder; propertiesContainer(): ComponentBuilder; @@ -301,6 +302,28 @@ declare module 'azdata' { } + export interface ListViewComponentProperties extends ComponentProperties { + title?: ListViewTitle; + options: ListViewOption[]; + selectedOptionId?: string; + } + + export interface ListViewTitle { + text?: string; + style?: CssStyles; + } + + export interface ListViewOption { + label: string; + id: string; + } + + export type ListViewClickEvent = { id: string }; + + export interface ListViewComponent extends Component, ListViewComponentProperties { + onDidClick: vscode.Event; + } + export interface SeparatorComponent extends Component { } export interface SeparatorComponentProperties extends ComponentProperties { diff --git a/src/sql/platform/dashboard/browser/interfaces.ts b/src/sql/platform/dashboard/browser/interfaces.ts index 791c584032..9b60dd8953 100644 --- a/src/sql/platform/dashboard/browser/interfaces.ts +++ b/src/sql/platform/dashboard/browser/interfaces.ts @@ -135,6 +135,7 @@ export enum ModelComponentTypes { Hyperlink, Image, RadioCardGroup, + ListView, TabbedPanel, Separator, PropertiesContainer diff --git a/src/sql/workbench/api/common/extHostModelView.ts b/src/sql/workbench/api/common/extHostModelView.ts index ee9f6f8f73..4a9b9025da 100644 --- a/src/sql/workbench/api/common/extHostModelView.ts +++ b/src/sql/workbench/api/common/extHostModelView.ts @@ -249,6 +249,13 @@ class ModelBuilderImpl implements azdata.ModelBuilder { return builder; } + listView(): azdata.ComponentBuilder { + let id = this.getNextComponentId(); + let builder: ComponentBuilderImpl = this.getComponentBuilder(new ListViewComponentWrapper(this._proxy, this._handle, id), id); + this._componentBuilders.set(id, builder); + return builder; + } + tabbedPanel(): azdata.TabbedPanelComponentBuilder { let id = this.getNextComponentId(); let builder = new TabbedPanelComponentBuilder(new TabbedPanelComponentWrapper(this._proxy, this._handle, id)); @@ -1819,6 +1826,43 @@ class RadioCardGroupComponentWrapper extends ComponentWrapper implements azdata. } } +class ListViewComponentWrapper extends ComponentWrapper implements azdata.ListViewComponent { + constructor(proxy: MainThreadModelViewShape, handle: number, id: string) { + super(proxy, handle, ModelComponentTypes.ListView, id); + this.properties = {}; + + this._emitterMap.set(ComponentEventType.onDidClick, new Emitter()); + } + + public get title(): azdata.ListViewTitle { + return this.properties['title']; + } + + public set title(v: azdata.ListViewTitle) { + this.setProperty('title', v); + } + + public get options(): azdata.ListViewOption[] { + return this.properties['options']; + } + public set options(v: azdata.ListViewOption[]) { + this.setProperty('options', v); + } + + public get selectedOptionId(): string | undefined { + return this.properties['selectedOptionId']; + } + + public set selectedOptionId(v: string | undefined) { + this.setProperty('selectedOptionId', v); + } + + public get onDidClick(): vscode.Event { + let emitter = this._emitterMap.get(ComponentEventType.onDidClick); + return emitter && emitter.event; + } +} + class TabbedPanelComponentWrapper extends ComponentWrapper implements azdata.TabbedPanelComponent { constructor(proxy: MainThreadModelViewShape, handle: number, id: string) { super(proxy, handle, ModelComponentTypes.TabbedPanel, id); diff --git a/src/sql/workbench/api/common/sqlExtHostTypes.ts b/src/sql/workbench/api/common/sqlExtHostTypes.ts index 15a0815c2f..94485b65d1 100644 --- a/src/sql/workbench/api/common/sqlExtHostTypes.ts +++ b/src/sql/workbench/api/common/sqlExtHostTypes.ts @@ -174,6 +174,7 @@ export enum ModelComponentTypes { Hyperlink, Image, RadioCardGroup, + ListView, TabbedPanel, Separator, PropertiesContainer diff --git a/src/sql/workbench/browser/modelComponents/listView.component.html b/src/sql/workbench/browser/modelComponents/listView.component.html new file mode 100644 index 0000000000..f62fa6ee16 --- /dev/null +++ b/src/sql/workbench/browser/modelComponents/listView.component.html @@ -0,0 +1,4 @@ +
+
{{title.text}}
+
+
diff --git a/src/sql/workbench/browser/modelComponents/listView.component.ts b/src/sql/workbench/browser/modelComponents/listView.component.ts new file mode 100644 index 0000000000..38f26c0f52 --- /dev/null +++ b/src/sql/workbench/browser/modelComponents/listView.component.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { ChangeDetectorRef, Component, ElementRef, forwardRef, Inject, Input, OnDestroy, ViewChild } from '@angular/core'; +import * as azdata from 'azdata'; +import { ComponentBase } from 'sql/workbench/browser/modelComponents/componentBase'; +import * as DOM from 'vs/base/browser/dom'; +import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import { IListOptions, List } from 'vs/base/browser/ui/list/listWidget'; + +import 'vs/css!./media/listView'; + +import { IComponent, IComponentDescriptor, IModelStore, ComponentEventType } from 'sql/platform/dashboard/browser/interfaces'; +import { IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; +import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; +import { attachListStyler } from 'vs/platform/theme/common/styler'; +import { ScrollbarVisibility } from 'vs/base/common/scrollable'; + +@Component({ + templateUrl: decodeURI(require.toUrl('./listView.component.html')) +}) + +export default class ListViewComponent extends ComponentBase implements IComponent, OnDestroy { + @Input() descriptor: IComponentDescriptor; + @Input() modelStore: IModelStore; + @ViewChild('vscodelist', { read: ElementRef }) private _vscodeList: ElementRef; + private _optionsList!: List; + private _selectedElementIdx!: number; + + static ROW_HEIGHT = 26; + + constructor( + @Inject(forwardRef(() => ChangeDetectorRef)) changeRef: ChangeDetectorRef, + @Inject(IWorkbenchThemeService) private themeService: IWorkbenchThemeService, + @Inject(forwardRef(() => ElementRef)) el: ElementRef, + ) { + super(changeRef, el); + } + + ngOnInit(): void { + this.baseInit(); + } + + ngAfterViewInit(): void { + const vscodelistOption: IListOptions = { + keyboardSupport: true, + mouseSupport: true, + smoothScrolling: true, + verticalScrollMode: ScrollbarVisibility.Auto, + + }; + + this._optionsList = new List('ModelViewListView', this._vscodeList.nativeElement, new OptionListDelegate(ListViewComponent.ROW_HEIGHT), [new OptionsListRenderer()], vscodelistOption); + this._register(attachListStyler(this._optionsList, this.themeService)); + + this._register(this._optionsList.onDidChangeSelection((e) => { + if (e.indexes.length !== 0) { + this.selectOptionByIdx(e.indexes[0]); + } + })); + + this._register(this._optionsList.onKeyDown((event: any) => { + if (!this.enabled || this.options.length === 0) { + return; + } + let e = new StandardKeyboardEvent(event); + if (e.keyCode === KeyCode.Space) { + this._optionsList.setSelection([this._optionsList.getFocus()[0]]); + DOM.EventHelper.stop(e, true); + } + })); + } + + setLayout(layout: any): void { + this.layout(); + } + + ngOnDestroy(): void { + this.baseDestroy(); + } + + public get options(): azdata.ListViewOption[] { + return this.getProperties().options ?? []; + } + + public get width(): string | number | undefined { + return this.getProperties().width ?? undefined; + } + + public get height(): string | number | undefined { + return this.getProperties().height ?? undefined; + } + + public get styles(): azdata.CssStyles | undefined { + return this.getProperties().CSSStyles ?? undefined; + } + + public get title(): azdata.ListViewTitle { + return this.getProperties().title ?? undefined; + } + + public get selectedOptionId(): string | undefined { + return this.getProperties().selectedOptionId ?? undefined; + } + + public setProperties(properties: { [key: string]: any }) { + super.setProperties(properties); + if (this.options) { + this._optionsList!.splice(0, this._optionsList!.length, this.options); + let height = (this.height) ?? (this.options.length * ListViewComponent.ROW_HEIGHT); + this._optionsList.layout(height); + } + + // This is the entry point for the extension to set the selectedOptionId + if (this.selectedOptionId) { + this._optionsList.setSelection([this.options.map(v => v.id).indexOf(this.selectedOptionId)]); + } + + + } + + public selectOptionByIdx(idx: number): void { + if (!this.enabled || this.options.length === 0) { + return; + } + this._selectedElementIdx = idx; + const selectedOption = this.options[idx]; + this.setPropertyFromUI((props, value) => props.selectedOptionId = value, selectedOption.id); + this.fireEvent({ + eventType: ComponentEventType.onDidClick, + args: { + id: selectedOption.id + } + }); + } + + public focus(): void { + super.focus(); + if (this._selectedElementIdx !== undefined) { + this._optionsList.domFocus(); + const focusElement = (this._selectedElementIdx === undefined) ? 0 : this._selectedElementIdx; + this._optionsList.setFocus([focusElement]); + } + } +} + +class OptionListDelegate implements IListVirtualDelegate { + constructor( + private _height: number + ) { + } + + public getHeight(element: azdata.ListViewOption): number { + return this._height; + } + + public getTemplateId(element: azdata.ListViewOption): string { + return 'optionListRenderer'; + } +} + +interface ExtensionListTemplate { + root: HTMLElement; +} + +class OptionsListRenderer implements IListRenderer { + public static TEMPLATE_ID = 'optionListRenderer'; + + public get templateId(): string { + return OptionsListRenderer.TEMPLATE_ID; + } + + public renderTemplate(container: HTMLElement): ExtensionListTemplate { + const tableTemplate: ExtensionListTemplate = Object.create(null); + tableTemplate.root = DOM.append(container, DOM.$('div.list-row.listview-option')); + return tableTemplate; + } + + public renderElement(option: azdata.ListViewOption, index: number, templateData: ExtensionListTemplate): void { + templateData.root.innerText = option.label ?? ''; + } + + public disposeTemplate(template: ExtensionListTemplate): void { + // noop + } + + public disposeElement(element: azdata.ListViewOption, index: number, templateData: ExtensionListTemplate): void { + // noop + } +} diff --git a/src/sql/workbench/browser/modelComponents/media/listView.css b/src/sql/workbench/browser/modelComponents/media/listView.css new file mode 100644 index 0000000000..26d2314ae6 --- /dev/null +++ b/src/sql/workbench/browser/modelComponents/media/listView.css @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.modelview-listview-container { + display: flex; + flex-direction: column; + height: 100%; + max-width: 150px; + min-width: 120px; + font-size: 100%; + font-weight: inherit; + overflow: auto; + padding: 0 0 0 0; + font-size: 12px; +} + +.modelview-listview-container .modelview-listview-title { + margin: 0 5px 5px 0; + font-weight: bold; + line-height: 16px; + padding: 5px 0px 5px 5px; +} + +.modelview-listview-container .listview-option { + line-height: 16px; + width: 95%; + padding: 5px 0px 5px 5px; +} + + + diff --git a/src/sql/workbench/contrib/modelView/browser/components.contribution.ts b/src/sql/workbench/contrib/modelView/browser/components.contribution.ts index fad2d992ed..b1fe467418 100644 --- a/src/sql/workbench/contrib/modelView/browser/components.contribution.ts +++ b/src/sql/workbench/contrib/modelView/browser/components.contribution.ts @@ -34,6 +34,8 @@ import TabbedPanelComponent from 'sql/workbench/browser/modelComponents/tabbedPa import SeparatorComponent from 'sql/workbench/browser/modelComponents/separator.component'; import { ModelComponentTypes } from 'sql/platform/dashboard/browser/interfaces'; import PropertiesContainerComponent from 'sql/workbench/browser/modelComponents/propertiesContainer.component'; +import ListViewComponent from 'sql/workbench/browser/modelComponents/listView.component'; + export const DIV_CONTAINER = 'div-container'; registerComponentType(DIV_CONTAINER, ModelComponentTypes.DivContainer, DivContainer); @@ -113,6 +115,9 @@ registerComponentType(HYPERLINK_COMPONENT, ModelComponentTypes.Hyperlink, Hyperl export const RADIOCARDGROUP_COMPONENT = 'radiocardgroup-component'; registerComponentType(RADIOCARDGROUP_COMPONENT, ModelComponentTypes.RadioCardGroup, RadioCardGroup); +export const LISTVIEW_COMPONENT = 'listView-component'; +registerComponentType(LISTVIEW_COMPONENT, ModelComponentTypes.ListView, ListViewComponent); + export const TABBEDPANEL_COMPONENT = 'tabbedpanel-component'; registerComponentType(TABBEDPANEL_COMPONENT, ModelComponentTypes.TabbedPanel, TabbedPanelComponent);