diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/deploymentProfilePage.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/deploymentProfilePage.ts index 57aa46ea07..9540b8946d 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/deploymentProfilePage.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/deploymentProfilePage.ts @@ -5,38 +5,49 @@ import * as azdata from 'azdata'; import * as nls from 'vscode-nls'; -import { DeployClusterWizard } from '../deployClusterWizard'; -import { WizardPageBase } from '../../wizardPageBase'; -import * as VariableNames from '../constants'; -import { createFlexContainer } from '../../modelViewUtils'; import { BdcDeploymentType } from '../../../interfaces'; import { BigDataClusterDeploymentProfile } from '../../../services/bigDataClusterDeploymentProfile'; +import { createFlexContainer } from '../../modelViewUtils'; +import { WizardPageBase } from '../../wizardPageBase'; +import * as VariableNames from '../constants'; +import { DeployClusterWizard } from '../deployClusterWizard'; const localize = nls.loadMessageBundle(); export class DeploymentProfilePage extends WizardPageBase { - private _cards: azdata.CardComponent[] = []; - private _cardContainer: azdata.FlexContainer | undefined; + private _profiles: BigDataClusterDeploymentProfile[] = []; + private _cardContainer: azdata.RadioCardGroupComponent | undefined; private _loadingComponent: azdata.LoadingComponent | undefined; - private _view: azdata.ModelView | undefined; constructor(wizard: DeployClusterWizard) { - super(localize('deployCluster.summaryPageTitle', "Deployment configuration template"), - localize('deployCluster.summaryPageDescription', "Select the target configuration template"), wizard); + super(localize('deployCluster.summaryPageTitle', "Deployment configuration profile"), + localize('deployCluster.summaryPageDescription', "Select the target configuration profile"), wizard); } public initialize(): void { - this.pageObject.registerContent((view: azdata.ModelView) => { - this._view = view; - this._cardContainer = view.modelBuilder.flexContainer().withLayout({ flexFlow: 'row', flexWrap: 'wrap' }).component(); + this.pageObject.registerContent(async (view: azdata.ModelView): Promise => { + this._cardContainer = view.modelBuilder.radioCardGroup().withProperties({ + cards: [], + cardWidth: '240px', + cardHeight: '340px', + ariaLabel: localize('deploymentDialog.deploymentOptions', "Deployment options"), + width: '1000px' + }).component(); + this.wizard.registerDisposable(this._cardContainer.onSelectionChanged((profileName) => { + const selectedProfile = this._profiles.find(p => profileName === p.profileName); + this.wizard.wizardObject.message = { text: '' }; + if (selectedProfile) { + this.setModelValuesByProfile(selectedProfile); + } + })); const hintText = view.modelBuilder.text().withProperties({ value: localize('deployCluster.ProfileHintText', "Note: The settings of the deployment profile can be customized in later steps.") }).component(); const container = createFlexContainer(view, [this._cardContainer, hintText], false); this._loadingComponent = view.modelBuilder.loadingComponent().withItem(container).withProperties({ loading: true, - loadingText: localize('deployCluster.loadingProfiles', "Loading deployment profiles"), - loadingCompletedText: localize('deployCluster.loadingProfilesCompleted', "Loading deployment profiles completed"), + loadingText: localize('deployCluster.loadingProfiles', "Loading profiles"), + loadingCompletedText: localize('deployCluster.loadingProfilesCompleted', "Loading profiles completed"), showText: true }).component(); let formBuilder = view.modelBuilder.formContainer().withFormItems( @@ -51,99 +62,74 @@ export class DeploymentProfilePage extends WizardPageBase { } ).withLayout({ width: '100%', height: '100%' }); const form = formBuilder.withLayout({ width: '100%' }).component(); - this.loadCards().then(() => { - this._loadingComponent!.loading = false; - }, (error) => { - this.wizard.wizardObject.message = { - level: azdata.window.MessageLevel.Error, - text: localize('deployCluster.loadProfileFailed', "Failed to load the deployment profiles: {0}", error.message) - }; - this._loadingComponent!.loading = false; - }); - return view.initializeModel(form); + await view.initializeModel(form); + await this.loadCards(); }); } - private createProfileCard(profile: BigDataClusterDeploymentProfile, view: azdata.ModelView): azdata.CardComponent { - const descriptions: azdata.CardDescriptionItem[] = [{ - label: localize('deployCluster.serviceLabel', "Service"), - value: localize('deployCluster.instancesLabel', "Instances"), - fontWeight: 'bold' - }, { - label: localize('deployCluster.masterPoolLabel', "SQL Server Master"), - value: profile.sqlServerReplicas.toString() - }, { - label: localize('deployCluster.computePoolLable', "Compute"), - value: profile.computeReplicas.toString() - }, { - label: localize('deployCluster.dataPoolLabel', "Data"), - value: profile.dataReplicas.toString() - }, { - label: localize('deployCluster.hdfsLabel', "HDFS + Spark"), - value: profile.hdfsReplicas.toString() - }, { - label: '' // line separator - }, { - label: localize('deployCluster.storageSize', "Storage size"), - value: localize('deployCluster.gbPerInstance', "GB per Instance"), - fontWeight: 'bold' - }, { - label: localize('deployCluster.defaultDataStorage', "Data storage"), - value: profile.controllerDataStorageSize.toString() - }, { - label: localize('deployCluster.defaultLogStorage', "Log storage"), - value: profile.controllerLogsStorageSize.toString() - }, { - label: '' // line separator - }, { - label: localize('deployCluster.features', "Features"), - value: '', - fontWeight: 'bold' - }, { - label: localize('deployCluster.basicAuthentication', "Basic authentication"), - value: '' - }]; + private createProfileCard(profile: BigDataClusterDeploymentProfile): azdata.RadioCard { + const scaleDescription: azdata.RadioCardDescription = { + ariaLabel: localize('deployCluster.scaleDescription', "Scale description"), + labelHeader: localize('deployCluster.serviceLabel', "Service"), + valueHeader: localize('deployCluster.instancesLabel', "Instances"), + contents: [ + { + label: localize('deployCluster.masterPoolLabel', "SQL Server Master"), + value: profile.sqlServerReplicas.toString() + }, + { + label: localize('deployCluster.computePoolLable', "Compute"), + value: profile.computeReplicas.toString() + }, + { + label: localize('deployCluster.dataPoolLabel', "Data"), + value: profile.dataReplicas.toString() + }, { + label: localize('deployCluster.hdfsLabel', "HDFS + Spark"), + value: profile.hdfsReplicas.toString() + }] + }; + const storageDescription: azdata.RadioCardDescription = { + ariaLabel: localize('deployCluster.storageDescription', "Storage description"), + labelHeader: localize('deployCluster.storageSize', "Storage size"), + valueHeader: localize('deployCluster.gbPerInstance', "GB per Instance"), + contents: [ + { + label: localize('deployCluster.defaultDataStorage', "Data storage"), + value: profile.controllerDataStorageSize.toString() + }, { + label: localize('deployCluster.defaultLogStorage', "Log storage"), + value: profile.controllerLogsStorageSize.toString() + } + ] + }; + + const featureDescription: azdata.RadioCardDescription = { + ariaLabel: localize('deployCluster.featureDescription', "Feature description"), + labelHeader: localize('deployCluster.features', "Features"), + contents: [ + { + label: localize('deployCluster.basicAuthentication', "Basic authentication") + } + ] + }; if (profile.activeDirectorySupported) { - descriptions.push({ - label: localize('deployCluster.activeDirectoryAuthentication', "Active Directory authentication"), - value: '' + featureDescription.contents.push({ + label: localize('deployCluster.activeDirectoryAuthentication', "Active Directory authentication") }); } if (profile.sqlServerReplicas > 1) { - descriptions.push({ - label: localize('deployCluster.hadr', "High Availability"), - value: '' + featureDescription.contents.push({ + label: localize('deployCluster.hadr', "High Availability") }); } - const card = view.modelBuilder.card().withProperties({ - cardType: azdata.CardType.VerticalButton, + return { + id: profile.profileName, label: profile.profileName, - descriptions: descriptions, - width: '240px', - height: '320px', - }).component(); - this._cards.push(card); - this.wizard.registerDisposable(card.onCardSelectedChanged(() => { - if (card.selected) { - this.wizard.wizardObject.message = { text: '' }; - this.setModelValuesByProfile(profile); - // clear the selected state of the previously selected card - this._cards.forEach(c => { - if (c !== card) { - c.selected = false; - } - }); - } else { - // keep the selected state if no other card is selected - if (this._cards.filter(c => { return c !== card && c.selected; }).length === 0) { - card.selected = true; - } - } - })); - - return card; + descriptions: [scaleDescription, storageDescription, featureDescription] + }; } private setModelValuesByProfile(selectedProfile: BigDataClusterDeploymentProfile): void { @@ -174,20 +160,20 @@ export class DeploymentProfilePage extends WizardPageBase { this.wizard.model.selectedProfile = selectedProfile; } - private loadCards(): Promise { - return this.wizard.azdataService.getDeploymentProfiles(this.wizard.deploymentType).then((profiles: BigDataClusterDeploymentProfile[]) => { + private async loadCards(): Promise { + try { + this._profiles = await this.wizard.azdataService.getDeploymentProfiles(this.wizard.deploymentType); const defaultProfile: string = this.getDefaultProfile(); - - profiles.forEach(profile => { - const card = this.createProfileCard(profile, this._view!); - if (profile.profileName === defaultProfile) { - card.selected = true; - card.focus(); - this.setModelValuesByProfile(profile); - } - this._cardContainer!.addItem(card, { flex: '0 0 auto' }); - }); - }); + this._cardContainer!.cards = this._profiles.map(profile => this.createProfileCard(profile)); + this._loadingComponent!.loading = false; + this._cardContainer!.selectedCardId = defaultProfile; + } catch (error) { + this.wizard.wizardObject.message = { + level: azdata.window.MessageLevel.Error, + text: localize('deployCluster.loadProfileFailed', "Failed to load the deployment profiles: {0}", error.message) + }; + this._loadingComponent!.loading = false; + } } public onEnter() { diff --git a/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts b/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts index fd9256d6c9..5eaeee8c04 100644 --- a/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts +++ b/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts @@ -17,19 +17,17 @@ const localize = nls.loadMessageBundle(); export class ResourceTypePickerDialog extends DialogBase { private toolRefreshTimestamp: number = 0; private _selectedResourceType: ResourceType; - private _resourceTypeCards: azdata.CardComponent[] = []; private _view!: azdata.ModelView; private _resourceDescriptionLabel!: azdata.TextComponent; private _optionsContainer!: azdata.FlexContainer; private _toolsTable!: azdata.TableComponent; - private _cardResourceTypeMap: Map = new Map(); + private _cardGroup!: azdata.RadioCardGroupComponent; private _optionDropDownMap: Map = new Map(); private _toolsLoadingComponent!: azdata.LoadingComponent; private _agreementContainer!: azdata.DivContainer; private _agreementCheckboxChecked: boolean = false; private _installToolButton: azdata.window.Button; private _tools: ITool[] = []; - private _cardsContainer!: azdata.FlexContainer; constructor( private toolsService: IToolsService, @@ -61,10 +59,30 @@ export class ResourceTypePickerDialog extends DialogBase { tab.registerContent((view: azdata.ModelView) => { const tableWidth = 1126; this._view = view; - this.resourceTypeService.getResourceTypes().sort((a: ResourceType, b: ResourceType) => { + const resourceTypes = this.resourceTypeService.getResourceTypes().sort((a: ResourceType, b: ResourceType) => { return (a.displayIndex || Number.MAX_VALUE) - (b.displayIndex || Number.MAX_VALUE); - }).forEach(resourceType => this.addCard(resourceType)); - this._cardsContainer = view.modelBuilder.flexContainer().withItems(this._resourceTypeCards, { flex: '0 0 auto', CSSStyles: { 'margin-bottom': '10px' } }).withLayout({ flexFlow: 'row' }).component(); + }); + this._cardGroup = view.modelBuilder.radioCardGroup().withProperties({ + cards: resourceTypes.map((resourceType) => { + return { + id: resourceType.name, + label: resourceType.displayName, + icon: resourceType.icon + }; + }), + iconHeight: '50px', + iconWidth: '50px', + cardWidth: '220px', + cardHeight: '180px', + ariaLabel: localize('deploymentDialog.deploymentOptions', "Deployment options"), + width: '1100px' + }).component(); + this._toDispose.push(this._cardGroup.onSelectionChanged((cardId: string) => { + const resourceType = resourceTypes.find(rt => { return rt.name === cardId; }); + if (resourceType) { + this.selectResourceType(resourceType); + } + })); this._resourceDescriptionLabel = view.modelBuilder.text().withProperties({ value: this._selectedResourceType ? this._selectedResourceType.description : undefined }).component(); this._optionsContainer = view.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component(); this._agreementContainer = view.modelBuilder.divContainer().component(); @@ -106,7 +124,7 @@ export class ResourceTypePickerDialog extends DialogBase { const formBuilder = view.modelBuilder.formContainer().withFormItems( [ { - component: this._cardsContainer, + component: this._cardGroup, title: '' }, { component: this._resourceDescriptionLabel, @@ -132,50 +150,15 @@ export class ResourceTypePickerDialog extends DialogBase { return view.initializeModel(form).then(() => { if (this._selectedResourceType) { - this.selectResourceType(this._selectedResourceType); + this._cardGroup.selectedCardId = this._selectedResourceType.name; } }); }); this._dialogObject.content = [tab]; } - private addCard(resourceType: ResourceType): void { - const card = this._view.modelBuilder.card().withProperties({ - cardType: azdata.CardType.VerticalButton, - iconPath: { - dark: resourceType.icon.dark, - light: resourceType.icon.light - }, - label: resourceType.displayName, - selected: (this._selectedResourceType && this._selectedResourceType.name === resourceType.name), - width: '220px', - height: '180px', - iconWidth: '50px', - iconHeight: '50px' - }).component(); - this._resourceTypeCards.push(card); - this._cardResourceTypeMap.set(resourceType.name, card); - this._toDispose.push(card.onCardSelectedChanged(() => this.selectResourceType(resourceType))); - } - private selectResourceType(resourceType: ResourceType): void { this._selectedResourceType = resourceType; - const card = this._cardResourceTypeMap.get(this._selectedResourceType.name)!; - if (card.selected) { - card.focus(); - // clear the selected state of the previously selected card - this._resourceTypeCards.forEach(c => { - if (c !== card) { - c.selected = false; - } - }); - } else { - // keep the selected state if no other card is selected - if (this._resourceTypeCards.filter(c => { return c !== card && c.selected; }).length === 0) { - card.selected = true; - } - } - this._resourceDescriptionLabel.value = resourceType.description; this._agreementCheckboxChecked = false; this._agreementContainer.clearItems(); @@ -346,7 +329,7 @@ export class ResourceTypePickerDialog extends DialogBase { } private enableUiControlsWhenNotInstalling(enabled: boolean): void { - this._cardsContainer.enabled = enabled; + this._cardGroup.enabled = enabled; this._agreementContainer.enabled = enabled; this._optionsContainer.enabled = enabled; this._dialogObject.cancelButton.enabled = enabled; diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index 84a2a5723d..a17b54fccc 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -124,6 +124,42 @@ declare module 'azdata' { OssRdbms = 2 } + export interface ModelBuilder { + radioCardGroup(): ComponentBuilder; + } + + export interface RadioCard { + id: string; + label: string; + descriptions?: RadioCardDescription[]; + icon?: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri }; + } + + export interface RadioCardDescription { + ariaLabel: string; + labelHeader: string; + contents: RadioCardLabelValuePair[]; + valueHeader?: string; + } + + export interface RadioCardLabelValuePair { + label: string; + value?: string; + } + + export interface RadioCardGroupComponentProperties extends ComponentProperties, TitledComponentProperties { + cards: RadioCard[]; + cardWidth: string; + cardHeight: string; + iconWidth?: string; + iconHeight?: string; + selectedCardId?: string; + } + + export interface RadioCardGroupComponent extends Component, RadioCardGroupComponentProperties { + onSelectionChanged: vscode.Event; + } + export interface DeclarativeTableProperties extends ComponentProperties { } } diff --git a/src/sql/workbench/api/common/extHostModelView.ts b/src/sql/workbench/api/common/extHostModelView.ts index 636b2ce6a3..d24c25cbeb 100644 --- a/src/sql/workbench/api/common/extHostModelView.ts +++ b/src/sql/workbench/api/common/extHostModelView.ts @@ -226,6 +226,13 @@ class ModelBuilderImpl implements azdata.ModelBuilder { return builder; } + radioCardGroup(): azdata.ComponentBuilder { + let id = this.getNextComponentId(); + let builder: ComponentBuilderImpl = this.getComponentBuilder(new RadioCardGroupComponentWrapper(this._proxy, this._handle, id), id); + this._componentBuilders.set(id, builder); + return builder; + } + getComponentBuilder(component: ComponentWrapper, id: string): ComponentBuilderImpl { let componentBuilder: ComponentBuilderImpl = new ComponentBuilderImpl(component); this._componentBuilders.set(id, componentBuilder); @@ -1591,6 +1598,66 @@ class HyperlinkComponentWrapper extends ComponentWrapper implements azdata.Hyper } } +class RadioCardGroupComponentWrapper extends ComponentWrapper implements azdata.RadioCardGroupComponent { + constructor(proxy: MainThreadModelViewShape, handle: number, id: string) { + super(proxy, handle, ModelComponentTypes.RadioCardGroup, id); + this.properties = {}; + this._emitterMap.set(ComponentEventType.onDidChange, new Emitter()); + } + + public get iconWidth(): string | undefined { + return this.properties['iconWidth']; + } + + public set iconWidth(v: string | undefined) { + this.setProperty('iconWidth', v); + } + + public get iconHeight(): string | undefined { + return this.properties['iconHeight']; + } + + public set iconHeight(v: string | undefined) { + this.setProperty('iconHeight', v); + } + + public get cardWidth(): string | undefined { + return this.properties['cardWidth']; + } + + public set cardWidth(v: string | undefined) { + this.setProperty('cardWidth', v); + } + + public get cardHeight(): string | undefined { + return this.properties['cardHeight']; + } + + public set cardHeight(v: string | undefined) { + this.setProperty('cardHeight', v); + } + + public get cards(): azdata.RadioCard[] { + return this.properties['cards']; + } + public set cards(v: azdata.RadioCard[]) { + this.setProperty('cards', v); + } + + public get selectedCardId(): string | undefined { + return this.properties['selectedCardId']; + } + + public set selectedCardId(v: string | undefined) { + this.setProperty('selectedCardId', v); + } + + public get onSelectionChanged(): vscode.Event { + let emitter = this._emitterMap.get(ComponentEventType.onDidChange); + return emitter && emitter.event; + } +} + class GroupContainerComponentWrapper extends ComponentWrapper implements azdata.GroupContainer { constructor(proxy: MainThreadModelViewShape, handle: number, type: ModelComponentTypes, id: string) { super(proxy, handle, type, id); diff --git a/src/sql/workbench/api/common/sqlExtHostTypes.ts b/src/sql/workbench/api/common/sqlExtHostTypes.ts index ebf77e4065..2844c0f9b4 100644 --- a/src/sql/workbench/api/common/sqlExtHostTypes.ts +++ b/src/sql/workbench/api/common/sqlExtHostTypes.ts @@ -167,7 +167,8 @@ export enum ModelComponentTypes { DiffEditor, Dom, Hyperlink, - Image + Image, + RadioCardGroup } export enum ColumnSizingMode { diff --git a/src/sql/workbench/browser/modelComponents/componentWithIconBase.ts b/src/sql/workbench/browser/modelComponents/componentWithIconBase.ts index 4ca478dd80..309b85ad55 100644 --- a/src/sql/workbench/browser/modelComponents/componentWithIconBase.ts +++ b/src/sql/workbench/browser/modelComponents/componentWithIconBase.ts @@ -4,23 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { ChangeDetectorRef, ElementRef } from '@angular/core'; - -import { IComponentDescriptor } from 'sql/workbench/browser/modelComponents/interfaces'; import * as azdata from 'azdata'; -import { URI } from 'vs/base/common/uri'; -import { IdGenerator } from 'vs/base/common/idGenerator'; -import { createCSSRule, removeCSSRulesContainingSelector, asCSSUrl } from 'vs/base/browser/dom'; import { ComponentBase } from 'sql/workbench/browser/modelComponents/componentBase'; - - -export type IUserFriendlyIcon = string | URI | { light: string | URI; dark: string | URI }; +import { createIconCssClass, IUserFriendlyIcon } from 'sql/workbench/browser/modelComponents/iconUtils'; +import { IComponentDescriptor } from 'sql/workbench/browser/modelComponents/interfaces'; +import { removeCSSRulesContainingSelector } from 'vs/base/browser/dom'; +import { URI } from 'vs/base/common/uri'; export class ItemDescriptor { constructor(public descriptor: IComponentDescriptor, public config: T) { } } -const ids = new IdGenerator('model-view-component-icon-'); - export abstract class ComponentWithIconBase extends ComponentBase { protected _iconClass: string; @@ -40,42 +34,11 @@ export abstract class ComponentWithIconBase extends ComponentBase { protected updateIcon() { if (this.iconPath && this.iconPath !== this._iconPath) { this._iconPath = this.iconPath; - if (!this._iconClass) { - this._iconClass = ids.nextId(); - } - - removeCSSRulesContainingSelector(this._iconClass); - const icon = this.getLightIconUri(this.iconPath); - const iconDark = this.getDarkIconUri(this.iconPath) || icon; - createCSSRule(`.icon.${this._iconClass}`, `background-image: ${asCSSUrl(icon)}`); - createCSSRule(`.vs-dark .icon.${this._iconClass}, .hc-black .icon.${this._iconClass}`, `background-image: ${asCSSUrl(iconDark)}`); + this._iconClass = createIconCssClass(this.iconPath, this._iconClass); this._changeRef.detectChanges(); } } - private getLightIconUri(iconPath: IUserFriendlyIcon): URI { - if (iconPath && iconPath['light']) { - return this.getIconUri(iconPath['light']); - } else { - return this.getIconUri(iconPath); - } - } - - private getDarkIconUri(iconPath: IUserFriendlyIcon): URI { - if (iconPath && iconPath['dark']) { - return this.getIconUri(iconPath['dark']); - } - return null; - } - - private getIconUri(iconPath: string | URI): URI { - if (typeof iconPath === 'string') { - return URI.file(iconPath); - } else { - return URI.revive(iconPath); - } - } - public getIconWidth(): string { return this.convertSize(this.iconWidth, '40px'); } diff --git a/src/sql/workbench/browser/modelComponents/components.contribution.ts b/src/sql/workbench/browser/modelComponents/components.contribution.ts index 281a582d74..cebfff409a 100644 --- a/src/sql/workbench/browser/modelComponents/components.contribution.ts +++ b/src/sql/workbench/browser/modelComponents/components.contribution.ts @@ -29,6 +29,7 @@ import { registerComponentType } from 'sql/platform/dashboard/browser/modelCompo import { ModelComponentTypes } from 'sql/workbench/api/common/sqlExtHostTypes'; import HyperlinkComponent from 'sql/workbench/browser/modelComponents/hyperlink.component'; import SplitViewContainer from 'sql/workbench/browser/modelComponents/splitviewContainer.component'; +import RadioCardGroup from 'sql/workbench/browser/modelComponents/radioCardGroup.component'; export const DIV_CONTAINER = 'div-container'; registerComponentType(DIV_CONTAINER, ModelComponentTypes.DivContainer, DivContainer); @@ -105,3 +106,7 @@ registerComponentType(DOM_COMPONENT, ModelComponentTypes.Dom, DomComponent); export const HYPERLINK_COMPONENT = 'hyperlink-component'; registerComponentType(HYPERLINK_COMPONENT, ModelComponentTypes.Hyperlink, HyperlinkComponent); + +export const RADIOCARDGROUP_COMPONENT = 'radiocardgroup-component'; +registerComponentType(RADIOCARDGROUP_COMPONENT, ModelComponentTypes.RadioCardGroup, RadioCardGroup); + diff --git a/src/sql/workbench/browser/modelComponents/iconUtils.ts b/src/sql/workbench/browser/modelComponents/iconUtils.ts new file mode 100644 index 0000000000..6e6c7d0d36 --- /dev/null +++ b/src/sql/workbench/browser/modelComponents/iconUtils.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { asCSSUrl, createCSSRule, removeCSSRulesContainingSelector } from 'vs/base/browser/dom'; +import { IdGenerator } from 'vs/base/common/idGenerator'; +import { URI } from 'vs/base/common/uri'; + +const ids = new IdGenerator('model-view-component-icon-'); + +export type IUserFriendlyIcon = string | URI | { light: string | URI; dark: string | URI }; + +/** + * Create a CSS class for the specified icon, if a class with the name already exists, it will be deleted first. + * @param iconPath icon specification + * @param className optional, the class name you want to reuse. + * @returns the CSS class name + */ +export function createIconCssClass(iconPath: IUserFriendlyIcon, className?: string): string { + let iconClass = className; + if (!iconClass) { + iconClass = ids.nextId(); + } + removeCSSRulesContainingSelector(iconClass); + const icon = getLightIconUri(iconPath); + const iconDark = getDarkIconUri(iconPath) || icon; + createCSSRule(`.icon.${iconClass}`, `background-image: ${asCSSUrl(icon)}`); + createCSSRule(`.vs-dark .icon.${iconClass}, .hc-black .icon.${iconClass}`, `background-image: ${asCSSUrl(iconDark)}`); + return iconClass; +} + +function getLightIconUri(iconPath: IUserFriendlyIcon): URI { + if (iconPath && iconPath['light']) { + return getIconUri(iconPath['light']); + } else { + return getIconUri(iconPath); + } +} + +function getDarkIconUri(iconPath: IUserFriendlyIcon): URI { + if (iconPath && iconPath['dark']) { + return getIconUri(iconPath['dark']); + } + return null; +} + +function getIconUri(iconPath: string | URI): URI { + if (typeof iconPath === 'string') { + return URI.file(iconPath); + } else { + return URI.revive(iconPath); + } +} diff --git a/src/sql/workbench/browser/modelComponents/media/card.css b/src/sql/workbench/browser/modelComponents/media/card.css index 4dcca366c6..8b67b22391 100644 --- a/src/sql/workbench/browser/modelComponents/media/card.css +++ b/src/sql/workbench/browser/modelComponents/media/card.css @@ -13,16 +13,7 @@ border-style: solid; text-align: left; vertical-align: top; -} - -.model-card-list-item.selected, .model-card.selected { - border-color: rgb(0, 120, 215); - box-shadow: rgba(0, 120, 215, 0.75) 0px 0px 6px; -} - -.model-card-list-item.unselected, .model-card.unselected { border-color: rgb(214, 214, 214); - box-shadow: none; } .model-card .card-content { @@ -122,7 +113,7 @@ border-radius: 50%; background-color: white; border-width: 1px; - border-color: rgb(0, 120, 215); + border-color: rgb(214, 214, 214); border-style: solid; } @@ -207,3 +198,22 @@ .model-card-list-item-description-value { float: right; } + +.card-group { + display: flex; + flex-flow: row; +} + +.model-card-description-table { + margin-bottom: 10px; +} + +.model-card-description-label-column { + text-align: left; + width: 100%; +} + +.model-card-description-value-column { + text-align: right; + white-space: nowrap; +} diff --git a/src/sql/workbench/browser/modelComponents/radioCardGroup.component.html b/src/sql/workbench/browser/modelComponents/radioCardGroup.component.html new file mode 100644 index 0000000000..3c597e5232 --- /dev/null +++ b/src/sql/workbench/browser/modelComponents/radioCardGroup.component.html @@ -0,0 +1,31 @@ +
+ +
diff --git a/src/sql/workbench/browser/modelComponents/radioCardGroup.component.ts b/src/sql/workbench/browser/modelComponents/radioCardGroup.component.ts new file mode 100644 index 0000000000..3b0a6d4bfd --- /dev/null +++ b/src/sql/workbench/browser/modelComponents/radioCardGroup.component.ts @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * 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, QueryList, ViewChildren } from '@angular/core'; +import * as azdata from 'azdata'; +import { ComponentBase } from 'sql/workbench/browser/modelComponents/componentBase'; +import { createIconCssClass } from 'sql/workbench/browser/modelComponents/iconUtils'; +import { ComponentEventType, IComponent, IComponentDescriptor, IModelStore } from 'sql/workbench/browser/modelComponents/interfaces'; +import * as DOM from 'vs/base/browser/dom'; +import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import 'vs/css!./media/card'; +import { ILogService } from 'vs/platform/log/common/log'; + +@Component({ + templateUrl: decodeURI(require.toUrl('./radioCardGroup.component.html')) + +}) +export default class RadioCardGroup extends ComponentBase implements IComponent, OnDestroy { + @Input() descriptor: IComponentDescriptor; + @Input() modelStore: IModelStore; + @ViewChildren('cardDiv') cardElements: QueryList; + + private selectedCard: azdata.RadioCard; + private focusedCard: azdata.RadioCard; + private iconClasses: { [key: string]: string } = {}; + + constructor( + @Inject(forwardRef(() => ChangeDetectorRef)) changeRef: ChangeDetectorRef, + @Inject(forwardRef(() => ElementRef)) el: ElementRef, + @Inject(ILogService) private _logService: ILogService + ) { + super(changeRef, el); + } + + ngOnInit(): void { + this.baseInit(); + } + + setLayout(layout: any): void { + this.layout(); + } + + ngOnDestroy(): void { + Object.keys(this.iconClasses).forEach((key) => { + DOM.removeCSSRulesContainingSelector(this.iconClasses[key]); + }); + this.baseDestroy(); + } + + onKeyDown(event: KeyboardEvent): void { + if (!this.enabled || this.cards.length === 0) { + return; + } + + let e = new StandardKeyboardEvent(event); + if (e.keyCode === KeyCode.Enter || e.keyCode === KeyCode.Space) { + if (this.focusedCard && !this.selectedCard) { + this.selectCard(this.focusedCard); + } + DOM.EventHelper.stop(e, true); + } + else if (e.keyCode === KeyCode.LeftArrow || e.keyCode === KeyCode.UpArrow) { + if (this.focusedCard) { + this.selectCard(this.findPreviousCard(this.focusedCard)); + } + DOM.EventHelper.stop(e, true); + } else if (e.keyCode === KeyCode.RightArrow || e.keyCode === KeyCode.DownArrow) { + if (this.focusedCard) { + this.selectCard(this.findNextCard(this.focusedCard)); + } + DOM.EventHelper.stop(e, true); + } + } + + private findPreviousCard(currentCard: azdata.RadioCard): azdata.RadioCard { + const currentIndex = this.cards.indexOf(currentCard); + const previousCardIndex = currentIndex === 0 ? this.cards.length - 1 : currentIndex - 1; + return this.cards[previousCardIndex]; + } + + private findNextCard(currentCard: azdata.RadioCard): azdata.RadioCard { + const currentIndex = this.cards.indexOf(currentCard); + const nextCardIndex = currentIndex === this.cards.length - 1 ? 0 : currentIndex + 1; + return this.cards[nextCardIndex]; + } + + public get cards(): azdata.RadioCard[] { + return this.getPropertyOrDefault((props) => props.cards, []); + } + + public get cardWidth(): string | undefined { + return this.getPropertyOrDefault((props) => props.cardWidth, undefined); + } + + public get cardHeight(): string | undefined { + return this.getPropertyOrDefault((props) => props.cardHeight, undefined); + } + + public get iconWidth(): string | undefined { + return this.getPropertyOrDefault((props) => props.iconWidth, undefined); + } + + public get iconHeight(): string | undefined { + return this.getPropertyOrDefault((props) => props.iconHeight, undefined); + } + + public get selectedCardId(): string | undefined { + return this.getPropertyOrDefault((props) => props.selectedCardId, undefined); + } + + public getIconClass(card: azdata.RadioCard): string { + if (!this.iconClasses[card.id]) { + this.iconClasses[card.id] = `cardIcon icon ${createIconCssClass(card.icon)}`; + } + return this.iconClasses[card.id]; + } + + public setProperties(properties: { [key: string]: any }) { + super.setProperties(properties); + // This is the entry point for the extension to set the selectedCardId + if (this.selectedCardId) { + const filteredCards = this.cards.filter(c => { return c.id === this.selectedCardId; }); + if (filteredCards.length === 1) { + this.selectCard(filteredCards[0]); + } else { + this._logService.error(`There should be one and only one matching card for the giving selectedCardId, actual number: ${filteredCards.length}, selectedCardId: ${this.selectedCardId} $`); + } + } + } + + public selectCard(card: azdata.RadioCard): void { + if (!this.enabled || this.selectedCard === card || this.cards.indexOf(card) === -1) { + return; + } + this.selectedCard = card; + this._changeRef.detectChanges(); + const cardElement = this.getCardElement(this.selectedCard); + cardElement.nativeElement.focus(); + this.setPropertyFromUI((props, value) => props.selectedCardId = value, card.id); + this.fireEvent({ + eventType: ComponentEventType.onDidChange, + args: this.selectedCard.id + }); + } + + public getCardElement(card: azdata.RadioCard): ElementRef { + return this.cardElements.toArray()[this.cards.indexOf(card)]; + } + + public getTabIndex(card: azdata.RadioCard): number { + if (!this.enabled) { + return -1; + } + else if (!this.selectedCard) { + return this.cards.indexOf(card) === 0 ? 0 : -1; + } else { + return card === this.selectedCard ? 0 : -1; + } + } + + public isCardSelected(card: azdata.RadioCard): boolean { + return card === this.selectedCard; + } + + public onCardFocus(card: azdata.RadioCard): void { + this.focusedCard = card; + this._changeRef.detectChanges(); + } + + public onCardBlur(card: azdata.RadioCard): void { + this.focusedCard = undefined; + this._changeRef.detectChanges(); + } +}