diff --git a/extensions/mssql/package.json b/extensions/mssql/package.json index e63547ea72..9331ec341b 100644 --- a/extensions/mssql/package.json +++ b/extensions/mssql/package.json @@ -767,6 +767,9 @@ "supportedExecutionPlanFileExtensions": [ "sqlplan" ], + "connectionStringOptions": { + "isEnabled": true + }, "iconPath": [ { "id": "mssql:cloud", diff --git a/src/sql/base/browser/ui/radioButton/radioButton.ts b/src/sql/base/browser/ui/radioButton/radioButton.ts index 60b65a03b7..5b2aa60245 100644 --- a/src/sql/base/browser/ui/radioButton/radioButton.ts +++ b/src/sql/base/browser/ui/radioButton/radioButton.ts @@ -40,6 +40,7 @@ export class RadioButton extends Widget { this.label = opts.label; this.enabled = opts.enabled || true; this.checked = opts.checked || false; + this._internalCheckedStateTracker = this.checked; this.onclick(this.inputElement, () => { this._onClicked.fire(); this.checked = true; diff --git a/src/sql/platform/capabilities/common/capabilitiesService.ts b/src/sql/platform/capabilities/common/capabilitiesService.ts index 41a8a75066..0c7e0d0b8d 100644 --- a/src/sql/platform/capabilities/common/capabilitiesService.ts +++ b/src/sql/platform/capabilities/common/capabilitiesService.ts @@ -20,6 +20,20 @@ export const clientCapabilities = { hostVersion: HOST_VERSION }; +/** + * The connection string options for connection provider. + */ +export interface ConnectionStringOptions { + /** + * Whether the connection provider supports connection string as an input option. The default value is false. + */ + isEnabled?: boolean; + /** + * Whether the connection provider uses connection string as the default option to connect. The default value is false. + */ + isDefault?: boolean; +} + export interface ConnectionProviderProperties { providerId: string; iconPath?: URI | IconPath | { id: string, path: IconPath, default?: boolean }[] @@ -29,6 +43,7 @@ export interface ConnectionProviderProperties { connectionOptions: azdata.ConnectionOption[]; isQueryProvider?: boolean; supportedExecutionPlanFileExtensions?: string[]; + connectionStringOptions?: ConnectionStringOptions; } export interface ProviderFeatures { diff --git a/src/sql/platform/capabilities/common/capabilitiesServiceImpl.ts b/src/sql/platform/capabilities/common/capabilitiesServiceImpl.ts index e546fcf91c..7b3d7ab760 100644 --- a/src/sql/platform/capabilities/common/capabilitiesServiceImpl.ts +++ b/src/sql/platform/capabilities/common/capabilitiesServiceImpl.ts @@ -40,6 +40,10 @@ export class CapabilitiesService extends Disposable implements ICapabilitiesServ } // By default isQueryProvider is true. provider.connection.isQueryProvider = provider.connection.isQueryProvider !== false; + provider.connection.connectionStringOptions = { + isEnabled: !!(provider.connection.connectionStringOptions?.isEnabled), + isDefault: !!(provider.connection.connectionStringOptions?.isDefault) + }; this._onCapabilitiesRegistered.fire({ id, features: provider }); } diff --git a/src/sql/workbench/contrib/connection/browser/connection.contribution.ts b/src/sql/workbench/contrib/connection/browser/connection.contribution.ts index dce70d2ae5..7ff0086d19 100644 --- a/src/sql/workbench/contrib/connection/browser/connection.contribution.ts +++ b/src/sql/workbench/contrib/connection/browser/connection.contribution.ts @@ -193,11 +193,6 @@ configurationRegistry.registerConfiguration({ 'description': localize('sql.defaultEngineDescription', "Default SQL Engine to use. This drives default language provider in .sql files and the default to use when creating a new connection."), 'default': 'MSSQL' }, - 'connection.parseClipboardForConnectionString': { - 'type': 'boolean', - 'default': true, - 'description': localize('connection.parseClipboardForConnectionStringDescription', "Attempt to parse the contents of the clipboard when the connection dialog is opened or a paste is performed.") - }, 'connection.showUnsupportedServerVersionWarning': { 'type': 'boolean', 'default': true, diff --git a/src/sql/workbench/contrib/connection/common/connectionProviderExtension.ts b/src/sql/workbench/contrib/connection/common/connectionProviderExtension.ts index 2f3b0302f6..94f0bc514e 100644 --- a/src/sql/workbench/contrib/connection/common/connectionProviderExtension.ts +++ b/src/sql/workbench/contrib/connection/common/connectionProviderExtension.ts @@ -34,6 +34,19 @@ const ConnectionProviderContrib: IJSONSchema = { type: 'boolean', description: localize('schema.isQueryProvider', "Whether the provider is also a query provider. The default value is true.") }, + connectionStringOptions: { + type: 'object', + properties: { + isEnabled: { + type: 'boolean', + description: localize('schema.enableConnectionStringOption', "Whether the provider supports connection string as an input option. The default value is false.") + }, + isDefaultOption: { + type: 'boolean', + description: localize('schema.useConnectionStringAsDefaultOption', "Whether the connection provider uses connection string as the default option to connect. The default value is false.") + } + }, + }, iconPath: { description: localize('schema.iconPath', "Icon path for the server type"), oneOf: [ diff --git a/src/sql/workbench/services/connection/browser/cmsConnectionWidget.ts b/src/sql/workbench/services/connection/browser/cmsConnectionWidget.ts index 26fad1c595..ef81810ede 100644 --- a/src/sql/workbench/services/connection/browser/cmsConnectionWidget.ts +++ b/src/sql/workbench/services/connection/browser/cmsConnectionWidget.ts @@ -13,7 +13,6 @@ import { IConnectionProfile } from 'sql/platform/connection/common/interfaces'; import { ConnectionOptionSpecialType } from 'sql/workbench/api/common/sqlExtHostTypes'; import * as Constants from 'sql/platform/connection/common/constants'; import { IConnectionManagementService } from 'sql/platform/connection/common/connectionManagement'; -import { ICapabilitiesService } from 'sql/platform/capabilities/common/capabilitiesService'; import * as styler from 'sql/platform/theme/common/styler'; import { IAccountManagementService } from 'sql/platform/accounts/common/interfaces'; @@ -23,11 +22,10 @@ import { localize } from 'vs/nls'; import * as DOM from 'vs/base/browser/dom'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { OS, OperatingSystem } from 'vs/base/common/platform'; -import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; import { ConnectionWidget, AuthenticationType } from 'sql/workbench/services/connection/browser/connectionWidget'; import { ILogService } from 'vs/platform/log/common/log'; +import { IErrorMessageService } from 'sql/platform/errorMessage/common/errorMessageService'; /** * Connection Widget clas for CMS Connections @@ -46,14 +44,11 @@ export class CmsConnectionWidget extends ConnectionWidget { @IContextViewService _contextViewService: IContextViewService, @ILayoutService _layoutService: ILayoutService, @IConnectionManagementService _connectionManagementService: IConnectionManagementService, - @ICapabilitiesService _capabilitiesService: ICapabilitiesService, - @IClipboardService _clipboardService: IClipboardService, - @IConfigurationService _configurationService: IConfigurationService, @IAccountManagementService _accountManagementService: IAccountManagementService, @ILogService _logService: ILogService, + @IErrorMessageService _errorMessageService: IErrorMessageService, ) { - super(options, callbacks, providerName, _themeService, _contextViewService, _connectionManagementService, _capabilitiesService, - _clipboardService, _configurationService, _accountManagementService, _logService); + super(options, callbacks, providerName, _themeService, _contextViewService, _connectionManagementService, _accountManagementService, _logService, _errorMessageService); let authTypeOption = this._optionsMaps[ConnectionOptionSpecialType.authType]; if (authTypeOption) { let authTypeDefault = this.getAuthTypeDefault(authTypeOption, OS); @@ -132,10 +127,6 @@ export class CmsConnectionWidget extends ConnectionWidget { if (this._authTypeSelectBox) { this.onAuthTypeSelected(this._authTypeSelectBox.value); } - - DOM.addDisposableListener(container, 'paste', e => { - this._handleClipboard().catch(err => this._logService.error(`Unexpected error parsing clipboard contents for CMS Connection Dialog ${err}`)); - }); } public override handleOnConnecting(): void { @@ -156,8 +147,8 @@ export class CmsConnectionWidget extends ConnectionWidget { return this._serverDescriptionInputBox.value; } - public override connect(model: IConnectionProfile): boolean { - let validInputs = super.connect(model); + public override async connect(model: IConnectionProfile): Promise { + let validInputs = await super.connect(model); if (this._serverDescriptionInputBox) { model.options.registeredServerDescription = this._serverDescriptionInputBox.value; model.options.registeredServerName = this._connectionNameInputBox.value; diff --git a/src/sql/workbench/services/connection/browser/connectionController.ts b/src/sql/workbench/services/connection/browser/connectionController.ts index 654fdccb75..2098b302c4 100644 --- a/src/sql/workbench/services/connection/browser/connectionController.ts +++ b/src/sql/workbench/services/connection/browser/connectionController.ts @@ -177,8 +177,8 @@ export class ConnectionController implements IConnectionComponentController { this._connectionWidget.focusOnOpen(); } - public validateConnection(): IConnectionValidateResult { - return { isValid: this._connectionWidget.connect(this._model), connection: this._model }; + public async validateConnection(): Promise { + return { isValid: await this._connectionWidget.connect(this._model), connection: this._model }; } public fillInConnectionInputs(connectionInfo: IConnectionProfile): void { diff --git a/src/sql/workbench/services/connection/browser/connectionDialogService.ts b/src/sql/workbench/services/connection/browser/connectionDialogService.ts index a0e5354b68..9b89acfa77 100644 --- a/src/sql/workbench/services/connection/browser/connectionDialogService.ts +++ b/src/sql/workbench/services/connection/browser/connectionDialogService.ts @@ -49,7 +49,7 @@ export interface IConnectionComponentCallbacks { export interface IConnectionComponentController { showUiComponent(container: HTMLElement, didChange?: boolean): void; initDialog(providers: string[], model: IConnectionProfile): void; - validateConnection(): IConnectionValidateResult; + validateConnection(): Promise; fillInConnectionInputs(connectionInfo: IConnectionProfile): void; handleOnConnecting(): void; handleResetConnection(): void; @@ -162,14 +162,14 @@ export class ConnectionDialogService implements IConnectionDialogService { } - private handleOnConnect(params: INewConnectionParams, profile?: IConnectionProfile): void { + private async handleOnConnect(params: INewConnectionParams, profile?: IConnectionProfile): Promise { this._logService.debug('ConnectionDialogService: onConnect event is received'); if (!this._connecting) { this._logService.debug('ConnectionDialogService: Start connecting'); this._connecting = true; this.handleProviderOnConnecting(); if (!profile) { - let result = this.uiController.validateConnection(); + let result = await this.uiController.validateConnection(); if (!result.isValid) { this._logService.debug('ConnectionDialogService: Connection is invalid'); this._connecting = false; @@ -472,8 +472,8 @@ export class ConnectionDialogService implements IConnectionDialogService { this._connectionDialog.databaseDropdownExpanded = this.uiController.databaseDropdownExpanded; this.handleOnCancel(this._connectionDialog.newConnectionParams); }); - this._connectionDialog.onConnect((profile) => { - this.handleOnConnect(this._connectionDialog.newConnectionParams, profile as IConnectionProfile); + this._connectionDialog.onConnect(async (profile) => { + await this.handleOnConnect(this._connectionDialog.newConnectionParams, profile as IConnectionProfile); }); this._connectionDialog.onShowUiComponent((input) => this.handleShowUiComponent(input)); this._connectionDialog.onInitDialog(() => this.handleInitDialog()); diff --git a/src/sql/workbench/services/connection/browser/connectionWidget.ts b/src/sql/workbench/services/connection/browser/connectionWidget.ts index eb3bddffb5..dd928ba4ab 100644 --- a/src/sql/workbench/services/connection/browser/connectionWidget.ts +++ b/src/sql/workbench/services/connection/browser/connectionWidget.ts @@ -15,8 +15,6 @@ import { IConnectionProfile } from 'sql/platform/connection/common/interfaces'; import { ConnectionOptionSpecialType } from 'sql/workbench/api/common/sqlExtHostTypes'; import { ConnectionProfileGroup, IConnectionProfileGroup } from 'sql/platform/connection/common/connectionProfileGroup'; import { IConnectionManagementService } from 'sql/platform/connection/common/connectionManagement'; -import { ICapabilitiesService } from 'sql/platform/capabilities/common/capabilitiesService'; -import { ConnectionProfile } from 'sql/platform/connection/common/connectionProfile'; import * as styler from 'sql/platform/theme/common/styler'; import { IAccountManagementService } from 'sql/platform/accounts/common/interfaces'; @@ -28,12 +26,15 @@ import { localize } from 'vs/nls'; import * as DOM from 'vs/base/browser/dom'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { OS, OperatingSystem } from 'vs/base/common/platform'; -import { MessageType } from 'vs/base/browser/ui/inputbox/inputBox'; -import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IMessage, MessageType } from 'vs/base/browser/ui/inputbox/inputBox'; import { ILogService } from 'vs/platform/log/common/log'; import { attachButtonStyler } from 'vs/platform/theme/common/styler'; import { Dropdown } from 'sql/base/browser/ui/editableDropdown/browser/dropdown'; +import { RadioButton } from 'sql/base/browser/ui/radioButton/radioButton'; +import { IErrorMessageService } from 'sql/platform/errorMessage/common/errorMessageService'; +import Severity from 'vs/base/common/severity'; +import { ConnectionStringOptions } from 'sql/platform/capabilities/common/capabilitiesService'; +import { isFalsyOrWhitespace } from 'vs/base/common/strings'; export enum AuthenticationType { SqlLogin = 'SqlLogin', @@ -44,9 +45,14 @@ export enum AuthenticationType { None = 'None' // Kusto supports no authentication } +const ConnectionStringText = localize('connectionWidget.connectionString', "Connection string"); + export class ConnectionWidget extends lifecycle.Disposable { + private _defaultInputOptionRadioButton: RadioButton; + private _connectionStringRadioButton: RadioButton; private _previousGroupOption: string; private _serverGroupOptions: IConnectionProfileGroup[]; + private _connectionStringInputBox: InputBox; private _serverNameInputBox: InputBox; private _userNameInputBox: InputBox; private _passwordInputBox: InputBox; @@ -66,6 +72,7 @@ export class ConnectionWidget extends lifecycle.Disposable { private _loadingDatabaseName: string = localize('loadingDatabaseOption', "Loading..."); private _serverGroupDisplayString: string = localize('serverGroup', "Server group"); private _token: string; + private _connectionStringOptions: ConnectionStringOptions; protected _container: HTMLElement; protected _serverGroupSelectBox: SelectBox; protected _authTypeSelectBox: SelectBox; @@ -110,11 +117,9 @@ export class ConnectionWidget extends lifecycle.Disposable { @IThemeService protected _themeService: IThemeService, @IContextViewService protected _contextViewService: IContextViewService, @IConnectionManagementService private _connectionManagementService: IConnectionManagementService, - @ICapabilitiesService private _capabilitiesService: ICapabilitiesService, - @IClipboardService private _clipboardService: IClipboardService, - @IConfigurationService private _configurationService: IConfigurationService, @IAccountManagementService private _accountManagementService: IAccountManagementService, @ILogService protected _logService: ILogService, + @IErrorMessageService private _errorMessageService: IErrorMessageService ) { super(); this._callbacks = callbacks; @@ -129,8 +134,10 @@ export class ConnectionWidget extends lifecycle.Disposable { let authTypeDefault = this.getAuthTypeDefault(authTypeOption, OS); let authTypeDefaultDisplay = this.getAuthTypeDisplayName(authTypeDefault); this._authTypeSelectBox = new SelectBox(authTypeOption.categoryValues.map(c => c.displayName), authTypeDefaultDisplay, this._contextViewService, undefined, { ariaLabel: authTypeOption.displayName }); + this._register(this._authTypeSelectBox); } this._providerName = providerName; + this._connectionStringOptions = this._connectionManagementService.getProviderProperties(this._providerName).connectionStringOptions; } protected getAuthTypeDefault(option: azdata.ConnectionOption, os: OperatingSystem): string { @@ -159,6 +166,7 @@ export class ConnectionWidget extends lifecycle.Disposable { public createConnectionWidget(container: HTMLElement, authTypeChanged: boolean = false): void { this._serverGroupOptions = [this.DefaultServerGroup]; this._serverGroupSelectBox = new SelectBox(this._serverGroupOptions.map(g => g.name), this.DefaultServerGroup.name, this._contextViewService, undefined, { ariaLabel: this._serverGroupDisplayString }); + this._register(this._serverGroupSelectBox); this._previousGroupOption = this._serverGroupSelectBox.value; this._container = DOM.append(container, DOM.$('div.connection-table')); this._tableContainer = DOM.append(this._container, DOM.$('table.connection-table-content')); @@ -168,53 +176,77 @@ export class ConnectionWidget extends lifecycle.Disposable { if (this._authTypeSelectBox) { this.onAuthTypeSelected(this._authTypeSelectBox.value); } - - DOM.addDisposableListener(container, 'paste', e => { - this._handleClipboard().catch(err => this._logService.error(`Unexpected error parsing clipboard contents for connection widget : ${err}`)); - }); - } - - protected async _handleClipboard(): Promise { - if (this._configurationService.getValue('connection.parseClipboardForConnectionString')) { - let paste = await this._clipboardService.readText(); - this._connectionManagementService.buildConnectionInfo(paste, this._providerName).then(e => { - if (e) { - let profile = new ConnectionProfile(this._capabilitiesService, this._providerName); - profile.options = e.options; - if (profile.serverName) { - this.initDialog(profile); - } - } - }); - } } protected fillInConnectionForm(authTypeChanged: boolean = false): void { - // Server Name + this.addInputOptionRadioButtons(); + this.addConnectionStringInput(); this.addServerNameOption(); - - // Authentication type this.addAuthenticationTypeOption(authTypeChanged); - - // Login Options this.addLoginOptions(); - - // Database this.addDatabaseOption(); - - // Server Group this.addServerGroupOption(); - - // Connection Name this.addConnectionNameOptions(); - - // Advanced Options this.addAdvancedOptions(); + this.updateRequiredStateForOptions(); + if (this._connectionStringOptions.isEnabled) { + // update the UI based on connection string setting after initialization + this.handleConnectionStringOptionChange(); + } + } + + private validateRequiredOptionValue(value: string, optionName: string): IMessage | undefined { + return isFalsyOrWhitespace(value) ? ({ type: MessageType.ERROR, content: localize('connectionWidget.missingRequireField', "{0} is required.", optionName) }) : undefined; + } + + private addInputOptionRadioButtons(): void { + if (this._connectionStringOptions.isEnabled) { + const groupName = 'input-option-type'; + const inputOptionsContainer = DialogHelper.appendRow(this._tableContainer, '', 'connection-label', 'connection-input', 'connection-input-options'); + this._defaultInputOptionRadioButton = new RadioButton(inputOptionsContainer, { label: 'Parameters', checked: !this._connectionStringOptions.isDefault }); + this._connectionStringRadioButton = new RadioButton(inputOptionsContainer, { label: 'Connection String', checked: this._connectionStringOptions.isDefault }); + this._defaultInputOptionRadioButton.name = groupName; + this._connectionStringRadioButton.name = groupName; + this._register(this._defaultInputOptionRadioButton); + this._register(this._connectionStringRadioButton); + this._register(this._defaultInputOptionRadioButton.onDidChangeCheckedState(() => { + this.handleConnectionStringOptionChange(); + })); + } + } + + private addConnectionStringInput(): void { + if (this._connectionStringOptions.isEnabled) { + const connectionStringContainer = DialogHelper.appendRow(this._tableContainer, ConnectionStringText, 'connection-label', 'connection-input', 'connection-string-row', true); + this._connectionStringInputBox = new InputBox(connectionStringContainer, this._contextViewService, { + validationOptions: { + validation: (value: string) => { + return this.validateRequiredOptionValue(value, ConnectionStringText); + } + }, + ariaLabel: ConnectionStringText, + flexibleHeight: true, + flexibleMaxHeight: 100 + }); + this._register(this._connectionStringInputBox); + this._register(this._connectionStringInputBox.onDidChange(() => { + this.setConnectButton(); + })); + } + } + + private updateRequiredStateForOptions(): void { + if (this._connectionStringInputBox) { + this._connectionStringInputBox.required = this.useConnectionString; + } + const userNameOption: azdata.ConnectionOption = this._optionsMaps[ConnectionOptionSpecialType.userName]; + this._serverNameInputBox.required = !this.useConnectionString; + this._userNameInputBox.required = (!this.useConnectionString) && userNameOption?.isRequired; } protected addAuthenticationTypeOption(authTypeChanged: boolean = false): void { if (this._optionsMaps[ConnectionOptionSpecialType.authType]) { - let authType = DialogHelper.appendRow(this._tableContainer, this._optionsMaps[ConnectionOptionSpecialType.authType].displayName, 'connection-label', 'connection-input'); + let authType = DialogHelper.appendRow(this._tableContainer, this._optionsMaps[ConnectionOptionSpecialType.authType].displayName, 'connection-label', 'connection-input', 'auth-type-row'); DialogHelper.appendInputSelectBox(authType, this._authTypeSelectBox); } } @@ -222,21 +254,16 @@ export class ConnectionWidget extends lifecycle.Disposable { protected addServerNameOption(): void { // Server name let serverNameOption = this._optionsMaps[ConnectionOptionSpecialType.serverName]; - let serverName = DialogHelper.appendRow(this._tableContainer, serverNameOption.displayName, 'connection-label', 'connection-input', undefined, true); + let serverName = DialogHelper.appendRow(this._tableContainer, serverNameOption.displayName, 'connection-label', 'connection-input', 'server-name-row', true); this._serverNameInputBox = new InputBox(serverName, this._contextViewService, { validationOptions: { validation: (value: string) => { - if (!value) { - return ({ type: MessageType.ERROR, content: localize('connectionWidget.missingRequireField', "{0} is required.", serverNameOption.displayName) }); - } else if (value.startsWith(' ') || value.endsWith(' ')) { - return ({ type: MessageType.WARNING, content: localize('connectionWidget.fieldWillBeTrimmed', "{0} will be trimmed.", serverNameOption.displayName) }); - } - return undefined; + return this.validateRequiredOptionValue(value, serverNameOption.displayName); } }, - ariaLabel: serverNameOption.displayName, - required: true + ariaLabel: serverNameOption.displayName }); + this._register(this._serverNameInputBox); } protected addLoginOptions(): void { @@ -248,23 +275,26 @@ export class ConnectionWidget extends lifecycle.Disposable { validationOptions: { validation: (value: string) => self.validateUsername(value, userNameOption.isRequired) ? ({ type: MessageType.ERROR, content: localize('connectionWidget.missingRequireField', "{0} is required.", userNameOption.displayName) }) : null }, - ariaLabel: userNameOption.displayName, - required: userNameOption.isRequired + ariaLabel: userNameOption.displayName }); + this._register(this._userNameInputBox); // Password let passwordOption = this._optionsMaps[ConnectionOptionSpecialType.password]; let password = DialogHelper.appendRow(this._tableContainer, passwordOption.displayName, 'connection-label', 'connection-input', 'password-row'); this._passwordInputBox = new InputBox(password, this._contextViewService, { ariaLabel: passwordOption.displayName }); this._passwordInputBox.inputElement.type = 'password'; + this._register(this._passwordInputBox); // Remember password let rememberPasswordLabel = localize('rememberPassword', "Remember password"); this._rememberPasswordCheckBox = this.appendCheckbox(this._tableContainer, rememberPasswordLabel, 'connection-input', 'password-row', false); + this._register(this._rememberPasswordCheckBox); // Azure account picker let accountLabel = localize('connection.azureAccountDropdownLabel', "Account"); let accountDropdown = DialogHelper.appendRow(this._tableContainer, accountLabel, 'connection-label', 'connection-input', 'azure-account-row'); this._azureAccountDropdown = new SelectBox([], undefined, this._contextViewService, accountDropdown, { ariaLabel: accountLabel }); + this._register(this._azureAccountDropdown); DialogHelper.appendInputSelectBox(accountDropdown, this._azureAccountDropdown); let refreshCredentials = DialogHelper.appendRow(this._tableContainer, '', 'connection-label', 'connection-input', ['azure-account-row', 'refresh-credentials-link']); this._refreshCredentialsLink = DOM.append(refreshCredentials, DOM.$('a')); @@ -274,6 +304,7 @@ export class ConnectionWidget extends lifecycle.Disposable { let tenantLabel = localize('connection.azureTenantDropdownLabel', "Azure AD tenant"); let tenantDropdown = DialogHelper.appendRow(this._tableContainer, tenantLabel, 'connection-label', 'connection-input', ['azure-account-row', 'azure-tenant-row']); this._azureTenantDropdown = new SelectBox([], undefined, this._contextViewService, tenantDropdown, { ariaLabel: tenantLabel }); + this._register(this._azureTenantDropdown); DialogHelper.appendInputSelectBox(tenantDropdown, this._azureTenantDropdown); } @@ -281,7 +312,7 @@ export class ConnectionWidget extends lifecycle.Disposable { // Database let databaseOption = this._optionsMaps[ConnectionOptionSpecialType.databaseName]; if (databaseOption) { - let databaseName = DialogHelper.appendRow(this._tableContainer, databaseOption.displayName, 'connection-label', 'connection-input'); + let databaseName = DialogHelper.appendRow(this._tableContainer, databaseOption.displayName, 'connection-label', 'connection-input', 'database-row'); this._databaseNameInputBox = new Dropdown(databaseName, this._contextViewService, { values: [this._defaultDatabaseName, this._loadingDatabaseName], strictSelection: false, @@ -289,6 +320,7 @@ export class ConnectionWidget extends lifecycle.Disposable { maxHeight: 125, ariaLabel: databaseOption.displayName }); + this._register(this._databaseNameInputBox); } } @@ -306,11 +338,34 @@ export class ConnectionWidget extends lifecycle.Disposable { connectionNameOption.displayName = localize('connectionName', "Name (optional)"); let connectionNameBuilder = DialogHelper.appendRow(this._tableContainer, connectionNameOption.displayName, 'connection-label', 'connection-input'); this._connectionNameInputBox = new InputBox(connectionNameBuilder, this._contextViewService, { ariaLabel: connectionNameOption.displayName }); + this._register(this._connectionNameInputBox); } protected addAdvancedOptions(): void { - let AdvancedLabel = localize('advanced', "Advanced..."); - this._advancedButton = this.createAdvancedButton(this._tableContainer, AdvancedLabel); + const rowContainer = DOM.append(this._tableContainer, DOM.$('tr.advanced-options-row')); + DOM.append(rowContainer, DOM.$('td')); + const buttonContainer = DOM.append(rowContainer, DOM.$('td')); + buttonContainer.setAttribute('align', 'right'); + const divContainer = DOM.append(buttonContainer, DOM.$('div.advanced-button')); + this._advancedButton = new Button(divContainer, { secondary: true }); + this._register(this._advancedButton); + this._advancedButton.label = localize('advanced', "Advanced..."); + this._register(this._advancedButton.onDidClick(() => { + //open advanced page + this._callbacks.onAdvancedProperties(); + })); + } + + private handleConnectionStringOptionChange(): void { + const connectionStringClass = 'use-connection-string'; + if (this.useConnectionString) { + this._tableContainer.classList.add(connectionStringClass); + this._connectionStringInputBox.layout(); + } else { + this._tableContainer.classList.remove(connectionStringClass); + } + this.updateRequiredStateForOptions(); + this.setConnectButton(); } private validateUsername(value: string, isOptionRequired: boolean): boolean { @@ -323,21 +378,6 @@ export class ConnectionWidget extends lifecycle.Disposable { return false; } - protected createAdvancedButton(container: HTMLElement, title: string): Button { - let rowContainer = DOM.append(container, DOM.$('tr')); - DOM.append(rowContainer, DOM.$('td')); - let cellContainer = DOM.append(rowContainer, DOM.$('td')); - cellContainer.setAttribute('align', 'right'); - let divContainer = DOM.append(cellContainer, DOM.$('div.advanced-button')); - let button = new Button(divContainer, { secondary: true }); - button.label = title; - button.onDidClick(() => { - //open advanced page - this._callbacks.onAdvancedProperties(); - }); - return button; - } - private appendCheckbox(container: HTMLElement, label: string, cellContainerClass: string, rowContainerClass: string, isChecked: boolean): Checkbox { let rowContainer = DOM.append(container, DOM.$(`tr.${rowContainerClass}`)); DOM.append(rowContainer, DOM.$('td')); @@ -389,6 +429,10 @@ export class ConnectionWidget extends lifecycle.Disposable { })); } + if (this._connectionStringInputBox) { + this._register(styler.attachInputBoxStyler(this._connectionStringInputBox, this._themeService)); + } + if (this._authTypeSelectBox) { // Theme styler this._register(styler.attachSelectBoxStyler(this._authTypeSelectBox, this._themeService)); @@ -443,12 +487,14 @@ export class ConnectionWidget extends lifecycle.Disposable { } private setConnectButton(): void { - let showUsername: boolean; - if (this.authType) { - showUsername = this.authType === AuthenticationType.SqlLogin || this.authType === AuthenticationType.AzureMFAAndUser; + let shouldEnableConnectButton: boolean; + if (this.useConnectionString) { + shouldEnableConnectButton = this._connectionStringInputBox.isInputValid(); + } else { + const showUsername: boolean = this.authType && (this.authType === AuthenticationType.SqlLogin || this.authType === AuthenticationType.AzureMFAAndUser); + shouldEnableConnectButton = showUsername ? (this._serverNameInputBox.isInputValid() && this._userNameInputBox.isInputValid()) : this._serverNameInputBox.isInputValid(); } - showUsername ? this._callbacks.onSetConnectButton(!!this.serverName && !!this.userName) : - this._callbacks.onSetConnectButton(!!this.serverName); + this._callbacks.onSetConnectButton(shouldEnableConnectButton); } protected onAuthTypeSelected(selectedAuthType: string) { @@ -623,9 +669,12 @@ export class ConnectionWidget extends lifecycle.Disposable { } public focusOnOpen(): void { - this._handleClipboard().catch(err => this._logService.error(`Unexpected error parsing clipboard contents for connection widget : ${err}`)); - this._serverNameInputBox.focus(); - this.focusPasswordIfNeeded(); + if (this.useConnectionString) { + this._connectionStringInputBox.focus(); + } else { + this._serverNameInputBox.focus(); + this.focusPasswordIfNeeded(); + } this.clearValidationMessages(); } @@ -633,6 +682,7 @@ export class ConnectionWidget extends lifecycle.Disposable { this._serverNameInputBox.hideMessage(); this._userNameInputBox.hideMessage(); this._azureAccountDropdown.hideMessage(); + this._connectionStringInputBox?.hideMessage(); } private getModelValue(value: string): string { @@ -641,6 +691,10 @@ export class ConnectionWidget extends lifecycle.Disposable { public fillInConnectionInputs(connectionInfo: IConnectionProfile) { if (connectionInfo) { + // If initializing from an existing connection, always switch to the parameters view. + if (connectionInfo.serverName && this._connectionStringOptions.isEnabled) { + this._defaultInputOptionRadioButton.checked = true; + } this._serverNameInputBox.value = this.getModelValue(connectionInfo.serverName); this._connectionNameInputBox.value = this.getModelValue(connectionInfo.connectionName); this._userNameInputBox.value = this.getModelValue(connectionInfo.userName); @@ -753,6 +807,12 @@ export class ConnectionWidget extends lifecycle.Disposable { if (this._authTypeSelectBox) { this._authTypeSelectBox.disable(); } + + if (this._connectionStringOptions.isEnabled) { + this._connectionStringInputBox.disable(); + this._defaultInputOptionRadioButton.enabled = false; + this._connectionStringRadioButton.enabled = false; + } } public handleResetConnection(): void { @@ -785,6 +845,19 @@ export class ConnectionWidget extends lifecycle.Disposable { if (this._databaseNameInputBox) { this._databaseNameInputBox.enabled = true; } + if (this._connectionStringOptions.isEnabled) { + this._connectionStringInputBox.enable(); + this._defaultInputOptionRadioButton.enabled = true; + this._connectionStringRadioButton.enabled = true; + } + } + + public get useConnectionString(): boolean { + return !!(this._connectionStringRadioButton?.checked); + } + + public get connectionString(): string { + return this._connectionStringInputBox?.value; } public get connectionName(): string { @@ -843,57 +916,76 @@ export class ConnectionWidget extends lifecycle.Disposable { } private validateInputs(): boolean { - let isFocused = false; - const isServerNameValid = this._serverNameInputBox.validate() === undefined; - if (!isServerNameValid) { - this._serverNameInputBox.focus(); - isFocused = true; + if (this.useConnectionString) { + const isConnectionStringValid = this._connectionStringInputBox.validate() === undefined; + if (!isConnectionStringValid) { + this._connectionStringInputBox.focus(); + } + return isConnectionStringValid; + } else { + let isFocused = false; + const isServerNameValid = this._serverNameInputBox.validate() === undefined; + if (!isServerNameValid) { + this._serverNameInputBox.focus(); + isFocused = true; + } + const isUserNameValid = this._userNameInputBox.validate() === undefined; + if (!isUserNameValid && !isFocused) { + this._userNameInputBox.focus(); + isFocused = true; + } + const isPasswordValid = this._passwordInputBox.validate() === undefined; + if (!isPasswordValid && !isFocused) { + this._passwordInputBox.focus(); + isFocused = true; + } + const isAzureAccountValid = this.validateAzureAccountSelection(); + if (!isAzureAccountValid && !isFocused) { + this._azureAccountDropdown.focus(); + isFocused = true; + } + return isServerNameValid && isUserNameValid && isPasswordValid && isAzureAccountValid; } - const isUserNameValid = this._userNameInputBox.validate() === undefined; - if (!isUserNameValid && !isFocused) { - this._userNameInputBox.focus(); - isFocused = true; - } - const isPasswordValid = this._passwordInputBox.validate() === undefined; - if (!isPasswordValid && !isFocused) { - this._passwordInputBox.focus(); - isFocused = true; - } - const isAzureAccountValid = this.validateAzureAccountSelection(); - if (!isAzureAccountValid && !isFocused) { - this._azureAccountDropdown.focus(); - isFocused = true; - } - return isServerNameValid && isUserNameValid && isPasswordValid && isAzureAccountValid; } - public connect(model: IConnectionProfile): boolean { + public async connect(model: IConnectionProfile): Promise { let validInputs = this.validateInputs(); if (validInputs) { - model.serverName = this.serverName; - model.userName = this.userName; - model.password = this.password; - model.authenticationType = this.authenticationType; - model.azureAccount = this.authToken; - model.savePassword = this._rememberPasswordCheckBox.checked; - model.connectionName = this.connectionName; - model.databaseName = this.databaseName; - if (this._serverGroupSelectBox) { - if (this._serverGroupSelectBox.value === this.DefaultServerGroup.name) { - model.groupFullName = ''; - model.saveProfile = true; - model.groupId = this.findGroupId(model.groupFullName); - } else if (this._serverGroupSelectBox.value === this.NoneServerGroup.name) { - model.groupFullName = ''; - model.saveProfile = false; - } else if (this._serverGroupSelectBox.value !== this._addNewServerGroup.name) { - model.groupFullName = this._serverGroupSelectBox.value; - model.saveProfile = true; - model.groupId = this.findGroupId(model.groupFullName); + if (this.useConnectionString) { + const connInfo = await this._connectionManagementService.buildConnectionInfo(this.connectionString, this._providerName); + if (connInfo) { + model.options = connInfo.options; + model.savePassword = true; + } else { + this._errorMessageService.showDialog(Severity.Error, localize('connectionWidget.Error', "Error"), localize('connectionWidget.ConnectionStringError', "Failed to parse the connection string.")); + return false; + } + } else { + model.serverName = this.serverName; + model.userName = this.userName; + model.password = this.password; + model.authenticationType = this.authenticationType; + model.azureAccount = this.authToken; + model.savePassword = this._rememberPasswordCheckBox.checked; + model.connectionName = this.connectionName; + model.databaseName = this.databaseName; + if (this._serverGroupSelectBox) { + if (this._serverGroupSelectBox.value === this.DefaultServerGroup.name) { + model.groupFullName = ''; + model.saveProfile = true; + model.groupId = this.findGroupId(model.groupFullName); + } else if (this._serverGroupSelectBox.value === this.NoneServerGroup.name) { + model.groupFullName = ''; + model.saveProfile = false; + } else if (this._serverGroupSelectBox.value !== this._addNewServerGroup.name) { + model.groupFullName = this._serverGroupSelectBox.value; + model.saveProfile = true; + model.groupId = this.findGroupId(model.groupFullName); + } + } + if (this.authType === AuthenticationType.AzureMFA || this.authType === AuthenticationType.AzureMFAAndUser) { + model.azureTenantId = this._azureTenantId; } - } - if (this.authType === AuthenticationType.AzureMFA || this.authType === AuthenticationType.AzureMFAAndUser) { - model.azureTenantId = this._azureTenantId; } } return validInputs; diff --git a/src/sql/workbench/services/connection/browser/media/connectionDialog.css b/src/sql/workbench/services/connection/browser/media/connectionDialog.css index 8b19e330f1..352569b633 100644 --- a/src/sql/workbench/services/connection/browser/media/connectionDialog.css +++ b/src/sql/workbench/services/connection/browser/media/connectionDialog.css @@ -49,7 +49,7 @@ } .connection-provider-info { - margin: 15px; + margin: 0 13px; } .connection-recent-content { @@ -146,3 +146,23 @@ /* Hide twisties */ display: none !important; } + +.connection-dialog .use-connection-string .username-row, +.connection-dialog .use-connection-string .password-row, +.connection-dialog .use-connection-string .server-name-row, +.connection-dialog .use-connection-string .azure-tenant-row, +.connection-dialog .use-connection-string .auth-type-row, +.connection-dialog .use-connection-string .database-row, +.connection-dialog .use-connection-string .advanced-button, +.connection-dialog .connection-string-row { + display: none; +} + +.use-connection-string .connection-string-row { + display: table-row; +} + +.connection-dialog .connection-input-options .connection-input label { + margin-right: 10px; +} + diff --git a/src/sql/workbench/services/connection/test/browser/connectionDialogService.test.ts b/src/sql/workbench/services/connection/test/browser/connectionDialogService.test.ts index 9e9497860f..b82c6b5d0d 100644 --- a/src/sql/workbench/services/connection/test/browser/connectionDialogService.test.ts +++ b/src/sql/workbench/services/connection/test/browser/connectionDialogService.test.ts @@ -52,7 +52,7 @@ import { TestTreeView } from 'sql/workbench/services/connection/test/browser/tes import { TestConfigurationService } from 'sql/platform/connection/test/common/testConfigurationService'; import { ConnectionTreeService, IConnectionTreeService } from 'sql/workbench/services/connection/common/connectionTreeService'; import { ConnectionBrowserView } from 'sql/workbench/services/connection/browser/connectionBrowseTab'; -import { ICapabilitiesService } from 'sql/platform/capabilities/common/capabilitiesService'; +import { ConnectionProviderProperties, ICapabilitiesService } from 'sql/platform/capabilities/common/capabilitiesService'; suite('ConnectionDialogService tests', () => { const testTreeViewId = 'testTreeView'; @@ -115,6 +115,14 @@ suite('ConnectionDialogService tests', () => { mockConnectionManagementService.setup(x => x.getConnectionGroups(TypeMoq.It.isAny())).returns(() => { return [new ConnectionProfileGroup('test_group', undefined, 'test_group')]; }); + mockConnectionManagementService.setup(x => x.getProviderProperties(TypeMoq.It.isAny())).returns(() => { + return { + connectionStringOptions: { + isEnabled: true, + isDefault: false + } + }; + }); testConnectionDialog = new TestConnectionDialogWidget(providerDisplayNames, providerNameToDisplayMap['MSSQL'], providerNameToDisplayMap, testInstantiationService, mockConnectionManagementService.object, undefined, undefined, viewDescriptorService, new TestThemeService(), new TestLayoutService(), new NullAdsTelemetryService(), new MockContextKeyService(), undefined, new NullLogService(), new TestTextResourcePropertiesService(new TestConfigurationService), new TestConfigurationService(), new TestCapabilitiesService()); testConnectionDialog.render(); testConnectionDialog['renderBody'](DOM.createStyleSheet()); @@ -169,11 +177,11 @@ suite('ConnectionDialogService tests', () => { mockConnectionManagementService.setup(x => x.addSavedPassword(TypeMoq.It.isAny())).returns(() => { return Promise.resolve(connectionProfile); }); - mockWidget = TypeMoq.Mock.ofType(ConnectionWidget, TypeMoq.MockBehavior.Strict, [], undefined, 'MSSQL'); + mockWidget = TypeMoq.Mock.ofType(ConnectionWidget, TypeMoq.MockBehavior.Strict, [], undefined, 'MSSQL', undefined, undefined, mockConnectionManagementService.object); mockWidget.setup(x => x.focusOnOpen()); mockWidget.setup(x => x.handleOnConnecting()); mockWidget.setup(x => x.handleResetConnection()); - mockWidget.setup(x => x.connect(TypeMoq.It.isValue(connectionProfile))).returns(() => true); + mockWidget.setup(x => x.connect(TypeMoq.It.isValue(connectionProfile))).returns(() => Promise.resolve(true)); mockWidget.setup(x => x.createConnectionWidget(TypeMoq.It.isAny())); mockWidget.setup(x => x.updateServerGroup(TypeMoq.It.isAny())); mockWidget.setup(x => x.initDialog(TypeMoq.It.isAny())); @@ -313,7 +321,7 @@ suite('ConnectionDialogService tests', () => { ((connectionDialogService as any)._connectionDialog as any).connect(connectionProfile); }); - assert(called); + setTimeout(() => { assert(called); }, 200); }); test('handleOnConnect calls connectAndSaveProfile when called without profile', async () => { @@ -325,13 +333,11 @@ suite('ConnectionDialogService tests', () => { (connectionDialogService as any)._connectionDialog = undefined; (connectionDialogService as any)._dialogDeferredPromise = new Deferred(); - await connectionDialogService.showDialog(mockConnectionManagementService.object, testConnectionParams, connectionProfile).then(() => { - ((connectionDialogService as any)._connectionControllerMap['MSSQL'] as any)._model = connectionProfile; - (connectionDialogService as any)._connectionDialog.connectButtonState = true; - ((connectionDialogService as any)._connectionDialog as any).connect(); - }); - - assert(called); + await connectionDialogService.showDialog(mockConnectionManagementService.object, testConnectionParams, connectionProfile); + ((connectionDialogService as any)._connectionControllerMap['MSSQL'] as any)._model = connectionProfile; + (connectionDialogService as any)._connectionDialog.connectButtonState = true; + ((connectionDialogService as any)._connectionDialog as any).connect(); + setTimeout(() => { assert(called); }, 200); }); test('handleOnCancel calls cancelEditorConnection', async () => {