diff --git a/extensions/data-workspace/src/services/workspaceService.ts b/extensions/data-workspace/src/services/workspaceService.ts index 7f65f64016..13d67e16d2 100644 --- a/extensions/data-workspace/src/services/workspaceService.ts +++ b/extensions/data-workspace/src/services/workspaceService.ts @@ -13,6 +13,7 @@ import { IWorkspaceService } from '../common/interfaces'; import { ProjectProviderRegistry } from '../common/projectProviderRegistry'; import Logger from '../common/logger'; import { TelemetryReporter, TelemetryViews, TelemetryActions } from '../common/telemetry'; +import { getAzdataApi } from '../common/utils'; export class WorkspaceService implements IWorkspaceService { private _onDidWorkspaceProjectsChange: vscode.EventEmitter = new vscode.EventEmitter(); @@ -31,10 +32,10 @@ export class WorkspaceService implements IWorkspaceService { } /** - * Verify that a workspace is open or that if one isn't, it's ok to create a workspace and restart ADS + * Verify that a workspace is open or that if one isn't and we're running in ADS, it's ok to create a workspace and restart ADS */ async validateWorkspace(): Promise { - if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) { + if (getAzdataApi() && (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0)) { const result = await vscode.window.showWarningMessage(constants.RestartConfirmation, { modal: true }, constants.OkButtonText); if (result === constants.OkButtonText) { return true; @@ -42,7 +43,8 @@ export class WorkspaceService implements IWorkspaceService { return false; } } else { - // workspace is open + // workspace is open or we're running in VS Code. VS Code doesn't require reloading the window when creating a workspace or + // adding the first item to an open workspace and so this check is unnecessary there. return true; } } diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 88fa110086..258679f92d 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -166,8 +166,11 @@ export const projectNamePlaceholderText = localize('projectNamePlaceholderText', 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"); export const folderStructureLabel = localize('folderStructureLabel', "Folder structure"); export const WorkspaceFileExtension = '.code-workspace'; +export const browseEllipsis = localize('browseEllipsis', "Browse..."); +export const selectProjectLocation = localize('selectProjectLocation', "Select project location"); export const ProjectParentDirectoryNotExistError = (location: string): string => { return localize('dataworkspace.projectParentDirectoryNotExistError', "The selected project location '{0}' does not exist or is not a directory.", location); }; export const ProjectDirectoryAlreadyExistError = (projectName: string, location: string): string => { return localize('dataworkspace.projectDirectoryAlreadyExistError', "There is already a directory named '{0}' in the selected location: '{1}'.", projectName, location); }; @@ -207,6 +210,7 @@ export function fileOrFolderDoesNotExist(name: string) { return localize('fileOr export function cannotResolvePath(path: string) { return localize('cannotResolvePath', "Cannot resolve path {0}", path); } export function fileAlreadyExists(filename: string) { return localize('fileAlreadyExists', "A file with the name '{0}' already exists on disk at this location. Please choose another name.", filename); } export function folderAlreadyExists(filename: string) { return localize('folderAlreadyExists', "A folder with the name '{0}' already exists on disk at this location. Please choose another name.", filename); } +export function folderAlreadyExistsChooseNewLocation(filename: string) { return localize('folderAlreadyExistsChooseNewLocation', "A folder with the name '{0}' already exists on disk at this location. Please choose another location.", filename); } export function invalidInput(input: string) { return localize('invalidInput', "Invalid input: {0}", input); } export function invalidProjectPropertyValue(propertyName: string) { return localize('invalidPropertyValue', "Invalid value specified for the property '{0}' in .sqlproj file", propertyName); } export function unableToCreatePublishConnection(input: string) { return localize('unableToCreatePublishConnection', "Unable to construct connection: {0}", input); } diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index f971aa3f49..d1b403aa45 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -36,6 +36,7 @@ import { IconPathHelper } from '../common/iconHelper'; import { DashboardData, PublishData, Status } from '../models/dashboardData/dashboardData'; import { launchPublishDatabaseQuickpick } from '../dialogs/publishDatabaseQuickpick'; import { SqlTargetPlatform } from 'sqldbproj'; +import { createNewProjectFromDatabaseWithQuickpick } from '../dialogs/createProjectFromDatabaseQuickpick'; const maxTableLength = 10; @@ -862,15 +863,24 @@ export class ProjectsController { * Creates a new SQL database project from the existing database, * prompting the user for a name, file path location and extract target */ - public async createProjectFromDatabase(context: azdataType.IConnectionProfile | any): Promise { + public async createProjectFromDatabase(context: azdataType.IConnectionProfile | any): Promise { const profile = this.getConnectionProfileFromContext(context); - let createProjectFromDatabaseDialog = this.getCreateProjectFromDatabaseDialog(profile); + if (utils.getAzdataApi()) { + let createProjectFromDatabaseDialog = this.getCreateProjectFromDatabaseDialog(profile); - createProjectFromDatabaseDialog.createProjectFromDatabaseCallback = async (model) => await this.createProjectFromDatabaseCallback(model); + createProjectFromDatabaseDialog.createProjectFromDatabaseCallback = async (model) => await this.createProjectFromDatabaseCallback(model); - await createProjectFromDatabaseDialog.openDialog(); + await createProjectFromDatabaseDialog.openDialog(); + + return createProjectFromDatabaseDialog; + } else { + const model = await createNewProjectFromDatabaseWithQuickpick(); + if (model) { + await this.createProjectFromDatabaseCallback(model); + } + return undefined; + } - return createProjectFromDatabaseDialog; } public getCreateProjectFromDatabaseDialog(profile: azdataType.IConnectionProfile | undefined): CreateProjectFromDatabaseDialog { @@ -926,13 +936,14 @@ export class ProjectsController { } public async createProjectFromDatabaseApiCall(model: ImportDataModel): Promise { - let ext = vscode.extensions.getExtension(mssql.extension.name)!; - - const service = (await ext.activate() as mssql.IExtension).dacFx; - const ownerUri = await utils.getAzdataApi()!.connection.getUriForConnection(model.serverId); - - await service.createProjectFromDatabase(model.database, model.filePath, model.projName, model.version, ownerUri, model.extractTarget, utils.getAzdataApi()!.TaskExecutionMode.execute); + const service = await utils.getDacFxService(); + const azdataApi = utils.getAzdataApi(); + if (azdataApi) { + await (service as mssql.IDacFxService).createProjectFromDatabase(model.database, model.filePath, model.projName, model.version, model.connectionUri, model.extractTarget as mssql.ExtractTarget, azdataApi.TaskExecutionMode.execute); + } else { + await (service as mssqlVscode.IDacFxService).createProjectFromDatabase(model.database, model.filePath, model.projName, model.version, model.connectionUri, model.extractTarget as mssqlVscode.ExtractTarget, TaskExecutionMode.execute as unknown as mssqlVscode.TaskExecutionMode); + } // TODO: Check for success; throw error } diff --git a/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts index 10e3ff8eb4..4a9a6536e1 100644 --- a/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts +++ b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts @@ -353,36 +353,23 @@ export class CreateProjectFromDatabaseDialog { } public async handleCreateButtonClick(): Promise { + const azdataApi = getAzdataApi()!; + const connectionUri = await azdataApi!.connection.getUriForConnection(this.connectionId!); const model: ImportDataModel = { - serverId: this.connectionId!, + connectionUri: connectionUri, database: this.sourceDatabaseDropDown!.value, projName: this.projectNameTextBox!.value!, filePath: this.projectLocationTextBox!.value!, version: '1.0.0.0', - extractTarget: this.mapExtractTargetEnum(this.folderStructureDropDown!.value) + extractTarget: mapExtractTargetEnum(this.folderStructureDropDown!.value) }; - getAzdataApi()!.window.closeDialog(this.dialog); + azdataApi!.window.closeDialog(this.dialog); await this.createProjectFromDatabaseCallback!(model); this.dispose(); } - private mapExtractTargetEnum(inputTarget: any): mssql.ExtractTarget { - if (inputTarget) { - switch (inputTarget) { - case constants.file: return mssql.ExtractTarget['file']; - case constants.flat: return mssql.ExtractTarget['flat']; - case constants.objectType: return mssql.ExtractTarget['objectType']; - case constants.schema: return mssql.ExtractTarget['schema']; - case constants.schemaObjectType: return mssql.ExtractTarget['schemaObjectType']; - default: throw new Error(constants.invalidInput(inputTarget)); - } - } else { - throw new Error(constants.extractTargetRequired); - } - } - async validate(): Promise { try { if (await getDataWorkspaceExtensionApi().validateWorkspace() === false) { @@ -415,3 +402,18 @@ export class CreateProjectFromDatabaseDialog { }; } } + +export function mapExtractTargetEnum(inputTarget: string): mssql.ExtractTarget { + if (inputTarget) { + switch (inputTarget) { + case constants.file: return mssql.ExtractTarget.file; + case constants.flat: return mssql.ExtractTarget.flat; + case constants.objectType: return mssql.ExtractTarget.objectType; + case constants.schema: return mssql.ExtractTarget.schema; + case constants.schemaObjectType: return mssql.ExtractTarget.schemaObjectType; + default: throw new Error(constants.invalidInput(inputTarget)); + } + } else { + throw new Error(constants.extractTargetRequired); + } +} diff --git a/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseQuickpick.ts b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseQuickpick.ts new file mode 100644 index 0000000000..f9a8ee86db --- /dev/null +++ b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseQuickpick.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as constants from '../common/constants'; +import { exists, getVscodeMssqlApi } from '../common/utils'; +import { IConnectionInfo } from 'vscode-mssql'; +import { defaultProjectNameFromDb, defaultProjectSaveLocation } from '../tools/newProjectTool'; +import { ImportDataModel } from '../models/api/import'; +import { mapExtractTargetEnum } from './createProjectFromDatabaseDialog'; + +/** + * Create flow for a New Project using only VS Code-native APIs such as QuickPick + */ +export async function createNewProjectFromDatabaseWithQuickpick(): Promise { + + // 1. Select connection + const vscodeMssqlApi = await getVscodeMssqlApi(); + let connectionProfile: IConnectionInfo | undefined = undefined; + let connectionUri: string = ''; + let dbs: string[] | undefined = undefined; + while (!dbs) { + connectionProfile = await vscodeMssqlApi.promptForConnection(true); + if (!connectionProfile) { + // User cancelled + return undefined; + } + // Get the list of databases now to validate that the connection is valid and re-prompt them if it isn't + try { + connectionUri = await vscodeMssqlApi.connect(connectionProfile); + dbs = (await vscodeMssqlApi.listDatabases(connectionUri)) + .filter(db => !constants.systemDbs.includes(db)); // Filter out system dbs + } catch (err) { + // no-op, the mssql extension handles showing the error to the user. We'll just go + // back and prompt the user for a connection again + } + } + + // 2. Select database + const selectedDatabase = await vscode.window.showQuickPick( + dbs, + { title: constants.selectDatabase, ignoreFocusOut: true }); + if (!selectedDatabase) { + // User cancelled + return undefined; + } + + // 3. Prompt for project name + const projectName = await vscode.window.showInputBox( + { + title: constants.projectNamePlaceholderText, + value: defaultProjectNameFromDb(selectedDatabase), + validateInput: (value) => { + return value ? undefined : constants.nameMustNotBeEmpty; + }, + ignoreFocusOut: true + }); + if (!projectName) { + return undefined; + } + + // 4. Prompt for Project 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 browseProjectLocation = await vscode.window.showQuickPick( + [constants.browseEllipsis], + { title: constants.projectLocationPlaceholderText, ignoreFocusOut: true }); + if (!browseProjectLocation) { + return undefined; + } + // We validate that the folder doesn't already exist, and if it does keep prompting them to pick a new one + let valid = false; + let projectLocation = ''; + while (!valid) { + const locations = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: constants.selectString, + title: constants.selectProjectLocation, + defaultUri: defaultProjectSaveLocation() + }); + if (!locations) { + // User cancelled + return undefined; + } + projectLocation = locations[0].fsPath; + const locationExists = await exists(path.join(projectLocation, projectName)); + if (locationExists) { + // Show the browse quick pick again with the title updated with the error + const browseProjectLocation = await vscode.window.showQuickPick( + [constants.browseEllipsis], + { title: constants.folderAlreadyExistsChooseNewLocation(projectName), ignoreFocusOut: true }); + if (!browseProjectLocation) { + return undefined; + } + } else { + valid = true; + } + } + + // 5: Prompt for folder structure + const folderStructure = await vscode.window.showQuickPick( + [constants.file, constants.flat, constants.objectType, constants.schema, constants.schemaObjectType], + { title: constants.selectFolderStructure, ignoreFocusOut: true, }); + if (!folderStructure) { + // User cancelled + return undefined; + } + + return { + connectionUri: connectionUri, + database: selectedDatabase, + projName: projectName, + filePath: projectLocation, + version: '1.0.0.0', + extractTarget: mapExtractTargetEnum(folderStructure) + }; +} diff --git a/extensions/sql-database-projects/src/models/api/import.ts b/extensions/sql-database-projects/src/models/api/import.ts index 476d522604..d97c780e91 100644 --- a/extensions/sql-database-projects/src/models/api/import.ts +++ b/extensions/sql-database-projects/src/models/api/import.ts @@ -3,13 +3,16 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ExtractTarget } from '../../../../mssql'; +import * as mssql from '../../../../mssql'; +import * as vscodeMssql from 'vscode-mssql'; + +type ExtractTarget = mssql.ExtractTarget | vscodeMssql.ExtractTarget; /** * Data model to communicate for Import API */ export interface ImportDataModel { - serverId: string; + connectionUri: string; database: string; projName: string; filePath: string; diff --git a/extensions/sql-database-projects/src/test/dialogs/createProjectFromDatabaseDialog.test.ts b/extensions/sql-database-projects/src/test/dialogs/createProjectFromDatabaseDialog.test.ts index b3f03235da..e41db56318 100644 --- a/extensions/sql-database-projects/src/test/dialogs/createProjectFromDatabaseDialog.test.ts +++ b/extensions/sql-database-projects/src/test/dialogs/createProjectFromDatabaseDialog.test.ts @@ -83,8 +83,10 @@ describe('Create Project From Database Dialog', () => { }); it('Should include all info in import data model and connect to appropriate call back properties', async function (): Promise { + const stubUri = 'My URI'; const dialog = new CreateProjectFromDatabaseDialog(mockConnectionProfile); sinon.stub(azdata.connection, 'listDatabases').resolves(['My Database']); + sinon.stub(azdata.connection, 'getUriForConnection').resolves(stubUri); await dialog.openDialog(); dialog.projectNameTextBox!.value = 'testProject'; @@ -93,12 +95,12 @@ describe('Create Project From Database Dialog', () => { let model: ImportDataModel; const expectedImportDataModel: ImportDataModel = { - serverId: 'My Id', + connectionUri: stubUri, database: 'My Database', projName: 'testProject', filePath: 'testLocation', version: '1.0.0.0', - extractTarget: mssql.ExtractTarget['schemaObjectType'] + extractTarget: mssql.ExtractTarget.schemaObjectType }; dialog.createProjectFromDatabaseCallback = (m) => { model = m; }; diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index 17d6b0ae2f..5ef02721c9 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -490,7 +490,7 @@ describe('ProjectsController', function (): void { createProjectFromDatabaseDialog.callBase = true; createProjectFromDatabaseDialog.setup(x => x.handleCreateButtonClick()).returns(async () => { await projController.object.createProjectFromDatabaseCallback({ - serverId: 'My Id', + connectionUri: 'My Id', database: 'My Database', projName: 'testProject', filePath: 'testLocation', @@ -510,7 +510,7 @@ describe('ProjectsController', function (): void { }); let dialog = await projController.object.createProjectFromDatabase(undefined); - await dialog.handleCreateButtonClick(); + await dialog!.handleCreateButtonClick(); should(holler).equal(createProjectFromDbHoller, 'executionCallback() is supposed to have been setup and called for create project from database scenario'); }); @@ -519,7 +519,7 @@ describe('ProjectsController', function (): void { let folderPath = await testUtils.generateTestFolderPath(); let projectName = 'My Project'; let importPath; - let model: ImportDataModel = { serverId: 'My Id', database: 'My Database', projName: projectName, filePath: folderPath, version: '1.0.0.0', extractTarget: mssql.ExtractTarget['file'] }; + let model: ImportDataModel = { connectionUri: 'My Id', database: 'My Database', projName: projectName, filePath: folderPath, version: '1.0.0.0', extractTarget: mssql.ExtractTarget['file'] }; const projController = new ProjectsController(); projController.setFilePath(model); @@ -532,7 +532,7 @@ describe('ProjectsController', function (): void { let folderPath = await testUtils.generateTestFolderPath(); let projectName = 'My Project'; let importPath; - let model: ImportDataModel = { serverId: 'My Id', database: 'My Database', projName: projectName, filePath: folderPath, version: '1.0.0.0', extractTarget: mssql.ExtractTarget['schemaObjectType'] }; + let model: ImportDataModel = { connectionUri: 'My Id', database: 'My Database', projName: projectName, filePath: folderPath, version: '1.0.0.0', extractTarget: mssql.ExtractTarget['schemaObjectType'] }; const projController = new ProjectsController(); projController.setFilePath(model);