diff --git a/appinfo/routes.php b/appinfo/routes.php index 22c4b132c..63a3d023a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -92,6 +92,16 @@ 'verb' => 'DELETE', 'requirements' => ['id' => '\d+'], ], + [ + 'name' => 'notes#renameCategory', + 'url' => '/notes/category', + 'verb' => 'PATCH', + ], + [ + 'name' => 'notes#deleteCategory', + 'url' => '/notes/category', + 'verb' => 'DELETE', + ], ////////// A T T A C H M E N T S ////////// diff --git a/lib/Controller/NotesController.php b/lib/Controller/NotesController.php index c5c1c9419..2308dd23e 100644 --- a/lib/Controller/NotesController.php +++ b/lib/Controller/NotesController.php @@ -329,6 +329,26 @@ public function destroy(int $id) : JSONResponse { }); } + /** + * + */ + #[NoAdminRequired] + public function renameCategory(string $oldCategory, string $newCategory) : JSONResponse { + return $this->helper->handleErrorResponse(function () use ($oldCategory, $newCategory) { + return $this->notesService->renameCategory($this->helper->getUID(), $oldCategory, $newCategory); + }); + } + + /** + * + */ + #[NoAdminRequired] + public function deleteCategory(string $category) : JSONResponse { + return $this->helper->handleErrorResponse(function () use ($category) { + return $this->notesService->deleteCategory($this->helper->getUID(), $category); + }); + } + /** * With help from: https://github.com/nextcloud/cookbook * @return JSONResponse|StreamResponse diff --git a/lib/Service/NoteUtil.php b/lib/Service/NoteUtil.php index dd03aa6d5..9b38c1fd2 100644 --- a/lib/Service/NoteUtil.php +++ b/lib/Service/NoteUtil.php @@ -60,16 +60,22 @@ public function getTagService() : TagService { return $this->tagService; } - public function getCategoryFolder(Folder $notesFolder, string $category) { - $path = $notesFolder->getPath(); - // sanitise path + public function normalizeCategoryPath(string $category) : string { $cats = explode('/', $category); $cats = array_map([$this, 'sanitisePath'], $cats); $cats = array_filter($cats, function ($str) { return $str !== ''; }); - $path .= '/' . implode('/', $cats); - return $this->getOrCreateFolder($path); + return implode('/', $cats); + } + + public function getCategoryFolder(Folder $notesFolder, string $category, bool $create = true) : Folder { + $path = $notesFolder->getPath(); + $normalized = $this->normalizeCategoryPath($category); + if ($normalized !== '') { + $path .= '/' . $normalized; + } + return $this->getOrCreateFolder($path, $create); } /** diff --git a/lib/Service/NotesService.php b/lib/Service/NotesService.php index 1716bc106..9eb955b9e 100644 --- a/lib/Service/NotesService.php +++ b/lib/Service/NotesService.php @@ -153,6 +153,86 @@ public function delete(string $userId, int $id) { $this->noteUtil->deleteEmptyFolder($parent, $notesFolder); } + /** + * @throws NoteDoesNotExistException + */ + public function renameCategory(string $userId, string $oldCategory, string $newCategory) : array { + $oldCategory = $this->noteUtil->normalizeCategoryPath($oldCategory); + $newCategory = $this->noteUtil->normalizeCategoryPath($newCategory); + if ($oldCategory === '' || $newCategory === '') { + throw new \InvalidArgumentException('Category must not be empty'); + } + if ($oldCategory === $newCategory) { + return [ + 'oldCategory' => $oldCategory, + 'newCategory' => $newCategory, + ]; + } + if (str_starts_with($newCategory, $oldCategory . '/')) { + throw new \InvalidArgumentException('Target category must not be a descendant of source category'); + } + + $notesFolder = $this->getNotesFolder($userId); + try { + $oldFolder = $this->noteUtil->getCategoryFolder($notesFolder, $oldCategory, false); + } catch (NotesFolderException $e) { + throw new NoteDoesNotExistException(); + } + + if ($notesFolder->nodeExists($newCategory)) { + throw new \InvalidArgumentException('Target category already exists'); + } + + $targetParentCategory = dirname($newCategory); + if ($targetParentCategory === '.') { + $targetParentCategory = ''; + } + $targetParent = $this->noteUtil->getCategoryFolder($notesFolder, $targetParentCategory, true); + + $oldParent = $oldFolder->getParent(); + $targetPath = $targetParent->getPath() . '/' . basename($newCategory); + $oldFolder->move($targetPath); + if ($oldParent instanceof Folder) { + $this->noteUtil->deleteEmptyFolder($oldParent, $notesFolder); + } + + return [ + 'oldCategory' => $oldCategory, + 'newCategory' => $newCategory, + ]; + } + + /** + * @throws NoteDoesNotExistException + */ + public function deleteCategory(string $userId, string $category) : array { + $category = $this->noteUtil->normalizeCategoryPath($category); + if ($category === '') { + throw new \InvalidArgumentException('Category must not be empty'); + } + + $notesFolder = $this->getNotesFolder($userId); + try { + $folder = $this->noteUtil->getCategoryFolder($notesFolder, $category, false); + } catch (NotesFolderException $e) { + // If category folder was already removed (e.g. last note moved away), + // treat delete as idempotent success. + return [ + 'category' => $category, + ]; + } + + $parent = $folder->getParent(); + $folder->delete(); + if ($parent instanceof Folder) { + $this->noteUtil->deleteEmptyFolder($parent, $notesFolder); + } + + return [ + 'category' => $category, + ]; + } + public function getTitleFromContent(string $content) : string { $content = $this->noteUtil->stripMarkdown($content); return $this->noteUtil->getSafeTitle($content); diff --git a/playwright/e2e/basic.spec.ts b/playwright/e2e/basic.spec.ts index 160aa75b1..b44513d1c 100644 --- a/playwright/e2e/basic.spec.ts +++ b/playwright/e2e/basic.spec.ts @@ -19,10 +19,10 @@ test.describe('Basic checks', () => { test('Create note and type', async ({ page }) => { await page.goto('/index.php/apps/notes/') - await page - .locator('#app-navigation-vue') - .getByRole('button', { name: 'New note' }) - .click() + const newNoteButton = page.getByRole('button', { name: 'New note', exact: true }) + await expect(newNoteButton).toBeVisible() + await newNoteButton.click() + const editor = new NoteEditor(page) await editor.type('Hello from Playwright') await expect(editor.content).toContainText('Hello from Playwright') diff --git a/playwright/e2e/category-actions.spec.ts b/playwright/e2e/category-actions.spec.ts new file mode 100644 index 000000000..76d61c320 --- /dev/null +++ b/playwright/e2e/category-actions.spec.ts @@ -0,0 +1,157 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, type Locator, type Page, type TestInfo, test } from '@playwright/test' +import { login } from '../support/login' + +function appNavigation(page: Page): Locator { + return page.getByRole('navigation').filter({ + has: page.getByRole('button', { name: 'New category', exact: true }), + }).first() +} + +function newCategoryButton(page: Page): Locator { + return page.getByRole('button', { name: 'New category', exact: true }) +} + +function contentNewNoteButton(page: Page): Locator { + return page.getByRole('button', { name: 'New note', exact: true }) +} + +function notesSearchField(page: Page): Locator { + return page.getByRole('textbox', { name: 'Search for notes', exact: true }) +} + +function notesViewNewNoteButton(page: Page): Locator { + return notesSearchField(page) + .locator('xpath=ancestor::div[contains(@class, "content-list__search")][1]') + .getByRole('button', { name: 'New note', exact: true }) +} + +function navigationRow(page: Page, name: string): Locator { + return page.getByRole('link', { name, exact: true }) + .locator('xpath=ancestor::li[1]') + .first() +} + +function uniqueCategoryName(prefix: string, testInfo: TestInfo): string { + return `Playwright ${prefix} ${testInfo.parallelIndex}-${Date.now()}` +} + +function currentNoteId(page: Page): number | null { + const match = page.url().match(/\/note\/(\d+)(?:\?.*)?$/) + return match ? Number(match[1]) : null +} + +async function openNotesApp(page: Page): Promise { + await page.goto('/index.php/apps/notes/') + await expect(newCategoryButton(page)).toBeVisible() + await expect(contentNewNoteButton(page)).toHaveCount(1) + await expect(contentNewNoteButton(page)).toBeVisible() +} + +async function createCategory(page: Page, name: string): Promise { + const navigation = appNavigation(page) + + await newCategoryButton(page).click() + + const input = navigation.getByPlaceholder('New category', { exact: true }) + await expect(input).toBeVisible() + await input.fill(name) + await input.press('Enter') + + await expect(navigationRow(page, name)).toBeVisible() + await expect(navigationRow(page, name)).toHaveClass(/active/) +} + +async function waitForNewNoteRoute(page: Page, previousNoteId: number | null): Promise { + await expect.poll(() => currentNoteId(page)).not.toBe(previousNoteId) + + const noteId = currentNoteId(page) + if (noteId === null || noteId === previousNoteId) { + throw new Error('Expected a new note route after creating a note') + } + + return noteId +} + +async function ensureNotesView(page: Page): Promise { + if (await notesSearchField(page).isVisible()) { + return + } + + const previousNoteId = currentNoteId(page) + await contentNewNoteButton(page).click() + await waitForNewNoteRoute(page, previousNoteId) + await expect(notesSearchField(page)).toBeVisible() +} + +async function createNoteInSelectedCategory(page: Page, category: string): Promise { + await ensureNotesView(page) + + const previousNoteId = currentNoteId(page) + await notesViewNewNoteButton(page).click() + + const noteId = await waitForNewNoteRoute(page, previousNoteId) + await expect(navigationRow(page, category).locator('.app-navigation-entry__counter-wrapper')).toContainText('1') + + return noteId +} + +async function openCategoryActions(page: Page, category: string): Promise { + const row = navigationRow(page, category) + + await expect(row).toBeVisible() + await row.getByRole('button', { name: 'Actions', exact: true }).click() +} + +test.describe('Category actions', () => { + test.beforeEach(async ({ page }) => { + await login(page) + await openNotesApp(page) + }) + + test('renames a category from the actions menu', async ({ page }, testInfo: TestInfo) => { + const category = uniqueCategoryName('rename', testInfo) + const renamedCategory = `${category} renamed` + const noteId = await (async () => { + await createCategory(page, category) + return createNoteInSelectedCategory(page, category) + })() + + await openCategoryActions(page, category) + await page.getByRole('menuitem', { name: 'Rename category', exact: true }).click() + + const renameInput = appNavigation(page).getByPlaceholder(category, { exact: true }) + await expect(renameInput).toBeVisible() + await renameInput.fill(renamedCategory) + await renameInput.press('Enter') + + await expect(navigationRow(page, renamedCategory)).toBeVisible() + await expect(navigationRow(page, renamedCategory)).toHaveClass(/active/) + await expect(navigationRow(page, renamedCategory).locator('.app-navigation-entry__counter-wrapper')).toContainText('1') + await expect(navigationRow(page, category)).toHaveCount(0) + await expect(page).toHaveURL(new RegExp(`/note/${noteId}(\\?.*)?$`)) + }) + + test('deletes a category from the actions menu', async ({ page }, testInfo: TestInfo) => { + const category = uniqueCategoryName('delete', testInfo) + await createCategory(page, category) + const noteId = await createNoteInSelectedCategory(page, category) + const deletedNoteUrl = new RegExp(`/note/${noteId}(\\?.*)?$`) + + await openCategoryActions(page, category) + await page.getByRole('menuitem', { name: 'Delete category', exact: true }).click() + + const confirmationText = `Delete category "${category}" and its 1 note?` + await expect(page.getByText(confirmationText, { exact: true })).toBeVisible() + + await page.getByRole('button', { name: 'Delete', exact: true }).click() + + await expect(navigationRow(page, category)).toHaveCount(0) + await expect(navigationRow(page, 'All notes')).toHaveClass(/active/) + await expect(page).not.toHaveURL(deletedNoteUrl) + }) +}) diff --git a/src/App.vue b/src/App.vue index 5816cacde..59cc24055 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,20 +7,17 @@ -