From 5ece9b968aa2936243a61691eebd91fa179ee124 Mon Sep 17 00:00:00 2001 From: Barbara Valdez <34872381+barbaravaldez@users.noreply.github.com> Date: Mon, 22 Feb 2021 13:29:09 -0800 Subject: [PATCH] Create book UI (#14210) * Add dialog for creating books * create empty book * add localized constants * add validation to dialog * reset the create book command to original * address pr comments * change error message * Init book toc manager in bookTreeView --- .../resources/dark/folder_inverse.svg | 1 + .../notebook/resources/light/folder.svg | 1 + .../notebook/src/book/bookTocManager.ts | 22 ++- extensions/notebook/src/book/bookTreeView.ts | 24 +-- extensions/notebook/src/common/iconHelper.ts | 5 + .../notebook/src/common/localizedConstants.ts | 15 ++ extensions/notebook/src/common/utils.ts | 11 ++ .../notebook/src/dialog/createBookDialog.ts | 171 ++++++++++++++++++ 8 files changed, 227 insertions(+), 23 deletions(-) create mode 100644 extensions/notebook/resources/dark/folder_inverse.svg create mode 100644 extensions/notebook/resources/light/folder.svg create mode 100644 extensions/notebook/src/dialog/createBookDialog.ts diff --git a/extensions/notebook/resources/dark/folder_inverse.svg b/extensions/notebook/resources/dark/folder_inverse.svg new file mode 100644 index 0000000000..f94d427cb1 --- /dev/null +++ b/extensions/notebook/resources/dark/folder_inverse.svg @@ -0,0 +1 @@ +folder_inverse_16x16 \ No newline at end of file diff --git a/extensions/notebook/resources/light/folder.svg b/extensions/notebook/resources/light/folder.svg new file mode 100644 index 0000000000..8442363a76 --- /dev/null +++ b/extensions/notebook/resources/light/folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/extensions/notebook/src/book/bookTocManager.ts b/extensions/notebook/src/book/bookTocManager.ts index 2476a8d9bc..7f4b15dc6f 100644 --- a/extensions/notebook/src/book/bookTocManager.ts +++ b/extensions/notebook/src/book/bookTocManager.ts @@ -221,14 +221,22 @@ export class BookTocManager implements IBookTocManager { /** * Follows the same logic as the JupyterBooksCreate.ipynb. It receives a path that contains a notebooks and * a path where it creates the book. It copies the contents from one folder to another and creates a table of contents. - * @param bookContentPath The path to the book folder, the basename of the path is the name of the book - * @param contentFolder The path to the folder that contains the notebooks and markdown files to be added to the created book. + * @param bookContentPath - The path to the book folder, the basename of the path is the name of the book + * @param contentFolder - (Optional) The path to the folder that contains the notebooks and markdown files to be added to the created book. + * If it's undefined then a blank notebook is attached to the book. */ - public async createBook(bookContentPath: string, contentFolder: string): Promise { - await fs.promises.mkdir(bookContentPath); - await fs.copy(contentFolder, bookContentPath); - let filesinDir = await fs.readdir(bookContentPath); - this.tableofContents = await this.getAllFiles([], bookContentPath, filesinDir, bookContentPath); + public async createBook(bookContentPath: string, contentFolder?: string): Promise { + let filesinDir: string[]; + await fs.promises.mkdir(bookContentPath, { recursive: true }); + if (contentFolder) { + await fs.copy(contentFolder, bookContentPath); + filesinDir = await fs.readdir(bookContentPath); + this.tableofContents = await this.getAllFiles([], bookContentPath, filesinDir, bookContentPath); + } else { + await fs.writeFile(path.join(bookContentPath, 'README.md'), ''); + filesinDir = ['readme.md']; + this.tableofContents = await this.getAllFiles([], bookContentPath, filesinDir, bookContentPath); + } await fs.writeFile(path.join(bookContentPath, '_config.yml'), yaml.safeDump({ title: path.basename(bookContentPath) })); await fs.writeFile(path.join(bookContentPath, '_toc.yml'), yaml.safeDump(this.tableofContents, { lineWidth: Infinity })); await vscode.commands.executeCommand('notebook.command.openNotebookFolder', bookContentPath, undefined, true); diff --git a/extensions/notebook/src/book/bookTreeView.ts b/extensions/notebook/src/book/bookTreeView.ts index 2b0c2596a0..6b97e12c15 100644 --- a/extensions/notebook/src/book/bookTreeView.ts +++ b/extensions/notebook/src/book/bookTreeView.ts @@ -8,7 +8,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs-extra'; import * as constants from '../common/constants'; -import { IPrompter, IQuestion, QuestionTypes } from '../prompts/question'; +import { IPrompter } from '../prompts/question'; import CodeAdapter from '../prompts/adapter'; import { BookTreeItem, BookTreeItemType } from './bookTreeItem'; import { BookModel } from './bookModel'; @@ -16,9 +16,10 @@ import { Deferred } from '../common/promise'; import { IBookTrustManager, BookTrustManager } from './bookTrustManager'; import * as loc from '../common/localizedConstants'; import * as glob from 'fast-glob'; -import { debounce, getPinnedNotebooks } from '../common/utils'; +import { debounce, getPinnedNotebooks, confirmReplace } from '../common/utils'; import { IBookPinManager, BookPinManager } from './bookPinManager'; import { BookTocManager, IBookTocManager, quickPickResults } from './bookTocManager'; +import { CreateBookDialog } from '../dialog/createBookDialog'; import { getContentPath } from './bookVersionHandler'; import { TelemetryReporter, BookTelemetryView, NbTelemetryActions } from '../telemetry'; @@ -52,6 +53,7 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider console.error(e)); this.prompter = new CodeAdapter(); this._bookTrustManager = new BookTrustManager(this.books); + this.bookTocManager = new BookTocManager(); this._extensionContext.subscriptions.push(azdata.nb.registerNavigationProvider(this)); } @@ -142,10 +144,9 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { - bookPath = path.normalize(bookPath); - contentPath = path.normalize(contentPath); - await this.bookTocManager.createBook(bookPath, contentPath); + async createBook(): Promise { + const dialog = new CreateBookDialog(this.bookTocManager); + dialog.createDialog(); TelemetryReporter.createActionEvent(BookTelemetryView, NbTelemetryActions.CreateBook).send(); } @@ -502,7 +503,7 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { - return await this.prompter.promptSingle({ - type: QuestionTypes.confirm, - message: loc.confirmReplace, - default: false - }); - } - getNavigation(uri: vscode.Uri): Thenable { let result: azdata.nb.NavigationResult; let notebook = this.currentBook?.getNotebook(uri.fsPath); diff --git a/extensions/notebook/src/common/iconHelper.ts b/extensions/notebook/src/common/iconHelper.ts index b307fc1cae..23d1011399 100644 --- a/extensions/notebook/src/common/iconHelper.ts +++ b/extensions/notebook/src/common/iconHelper.ts @@ -14,6 +14,7 @@ export class IconPathHelper { private static extensionContext: vscode.ExtensionContext; public static delete: IconPath; + public static folder: IconPath; public static setExtensionContext(extensionContext: vscode.ExtensionContext) { IconPathHelper.extensionContext = extensionContext; @@ -21,5 +22,9 @@ export class IconPathHelper { dark: IconPathHelper.extensionContext.asAbsolutePath('resources/dark/delete_inverse.svg'), light: IconPathHelper.extensionContext.asAbsolutePath('resources/light/delete.svg') }; + IconPathHelper.folder = { + dark: IconPathHelper.extensionContext.asAbsolutePath('resources/dark/folder_inverse.svg'), + light: IconPathHelper.extensionContext.asAbsolutePath('resources/light/folder.svg') + }; } } diff --git a/extensions/notebook/src/common/localizedConstants.ts b/extensions/notebook/src/common/localizedConstants.ts index f1ab7a3643..94823b4e88 100644 --- a/extensions/notebook/src/common/localizedConstants.ts +++ b/extensions/notebook/src/common/localizedConstants.ts @@ -81,3 +81,18 @@ export const msgReleaseNotFound = localize('msgReleaseNotFound', "Releases not F export const msgUndefinedAssetError = localize('msgUndefinedAssetError', "The selected book is not valid"); export function httpRequestError(code: number, message: string): string { return localize('httpRequestError', "Http Request failed with error: {0} {1}", code, message); } export function msgDownloadLocation(downloadLocation: string): string { return localize('msgDownloadLocation', "Downloading to {0}", downloadLocation); } + +// Create Book dialog constants +export const newGroup = localize('newGroup', "New Group"); +export const groupDescription = localize('groupDescription', "Groups are used to organize Notebooks."); +export const locationBrowser = localize('locationBrowser', "Browse locations..."); +export const selectContentFolder = localize('selectContentFolder', "Select content folder"); +export const browse = localize('browse', "Browse"); +export const create = localize('create', "Create"); +export const name = localize('name', "Name"); +export const saveLocation = localize('saveLocation', "Save location"); +export const contentFolder = localize('contentFolder', "Content folder (Optional)"); +export const msgContentFolderError = localize('msgContentFolderError', "Content folder path does not exist"); +export const msgSaveFolderError = localize('msgSaveFolderError', "Save location path does not exist"); + + diff --git a/extensions/notebook/src/common/utils.ts b/extensions/notebook/src/common/utils.ts index 7cd5e04d32..707f19fb58 100644 --- a/extensions/notebook/src/common/utils.ts +++ b/extensions/notebook/src/common/utils.ts @@ -11,6 +11,8 @@ import * as vscode from 'vscode'; import * as azdata from 'azdata'; import * as crypto from 'crypto'; import { notebookLanguages, notebookConfigKey, pinnedBooksConfigKey, AUTHTYPE, INTEGRATED_AUTH, KNOX_ENDPOINT_PORT, KNOX_ENDPOINT_SERVER } from './constants'; +import { IPrompter, IQuestion, QuestionTypes } from '../prompts/question'; +import * as loc from '../common/localizedConstants'; const localize = nls.loadMessageBundle(); @@ -390,3 +392,12 @@ export interface IBookNotebook { bookPath?: string; notebookPath: string; } + +//Confirmation message dialog +export async function confirmReplace(prompter: IPrompter): Promise { + return await prompter.promptSingle({ + type: QuestionTypes.confirm, + message: loc.confirmReplace, + default: false + }); +} diff --git a/extensions/notebook/src/dialog/createBookDialog.ts b/extensions/notebook/src/dialog/createBookDialog.ts new file mode 100644 index 0000000000..fc56b59c6e --- /dev/null +++ b/extensions/notebook/src/dialog/createBookDialog.ts @@ -0,0 +1,171 @@ +/*--------------------------------------------------------------------------------------------- + * 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 azdata from 'azdata'; +import * as path from 'path'; +import { pathExists, remove } from 'fs-extra'; +import * as loc from '../common/localizedConstants'; +import { IconPathHelper } from '../common/iconHelper'; +import { IBookTocManager } from '../book/bookTocManager'; +import { confirmReplace } from '../common/utils'; +import { IPrompter } from '../prompts/question'; +import CodeAdapter from '../prompts/adapter'; + +export class CreateBookDialog { + + private dialog: azdata.window.Dialog; + public view: azdata.ModelView; + private formModel: azdata.FormContainer; + private bookNameInputBox: azdata.InputBoxComponent; + private saveLocationInputBox: azdata.InputBoxComponent; + private contentFolderInputBox: azdata.InputBoxComponent; + private prompter: IPrompter; + + constructor(private tocManager: IBookTocManager) { + this.prompter = new CodeAdapter(); + } + + protected createHorizontalContainer(view: azdata.ModelView, items: azdata.Component[]): azdata.FlexContainer { + return view.modelBuilder.flexContainer().withItems(items, { CSSStyles: { 'margin-right': '10px', 'margin-bottom': '10px' } }).withLayout({ flexFlow: 'row' }).component(); + } + + public async selectFolder(): Promise { + const allFilesFilter = loc.allFiles; + let filter: any = {}; + filter[allFilesFilter] = '*'; + let uris = await vscode.window.showOpenDialog({ + filters: filter, + canSelectFiles: false, + canSelectMany: false, + canSelectFolders: true, + openLabel: loc.labelSelectFolder + }); + if (uris && uris.length > 0) { + return uris[0].fsPath; + } + return undefined; + } + + public async validatePath(folderPath: string): Promise { + const destinationUri = path.join(folderPath, path.basename(this.bookNameInputBox.value)); + if (await pathExists(destinationUri)) { + const doReplace = await confirmReplace(this.prompter); + if (doReplace) { + //remove folder if exists + await remove(destinationUri); + return true; + } + return false; + } + return await pathExists(folderPath); + } + + public async createDialog(): Promise { + this.dialog = azdata.window.createModelViewDialog(loc.newGroup); + this.dialog.registerContent(async view => { + this.view = view; + + const groupLabel = this.view.modelBuilder.text() + .withProperties({ + value: loc.groupDescription, + CSSStyles: { 'margin-bottom': '0px', 'margin-top': '0px', 'font-size': 'small' } + }).component(); + + this.bookNameInputBox = this.view.modelBuilder.inputBox() + .withProperties({ + values: [], + value: '', + enabled: true + }).component(); + + this.saveLocationInputBox = this.view.modelBuilder.inputBox().withProperties({ + values: [], + value: '', + placeHolder: loc.locationBrowser, + width: '400px' + }).component(); + + this.contentFolderInputBox = this.view.modelBuilder.inputBox().withProperties({ + values: [], + value: '', + placeHolder: loc.selectContentFolder, + width: '400px' + }).component(); + + const browseFolderButton = view.modelBuilder.button().withProperties({ + ariaLabel: loc.browse, + iconPath: IconPathHelper.folder, + width: '18px', + height: '20px', + }).component(); + + const browseContentFolderButton = view.modelBuilder.button().withProperties({ + ariaLabel: loc.browse, + iconPath: IconPathHelper.folder, + width: '18px', + height: '20px', + }).component(); + + browseFolderButton.onDidClick(async () => { + this.saveLocationInputBox.value = await this.selectFolder(); + }); + + browseContentFolderButton.onDidClick(async () => { + this.contentFolderInputBox.value = await this.selectFolder(); + }); + + this.formModel = this.view.modelBuilder.formContainer() + .withFormItems([{ + components: [ + { + component: groupLabel, + required: false + }, + { + component: this.bookNameInputBox, + title: loc.name, + required: true + }, + { + title: loc.saveLocation, + required: true, + component: this.createHorizontalContainer(view, [this.saveLocationInputBox, browseFolderButton]) + }, + { + title: loc.contentFolder, + required: false, + component: this.createHorizontalContainer(view, [this.contentFolderInputBox, browseContentFolderButton]) + }, + ], + title: '' + }]).withLayout({ width: '100%' }).component(); + await this.view.initializeModel(this.formModel); + }); + this.dialog.okButton.label = loc.create; + this.dialog.registerCloseValidator(async () => await this.create()); + azdata.window.openDialog(this.dialog); + } + + private async create(): Promise { + try { + const isValid = await this.validatePath(this.saveLocationInputBox.value); + if (!isValid) { + throw (new Error(loc.msgSaveFolderError)); + } + if (this.contentFolderInputBox.value !== '' && !await pathExists(this.contentFolderInputBox.value)) { + throw (new Error(loc.msgContentFolderError)); + } + const bookPath = path.join(this.saveLocationInputBox.value, this.bookNameInputBox.value); + await this.tocManager.createBook(bookPath, this.contentFolderInputBox.value); + return true; + } catch (error) { + this.dialog.message = { + text: error.message, + level: azdata.window.MessageLevel.Error + }; + return false; + } + } +}