Files
azuredatastudio/extensions/data-workspace/src/dialogs/newProjectDialog.ts
Sakshi Sharma 9d8006562d Fix project name validation (#22547)
* Fix project name validation

* Add/update tests

* Address comments

* Fix error
2023-03-31 08:46:58 -07:00

330 lines
12 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type * as azdataType from 'azdata';
import * as vscode from 'vscode';
import * as path from 'path';
import { Deferred, DialogBase } from './dialogBase';
import { IWorkspaceService } from '../common/interfaces';
import * as constants from '../common/constants';
import { IProjectType } from 'dataworkspace';
import { directoryExist } from '../common/utils';
import { IconPathHelper } from '../common/iconHelper';
import { defaultProjectSaveLocation } from '../common/projectLocationHelper';
import { TelemetryActions, TelemetryReporter, TelemetryViews } from '../common/telemetry';
import { WorkspaceService } from '../services/workspaceService';
import { isValidBasename, isValidBasenameErrorMessage } from '../common/pathUtilsHelper';
class NewProjectDialogModel {
projectTypeId: string = '';
projectFileExtension: string = '';
name: string = '';
location: string = '';
targetPlatform?: string;
sdkStyleProject?: boolean;
}
export async function openSpecificProjectNewProjectDialog(projectType: IProjectType, workspaceService: WorkspaceService): Promise<vscode.Uri | undefined> {
const dialog = new NewProjectDialog(workspaceService, projectType);
await dialog.open();
await dialog.newDialogPromise;
return dialog.projectUri;
}
export class NewProjectDialog extends DialogBase {
public model: NewProjectDialogModel = new NewProjectDialogModel();
public formBuilder: azdataType.FormBuilder | undefined;
public targetPlatformDropdownFormComponent: azdataType.FormComponent | undefined;
public sdkProjectCheckboxFormComponent: azdataType.FormComponent | undefined;
public newProjectDialogComplete: Deferred<void> | undefined;
public newDialogPromise: Promise<void> = new Promise<void>((resolve, reject) => this.newProjectDialogComplete = { resolve, reject });
public projectUri: vscode.Uri | undefined;
constructor(private workspaceService: IWorkspaceService, private specificProjectType?: IProjectType) {
super(constants.NewProjectDialogTitle, 'NewProject', constants.CreateButtonText);
// dialog launched from Welcome message button (only visible when no current workspace) vs. "add project" button
TelemetryReporter.createActionEvent(TelemetryViews.NewProjectDialog, TelemetryActions.NewProjectDialogLaunched)
.withAdditionalProperties({ isWorkspaceOpen: (vscode.workspace.workspaceFile !== undefined).toString() })
.send();
}
async validate(): Promise<boolean> {
try {
// the selected location should be an existing directory
const parentDirectoryExists = await directoryExist(this.model.location);
if (!parentDirectoryExists) {
this.showErrorMessage(constants.ProjectParentDirectoryNotExistError(this.model.location));
return false;
}
// there shouldn't be an existing sub directory with the same name as the project in the selected location
const projectDirectoryExists = await directoryExist(path.join(this.model.location, this.model.name));
if (projectDirectoryExists) {
this.showErrorMessage(constants.ProjectDirectoryAlreadyExistError(this.model.name, this.model.location));
return false;
}
if (await this.workspaceService.validateWorkspace() === false) {
return false;
}
return true;
}
catch (err) {
this.showErrorMessage(err?.message ? err.message : err);
return false;
}
}
override onCancelButtonClicked(): void {
this.newProjectDialogComplete?.resolve();
this.dispose();
}
override async onComplete(): Promise<void> {
try {
TelemetryReporter.createActionEvent(TelemetryViews.NewProjectDialog, TelemetryActions.NewProjectDialogCompleted)
.withAdditionalProperties({ projectFileExtension: this.model.projectFileExtension, projectTemplateId: this.model.projectTypeId })
.send();
this.projectUri = await this.workspaceService.createProject(this.model.name, vscode.Uri.file(this.model.location), this.model.projectTypeId, this.model.targetPlatform, this.model.sdkStyleProject);
this.newProjectDialogComplete?.resolve();
}
catch (err) {
TelemetryReporter.createErrorEvent2(TelemetryViews.NewProjectDialog, TelemetryActions.NewProjectDialogCompleted, err)
.withAdditionalProperties({ projectFileExtension: this.model.projectFileExtension, projectTemplateId: this.model.projectTypeId, error: err?.message ? err.message : err })
.send();
void vscode.window.showErrorMessage(err?.message ? err.message : err);
}
}
protected async initialize(view: azdataType.ModelView): Promise<void> {
let allProjectTypes = await this.workspaceService.getAllProjectTypes();
// if a specific project type is specified, only show that one
if (this.specificProjectType && allProjectTypes.find(p => p.id === this.specificProjectType?.id)) {
allProjectTypes = [this.specificProjectType];
}
const projectTypeRadioCardGroup = view.modelBuilder.radioCardGroup().withProps({
cards: allProjectTypes.map((projectType: IProjectType) => {
return <azdataType.RadioCard>{
id: projectType.id,
label: projectType.displayName,
icon: projectType.icon,
descriptions: [
{
textValue: projectType.displayName,
textStyles: {
'font-size': '13px',
'font-weight': 'bold'
}
}, {
textValue: projectType.description
}
]
};
}),
iconHeight: '75px',
iconWidth: '75px',
cardWidth: '215px',
cardHeight: '195px',
ariaLabel: constants.TypeTitle,
width: '500px',
iconPosition: 'top',
selectedCardId: allProjectTypes.length > 0 ? allProjectTypes[0].id : undefined
}).component();
this.register(projectTypeRadioCardGroup.onSelectionChanged(async (e) => {
this.model.projectTypeId = e.cardId;
const selectedProject = allProjectTypes.find(p => p.id === e.cardId);
if (selectedProject?.targetPlatforms) {
// update the target platforms dropdown for the selected project type
targetPlatformDropdown.values = selectedProject?.targetPlatforms;
targetPlatformDropdown.value = this.getDefaultTargetPlatform(selectedProject);
this.formBuilder?.insertFormItem(this.targetPlatformDropdownFormComponent!, 3);
} else {
// remove the target version dropdown if the selected project type didn't provide values for this
this.formBuilder?.removeFormItem(this.targetPlatformDropdownFormComponent!);
this.model.targetPlatform = undefined;
}
if (selectedProject?.sdkStyleOption) {
sdkProjectCheckbox.checked = true;
this.model.sdkStyleProject = true;
if (selectedProject.sdkStyleLearnMoreUrl) {
await sdkLearnMore.updateProperty('url', selectedProject.sdkStyleLearnMoreUrl);
sdkFormComponentGroup.addItem(sdkLearnMore);
} else {
// remove learn more link if the project type didn't provide it
sdkFormComponentGroup.removeItem(sdkLearnMore);
}
this.formBuilder?.addFormItem(this.sdkProjectCheckboxFormComponent!);
} else {
this.model.sdkStyleProject = false;
this.formBuilder?.removeFormItem(this.sdkProjectCheckboxFormComponent!);
}
}));
const projectNameTextBox = view.modelBuilder.inputBox().withValidation(
component => isValidBasename(component.value)
)
.withProps({
ariaLabel: constants.ProjectNameTitle,
placeHolder: constants.ProjectNamePlaceholder,
required: true,
width: constants.DefaultInputWidth
}).component();
this.register(projectNameTextBox.onTextChanged(text => {
const errorMessage = isValidBasenameErrorMessage(text);
if (errorMessage !== undefined) {
// Set validation error message if project name is invalid
return void projectNameTextBox.updateProperty('validationErrorMessage', errorMessage);
} else {
this.model.name = projectNameTextBox.value!;
return projectNameTextBox.updateProperty('title', projectNameTextBox.value);
}
}));
const locationTextBox = view.modelBuilder.inputBox().withProps({
ariaLabel: constants.ProjectLocationTitle,
placeHolder: constants.ProjectLocationPlaceholder,
required: true,
width: constants.DefaultInputWidth
}).component();
// use the default save location if it's there
// otherwise, if there's an open folder, set the initial location to that. If there are multiple folders in the workspace, default to the first one
const defaultSaveLocation = defaultProjectSaveLocation();
locationTextBox.value = defaultSaveLocation?.fsPath ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
this.register(locationTextBox.onTextChanged(() => {
this.model.location = locationTextBox.value!;
return locationTextBox.updateProperty('title', locationTextBox.value);
}));
const browseFolderButton = view.modelBuilder.button().withProps({
ariaLabel: constants.BrowseButtonText,
title: constants.BrowseButtonText,
iconPath: IconPathHelper.folder,
height: '16px',
width: '18px'
}).component();
this.register(browseFolderButton.onDidClick(async () => {
let folderUris = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
defaultUri: locationTextBox.value ? vscode.Uri.file(locationTextBox.value) : undefined
});
if (!folderUris || folderUris.length === 0) {
return;
}
const selectedFolder = folderUris[0].fsPath;
locationTextBox.value = selectedFolder;
this.model.location = selectedFolder;
}));
const targetPlatformDropdown = view.modelBuilder.dropDown().withProps({
values: allProjectTypes[0].targetPlatforms,
value: this.getDefaultTargetPlatform(allProjectTypes[0]),
ariaLabel: constants.TargetPlatform,
required: true,
width: constants.DefaultInputWidth
}).component();
this.register(targetPlatformDropdown.onValueChanged(() => {
this.model.targetPlatform = targetPlatformDropdown.value! as string;
}));
this.targetPlatformDropdownFormComponent = {
title: constants.TargetPlatform,
required: true,
component: targetPlatformDropdown
};
const sdkProjectCheckbox = view.modelBuilder.checkBox().withProps({
checked: true,
label: constants.SdkStyleProject
}).component();
this.register(sdkProjectCheckbox.onChanged(() => {
this.model.sdkStyleProject = sdkProjectCheckbox.checked;
}));
const sdkLearnMore = view.modelBuilder.hyperlink().withProps({
label: constants.LearnMore,
url: ''
}).component();
const sdkFormComponentGroup = view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'row', alignItems: 'baseline' })
.withItems([sdkProjectCheckbox], { CSSStyles: { flex: '0 0 auto', 'margin-right': '10px' } })
.component();
if (allProjectTypes[0].sdkStyleLearnMoreUrl) {
await sdkLearnMore.updateProperty('url', allProjectTypes[0].sdkStyleLearnMoreUrl);
sdkFormComponentGroup.addItem(sdkLearnMore);
}
this.sdkProjectCheckboxFormComponent = {
component: sdkFormComponentGroup,
};
this.formBuilder = view.modelBuilder.formContainer().withFormItems([
{
title: constants.TypeTitle,
component: projectTypeRadioCardGroup
},
{
title: constants.ProjectNameTitle,
required: true,
component: this.createHorizontalContainer(view, [projectNameTextBox])
},
{
title: constants.ProjectLocationTitle,
required: true,
component: this.createHorizontalContainer(view, [locationTextBox, browseFolderButton])
}
]);
// add version dropdown if the first project type has one
if (allProjectTypes[0].targetPlatforms) {
this.formBuilder.addFormItem(this.targetPlatformDropdownFormComponent);
}
// add sdk style checkbox is the first project has the option
if (allProjectTypes[0].sdkStyleOption) {
this.formBuilder.addFormItem(this.sdkProjectCheckboxFormComponent);
}
await view.initializeModel(this.formBuilder.component());
this.initDialogComplete?.resolve();
}
/**
* Gets the default target platform of the project type if there is one
* @param projectType
* @returns
*/
getDefaultTargetPlatform(projectType: IProjectType): string | undefined {
// only return the specified default target platform if it's also included in the project type's array of target platforms
if (projectType.defaultTargetPlatform && projectType.targetPlatforms?.includes(projectType.defaultTargetPlatform)) {
return projectType.defaultTargetPlatform;
} else {
return undefined;
}
}
}