From ce13d01efb8f20f43165b0cfdd3ec5ab876ebaae Mon Sep 17 00:00:00 2001 From: Barbara Valdez <34872381+barbaravaldez@users.noreply.github.com> Date: Tue, 14 Sep 2021 11:31:43 -0700 Subject: [PATCH] Book Editing: Support adding new sections to a book (#17074) * add a new section command --- extensions/notebook/package.json | 9 ++++++++ extensions/notebook/package.nls.json | 1 + .../notebook/src/book/bookTocManager.ts | 9 ++++++-- extensions/notebook/src/book/bookTreeItem.ts | 2 +- extensions/notebook/src/book/bookTreeView.ts | 13 ++++++++--- .../notebook/src/common/localizedConstants.ts | 3 ++- ...{addFileDialog.ts => addTocEntryDialog.ts} | 8 +++---- extensions/notebook/src/extension.ts | 1 + .../{addFile.test.ts => addTocEntry.test.ts} | 16 +++++++------- .../src/test/book/bookTocManager.test.ts | 22 +++++++++++++++++++ 10 files changed, 65 insertions(+), 19 deletions(-) rename extensions/notebook/src/dialog/{addFileDialog.ts => addTocEntryDialog.ts} (92%) rename extensions/notebook/src/test/book/{addFile.test.ts => addTocEntry.test.ts} (82%) diff --git a/extensions/notebook/package.json b/extensions/notebook/package.json index 53908b9ac3..922e8cba10 100644 --- a/extensions/notebook/package.json +++ b/extensions/notebook/package.json @@ -251,6 +251,10 @@ "command": "notebook.command.addMarkdown", "title": "%title.addMarkdown%" }, + { + "command": "notebook.command.addSection", + "title": "%title.addSection%" + }, { "command": "notebook.command.closeNotebook", "title": "%title.closeNotebook%" @@ -490,6 +494,11 @@ "when": "view == bookTreeView && viewItem == section && listMultiSelection == false || view == bookTreeView && viewItem == savedBook && listMultiSelection == false", "group": "newFile@1" }, + { + "command": "notebook.command.addSection", + "when": "view == bookTreeView && viewItem == section && listMultiSelection == false || view == bookTreeView && viewItem == savedBook && listMultiSelection == false", + "group": "newFile@1" + }, { "command": "notebook.command.moveTo", "when": "view == bookTreeView && viewItem == savedNotebook || view == bookTreeView && viewItem == savedBookNotebook || view == bookTreeView && viewItem == section || view == bookTreeView && viewItem == Markdown" diff --git a/extensions/notebook/package.nls.json b/extensions/notebook/package.nls.json index 8329b47c32..244ab08596 100644 --- a/extensions/notebook/package.nls.json +++ b/extensions/notebook/package.nls.json @@ -49,6 +49,7 @@ "title.removeNotebook": "Remove Notebook", "title.addNotebook": "Add Notebook", "title.addMarkdown": "Add Markdown File", + "title.addSection": "Add Section", "title.revealInBooksViewlet": "Reveal in Books", "title.createJupyterBook": "Create Jupyter Book", "title.openNotebookFolder": "Open Notebooks in Folder", diff --git a/extensions/notebook/src/book/bookTocManager.ts b/extensions/notebook/src/book/bookTocManager.ts index d1b4bc73b6..e0a48b48ae 100644 --- a/extensions/notebook/src/book/bookTocManager.ts +++ b/extensions/notebook/src/book/bookTocManager.ts @@ -18,7 +18,7 @@ export interface IBookTocManager { updateBook(sources: BookTreeItem[], target: BookTreeItem, targetSection?: JupyterBookSection): Promise; removeNotebook(element: BookTreeItem): Promise; createBook(bookContentPath: string, contentFolder: string): Promise; - addNewFile(pathDetails: TocEntryPathHandler, bookItem: BookTreeItem): Promise; + addNewTocEntry(pathDetails: TocEntryPathHandler, bookItem: BookTreeItem, isSection?: boolean): Promise; recovery(): Promise; } @@ -443,7 +443,7 @@ export class BookTocManager implements IBookTocManager { } } - public async addNewFile(pathDetails: TocEntryPathHandler, bookItem: BookTreeItem): Promise { + public async addNewTocEntry(pathDetails: TocEntryPathHandler, bookItem: BookTreeItem, isSection?: boolean): Promise { let findSection: JupyterBookSection | undefined = undefined; await fs.writeFile(pathDetails.filePath, ''); if (bookItem.contextValue === 'section') { @@ -453,6 +453,11 @@ export class BookTocManager implements IBookTocManager { title: pathDetails.titleInTocEntry, file: pathDetails.fileInTocEntry }; + + if (isSection) { + fileEntryInToc.sections = []; + } + if (bookItem.book.version === BookVersion.v1) { fileEntryInToc = convertTo(BookVersion.v1, fileEntryInToc); } diff --git a/extensions/notebook/src/book/bookTreeItem.ts b/extensions/notebook/src/book/bookTreeItem.ts index bfac0d1613..de04efd426 100644 --- a/extensions/notebook/src/book/bookTreeItem.ts +++ b/extensions/notebook/src/book/bookTreeItem.ts @@ -59,7 +59,7 @@ export class BookTreeItem extends vscode.TreeItem { this.contextValue = BookTreeItemType.savedBook; } } else { - if (book.page && book.page.sections && book.page.sections.length > 0) { + if (book.page && book.page.sections) { this.contextValue = BookTreeItemType.section; } else if (book.type === BookTreeItemType.Notebook && !book.tableOfContents.sections) { if (book.isUntitled) { diff --git a/extensions/notebook/src/book/bookTreeView.ts b/extensions/notebook/src/book/bookTreeView.ts index 6dfe83a122..b282e0eabf 100644 --- a/extensions/notebook/src/book/bookTreeView.ts +++ b/extensions/notebook/src/book/bookTreeView.ts @@ -20,7 +20,7 @@ import { getPinnedNotebooks, confirmMessageDialog, getNotebookType, FileExtensio import { IBookPinManager, BookPinManager } from './bookPinManager'; import { BookTocManager, IBookTocManager, quickPickResults } from './bookTocManager'; import { CreateBookDialog } from '../dialog/createBookDialog'; -import { AddFileDialog } from '../dialog/addFileDialog'; +import { AddTocEntryDialog } from '../dialog/addTocEntryDialog'; import { getContentPath } from './bookVersionHandler'; import { TelemetryReporter, BookTelemetryView, NbTelemetryActions } from '../telemetry'; @@ -276,14 +276,21 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { const book = this.books.find(b => b.bookPath === bookItem.root); this.bookTocManager = new BookTocManager(book); - const dialog = new AddFileDialog(this.bookTocManager, bookItem, FileExtension.Markdown); + const dialog = new AddTocEntryDialog(this.bookTocManager, bookItem, FileExtension.Markdown); await dialog.createDialog(); } async createNotebook(bookItem: BookTreeItem): Promise { const book = this.books.find(b => b.bookPath === bookItem.root); this.bookTocManager = new BookTocManager(book); - const dialog = new AddFileDialog(this.bookTocManager, bookItem, FileExtension.Notebook); + const dialog = new AddTocEntryDialog(this.bookTocManager, bookItem, FileExtension.Notebook); + await dialog.createDialog(); + } + + async createSection(bookItem: BookTreeItem): Promise { + const book = this.books.find(b => b.bookPath === bookItem.root); + this.bookTocManager = new BookTocManager(book); + const dialog = new AddTocEntryDialog(this.bookTocManager, bookItem, FileExtension.Markdown, true); await dialog.createDialog(); } diff --git a/extensions/notebook/src/common/localizedConstants.ts b/extensions/notebook/src/common/localizedConstants.ts index 1bda0eeac1..707d77f8ed 100644 --- a/extensions/notebook/src/common/localizedConstants.ts +++ b/extensions/notebook/src/common/localizedConstants.ts @@ -97,9 +97,10 @@ export const msgContentFolderError = localize('msgContentFolderError', "Content export const msgSaveFolderError = localize('msgSaveFolderError', "Save location path does not exist."); export function msgCreateBookWarningMsg(file: string): string { return localize('msgCreateBookWarningMsg', "Error while trying to access: {0}", file); } -// Add a notebook dialog constants +// Add a new entry in toc dialog constants export const newNotebook = localize('newNotebook', "New Notebook (Preview)"); export const newMarkdown = localize('newMarkdown', "New Markdown (Preview)"); +export const newSection = localize('newSection', "New Section (Preview)"); export const fileExtension = localize('fileExtension', "File Extension"); export const confirmOverwrite = localize('confirmOverwrite', "File already exists. Are you sure you want to overwrite this file?"); export const title = localize('title', "Title"); diff --git a/extensions/notebook/src/dialog/addFileDialog.ts b/extensions/notebook/src/dialog/addTocEntryDialog.ts similarity index 92% rename from extensions/notebook/src/dialog/addFileDialog.ts rename to extensions/notebook/src/dialog/addTocEntryDialog.ts index a192d6de6e..43630c1297 100644 --- a/extensions/notebook/src/dialog/addFileDialog.ts +++ b/extensions/notebook/src/dialog/addTocEntryDialog.ts @@ -13,7 +13,7 @@ import CodeAdapter from '../prompts/adapter'; import { BookTreeItem, BookTreeItemType } from '../book/bookTreeItem'; import { TocEntryPathHandler } from '../book/tocEntryPathHandler'; -export class AddFileDialog { +export class AddTocEntryDialog { private _dialog: azdata.window.Dialog; private readonly _dialogName = 'addNewFileBookTreeViewDialog'; public view: azdata.ModelView; @@ -23,7 +23,7 @@ export class AddFileDialog { private _saveLocationInputBox: azdata.TextComponent; private _prompter: IPrompter; - constructor(private _tocManager: IBookTocManager, private _bookItem: BookTreeItem, private _extension: FileExtension) { + constructor(private _tocManager: IBookTocManager, private _bookItem: BookTreeItem, private _extension: FileExtension, private _isSection?: boolean) { this._prompter = new CodeAdapter(); } @@ -45,7 +45,7 @@ export class AddFileDialog { } public async createDialog(): Promise { - const dialogTitle = this._extension === FileExtension.Notebook ? loc.newNotebook : loc.newMarkdown; + const dialogTitle = this._isSection ? loc.newSection : this._extension === FileExtension.Notebook ? loc.newNotebook : loc.newMarkdown; this._dialog = azdata.window.createModelViewDialog(dialogTitle, this._dialogName); this._dialog.registerContent(async view => { this.view = view; @@ -102,7 +102,7 @@ export class AddFileDialog { const filePath = path.posix.join(dirPath, fileName).concat(this._extension); await this.validatePath(dirPath, fileName.concat(this._extension)); const pathDetails = new TocEntryPathHandler(filePath, this._bookItem.rootContentPath, titleName); - await this._tocManager.addNewFile(pathDetails, this._bookItem); + await this._tocManager.addNewTocEntry(pathDetails, this._bookItem, this._isSection); return true; } catch (error) { this._dialog.message = { diff --git a/extensions/notebook/src/extension.ts b/extensions/notebook/src/extension.ts index 18e8e71353..d999f76a95 100644 --- a/extensions/notebook/src/extension.ts +++ b/extensions/notebook/src/extension.ts @@ -57,6 +57,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.removeNotebook', (book: BookTreeItem) => bookTreeViewProvider.removeNotebook(book))); extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.addNotebook', (book: BookTreeItem) => bookTreeViewProvider.createNotebook(book))); extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.addMarkdown', (book: BookTreeItem) => bookTreeViewProvider.createMarkdownFile(book))); + extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.addSection', (book: BookTreeItem) => bookTreeViewProvider.createSection(book))); extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.createBook', () => bookTreeViewProvider.createBook())); extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.openNotebookFolder', (folderPath?: string, urlToOpen?: string, showPreview?: boolean) => bookTreeViewProvider.openNotebookFolder(folderPath, urlToOpen, showPreview))); extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.pinNotebook', async (book: BookTreeItem) => { diff --git a/extensions/notebook/src/test/book/addFile.test.ts b/extensions/notebook/src/test/book/addTocEntry.test.ts similarity index 82% rename from extensions/notebook/src/test/book/addFile.test.ts rename to extensions/notebook/src/test/book/addTocEntry.test.ts index afe017aaa6..bdc21bdcb4 100644 --- a/extensions/notebook/src/test/book/addFile.test.ts +++ b/extensions/notebook/src/test/book/addTocEntry.test.ts @@ -8,7 +8,7 @@ import * as should from 'should'; import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs-extra'; -import { AddFileDialog } from '../../dialog/addFileDialog'; +import { AddTocEntryDialog } from '../../dialog/addTocEntryDialog'; import { IBookTocManager } from '../../book/bookTocManager'; import { BookTreeItem, BookTreeItemFormat, BookTreeItemType } from '../../book/bookTreeItem'; import * as utils from '../../common/utils'; @@ -23,7 +23,7 @@ describe('Add File Dialog', function () { beforeEach(() => { let mockBookManager = TypeMoq.Mock.ofType(); - mockBookManager.setup(m => m.addNewFile(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve()); + mockBookManager.setup(m => m.addNewTocEntry(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve()); bookTocManager = mockBookManager.object; let mockTreeItem = TypeMoq.Mock.ofType(); @@ -39,14 +39,14 @@ describe('Add File Dialog', function () { }); it('Create dialog', async () => { - let fileDialog = new AddFileDialog(bookTocManager, bookTreeItem, fileExtension); + let fileDialog = new AddTocEntryDialog(bookTocManager, bookTreeItem, fileExtension); await fileDialog.createDialog(); should(fileDialog.dialog).not.be.undefined(); should(fileDialog.dialog.message).be.undefined(); }); it('Validate path', async () => { - let fileDialog = new AddFileDialog(bookTocManager, bookTreeItem, fileExtension); + let fileDialog = new AddTocEntryDialog(bookTocManager, bookTreeItem, fileExtension); await fileDialog.createDialog(); let tempDir = os.tmpdir(); @@ -84,9 +84,9 @@ describe('Add File Dialog', function () { // Error case let mockBookManager = TypeMoq.Mock.ofType(); - mockBookManager.setup(m => m.addNewFile(TypeMoq.It.isAny(), TypeMoq.It.isAny())).throws(new Error('Expected test error.')); + mockBookManager.setup(m => m.addNewTocEntry(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).throws(new Error('Expected test error.')); - let fileDialog = new AddFileDialog(mockBookManager.object, bookTreeItem, fileExtension); + let fileDialog = new AddTocEntryDialog(mockBookManager.object, bookTreeItem, fileExtension); await fileDialog.createDialog(); await should(fileDialog.createFile(testFileName, testTitle)).be.resolvedWith(false); @@ -97,13 +97,13 @@ describe('Add File Dialog', function () { // Success case let testPathDetails: TocEntryPathHandler[] = []; mockBookManager = TypeMoq.Mock.ofType(); - mockBookManager.setup(m => m.addNewFile(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((path, item) => { testPathDetails.push(path); return Promise.resolve(); }); + mockBookManager.setup(m => m.addNewTocEntry(TypeMoq.It.isAny(), TypeMoq.It.isAny(),TypeMoq.It.isAny())).returns((path, item) => { testPathDetails.push(path); return Promise.resolve(); }); let mockTreeItem = TypeMoq.Mock.ofType(); mockTreeItem.setup(i => i.contextValue).returns(() => BookTreeItemType.savedBook); mockTreeItem.setup(i => i.rootContentPath).returns(() => testDir); - fileDialog = new AddFileDialog(mockBookManager.object, mockTreeItem.object, fileExtension); + fileDialog = new AddTocEntryDialog(mockBookManager.object, mockTreeItem.object, fileExtension); await fileDialog.createDialog(); let createFileResult = await fileDialog.createFile(testFileName, testTitle); diff --git a/extensions/notebook/src/test/book/bookTocManager.test.ts b/extensions/notebook/src/test/book/bookTocManager.test.ts index a1108cfe27..200c532a56 100644 --- a/extensions/notebook/src/test/book/bookTocManager.test.ts +++ b/extensions/notebook/src/test/book/bookTocManager.test.ts @@ -20,6 +20,8 @@ import { BookTreeViewProvider } from '../../book/bookTreeView'; import { NavigationProviders } from '../../common/constants'; import { BookVersion } from '../../book/bookVersionHandler'; import * as yaml from 'js-yaml'; +import { TocEntryPathHandler } from '../../book/tocEntryPathHandler'; +import * as utils from '../../common/utils'; export function equalTOC(actualToc: IJupyterBookSectionV2[], expectedToc: IJupyterBookSectionV2[]): boolean { for (let [i, section] of actualToc.entries()) { @@ -552,6 +554,26 @@ describe('BookTocManagerTests', function () { should(JSON.stringify(listFiles).includes('test')).be.true('Empty directories within the moving element directory are not deleted'); }); + it('Add new section', async () => { + bookTocManager = new BookTocManager(sourceBookModel); + const fileBasename = `addSectionTest-${utils.generateGuid()}`; + const sectionTitle = 'Section Test'; + const testFilePath = path.join(run.sectionA.sectionRoot, fileBasename).concat(utils.FileExtension.Markdown); + await fs.writeFile(testFilePath, ''); + const pathDetails = new TocEntryPathHandler(testFilePath, run.sourceBook.rootBookFolderPath, sectionTitle); + await bookTocManager.addNewTocEntry(pathDetails, sectionA, true); + let toc: JupyterBookSection[] = yaml.safeLoad((await fs.promises.readFile(run.sourceBook.tocPath)).toString()); + const sectionAIndex = toc.findIndex(entry => entry.title === sectionA.title); + let newSectionIndex = -1; + let newSection = undefined; + if (sectionAIndex) { + newSectionIndex = toc[sectionAIndex].sections?.findIndex(entry => entry.title === sectionTitle); + newSection = toc[sectionAIndex].sections[newSectionIndex]; + } + should(newSectionIndex).not.be.equal(-1, 'The new section should exist in the toc file'); + should(newSection.sections).not.undefined(); + }); + afterEach(async function (): Promise { sinon.restore(); if (await exists(sourceBookFolderPath)) {