diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index f27ca8bc92..f665a923e0 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -69,6 +69,7 @@ export const databaseReferencesNodeName = localize('databaseReferencesNodeName', export const sqlConnectionStringFriendly = localize('sqlConnectionStringFriendly', "SQL connection string"); export const yesString = localize('yesString', "Yes"); export const noString = localize('noString', "No"); +export const noStringDefault = localize('noStringDefault', "No (default)"); export const okString = localize('okString', "Ok"); export const selectString = localize('selectString', "Select"); export const dacpacFiles = localize('dacpacFiles', "dacpac Files"); @@ -79,6 +80,7 @@ export const objectType = localize('objectType', "Object Type"); export const schema = localize('schema', "Schema"); export const schemaObjectType = localize('schemaObjectType', "Schema/Object Type"); export const defaultProjectNameStarter = localize('defaultProjectNameStarter', "DatabaseProject"); +export const location = localize('location', "Location"); export const reloadProject = localize('reloadProject', "Would you like to reload your database project?"); export function newObjectNamePrompt(objectType: string) { return localize('newObjectNamePrompt', 'New {0} name:', objectType); } export function deleteConfirmation(toDelete: string) { return localize('deleteConfirmation', "Are you sure you want to delete {0}?", toDelete); } @@ -129,12 +131,10 @@ export const nameMustNotBeEmpty = localize('nameMustNotBeEmpty', "Name must not export const addDatabaseReferenceDialogName = localize('addDatabaseReferencedialogName', "Add database reference"); export const addDatabaseReferenceOkButtonText = localize('addDatabaseReferenceOkButtonText', "Add reference"); export const referenceRadioButtonsGroupTitle = localize('referenceRadioButtonsGroupTitle', "Type"); -export const projectRadioButtonTitle = localize('projectRadioButtonTitle', "Project"); -export const systemDatabaseRadioButtonTitle = localize('systemDatabaseRadioButtonTitle', "System database"); +export const projectLabel = localize('projectLocString', "Project"); +export const systemDatabase = localize('systemDatabase', "System database"); export const dacpacText = localize('dacpacText', "Data-tier application (.dacpac)"); -export const dacpacPlaceholder = localize('dacpacPlaceholder', "Select .dacpac"); -export const loadDacpacButton = localize('loadDacpacButton', "Select .dacpac"); -export const locationDropdown = localize('locationDropdown', "Location"); +export const selectDacpac = localize('selectDacpac', "Select .dacpac"); export const sameDatabase = localize('sameDatabase', "Same database"); export const differentDbSameServer = localize('differentDbSameServer', "Different database, same server"); export const differentDbDifferentServer = localize('differentDbDifferentServer', "Different database, different server"); @@ -153,6 +153,7 @@ export const otherServer = 'OtherServer'; export const otherSeverVariable = 'OtherServer'; export const databaseProject = localize('databaseProject', "Database project"); export const dacpacNotOnSameDrive = (projectLocation: string): string => { return localize('dacpacNotOnSameDrive', "Dacpac references need to be located on the same drive as the project file. The project file is located at {0}", projectLocation); }; +export const referenceType = localize('referenceType', "Reference type"); // Create Project From Database dialog strings @@ -163,7 +164,6 @@ export const targetProject = localize('targetProject', "Target project"); export const createProjectSettings = localize('createProjectSettings', "Settings"); export const projectNameLabel = localize('projectNameLabel', "Name"); export const projectNamePlaceholderText = localize('projectNamePlaceholderText', "Enter project name"); -export const projectLocationLabel = localize('projectLocationLabel', "Location"); export const projectLocationPlaceholderText = localize('projectLocationPlaceholderText', "Select location to create project"); export const browseButtonText = localize('browseButtonText', "Browse folder"); export const selectFolderStructure = localize('selectFolderStructure', "Select folder structure"); diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index 170ac70e50..20cee4895a 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -37,6 +37,7 @@ import { DashboardData, PublishData, Status } from '../models/dashboardData/dash import { launchPublishDatabaseQuickpick } from '../dialogs/publishDatabaseQuickpick'; import { SqlTargetPlatform } from 'sqldbproj'; import { createNewProjectFromDatabaseWithQuickpick } from '../dialogs/createProjectFromDatabaseQuickpick'; +import { addDatabaseReferenceQuickpick } from '../dialogs/addDatabaseReferenceQuickpick'; const maxTableLength = 10; @@ -53,6 +54,8 @@ export enum TaskExecutionMode { executeAndScript = 2 } +export type AddDatabaseReferenceSettings = ISystemDatabaseReferenceSettings | IDacpacReferenceSettings | IProjectReferenceSettings; + /** * Controller for managing lifecycle of projects */ @@ -672,15 +675,25 @@ export class ProjectsController { * Adds a database reference to the project * @param context a treeItem in a project's hierarchy, to be used to obtain a Project */ - public async addDatabaseReference(context: Project | dataworkspace.WorkspaceTreeItem): Promise { + public async addDatabaseReference(context: Project | dataworkspace.WorkspaceTreeItem): Promise { const project = this.getProjectFromContext(context); - const addDatabaseReferenceDialog = this.getAddDatabaseReferenceDialog(project); - addDatabaseReferenceDialog.addReference = async (proj, prof) => await this.addDatabaseReferenceCallback(proj, prof, context as dataworkspace.WorkspaceTreeItem); + if (utils.getAzdataApi()) { + const addDatabaseReferenceDialog = this.getAddDatabaseReferenceDialog(project); + addDatabaseReferenceDialog.addReference = async (proj, settings) => await this.addDatabaseReferenceCallback(proj, settings, context as dataworkspace.WorkspaceTreeItem); + + addDatabaseReferenceDialog.openDialog(); + return addDatabaseReferenceDialog; + } else { + const settings = await addDatabaseReferenceQuickpick(project); + if (settings) { + await this.addDatabaseReferenceCallback(project, settings, context as dataworkspace.WorkspaceTreeItem); + } + return undefined; + } + - addDatabaseReferenceDialog.openDialog(); - return addDatabaseReferenceDialog; } /** @@ -689,7 +702,7 @@ export class ProjectsController { * @param settings settings for the database reference * @param context a treeItem in a project's hierarchy, to be used to obtain a Project */ - public async addDatabaseReferenceCallback(project: Project, settings: ISystemDatabaseReferenceSettings | IDacpacReferenceSettings | IProjectReferenceSettings, context: dataworkspace.WorkspaceTreeItem): Promise { + public async addDatabaseReferenceCallback(project: Project, settings: AddDatabaseReferenceSettings, context: dataworkspace.WorkspaceTreeItem): Promise { try { if ((settings).projectName !== undefined) { // get project path and guid diff --git a/extensions/sql-database-projects/src/dialogs/addDatabaseReferenceDialog.ts b/extensions/sql-database-projects/src/dialogs/addDatabaseReferenceDialog.ts index 8510b269cd..a2c1758918 100644 --- a/extensions/sql-database-projects/src/dialogs/addDatabaseReferenceDialog.ts +++ b/extensions/sql-database-projects/src/dialogs/addDatabaseReferenceDialog.ts @@ -165,7 +165,7 @@ export class AddDatabaseReferenceDialog { } else if (this.currentReferenceType === ReferenceType.systemDb) { referenceSettings = { databaseName: this.databaseNameTextbox?.value, - systemDb: this.systemDatabaseDropdown?.value === constants.master ? SystemDatabase.master : SystemDatabase.msdb, + systemDb: getSystemDatabase(this.systemDatabaseDropdown?.value), suppressMissingDependenciesErrors: this.suppressMissingDependenciesErrorsCheckbox?.checked }; } else { // this.currentReferenceType === ReferenceType.dacpac @@ -192,7 +192,7 @@ export class AddDatabaseReferenceDialog { this.projectRadioButton = this.view!.modelBuilder.radioButton() .withProps({ name: 'referenceType', - label: constants.projectRadioButtonTitle + label: constants.projectLabel }).component(); this.projectRadioButton.onDidClick(() => { @@ -202,7 +202,7 @@ export class AddDatabaseReferenceDialog { this.systemDatabaseRadioButton = this.view!.modelBuilder.radioButton() .withProps({ name: 'referenceType', - label: constants.systemDatabaseRadioButtonTitle + label: constants.systemDatabase }).component(); this.systemDatabaseRadioButton.onDidClick(() => { @@ -307,7 +307,7 @@ export class AddDatabaseReferenceDialog { private createSystemDatabaseDropdown(): azdataType.FormComponent { this.systemDatabaseDropdown = this.view!.modelBuilder.dropDown().withProps({ - values: [constants.master, constants.msdb], + values: getSystemDbOptions(this.project), ariaLabel: constants.databaseNameLabel }).component(); @@ -315,11 +315,6 @@ export class AddDatabaseReferenceDialog { this.setDefaultDatabaseValues(); }); - // only master is a valid system db reference for projects targetting Azure and DW - if (this.project.getProjectTargetVersion().toLowerCase().includes('azure') || this.project.getProjectTargetVersion().toLowerCase().includes('dw')) { - this.systemDatabaseDropdown.values?.splice(1); - } - return { component: this.systemDatabaseDropdown, title: constants.databaseNameLabel @@ -329,7 +324,7 @@ export class AddDatabaseReferenceDialog { private createDacpacTextbox(): azdataType.FormComponent { this.dacpacTextbox = this.view!.modelBuilder.inputBox().withProps({ ariaLabel: constants.dacpacText, - placeHolder: constants.dacpacPlaceholder, + placeHolder: constants.selectDacpac, width: '400px' }).component(); @@ -351,25 +346,14 @@ export class AddDatabaseReferenceDialog { private createLoadDacpacButton(): azdataType.ButtonComponent { const loadDacpacButton = this.view!.modelBuilder.button().withProps({ - ariaLabel: constants.loadDacpacButton, + ariaLabel: constants.selectDacpac, iconPath: IconPathHelper.folder_blue, height: '18px', width: '18px' }).component(); loadDacpacButton.onDidClick(async () => { - let fileUris = await vscode.window.showOpenDialog( - { - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false, - defaultUri: vscode.workspace.workspaceFolders ? (vscode.workspace.workspaceFolders as vscode.WorkspaceFolder[])[0].uri : undefined, - openLabel: constants.selectString, - filters: { - [constants.dacpacFiles]: ['dacpac'], - } - } - ); + let fileUris = await promptDacpacLocation(); if (!fileUris || fileUris.length === 0) { return; @@ -383,7 +367,7 @@ export class AddDatabaseReferenceDialog { private createLocationDropdown(): azdataType.FormComponent { this.locationDropdown = this.view!.modelBuilder.dropDown().withProps({ - ariaLabel: constants.locationDropdown, + ariaLabel: constants.location, values: this.currentReferenceType === ReferenceType.systemDb ? constants.systemDbLocationDropdownValues : constants.locationDropdownValues }).component(); @@ -397,7 +381,7 @@ export class AddDatabaseReferenceDialog { return { component: this.locationDropdown, - title: constants.locationDropdown + title: constants.location }; } @@ -630,3 +614,31 @@ export class AddDatabaseReferenceDialog { return !!this.databaseNameTextbox?.value && !!this.serverNameTextbox?.value && !!this.serverVariableTextbox?.value; } } + +export function getSystemDbOptions(project: Project): string[] { + // only master is a valid system db reference for projects targeting Azure and DW + if (project.getProjectTargetVersion().toLowerCase().includes('azure') || project.getProjectTargetVersion().toLowerCase().includes('dw')) { + return [constants.master]; + } + return [constants.master, constants.msdb]; +} + +export function getSystemDatabase(name: string): SystemDatabase { + return name === constants.master ? SystemDatabase.master : SystemDatabase.msdb; +} + +export async function promptDacpacLocation(): Promise { + return await vscode.window.showOpenDialog( + { + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + defaultUri: vscode.workspace.workspaceFolders ? (vscode.workspace.workspaceFolders as vscode.WorkspaceFolder[])[0].uri : undefined, + openLabel: constants.selectString, + title: constants.selectDacpac, + filters: { + [constants.dacpacFiles]: ['dacpac'], + } + } + ); +} diff --git a/extensions/sql-database-projects/src/dialogs/addDatabaseReferenceQuickpick.ts b/extensions/sql-database-projects/src/dialogs/addDatabaseReferenceQuickpick.ts new file mode 100644 index 0000000000..4cebf7bdf0 --- /dev/null +++ b/extensions/sql-database-projects/src/dialogs/addDatabaseReferenceQuickpick.ts @@ -0,0 +1,277 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import path = require('path'); +import * as vscode from 'vscode'; +import * as constants from '../common/constants'; +import { getSqlProjectsInWorkspace, isValidSqlCmdVariableName, removeSqlCmdVariableFormatting } from '../common/utils'; +import { AddDatabaseReferenceSettings } from '../controllers/projectController'; +import { IDacpacReferenceSettings, IProjectReferenceSettings, ISystemDatabaseReferenceSettings } from '../models/IDatabaseReferenceSettings'; +import { Project } from '../models/project'; +import { getSystemDatabase, getSystemDbOptions, promptDacpacLocation } from './addDatabaseReferenceDialog'; + +interface DbServerValues { + dbName?: string, + dbVariable?: string, + serverName?: string, + serverVariable?: string +} + +/** + * Create flow for adding a database reference using only VS Code-native APIs such as QuickPick + * @param connectionInfo Optional connection info to use instead of prompting the user for a connection + */ +export async function addDatabaseReferenceQuickpick(project: Project): Promise { + + const otherProjectsInWorkspace = (await getSqlProjectsInWorkspace()).filter(p => p.fsPath !== project.projectFilePath); + + // 1. Prompt for reference type + // Only show project option if we have at least one other project in the workspace + const referenceTypes = otherProjectsInWorkspace.length > 0 ? + [constants.projectLabel, constants.systemDatabase, constants.dacpacText] : + [constants.systemDatabase, constants.dacpacText]; + + const referenceType = await vscode.window.showQuickPick( + referenceTypes, + { title: constants.referenceType, ignoreFocusOut: true }); + if (!referenceType) { + // User cancelled + return undefined; + } + + switch (referenceType) { + case constants.projectLabel: + return addProjectReference(otherProjectsInWorkspace); + case constants.systemDatabase: + return addSystemDatabaseReference(project); + case constants.dacpacText: + return addDacpacReference(); + default: + console.log(`Unknown reference type ${referenceType}`); + return undefined; + } +} + +async function addProjectReference(otherProjectsInWorkspace: vscode.Uri[]): Promise { + // (steps continued from addDatabaseReferenceQuickpick) + // 2. Prompt database project + const otherProjectQuickpickItems: (vscode.QuickPickItem & { uri: vscode.Uri })[] = otherProjectsInWorkspace.map(p => { + return { + label: path.parse(p.fsPath).name, + uri: p + }; + }); + + const selectedProject = await vscode.window.showQuickPick( + otherProjectQuickpickItems, + { title: constants.databaseProject, ignoreFocusOut: true, }); + if (!selectedProject) { + return; + } + + // 3. Prompt location + const location = await promptLocation(); + if (!location) { + // User cancelled + return; + } + + const referenceSettings: IProjectReferenceSettings = { + projectName: selectedProject.label, + projectGuid: '', + projectRelativePath: undefined, + databaseName: undefined, + databaseVariable: undefined, + serverName: undefined, + serverVariable: undefined, + suppressMissingDependenciesErrors: false + }; + + const dbServerValues = await promptDbServerValues(location, selectedProject.label); + if (!dbServerValues) { + // User cancelled + return; + } + referenceSettings.databaseName = dbServerValues.dbName; + referenceSettings.databaseVariable = dbServerValues.dbVariable; + referenceSettings.serverName = dbServerValues.serverName; + referenceSettings.serverVariable = dbServerValues.serverVariable; + + // 7. Prompt suppress unresolved ref errors + const suppressErrors = await promptSuppressUnresolvedRefErrors(); + referenceSettings.suppressMissingDependenciesErrors = suppressErrors; + + return referenceSettings; +} + +async function addSystemDatabaseReference(project: Project): Promise { + // (steps continued from addDatabaseReferenceQuickpick) + // 2. Prompt System DB + + const selectedSystemDb = await vscode.window.showQuickPick( + getSystemDbOptions(project), + { title: constants.systemDatabase, ignoreFocusOut: true, }); + if (!selectedSystemDb) { + // User cancelled + return undefined; + } + + // 3. Prompt DB name + const dbName = await promptDbName(selectedSystemDb); + + // 4. Prompt suppress unresolved ref errors + const suppressErrors = await promptSuppressUnresolvedRefErrors(); + + return { + databaseName: dbName, + systemDb: getSystemDatabase(selectedSystemDb), + suppressMissingDependenciesErrors: suppressErrors + }; +} + +async function addDacpacReference(): Promise { + // (steps continued from addDatabaseReferenceQuickpick) + // 2. Prompt for location + const location = await promptLocation(); + if (!location) { + // User cancelled + return undefined; + } + + // 3. Prompt for dacpac location + // Show quick pick with just browse option to give user context about what the file dialog is for (since that doesn't always have a title) + const browseSelected = await vscode.window.showQuickPick( + [constants.browseEllipsis], + { title: constants.selectDacpac, ignoreFocusOut: true }); + if (!browseSelected) { + return undefined; + } + + const dacPacLocation = (await promptDacpacLocation())?.[0]; + if (!dacPacLocation) { + // User cancelled + return undefined; + } + + // 4. Prompt for db/server values + const dbServerValues = await promptDbServerValues(location, path.parse(dacPacLocation.fsPath).name); + if (!dbServerValues) { + // User cancelled + return; + } + + // 5. Prompt suppress unresolved ref errors + const suppressErrors = await promptSuppressUnresolvedRefErrors(); + + return { + databaseName: dbServerValues.dbName, + dacpacFileLocation: dacPacLocation, + databaseVariable: removeSqlCmdVariableFormatting(dbServerValues.dbVariable), + serverName: dbServerValues.serverName, + serverVariable: removeSqlCmdVariableFormatting(dbServerValues.serverVariable), + suppressMissingDependenciesErrors: suppressErrors + }; +} + +async function promptLocation(): Promise { + return vscode.window.showQuickPick( + constants.locationDropdownValues, + { title: constants.location, ignoreFocusOut: true, }); +} + +async function promptDbName(defaultValue: string): Promise { + return vscode.window.showInputBox( + { + title: constants.databaseName, + value: defaultValue, + validateInput: (value) => { + return value ? undefined : constants.nameMustNotBeEmpty; + }, + ignoreFocusOut: true + }); +} + +async function promptDbVar(defaultValue: string): Promise { + return await vscode.window.showInputBox( + { + title: constants.databaseVariable, + value: defaultValue, + validateInput: (value: string) => { + return isValidSqlCmdVariableName(value) ? '' : constants.notValidVariableName(value); + }, + ignoreFocusOut: true + }) ?? ''; +} + +async function promptServerName(): Promise { + return vscode.window.showInputBox( + { + title: constants.serverName, + value: constants.otherServer, + validateInput: (value) => { + return value ? undefined : constants.nameMustNotBeEmpty; + }, + ignoreFocusOut: true + }); +} + +async function promptServerVar(): Promise { + return await vscode.window.showInputBox( + { + title: constants.serverVariable, + value: constants.otherSeverVariable, + validateInput: (value: string) => { + return isValidSqlCmdVariableName(value) ? '' : constants.notValidVariableName(value); + }, + ignoreFocusOut: true + }) ?? ''; +} + +async function promptSuppressUnresolvedRefErrors(): Promise { + const selectedOption = await vscode.window.showQuickPick( + [constants.noStringDefault, constants.yesString], + { title: constants.suppressMissingDependenciesErrors, ignoreFocusOut: true, }); + return selectedOption === constants.yesString ? true : false; +} + +async function promptDbServerValues(location: string, defaultDbName: string): Promise { + const ret: DbServerValues = {}; + + // Only prompt db values if the location is on a different db/server + if (location !== constants.sameDatabase) { + // 4. Prompt database name + const dbName = await promptDbName(defaultDbName); + if (!dbName) { + // User cancelled + return undefined; + } + ret.dbName = dbName; + + // 5. Prompt db var + const dbVar = await promptDbVar(dbName); + // DB Variable is optional so treat escape as skipping it (not cancel in this case) + ret.dbVariable = dbVar; + } + + // Only prompt server values if location is different server + if (location === constants.differentDbDifferentServer) { + // 5. Prompt server name + const serverName = await promptServerName(); + if (!serverName) { + // User cancelled + return undefined; + } + ret.serverName = serverName; + + // 6. Prompt server var + const serverVar = await promptServerVar(); + if (!serverVar) { + // User cancelled + return undefined; + } + ret.serverVariable = serverVar; + } + return ret; +} diff --git a/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts index 4a9a6536e1..e3dde6cff2 100644 --- a/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts +++ b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts @@ -269,7 +269,7 @@ export class CreateProjectFromDatabaseDialog { this.projectLocationTextBox = view.modelBuilder.inputBox().withProps({ value: '', - ariaLabel: constants.projectLocationLabel, + ariaLabel: constants.location, placeHolder: constants.projectLocationPlaceholderText, width: cssStyles.createProjectFromDatabaseTextboxWidth }).component(); @@ -280,7 +280,7 @@ export class CreateProjectFromDatabaseDialog { }); const projectLocationLabel = view.modelBuilder.text().withProps({ - value: constants.projectLocationLabel, + value: constants.location, requiredIndicator: true, width: cssStyles.createProjectFromDatabaseLabelWidth }).component(); diff --git a/extensions/sql-database-projects/src/dialogs/publishDatabaseQuickpick.ts b/extensions/sql-database-projects/src/dialogs/publishDatabaseQuickpick.ts index 3ead5e86ed..9e680d87a7 100644 --- a/extensions/sql-database-projects/src/dialogs/publishDatabaseQuickpick.ts +++ b/extensions/sql-database-projects/src/dialogs/publishDatabaseQuickpick.ts @@ -192,7 +192,6 @@ export async function launchPublishDatabaseQuickpick(project: Project, projectCo return; } - // TODO@chgagnon: Get deployment options // 6. Generate script/publish let settings: IDeploySettings = { databaseName: databaseName, diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index 5ef02721c9..41efaf0615 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -583,7 +583,7 @@ describe('ProjectsController', function (): void { }); let dialog = await projController.object.addDatabaseReference(proj); - await dialog.addReferenceClick(); + await dialog!.addReferenceClick(); should(holler).equal(addDbRefHoller, 'executionCallback() is supposed to have been setup and called for add database reference scenario'); });