diff --git a/extensions/sql-database-projects/src/common/apiWrapper.ts b/extensions/sql-database-projects/src/common/apiWrapper.ts index ed076477b4..4a229d6f90 100644 --- a/extensions/sql-database-projects/src/common/apiWrapper.ts +++ b/extensions/sql-database-projects/src/common/apiWrapper.ts @@ -23,6 +23,10 @@ export class ApiWrapper { return azdata.connection.getCurrentConnection(); } + public openConnectionDialog(): Thenable { + return azdata.connection.openConnectionDialog(); + } + public getCredentials(connectionId: string): Thenable<{ [name: string]: string }> { return azdata.connection.getCredentials(connectionId); } diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 6535b5afa1..ce35d6c9a4 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -10,6 +10,7 @@ const localize = nls.loadMessageBundle(); // Placeholder values export const dataSourcesFileName = 'datasources.json'; export const sqlprojExtension = '.sqlproj'; +export const initialCatalogSetting = 'Initial Catalog'; // UI Strings @@ -21,6 +22,23 @@ export const newDatabaseProjectName = localize('newDatabaseProjectName', "New da export const sqlDatabaseProject = localize('sqlDatabaseProject', "SQL database project"); export function newObjectNamePrompt(objectType: string) { return localize('newObjectNamePrompt', 'New {0} name:', objectType); } +// Deploy dialog strings + +export const deployDialogName = localize('deployDialogName', "Deploy Database"); +export const deployDialogOkButtonText = localize('deployDialogOkButtonText', "Deploy"); +export const cancelButtonText = localize('cancelButtonText', "Cancel"); +export const generateScriptButtonText = localize('generateScriptButtonText', "Generate Script"); +export const targetDatabaseSettings = localize('targetDatabaseSettings', "Target Database Settings"); +export const databaseNameLabel = localize('databaseNameLabel', "Database"); +export const deployScriptNameLabel = localize('deployScriptName', "Deploy script name"); +export const targetConnectionLabel = localize('targetConnectionLabel', "Target Connection"); +export const editConnectionButtonText = localize('editConnectionButtonText', "Edit"); +export const clearButtonText = localize('clearButtonText', "Clear"); +export const dataSourceRadioButtonLabel = localize('dataSourceRadioButtonLabel', "Data sources"); +export const connectionRadioButtonLabel = localize('connectionRadioButtonLabel', "Connections"); +export const selectConnectionRadioButtonsTitle = localize('selectconnectionRadioButtonsTitle', "Specify connection from:"); +export const dataSourceDropdownTitle = localize('dataSourceDropdownTitle', "Data source"); + // Error messages export const multipleSqlProjFiles = localize('multipleSqlProjFilesSelected', "Multiple .sqlproj files selected; please select only one."); diff --git a/extensions/sql-database-projects/src/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index 432ded0ba6..f8e52fe5ae 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -50,7 +50,7 @@ export default class MainController implements Disposable { this.apiWrapper.registerCommand('sqlDatabaseProjects.properties', async (node: BaseProjectTreeItem) => { await this.apiWrapper.showErrorMessage(`Properties not yet implemented: ${node.uri.path}`); }); // TODO this.apiWrapper.registerCommand('sqlDatabaseProjects.build', async (node: BaseProjectTreeItem) => { await this.projectsController.build(node); }); - this.apiWrapper.registerCommand('sqlDatabaseProjects.deploy', async (node: BaseProjectTreeItem) => { await this.projectsController.deploy(node); }); + this.apiWrapper.registerCommand('sqlDatabaseProjects.deploy', (node: BaseProjectTreeItem) => { this.projectsController.deploy(node); }); this.apiWrapper.registerCommand('sqlDatabaseProjects.import', async (node: BaseProjectTreeItem) => { await this.projectsController.import(node); }); this.apiWrapper.registerCommand('sqlDatabaseProjects.newScript', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.script); }); diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index 1e971442a1..99adcdf12e 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -18,6 +18,7 @@ import { promises as fs } from 'fs'; import { BaseProjectTreeItem } from '../models/tree/baseTreeItem'; import { ProjectRootTreeItem } from '../models/tree/projectTreeItem'; import { FolderNode } from '../models/tree/fileFolderTreeItem'; +import { DeployDatabaseDialog } from '../dialogs/deployDatabaseDialog'; /** * Controller for managing project lifecycle @@ -118,9 +119,10 @@ export class ProjectsController { await this.apiWrapper.showErrorMessage(`Build not yet implemented: ${project.projectFilePath}`); // TODO } - public async deploy(treeNode: BaseProjectTreeItem) { + public deploy(treeNode: BaseProjectTreeItem): void { const project = this.getProjectContextFromTreeNode(treeNode); - await this.apiWrapper.showErrorMessage(`Deploy not yet implemented: ${project.projectFilePath}`); // TODO + const deployDatabaseDialog = new DeployDatabaseDialog(this.apiWrapper, project); + deployDatabaseDialog.openDialog(); } public async import(treeNode: BaseProjectTreeItem) { diff --git a/extensions/sql-database-projects/src/dialogs/deployDatabaseDialog.ts b/extensions/sql-database-projects/src/dialogs/deployDatabaseDialog.ts new file mode 100644 index 0000000000..6adc96093f --- /dev/null +++ b/extensions/sql-database-projects/src/dialogs/deployDatabaseDialog.ts @@ -0,0 +1,268 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as constants from '../common/constants'; +import { Project } from '../models/project'; +import { DataSource } from '../models/dataSources/dataSources'; +import { SqlConnectionDataSource } from '../models/dataSources/sqlConnectionStringSource'; +import { ApiWrapper } from '../common/apiWrapper'; + +interface DataSourceDropdownValue extends azdata.CategoryValue { + dataSource: DataSource; + database: string; +} + +export class DeployDatabaseDialog { + public dialog: azdata.window.Dialog; + public deployTab: azdata.window.DialogTab; + private targetConnectionTextBox: azdata.InputBoxComponent | undefined; + private targetConnectionFormComponent: azdata.FormComponent | undefined; + private dataSourcesFormComponent: azdata.FormComponent | undefined; + private dataSourcesDropDown: azdata.DropDownComponent | undefined; + private targetDatabaseTextBox: azdata.InputBoxComponent | undefined; + private connectionsRadioButton: azdata.RadioButtonComponent | undefined; + private dataSourcesRadioButton: azdata.RadioButtonComponent | undefined; + private formBuilder: azdata.FormBuilder | undefined; + + private connection: azdata.connection.Connection | undefined; + private connectionIsDataSource: boolean | undefined; + + constructor(private apiWrapper: ApiWrapper, private project: Project) { + this.dialog = azdata.window.createModelViewDialog(constants.deployDialogName); + this.deployTab = azdata.window.createTab(constants.deployDialogName); + } + + public openDialog(): void { + this.initializeDialog(); + this.dialog.okButton.label = constants.deployDialogOkButtonText; + this.dialog.okButton.enabled = false; + this.dialog.okButton.onClick(async () => await this.deploy()); + + this.dialog.cancelButton.label = constants.cancelButtonText; + + let generateScriptButton: azdata.window.Button = azdata.window.createButton(constants.generateScriptButtonText); + generateScriptButton.onClick(async () => await this.generateScript()); + generateScriptButton.enabled = false; + + this.dialog.customButtons = []; + this.dialog.customButtons.push(generateScriptButton); + + azdata.window.openDialog(this.dialog); + } + + + private initializeDialog(): void { + this.initializeDeployTab(); + this.dialog.content = [this.deployTab]; + } + + private initializeDeployTab(): void { + this.deployTab.registerContent(async view => { + + let selectConnectionRadioButtons = this.createRadioButtons(view); + this.targetConnectionFormComponent = this.createTargetConnectionComponent(view); + + this.targetDatabaseTextBox = view.modelBuilder.inputBox().withProperties({ + value: this.getDefaultDatabaseName(), + ariaLabel: constants.databaseNameLabel + }).component(); + + this.dataSourcesFormComponent = this.createDataSourcesDropdown(view); + + this.targetDatabaseTextBox.onTextChanged(() => { + this.tryEnableGenerateScriptAndOkButtons(); + }); + + this.formBuilder = view.modelBuilder.formContainer() + .withFormItems([ + { + title: constants.targetDatabaseSettings, + components: [ + { + title: constants.selectConnectionRadioButtonsTitle, + component: selectConnectionRadioButtons + }, + this.targetConnectionFormComponent, + { + title: constants.databaseNameLabel, + component: this.targetDatabaseTextBox + } + ] + } + ], { + horizontal: false + }) + .withLayout({ + width: '100%' + }); + + let formModel = this.formBuilder.component(); + await view.initializeModel(formModel); + }); + } + + private async deploy(): Promise { + // TODO: hook up with build and deploy + // if target connection is a data source, have to check if already connected or if connection dialog needs to be opened + } + + private async generateScript(): Promise { + // TODO: hook up with build and generate script + // if target connection is a data source, have to check if already connected or if connection dialog needs to be opened + azdata.window.closeDialog(this.dialog); + } + + public getDefaultDatabaseName(): string { + return this.project.projectFileName; + } + + private createRadioButtons(view: azdata.ModelView): azdata.Component { + this.connectionsRadioButton = view.modelBuilder.radioButton() + .withProperties({ + name: 'connection', + label: constants.connectionRadioButtonLabel + }).component(); + + this.connectionsRadioButton.checked = true; + this.connectionsRadioButton.onDidClick(async () => { + this.formBuilder!.removeFormItem(this.dataSourcesFormComponent); + this.formBuilder!.insertFormItem(this.targetConnectionFormComponent, 2); + this.connectionIsDataSource = false; + this.targetDatabaseTextBox!.value = this.getDefaultDatabaseName(); + }); + + this.dataSourcesRadioButton = view.modelBuilder.radioButton() + .withProperties({ + name: 'connection', + label: constants.dataSourceRadioButtonLabel + }).component(); + + this.dataSourcesRadioButton.onDidClick(async () => { + this.formBuilder!.removeFormItem(this.targetConnectionFormComponent); + this.formBuilder!.insertFormItem(this.dataSourcesFormComponent, 2); + this.connectionIsDataSource = true; + + this.setDatabaseToSelectedDataSourceDatabase(); + }); + + let flexRadioButtonsModel: azdata.FlexContainer = view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .withItems([this.connectionsRadioButton, this.dataSourcesRadioButton]) + .withProperties({ ariaRole: 'radiogroup' }) + .component(); + + return flexRadioButtonsModel; + } + + private createTargetConnectionComponent(view: azdata.ModelView): azdata.FormComponent { + // TODO: make this not editable + this.targetConnectionTextBox = view.modelBuilder.inputBox().withProperties({ + value: '', + ariaLabel: constants.targetConnectionLabel + }).component(); + + this.targetConnectionTextBox.onTextChanged(() => { + this.tryEnableGenerateScriptAndOkButtons(); + }); + + let editConnectionButton: azdata.Component = this.createEditConnectionButton(view); + let clearButton: azdata.Component = this.createClearButton(view); + + return { + title: constants.targetConnectionLabel, + component: this.targetConnectionTextBox, + actions: [editConnectionButton, clearButton] + }; + } + + private createDataSourcesDropdown(view: azdata.ModelView): azdata.FormComponent { + let dataSourcesValues: DataSourceDropdownValue[] = []; + + this.project.dataSources.forEach(dataSource => { + const dbName: string = (dataSource as SqlConnectionDataSource).getSetting(constants.initialCatalogSetting); + const displayName: string = `${dataSource.name}`; + dataSourcesValues.push({ + displayName: displayName, + name: dataSource.name, + dataSource: dataSource, + database: dbName + }); + }); + + this.dataSourcesDropDown = view.modelBuilder.dropDown().withProperties({ + values: dataSourcesValues, + }).component(); + + + this.dataSourcesDropDown.onValueChanged(() => { + this.setDatabaseToSelectedDataSourceDatabase(); + this.tryEnableGenerateScriptAndOkButtons(); + }); + + return { + title: constants.dataSourceDropdownTitle, + component: this.dataSourcesDropDown + }; + } + + private setDatabaseToSelectedDataSourceDatabase(): void { + if ((this.dataSourcesDropDown!.value).database) { + this.targetDatabaseTextBox!.value = (this.dataSourcesDropDown!.value).database; + } + } + + private createEditConnectionButton(view: azdata.ModelView): azdata.Component { + let editConnectionButton: azdata.ButtonComponent = view.modelBuilder.button().withProperties({ + label: constants.editConnectionButtonText, + title: constants.editConnectionButtonText, + ariaLabel: constants.editConnectionButtonText + }).component(); + + editConnectionButton.onDidClick(async () => { + this.connection = await this.apiWrapper.openConnectionDialog(); + + // show connection name if there is one, otherwise show connection string + if (this.connection.options['connectionName']) { + this.targetConnectionTextBox!.value = this.connection.options['connectionName']; + } else { + this.targetConnectionTextBox!.value = await azdata.connection.getConnectionString(this.connection.connectionId, false); + } + + // change the database inputbox value to the connection's database if there is one + if (this.connection.options.database) { + this.targetDatabaseTextBox!.value = this.connection.options.database; + } + }); + + return editConnectionButton; + } + + private createClearButton(view: azdata.ModelView): azdata.Component { + let clearButton: azdata.ButtonComponent = view.modelBuilder.button().withProperties({ + label: constants.clearButtonText, + title: constants.clearButtonText, + ariaLabel: constants.clearButtonText + }).component(); + + clearButton.onDidClick(() => { + this.targetConnectionTextBox!.value = ''; + }); + + return clearButton; + } + + // only enable Generate Script and Ok buttons if all fields are filled + private tryEnableGenerateScriptAndOkButtons(): void { + if (this.targetConnectionTextBox!.value && this.targetDatabaseTextBox!.value + || this.connectionIsDataSource && this.targetDatabaseTextBox!.value) { + this.dialog.okButton.enabled = true; + this.dialog.customButtons[0].enabled = true; + } else { + this.dialog.okButton.enabled = false; + this.dialog.customButtons[0].enabled = false; + } + } +} diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index 7270c515a9..89a535df4f 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -16,6 +16,7 @@ import { DataSource } from './dataSources/dataSources'; */ export class Project { public projectFilePath: string; + public projectFileName: string; public files: ProjectEntry[] = []; public dataSources: DataSource[] = []; @@ -27,6 +28,7 @@ export class Project { constructor(projectFilePath: string) { this.projectFilePath = projectFilePath; + this.projectFileName = path.basename(projectFilePath, '.sqlproj'); } /** diff --git a/extensions/sql-database-projects/src/test/deployDatabaseDialog.test.ts b/extensions/sql-database-projects/src/test/deployDatabaseDialog.test.ts new file mode 100644 index 0000000000..685ac63506 --- /dev/null +++ b/extensions/sql-database-projects/src/test/deployDatabaseDialog.test.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as should from 'should'; +import * as path from 'path'; +import * as os from 'os'; +import * as vscode from 'vscode'; +import * as baselines from './baselines/baselines'; +import * as templates from '../templates/templates'; +import { DeployDatabaseDialog } from '../dialogs/deployDatabaseDialog'; +import { Project } from '../models/project'; +import { SqlDatabaseProjectTreeViewProvider } from '../controllers/databaseProjectTreeViewProvider'; +import { ProjectsController } from '../controllers/projectController'; +import { createContext, TestContext } from './testContext'; + + +let testContext: TestContext; + +describe('Deploy Database Dialog', () => { + before(async function (): Promise { + testContext = createContext(); + await templates.loadTemplates(path.join(__dirname, '..', '..', 'resources', 'templates')); + await baselines.loadBaselines(); + }); + + it('Should open dialog successfully ', async function (): Promise { + const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + const projFileDir = path.join(os.tmpdir(), `TestProject_${new Date().getTime()}`); + + const projFilePath = await projController.createNewProject('TestProjectName', vscode.Uri.file(projFileDir), 'BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575'); + const project = new Project(projFilePath); + const deployDatabaseDialog = new DeployDatabaseDialog(testContext.apiWrapper.object, project); + deployDatabaseDialog.openDialog(); + should.notEqual(deployDatabaseDialog.deployTab, undefined); + }); + + it('Should create default database name correctly ', async function (): Promise { + const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + const projFolder = `TestProject_${new Date().getTime()}`; + const projFileDir = path.join(os.tmpdir(), projFolder); + + const projFilePath = await projController.createNewProject('TestProjectName', vscode.Uri.file(projFileDir), 'BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575'); + const project = new Project(projFilePath); + + const deployDatabaseDialog = new DeployDatabaseDialog(testContext.apiWrapper.object, project); + should.equal(deployDatabaseDialog.getDefaultDatabaseName(), project.projectFileName); + }); +});