diff --git a/extensions/data-workspace/package.json b/extensions/data-workspace/package.json index c207e2d209..0fc759fee4 100644 --- a/extensions/data-workspace/package.json +++ b/extensions/data-workspace/package.json @@ -71,6 +71,10 @@ { "command": "projects.removeProject", "title": "%remove-project-command%" + }, + { + "command": "projects.manageProject", + "title": "%manage-project-command%" } ], "menus": { @@ -114,9 +118,18 @@ }, { "command": "projects.openExisting" + }, + { + "command": "projects.manageProject", + "when": "false" } ], "view/item/context": [ + { + "command": "projects.manageProject", + "when": "view == dataworkspace.views.main", + "group": "0_projectsFirst@1" + }, { "command": "projects.removeProject", "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project", diff --git a/extensions/data-workspace/package.nls.json b/extensions/data-workspace/package.nls.json index 436821ed13..214a667ed3 100644 --- a/extensions/data-workspace/package.nls.json +++ b/extensions/data-workspace/package.nls.json @@ -11,5 +11,6 @@ "projects-view-no-project-content": "No projects open in current workspace.\n[Create new](command:projects.new)\n[Open existing](command:projects.openExisting)\nTo learn more about projects [read our docs](https://aka.ms/azuredatastudio-projects).\n", "open-existing-command": "Open existing", "projects.defaultProjectSaveLocation": "Full path to folder where new projects are saved by default.", - "projects.showNotAddedProjectsInWorkspacePrompt": "Always show information message when the current workspace folders contain projects that have not been added to the workspace's projects." + "projects.showNotAddedProjectsInWorkspacePrompt": "Always show information message when the current workspace folders contain projects that have not been added to the workspace's projects.", + "manage-project-command": "Manage" } diff --git a/extensions/data-workspace/src/common/constants.ts b/extensions/data-workspace/src/common/constants.ts index 7591fedb3c..ebb2731836 100644 --- a/extensions/data-workspace/src/common/constants.ts +++ b/extensions/data-workspace/src/common/constants.ts @@ -21,6 +21,8 @@ export const WorkspaceContainsNotAddedProjects = localize('dataworkspace.workspa export const LaunchOpenExisitingDialog = localize('dataworkspace.launchOpenExistingDialog', "Launch Open existing dialog"); export const DoNotShowAgain = localize('dataworkspace.doNotShowAgain', "Do not show again"); export const ProjectsFailedToLoad = localize('dataworkspace.projectsFailedToLoad', "Some projects failed to load. Please open console for more information"); +export const fileDoesNotExist = (name: string): string => { return localize('fileDoesNotExist', "File '{0}' doesn't exist", name); }; +export const projectNameNull = localize('projectNameNull', "Project name is null"); // config settings export const projectsConfigurationKey = 'projects'; @@ -72,3 +74,9 @@ export const LocalClonePathPlaceholder = localize('dataworkspace.localClonePathP // Workspace settings for saving new projects export const ProjectConfigurationKey = 'projects'; export const ProjectSaveLocationKey = 'defaultProjectSaveLocation'; + +export namespace cssStyles { + export const title = { 'font-size': '18px', 'font-weight': '600' }; + export const tableHeader = { 'text-align': 'left', 'font-weight': '500', 'font-size': '13px', 'user-select': 'text' }; + export const tableRow = { 'border-top': 'solid 1px #ccc', 'border-bottom': 'solid 1px #ccc', 'border-left': 'none', 'border-right': 'none' }; +} diff --git a/extensions/data-workspace/src/dataworkspace.d.ts b/extensions/data-workspace/src/dataworkspace.d.ts index 268e1228e9..34f324dc1a 100644 --- a/extensions/data-workspace/src/dataworkspace.d.ts +++ b/extensions/data-workspace/src/dataworkspace.d.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ declare module 'dataworkspace' { + import * as azdata from 'azdata'; import * as vscode from 'vscode'; export const enum extension { name = 'Microsoft.data-workspace' @@ -37,8 +38,8 @@ declare module 'dataworkspace' { defaultProjectSaveLocation: vscode.Uri | undefined; /** - * Verifies that a workspace is open or if it should be automatically created - */ + * Verifies that a workspace is open or if it should be automatically created + */ validateWorkspace(): Promise; } @@ -70,6 +71,16 @@ declare module 'dataworkspace' { * Gets the supported project types */ readonly supportedProjectTypes: IProjectType[]; + + /** + * Gets the project actions to be placed on the dashboard toolbar + */ + readonly projectActions: (IProjectAction | IProjectActionGroup)[]; + + /** + * Gets the project data to be placed in the dashboard container + */ + readonly dashboardComponents: IDashboardTable[]; } /** @@ -116,4 +127,71 @@ declare module 'dataworkspace' { */ element: any; } + + export interface IProjectAction { + /** + * id of the project action + */ + readonly id: string; + + /** + * icon path of the project action + */ + readonly icon?: azdata.IconPath; + + /** + * Run context for each project action + * @param treeItem The treeItem in a project's hierarchy, to be used to obtain a Project + */ + run(treeItem: WorkspaceTreeItem): void; + } + + /** + * List of project actions that should be grouped and have a separator after the last action + */ + export interface IProjectActionGroup { + actions: IProjectAction[]; + } + + /** + * Defines table to be presented in the dashboard container + */ + export interface IDashboardTable { + /** + * name of the table + */ + name: string; + + /** + * column definitions + */ + columns: IDashboardColumnInfo[]; + + /** + * project data + */ + data: (string | IconCellValue)[][]; + } + + /** + * Project dashboard table's column information + */ + export interface IDashboardColumnInfo { + displayName: string; + width: number; + type?: IDashboardColumnType; + } + + /** + * Cell value of an icon for the table data + */ + export interface IconCellValue { + text: string; + icon: azdata.IconPath; + } + + /** + * Union type representing data types in dashboard table + */ + export type IDashboardColumnType = 'string' | 'icon'; } diff --git a/extensions/data-workspace/src/dialogs/projectDashboard.ts b/extensions/data-workspace/src/dialogs/projectDashboard.ts new file mode 100644 index 0000000000..e4ad0924e1 --- /dev/null +++ b/extensions/data-workspace/src/dialogs/projectDashboard.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IDashboardColumnInfo, IDashboardTable, IProjectAction, IProjectActionGroup, IProjectProvider, WorkspaceTreeItem } from 'dataworkspace'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import * as constants from '../common/constants'; +import { IWorkspaceService } from '../common/interfaces'; +import { fileExist } from '../common/utils'; + +export class ProjectDashboard { + + private dashboard: azdata.window.ModelViewDashboard | undefined; + private modelView: azdata.ModelView | undefined; + private projectProvider: IProjectProvider | undefined; + private overviewTab: azdata.DashboardTab | undefined; + + constructor(private _workspaceService: IWorkspaceService, private _treeItem: WorkspaceTreeItem) { + } + + public async showDashboard(): Promise { + const project = this._treeItem.element.project; + + if (!(await fileExist(project.projectFilePath))) { + throw new Error(constants.fileDoesNotExist(project.projectFilePath)); + } + + if (!project.projectFileName) { + throw new Error(constants.projectNameNull); + } + + this.projectProvider = await this._workspaceService.getProjectProvider(vscode.Uri.file(project.projectFilePath)); + if (!this.projectProvider) { + throw new Error(constants.ProviderNotFoundForProjectTypeError(project.projectFilePath)); + } + + await this.createDashboard(project.projectFileName, project.projectFilePath); + await this.dashboard!.open(); + } + + private async createDashboard(title: string, location: string): Promise { + this.dashboard = azdata.window.createModelViewDashboard(title, 'ProjectDashboard', { alwaysShowTabs: false }); + this.dashboard.registerTabs(async (modelView: azdata.ModelView) => { + this.modelView = modelView; + + this.overviewTab = { + title: '', + id: 'overview-tab', + content: this.createContainer(title, location), + toolbar: this.createToolbarContainer() + }; + return [ + this.overviewTab + ]; + }); + } + + private createToolbarContainer(): azdata.ToolbarContainer { + const projectActions: (IProjectAction | IProjectActionGroup)[] = this.projectProvider!.projectActions; + + // Add actions as buttons + const buttons: azdata.ToolbarComponent[] = []; + + const projectActionsLength = projectActions.length; + + projectActions.forEach((action, actionIndex) => { + if (this.isProjectAction(action)) { + const button = this.createButton(action); + buttons.push({ component: button }); + } else { + const groupLength = action.actions.length; + + action.actions.forEach((groupAction, index) => { + const button = this.createButton(groupAction); + buttons.push({ component: button, toolbarSeparatorAfter: ((groupLength - 1 === index) && (projectActionsLength - 1 !== actionIndex)) }); // Add toolbar separator at the end of the group, if the group is not the last in the list + }); + } + }); + + return this.modelView!.modelBuilder.toolbarContainer() + .withToolbarItems( + buttons + ).component(); + } + + private isProjectAction(obj: any): obj is IProjectAction { + return obj.id !== undefined; + } + + private createButton(projectAction: IProjectAction): azdata.ButtonComponent { + let button = this.modelView!.modelBuilder.button() + .withProperties({ + label: projectAction.id, + iconPath: projectAction.icon, + height: '20px' + }).component(); + + button.onDidClick(async () => { + await projectAction.run(this._treeItem); + }); + + return button; + } + + private createContainer(title: string, location: string): azdata.FlexContainer { + const dashboardData: IDashboardTable[] = this.projectProvider!.dashboardComponents; + + const rootContainer = this.modelView!.modelBuilder.flexContainer().withLayout( + { + flexFlow: 'column', + width: '100%', + height: '100%' + }).component(); + + const titleLabel = this.modelView!.modelBuilder.text() + .withProperties({ value: title, CSSStyles: { 'margin-block-start': '20px', 'margin-block-end': '0px' } }) + .component(); + rootContainer.addItem(titleLabel, { CSSStyles: { 'padding-left': '34px', 'padding-top': '15px', 'font-size': '36px', 'font-weight': '400' } }); + + const projectFolderPath = path.dirname(location); + const locationLabel = this.modelView!.modelBuilder.text() + .withProperties({ value: projectFolderPath, CSSStyles: { 'margin-block-start': '20px', 'margin-block-end': '0px' } }) + .component(); + rootContainer.addItem(locationLabel, { CSSStyles: { 'padding-left': '34px', 'padding-top': '15px', 'padding-bottom': '50px', 'font-size': '16px' } }); + + // Add all the tables to the container + dashboardData.forEach(info => { + const tableNameLabel = this.modelView!.modelBuilder.text() + .withProperties({ value: info.name, CSSStyles: { 'margin-block-start': '30px', 'margin-block-end': '0px' } }) + .component(); + rootContainer.addItem(tableNameLabel, { CSSStyles: { 'padding-left': '25px', 'padding-bottom': '20px', ...constants.cssStyles.title } }); + + const columns: azdata.DeclarativeTableColumn[] = []; + info.columns.forEach((column: IDashboardColumnInfo) => { + let col = { + displayName: column.displayName, + valueType: column.type === 'icon' ? azdata.DeclarativeDataType.component : azdata.DeclarativeDataType.string, + isReadOnly: true, + width: column.width, + headerCssStyles: { + 'border': 'none', + ...constants.cssStyles.tableHeader + }, + rowCssStyles: { + ...constants.cssStyles.tableRow + }, + }; + columns.push(col); + }); + + const data: azdata.DeclarativeTableCellValue[][] = []; + info.data.forEach(values => { + const columnValue: azdata.DeclarativeTableCellValue[] = []; + values.forEach(val => { + if (typeof val === 'string') { + columnValue.push({ value: val }); + } else { + const iconComponent = this.modelView!.modelBuilder.image().withProperties({ + iconPath: val.icon, + width: '15px', + height: '15px', + iconHeight: '15px', + iconWidth: '15px' + }).component(); + const stringComponent = this.modelView!.modelBuilder.text().withProperties({ + value: val.text, + CSSStyles: { 'margin-block-start': 'auto', 'block-size': 'auto', 'margin-block-end': '0px' } + }).component(); + + const columnData = this.modelView!.modelBuilder.flexContainer().withItems([iconComponent, stringComponent], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px' } }).withLayout({ flexFlow: 'row' }).component(); + columnValue.push({ value: columnData }); + } + }); + data.push(columnValue); + }); + + const table = this.modelView!.modelBuilder.declarativeTable() + .withProperties({ columns: columns, dataValues: data, ariaLabel: info.name, CSSStyles: { 'margin-left': '30px' } }).component(); + + rootContainer.addItem(table); + }); + + return rootContainer; + } +} diff --git a/extensions/data-workspace/src/main.ts b/extensions/data-workspace/src/main.ts index 7e87987f1f..f0aa33d1db 100644 --- a/extensions/data-workspace/src/main.ts +++ b/extensions/data-workspace/src/main.ts @@ -12,6 +12,7 @@ import { NewProjectDialog } from './dialogs/newProjectDialog'; import { OpenExistingDialog } from './dialogs/openExistingDialog'; import { IWorkspaceService } from './common/interfaces'; import { IconPathHelper } from './common/iconHelper'; +import { ProjectDashboard } from './dialogs/projectDashboard'; export function activate(context: vscode.ExtensionContext): Promise { const workspaceService = new WorkspaceService(context); @@ -51,6 +52,10 @@ export function activate(context: vscode.ExtensionContext): Promise context.subscriptions.push(vscode.commands.registerCommand('projects.removeProject', async (treeItem: WorkspaceTreeItem) => { await workspaceService.removeProject(vscode.Uri.file(treeItem.element.project.projectFilePath)); })); + context.subscriptions.push(vscode.commands.registerCommand('projects.manageProject', async (treeItem: WorkspaceTreeItem) => { + const dashboard = new ProjectDashboard(workspaceService, treeItem); + await dashboard.showDashboard(); + })); IconPathHelper.setExtensionContext(context); diff --git a/extensions/data-workspace/src/test/projectProviderRegistry.test.ts b/extensions/data-workspace/src/test/projectProviderRegistry.test.ts index b77216c7f9..b12b3ccfe0 100644 --- a/extensions/data-workspace/src/test/projectProviderRegistry.test.ts +++ b/extensions/data-workspace/src/test/projectProviderRegistry.test.ts @@ -3,11 +3,11 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IDashboardTable, IProjectAction, IProjectProvider, IProjectType } from 'dataworkspace'; import 'mocha'; -import * as vscode from 'vscode'; import * as should from 'should'; +import * as vscode from 'vscode'; import { ProjectProviderRegistry } from '../common/projectProviderRegistry'; -import { IProjectProvider, IProjectType } from 'dataworkspace'; export class MockTreeDataProvider implements vscode.TreeDataProvider{ onDidChangeTreeData?: vscode.Event | undefined; @@ -19,7 +19,7 @@ export class MockTreeDataProvider implements vscode.TreeDataProvider{ } } -export function createProjectProvider(projectTypes: IProjectType[]): IProjectProvider { +export function createProjectProvider(projectTypes: IProjectType[], projectActions: IProjectAction[], dashboardComponents: IDashboardTable[]): IProjectProvider { const treeDataProvider = new MockTreeDataProvider(); const projectProvider: IProjectProvider = { supportedProjectTypes: projectTypes, @@ -31,7 +31,9 @@ export function createProjectProvider(projectTypes: IProjectType[]): IProjectPro }, createProject: (name: string, location: vscode.Uri, projectTypeId: string): Promise => { return Promise.resolve(location); - } + }, + projectActions: projectActions, + dashboardComponents: dashboardComponents }; return projectProvider; } @@ -52,7 +54,25 @@ suite('ProjectProviderRegistry Tests', function (): void { displayName: 'test project 1', description: '' } - ]); + ], + [{ + id: 'testAction1', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'testAction2', + run: async (): Promise => { return Promise.resolve(); } + }], + [{ + name: 'tableInfo1', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + }, + { + name: 'tableInfo2', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + }]); const provider2 = createProjectProvider([ { id: 'sp1', @@ -61,7 +81,37 @@ suite('ProjectProviderRegistry Tests', function (): void { displayName: 'sql project', description: '' } - ]); + ], + [{ + id: 'Add', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Schema Compare', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Build', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Publish', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Target Version', + run: async (): Promise => { return Promise.resolve(); } + }], + [{ + name: 'Deployments', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + }, + { + name: 'Builds', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + }]); should.strictEqual(ProjectProviderRegistry.providers.length, 0, 'there should be no project provider at the beginning of the test'); const disposable1 = ProjectProviderRegistry.registerProvider(provider1, 'test.testProvider'); let providerResult = ProjectProviderRegistry.getProviderByProjectExtension('testproj'); @@ -104,7 +154,16 @@ suite('ProjectProviderRegistry Tests', function (): void { displayName: 'test project', description: '' } - ]); + ], + [{ + id: 'testAction1', + run: async (): Promise => { return Promise.resolve(); } + }], + [{ + name: 'tableInfo1', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + }]); should.strictEqual(ProjectProviderRegistry.providers.length, 0, 'there should be no project provider at the beginning of the test'); ProjectProviderRegistry.registerProvider(provider, 'test.testProvider'); should.strictEqual(ProjectProviderRegistry.providers.length, 1, 'there should be only one project provider at this time'); diff --git a/extensions/data-workspace/src/test/workspaceService.test.ts b/extensions/data-workspace/src/test/workspaceService.test.ts index b0087db88b..904ee68054 100644 --- a/extensions/data-workspace/src/test/workspaceService.test.ts +++ b/extensions/data-workspace/src/test/workspaceService.test.ts @@ -136,6 +136,28 @@ suite('WorkspaceService Tests', function (): void { icon: '', displayName: 'test project 1' } + ], + [ + { + id: 'testAction1', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'testAction2', + run: async (): Promise => { return Promise.resolve(); } + } + ], + [ + { + name: 'tableInfo1', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + }, + { + name: 'tableInfo2', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + } ]); const provider2 = createProjectProvider([ { @@ -145,6 +167,40 @@ suite('WorkspaceService Tests', function (): void { icon: '', displayName: 'sql project' } + ], + [ + { + id: 'Add', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Schema Compare', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Build', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Publish', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Target Version', + run: async (): Promise => { return Promise.resolve(); } + } + ], + [ + { + name: 'Deployments', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + }, + { + name: 'Builds', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + } ]); sinon.stub(ProjectProviderRegistry, 'providers').value([provider1, provider2]); const consoleErrorStub = sinon.stub(console, 'error'); @@ -178,7 +234,37 @@ suite('WorkspaceService Tests', function (): void { icon: '', displayName: 'test project' } - ])); + ], + [{ + id: 'Add', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Schema Compare', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Build', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Publish', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Target Version', + run: async (): Promise => { return Promise.resolve(); } + }], + [{ + name: 'Deployments', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + }, + { + name: 'Builds', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + }])); let provider = await service.getProjectProvider(vscode.Uri.file('abc.sqlproj')); should.notStrictEqual(provider, undefined, 'Provider should be returned for sqlproj'); should.strictEqual(provider!.supportedProjectTypes[0].projectFileExtension, 'sqlproj'); @@ -193,7 +279,16 @@ suite('WorkspaceService Tests', function (): void { projectFileExtension: 'csproj', icon: '', displayName: 'test cs project' - }])); + }], + [{ + id: 'testAction2', + run: async (): Promise => { return Promise.resolve(); } + }], + [{ + name: 'tableInfo2', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + }])); provider = await service.getProjectProvider(vscode.Uri.file('abc.csproj')); should.notStrictEqual(provider, undefined, 'Provider should be returned for csproj'); should.strictEqual(provider!.supportedProjectTypes[0].projectFileExtension, 'csproj'); @@ -324,7 +419,7 @@ suite('WorkspaceService Tests', function (): void { await vscode.workspace.getConfiguration(constants.projectsConfigurationKey).update(constants.showNotAddedProjectsMessageKey, true, true); sinon.stub(service, 'getProjectsInWorkspace').returns([vscode.Uri.file('abc.sqlproj'), vscode.Uri.file('folder1/abc1.sqlproj')]); - sinon.stub(vscode.workspace, 'workspaceFolders').value([{uri: vscode.Uri.file('.')}]); + sinon.stub(vscode.workspace, 'workspaceFolders').value([{ uri: vscode.Uri.file('.') }]); sinon.stub(service, 'getAllProjectTypes').resolves([{ projectFileExtension: 'sqlproj', id: 'sql project', diff --git a/extensions/data-workspace/src/test/workspaceTreeDataProvider.test.ts b/extensions/data-workspace/src/test/workspaceTreeDataProvider.test.ts index 5fcbe08d36..0c31a90e25 100644 --- a/extensions/data-workspace/src/test/workspaceTreeDataProvider.test.ts +++ b/extensions/data-workspace/src/test/workspaceTreeDataProvider.test.ts @@ -3,14 +3,14 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IProjectProvider, WorkspaceTreeItem } from 'dataworkspace'; import 'mocha'; -import * as sinon from 'sinon'; -import * as vscode from 'vscode'; import * as should from 'should'; +import * as sinon from 'sinon'; import * as TypeMoq from 'typemoq'; +import * as vscode from 'vscode'; import { WorkspaceTreeDataProvider } from '../common/workspaceTreeDataProvider'; import { WorkspaceService } from '../services/workspaceService'; -import { IProjectProvider, WorkspaceTreeItem } from 'dataworkspace'; import { MockTreeDataProvider } from './projectProviderRegistry.test'; interface ExtensionGlobalMemento extends vscode.Memento { @@ -90,7 +90,37 @@ suite('workspaceTreeDataProvider Tests', function (): void { }, createProject: (name: string, location: vscode.Uri): Promise => { return Promise.resolve(location); - } + }, + projectActions: [{ + id: 'Add', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Schema Compare', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Build', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Publish', + run: async (): Promise => { return Promise.resolve(); } + }, + { + id: 'Target Version', + run: async (): Promise => { return Promise.resolve(); } + }], + dashboardComponents: [{ + name: 'Deployments', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + }, + { + name: 'Builds', + columns: [{ displayName: 'c1', width: 75, type: 'string' }], + data: [['d1']] + }] }; const getProjectProviderStub = sinon.stub(workspaceService, 'getProjectProvider'); getProjectProviderStub.onFirstCall().resolves(undefined); diff --git a/extensions/sql-database-projects/images/add.svg b/extensions/sql-database-projects/images/add.svg new file mode 100644 index 0000000000..ceb71e9db0 --- /dev/null +++ b/extensions/sql-database-projects/images/add.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/extensions/sql-database-projects/images/build.svg b/extensions/sql-database-projects/images/build.svg new file mode 100644 index 0000000000..bc30a16aec --- /dev/null +++ b/extensions/sql-database-projects/images/build.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/sql-database-projects/images/error.svg b/extensions/sql-database-projects/images/error.svg new file mode 100644 index 0000000000..263d5b1f60 --- /dev/null +++ b/extensions/sql-database-projects/images/error.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/extensions/sql-database-projects/images/inProgress.svg b/extensions/sql-database-projects/images/inProgress.svg new file mode 100644 index 0000000000..d97a26d767 --- /dev/null +++ b/extensions/sql-database-projects/images/inProgress.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/extensions/sql-database-projects/images/publish.svg b/extensions/sql-database-projects/images/publish.svg new file mode 100644 index 0000000000..3ff6253f0e --- /dev/null +++ b/extensions/sql-database-projects/images/publish.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/extensions/sql-database-projects/images/schemaCompare.svg b/extensions/sql-database-projects/images/schemaCompare.svg new file mode 100644 index 0000000000..81cfce115a --- /dev/null +++ b/extensions/sql-database-projects/images/schemaCompare.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/sql-database-projects/images/success.svg b/extensions/sql-database-projects/images/success.svg new file mode 100644 index 0000000000..a006c6cbeb --- /dev/null +++ b/extensions/sql-database-projects/images/success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extensions/sql-database-projects/images/targetPlatform.svg b/extensions/sql-database-projects/images/targetPlatform.svg new file mode 100644 index 0000000000..06a3052beb --- /dev/null +++ b/extensions/sql-database-projects/images/targetPlatform.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 4c4658e3b4..5eb2685158 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -30,6 +30,31 @@ export const edgeSqlDatabaseProjectTypeId = 'SqlDbEdgeProj'; export const edgeProjectTypeDisplayName = localize('edgeProjectTypeDisplayName', "SQL Edge"); export const edgeProjectTypeDescription = localize('edgeProjectTypeDescription', "Start with the core pieces to develop and publish schemas for SQL Edge"); +// Dashboard +export const addItemAction = localize('addItemAction', "Add Item"); +export const schemaCompareAction = localize('schemaCompareAction', "Schema Compare"); +export const buildAction = localize('buildAction', "Build"); +export const publishAction = localize('publishAction', "Publish"); +export const changeTargetPlatformAction = localize('changeTargetPlatformAction', "Change Target Platform"); + +export const ID = localize('ID', "ID"); +export const Status = localize('Status', "Status"); +export const Time = localize('Time', "Time"); +export const Date = localize('Date', "Date"); +export const Builds = localize('Builds', "Builds"); +export const Deployments = localize('Deployments', "Deployments"); + +export const Success = localize('Success', "Success"); +export const Failed = localize('Failed', "Failed"); +export const InProgress = localize('InProgress', "In progress"); + +export const hr = localize('hr', "hr"); +export const min = localize('min', "min"); +export const sec = localize('sec', "sec"); +export const msec = localize('msec', "msec"); + +export const at = localize('at', "at"); + // commands export const revealFileInOsCommand = 'revealFileInOS'; export const schemaCompareStartCommand = 'schemaCompare.start'; diff --git a/extensions/sql-database-projects/src/common/iconHelper.ts b/extensions/sql-database-projects/src/common/iconHelper.ts index 5467b1f2d4..456c76d7ca 100644 --- a/extensions/sql-database-projects/src/common/iconHelper.ts +++ b/extensions/sql-database-projects/src/common/iconHelper.ts @@ -29,6 +29,16 @@ export class IconPathHelper { public static folder: IconPath; + public static add: IconPath; + public static build: IconPath; + public static publish: IconPath; + public static schemaCompare: IconPath; + public static targetPlatform: IconPath; + + public static success: IconPath; + public static error: IconPath; + public static inProgress: IconPath; + public static setExtensionContext(extensionContext: vscode.ExtensionContext) { IconPathHelper.extensionContext = extensionContext; @@ -48,6 +58,16 @@ export class IconPathHelper { IconPathHelper.connect = IconPathHelper.makeIcon('connect', true); IconPathHelper.folder = IconPathHelper.makeIcon('folder'); + + IconPathHelper.add = IconPathHelper.makeIcon('add', true); + IconPathHelper.build = IconPathHelper.makeIcon('build', true); + IconPathHelper.publish = IconPathHelper.makeIcon('publish', true); + IconPathHelper.schemaCompare = IconPathHelper.makeIcon('schemaCompare', true); + IconPathHelper.targetPlatform = IconPathHelper.makeIcon('targetPlatform', true); + + IconPathHelper.success = IconPathHelper.makeIcon('success', true); + IconPathHelper.error = IconPathHelper.makeIcon('error', true); + IconPathHelper.inProgress = IconPathHelper.makeIcon('inProgress', true); } private static makeIcon(name: string, sameIcon: boolean = false) { diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index 55445e29b0..aeb530aa7d 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -284,3 +284,37 @@ export function getPackageInfo(packageJson?: any): IPackageInfo | undefined { return undefined; } + +/** + * Converts time in milliseconds to hr, min, sec + * @param duration time in milliseconds + * @returns string in "hr, min, sec" or "msec" format + */ +export function timeConversion(duration: number): string { + const portions: string[] = []; + + const msInHour = 1000 * 60 * 60; + const hours = Math.trunc(duration / msInHour); + if (hours > 0) { + portions.push(`${hours} ${constants.hr}`); + duration = duration - (hours * msInHour); + } + + const msInMinute = 1000 * 60; + const minutes = Math.trunc(duration / msInMinute); + if (minutes > 0) { + portions.push(`${minutes} ${constants.min}`); + duration = duration - (minutes * msInMinute); + } + + const seconds = Math.trunc(duration / 1000); + if (seconds > 0) { + portions.push(`${seconds} ${constants.sec}`); + } + + if (hours === 0 && minutes === 0 && seconds === 0) { + portions.push(`${duration} ${constants.msec}`); + } + + return portions.join(', '); +} diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index 6dc9e91339..d5e4a4fefd 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -31,6 +31,10 @@ import { ISystemDatabaseReferenceSettings, IDacpacReferenceSettings, IProjectRef import { DatabaseReferenceTreeItem } from '../models/tree/databaseReferencesTreeItem'; import { CreateProjectFromDatabaseDialog } from '../dialogs/createProjectFromDatabaseDialog'; import { TelemetryActions, TelemetryReporter, TelemetryViews } from '../common/telemetry'; +import { IconPathHelper } from '../common/iconHelper'; +import { DashboardData, Status } from '../models/dashboardData/dashboardData'; + +const maxTableLength = 10; /** * Controller for managing lifecycle of projects @@ -38,6 +42,8 @@ import { TelemetryActions, TelemetryReporter, TelemetryViews } from '../common/t export class ProjectsController { private netCoreTool: NetCoreTool; private buildHelper: BuildHelper; + private buildInfo: DashboardData[] = []; + private deployInfo: DashboardData[] = []; projFileWatchers = new Map(); @@ -46,6 +52,66 @@ export class ProjectsController { this.buildHelper = new BuildHelper(); } + public get dashboardDeployData(): (string | dataworkspace.IconCellValue)[][] { + const infoRows: (string | dataworkspace.IconCellValue)[][] = []; + let count = 0; + + for (let i = this.deployInfo.length - 1; i >= 0; i--) { + let icon: azdata.IconPath; + let text: string; + if (this.deployInfo[i].status === Status.success) { + icon = IconPathHelper.success; + text = constants.Success; + } else if (this.deployInfo[i].status === Status.failed) { + icon = IconPathHelper.error; + text = constants.Failed; + } else { + icon = IconPathHelper.inProgress; + text = constants.InProgress; + } + + let infoRow: (string | dataworkspace.IconCellValue)[] = [count.toString(), + { text: text, icon: icon }, + this.deployInfo[i].target, + this.deployInfo[i].timeToCompleteAction, + this.deployInfo[i].startDate]; + infoRows.push(infoRow); + count++; + } + + return infoRows; + } + + public get dashboardBuildData(): (string | dataworkspace.IconCellValue)[][] { + const infoRows: (string | dataworkspace.IconCellValue)[][] = []; + let count = 0; + + for (let i = this.buildInfo.length - 1; i >= 0; i--) { + let icon: azdata.IconPath; + let text: string; + if (this.buildInfo[i].status === Status.success) { + icon = IconPathHelper.success; + text = constants.Success; + } else if (this.buildInfo[i].status === Status.failed) { + icon = IconPathHelper.error; + text = constants.Failed; + } else { + icon = IconPathHelper.inProgress; + text = constants.InProgress; + } + + let infoRow: (string | dataworkspace.IconCellValue)[] = [count.toString(), + { text: text, icon: icon }, + this.buildInfo[i].target, + this.buildInfo[i].timeToCompleteAction, + this.buildInfo[i].startDate]; + infoRows.push(infoRow); + count++; + } + + return infoRows; + } + public refreshProjectsTree(workspaceTreeItem: dataworkspace.WorkspaceTreeItem): void { (workspaceTreeItem.treeDataProvider as SqlDatabaseProjectTreeViewProvider).notifyTreeDataChanged(); } @@ -108,6 +174,14 @@ export class ProjectsController { const project: Project = this.getProjectFromContext(context); const startTime = new Date(); + const currentBuildTimeInfo = `${startTime.toLocaleDateString()} ${constants.at} ${startTime.toLocaleTimeString()}`; + + let buildInfoNew = new DashboardData(Status.inProgress, project.getProjectTargetVersion(), currentBuildTimeInfo); + this.buildInfo.push(buildInfoNew); + + if (this.buildInfo.length - 1 === maxTableLength) { + this.buildInfo.shift(); // Remove the first element to maintain the length + } // Check mssql extension for project dlls (tracking issue #10273) await this.buildHelper.createBuildDirFolder(); @@ -120,15 +194,26 @@ export class ProjectsController { try { await this.netCoreTool.runDotnetCommand(options); + const timeToBuild = new Date().getTime() - startTime.getTime(); + + const currentBuildIndex = this.buildInfo.findIndex(b => b.startDate === currentBuildTimeInfo); + this.buildInfo[currentBuildIndex].status = Status.success; + this.buildInfo[currentBuildIndex].timeToCompleteAction = utils.timeConversion(timeToBuild); TelemetryReporter.createActionEvent(TelemetryViews.ProjectController, TelemetryActions.build) - .withAdditionalMeasurements({ duration: new Date().getTime() - startTime.getTime() }) + .withAdditionalMeasurements({ duration: timeToBuild }) .send(); return project.dacpacOutputPath; } catch (err) { + const timeToFailureBuild = new Date().getTime() - startTime.getTime(); + + const currentBuildIndex = this.buildInfo.findIndex(b => b.startDate === currentBuildTimeInfo); + this.buildInfo[currentBuildIndex].status = Status.failed; + this.buildInfo[currentBuildIndex].timeToCompleteAction = utils.timeConversion(timeToFailureBuild); + TelemetryReporter.createErrorEvent(TelemetryViews.ProjectController, TelemetryActions.build) - .withAdditionalMeasurements({ duration: new Date().getTime() - startTime.getTime() }) + .withAdditionalMeasurements({ duration: timeToFailureBuild }) .send(); vscode.window.showErrorMessage(constants.projBuildFailed(utils.getErrorMessage(err))); @@ -187,7 +272,16 @@ export class ProjectsController { let result: mssql.DacFxResult; telemetryProps.profileUsed = (settings.profileUsed ?? false).toString(); - const actionStartTime = new Date().getTime(); + const currentDate = new Date(); + const actionStartTime = currentDate.getTime(); + const currentDeployTimeInfo = `${currentDate.toLocaleDateString()} ${constants.at} ${currentDate.toLocaleTimeString()}`; + + let deployInfoNew = new DashboardData(Status.inProgress, project.getProjectTargetVersion(), currentDeployTimeInfo); + this.deployInfo.push(deployInfoNew); + + if (this.deployInfo.length - 1 === maxTableLength) { + this.deployInfo.shift(); // Remove the first element to maintain the length + } try { if ((settings).upgradeExisting) { @@ -200,20 +294,30 @@ export class ProjectsController { } } catch (err) { const actionEndTime = new Date().getTime(); - telemetryProps.actionDuration = (actionEndTime - actionStartTime).toString(); + const timeToFailureDeploy = actionEndTime - actionStartTime; + telemetryProps.actionDuration = timeToFailureDeploy.toString(); telemetryProps.totalDuration = (actionEndTime - buildStartTime).toString(); TelemetryReporter.createErrorEvent(TelemetryViews.ProjectController, TelemetryActions.publishProject) .withAdditionalProperties(telemetryProps) .send(); + const currentDeployIndex = this.deployInfo.findIndex(d => d.startDate === currentDeployTimeInfo); + this.deployInfo[currentDeployIndex].status = Status.failed; + this.deployInfo[currentDeployIndex].timeToCompleteAction = utils.timeConversion(timeToFailureDeploy); + throw err; } const actionEndTime = new Date().getTime(); - telemetryProps.actionDuration = (actionEndTime - actionStartTime).toString(); + const timeToDeploy = actionEndTime - actionStartTime; + telemetryProps.actionDuration = timeToDeploy.toString(); telemetryProps.totalDuration = (actionEndTime - buildStartTime).toString(); + const currentDeployIndex = this.deployInfo.findIndex(d => d.startDate === currentDeployTimeInfo); + this.deployInfo[currentDeployIndex].status = Status.success; + this.deployInfo[currentDeployIndex].timeToCompleteAction = utils.timeConversion(timeToDeploy); + TelemetryReporter.createActionEvent(TelemetryViews.ProjectController, TelemetryActions.publishProject) .withAdditionalProperties(telemetryProps) .send(); diff --git a/extensions/sql-database-projects/src/models/dashboardData/dashboardData.ts b/extensions/sql-database-projects/src/models/dashboardData/dashboardData.ts new file mode 100644 index 0000000000..8c1a95081a --- /dev/null +++ b/extensions/sql-database-projects/src/models/dashboardData/dashboardData.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export class DashboardData { + public status: Status; + public target: string; + public timeToCompleteAction: string; + public startDate: string; + + constructor(status: Status, target: string, startDate: string) { + this.status = status; + this.target = target; + this.timeToCompleteAction = ''; + this.startDate = startDate; + } +} + +export enum Status { + success, + failed, + inProgress +} diff --git a/extensions/sql-database-projects/src/projectProvider/projectProvider.ts b/extensions/sql-database-projects/src/projectProvider/projectProvider.ts index 232cf7aa25..0d5d517f29 100644 --- a/extensions/sql-database-projects/src/projectProvider/projectProvider.ts +++ b/extensions/sql-database-projects/src/projectProvider/projectProvider.ts @@ -77,7 +77,45 @@ export class SqlDatabaseProjectProvider implements dataworkspace.IProjectProvide } /** - * Adds the list of files and directories to the project, and saves the project file + * Gets the supported project types + */ + get projectActions(): (dataworkspace.IProjectAction | dataworkspace.IProjectActionGroup)[] { + const addItemAction: dataworkspace.IProjectAction = { + id: constants.addItemAction, + icon: IconPathHelper.add, + run: (treeItem: dataworkspace.WorkspaceTreeItem) => this.projectController.addItemPromptFromNode(treeItem) + }; + + const schemaCompareAction: dataworkspace.IProjectAction = { + id: constants.schemaCompareAction, + icon: IconPathHelper.schemaCompare, + run: (treeItem: dataworkspace.WorkspaceTreeItem) => this.projectController.schemaCompare(treeItem) + }; + + const buildAction: dataworkspace.IProjectAction = { + id: constants.buildAction, + icon: IconPathHelper.build, + run: (treeItem: dataworkspace.WorkspaceTreeItem) => this.projectController.buildProject(treeItem) + }; + + const publishAction: dataworkspace.IProjectAction = { + id: constants.publishAction, + icon: IconPathHelper.publish, + run: (treeItem: dataworkspace.WorkspaceTreeItem) => this.projectController.publishProject(treeItem) + }; + + const changeTargetPlatformAction: dataworkspace.IProjectAction = { + id: constants.changeTargetPlatformAction, + icon: IconPathHelper.targetPlatform, + run: (treeItem: dataworkspace.WorkspaceTreeItem) => this.projectController.changeTargetPlatform(treeItem) + }; + + let group: dataworkspace.IProjectActionGroup = { actions: [addItemAction, schemaCompareAction, buildAction, publishAction] }; + + return [group, changeTargetPlatformAction]; + } + + /** Adds the list of files and directories to the project, and saves the project file * @param projectFile The Uri of the project file * @param list list of uris of files and folders to add. Files and folders must already exist. Files and folders must already exist. No files or folders will be added if any do not exist. */ @@ -85,4 +123,31 @@ export class SqlDatabaseProjectProvider implements dataworkspace.IProjectProvide const project = await Project.openProject(projectFile.fsPath); await project.addToProject(list); } + + /** + * Gets the data to be displayed in the project dashboard + */ + get dashboardComponents(): dataworkspace.IDashboardTable[] { + const deployInfo: dataworkspace.IDashboardTable = { + name: constants.Deployments, + columns: [{ displayName: constants.ID, width: 75 }, + { displayName: constants.Status, width: 180, type: 'icon' }, + { displayName: constants.Target, width: 180 }, + { displayName: constants.Time, width: 180 }, + { displayName: constants.Date, width: 180 }], + data: this.projectController.dashboardDeployData + }; + + const buildInfo: dataworkspace.IDashboardTable = { + name: constants.Builds, + columns: [{ displayName: constants.ID, width: 75 }, + { displayName: constants.Status, width: 180, type: 'icon' }, + { displayName: constants.Target, width: 180 }, + { displayName: constants.Time, width: 180 }, + { displayName: constants.Date, width: 180 }], + data: this.projectController.dashboardBuildData + }; + + return [deployInfo, buildInfo]; + } } diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index 2ed1989873..026882b2ef 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -409,7 +409,9 @@ describe('ProjectsController', function (): void { projController.setup(x => x.getDaxFxService()).returns(() => Promise.resolve(testContext.dacFxService.object)); - await projController.object.publishProjectCallback(new Project(''), { connectionUri: '', databaseName: '' }); + const proj = await testUtils.createTestProject(baselines.openProjectFileBaseline); + + await projController.object.publishProjectCallback(proj, { connectionUri: '', databaseName: '' }); should(builtDacpacPath).not.equal('', 'built dacpac path should be set'); should(publishedDacpacPath).not.equal('', 'published dacpac path should be set'); diff --git a/extensions/sql-database-projects/src/test/utils.test.ts b/extensions/sql-database-projects/src/test/utils.test.ts index 80db2fe8f1..98b6471a5e 100644 --- a/extensions/sql-database-projects/src/test/utils.test.ts +++ b/extensions/sql-database-projects/src/test/utils.test.ts @@ -7,7 +7,7 @@ import * as should from 'should'; import * as path from 'path'; import * as os from 'os'; import { createDummyFileStructure } from './testUtils'; -import { exists, trimUri, removeSqlCmdVariableFormatting, formatSqlCmdVariable, isValidSqlCmdVariableName } from '../common/utils'; +import { exists, trimUri, removeSqlCmdVariableFormatting, formatSqlCmdVariable, isValidSqlCmdVariableName, timeConversion } from '../common/utils'; import { Uri } from 'vscode'; describe('Tests to verify utils functions', function (): void { @@ -78,5 +78,15 @@ describe('Tests to verify utils functions', function (): void { should(isValidSqlCmdVariableName('test\'')).equal(false); should(isValidSqlCmdVariableName('test-1')).equal(false); }); + + it('Should convert from milliseconds to hr min sec correctly', () => { + should(timeConversion((60 * 60 * 1000) + (59 * 60 * 1000) + (59 * 1000))).equal('1 hr, 59 min, 59 sec'); + should(timeConversion((60 * 60 * 1000) + (59 * 60 * 1000) )).equal('1 hr, 59 min'); + should(timeConversion((60 * 60 * 1000) )).equal('1 hr'); + should(timeConversion((60 * 60 * 1000) + (59 * 1000))).equal('1 hr, 59 sec'); + should(timeConversion( (59 * 60 * 1000) + (59 * 1000))).equal('59 min, 59 sec'); + should(timeConversion( (59 * 1000))).equal('59 sec'); + should(timeConversion( (59))).equal('59 msec'); + }); });