From 9aa1c155244e4c3d1875be8d244acddfb0a8a694 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Wed, 23 Sep 2026 14:45:45 +0200 Subject: [PATCH 1/4] feat(files): add personal template directory settings API Assisted-by: Codex:gpt-6-astra --- apps/files/appinfo/routes.php | 10 + .../lib/Controller/TemplateController.php | 66 +++++ apps/files/openapi.json | 273 ++++++++++++++++++ .../Controller/TemplateControllerTest.php | 122 ++++++++ openapi.json | 273 ++++++++++++++++++ 5 files changed, 744 insertions(+) create mode 100644 apps/files/tests/Controller/TemplateControllerTest.php diff --git a/apps/files/appinfo/routes.php b/apps/files/appinfo/routes.php index d3c74bd424c17..253b92b7c60e7 100644 --- a/apps/files/appinfo/routes.php +++ b/apps/files/appinfo/routes.php @@ -152,6 +152,16 @@ 'url' => '/api/v1/templates/path', 'verb' => 'POST' ], + [ + 'name' => 'Template#getPath', + 'url' => '/api/v1/templates/path', + 'verb' => 'GET', + ], + [ + 'name' => 'Template#setPath', + 'url' => '/api/v1/templates/path', + 'verb' => 'PUT', + ], [ 'name' => 'TransferOwnership#transfer', 'url' => '/api/v1/transferownership', diff --git a/apps/files/lib/Controller/TemplateController.php b/apps/files/lib/Controller/TemplateController.php index 4b4ea3c156985..70c98e43e808f 100644 --- a/apps/files/lib/Controller/TemplateController.php +++ b/apps/files/lib/Controller/TemplateController.php @@ -13,9 +13,15 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCSController; +use OCP\Files\Folder; use OCP\Files\GenericFileException; +use OCP\Files\InvalidPathException; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; use OCP\Files\Template\ITemplateManager; use OCP\Files\Template\Template; use OCP\Files\Template\TemplateFileCreator; @@ -33,10 +39,70 @@ public function __construct( $appName, IRequest $request, protected ITemplateManager $templateManager, + private IRootFolder $rootFolder, + private string $userId, ) { parent::__construct($appName, $request); } + /** + * Get the personal template directory + * + * @return DataResponse + * + * 200: Personal template directory returned + */ + #[NoAdminRequired] + public function getPath(): DataResponse { + $path = $this->templateManager->getTemplatePath(); + $available = false; + if ($path !== '') { + try { + $folder = $this->rootFolder->getUserFolder($this->userId)->get($path); + $available = $folder instanceof Folder && $folder->isReadable(); + } catch (NotFoundException|NotPermittedException|InvalidPathException $e) { + } + } + return new DataResponse(['template_path' => $path, 'available' => $available]); + } + + /** + * Select an existing personal template directory, or clear the selection + * + * @param string $templatePath User-relative folder path, or an empty string to clear the selection + * @return DataResponse + * @throws OCSBadRequestException The path does not refer to an existing folder + * @throws OCSForbiddenException The folder is not readable + * + * 200: Personal template directory updated + */ + #[NoAdminRequired] + public function setPath(string $templatePath): DataResponse { + if ($templatePath !== '') { + try { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $folder = $userFolder->get($templatePath); + if (!$folder instanceof Folder) { + throw new OCSBadRequestException('The template path must be an existing folder'); + } + if (!$folder->isReadable()) { + throw new OCSForbiddenException('The template folder must be readable'); + } + $templatePath = $userFolder->getRelativePath($folder->getPath()); + if ($templatePath === null) { + throw new OCSBadRequestException('Invalid template folder'); + } + $templatePath = '/' . trim($templatePath, '/'); + } catch (NotFoundException|InvalidPathException $e) { + throw new OCSBadRequestException('The template path must be an existing folder'); + } catch (NotPermittedException $e) { + throw new OCSForbiddenException('The template folder must be readable'); + } + } + $this->templateManager->setTemplatePath($templatePath); + return new DataResponse(['template_path' => $templatePath, 'available' => $templatePath !== '']); + } + /** * List the available templates * diff --git a/apps/files/openapi.json b/apps/files/openapi.json index 808a0e07a41a1..3249dc36d20da 100644 --- a/apps/files/openapi.json +++ b/apps/files/openapi.json @@ -1821,6 +1821,279 @@ } } } + }, + "get": { + "operationId": "template-get-path", + "summary": "Get the personal template directory", + "tags": [ + "template" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Personal template directory returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "template_path", + "available" + ], + "properties": { + "template_path": { + "type": "string" + }, + "available": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "template-set-path", + "summary": "Select an existing personal template directory, or clear the selection", + "tags": [ + "template" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "templatePath" + ], + "properties": { + "templatePath": { + "type": "string", + "description": "User-relative folder path, or an empty string to clear the selection" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Personal template directory updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "template_path", + "available" + ], + "properties": { + "template_path": { + "type": "string" + }, + "available": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "The path does not refer to an existing folder", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "The folder is not readable", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } } }, "/ocs/v2.php/apps/files/api/v1/transferownership": { diff --git a/apps/files/tests/Controller/TemplateControllerTest.php b/apps/files/tests/Controller/TemplateControllerTest.php new file mode 100644 index 0000000000000..ce12935ffc5a7 --- /dev/null +++ b/apps/files/tests/Controller/TemplateControllerTest.php @@ -0,0 +1,122 @@ +manager = $this->createMock(ITemplateManager::class); + $this->root = $this->createMock(IRootFolder::class); + $this->userFolder = $this->createMock(Folder::class); + $this->root->method('getUserFolder')->with('alice')->willReturn($this->userFolder); + $this->controller = new TemplateController('files', $this->createMock(IRequest::class), $this->manager, $this->root, 'alice'); + $this->manager->expects(self::never())->method('initializeTemplateDirectory'); + $this->userFolder->expects(self::never())->method('getOrCreateFolder'); + } + + public function testGetUnconfiguredPath(): void { + $this->manager->method('getTemplatePath')->willReturn(''); + $this->userFolder->expects(self::never())->method('get'); + self::assertSame(['template_path' => '', 'available' => false], $this->controller->getPath()->getData()); + } + + public function testGetReadablePath(): void { + $this->manager->method('getTemplatePath')->willReturn('/Templates'); + $folder = $this->createMock(Folder::class); + $folder->method('isReadable')->willReturn(true); + $this->userFolder->method('get')->with('/Templates')->willReturn($folder); + self::assertSame(['template_path' => '/Templates', 'available' => true], $this->controller->getPath()->getData()); + } + + public static function unavailablePaths(): array { + return [ + 'file' => ['file'], + 'unreadable folder' => ['unreadable'], + 'missing folder' => [NotFoundException::class], + 'permission denied' => [NotPermittedException::class], + 'invalid path' => [InvalidPathException::class], + ]; + } + + #[DataProvider('unavailablePaths')] + public function testGetUnavailablePath(string $reason): void { + $this->manager->method('getTemplatePath')->willReturn('/Templates'); + $this->mockUnavailablePath($reason); + $this->manager->expects(self::never())->method('setTemplatePath'); + self::assertSame(['template_path' => '/Templates', 'available' => false], $this->controller->getPath()->getData()); + } + + #[DataProvider('unavailablePaths')] + public function testRejectUnavailableSelection(string $reason): void { + $this->mockUnavailablePath($reason); + $this->manager->expects(self::never())->method('setTemplatePath'); + $this->expectException(in_array($reason, ['unreadable', NotPermittedException::class], true) ? OCSForbiddenException::class : OCSBadRequestException::class); + $this->controller->setPath('/Templates'); + } + + private function mockUnavailablePath(string $reason): void { + if ($reason === 'file') { + $this->userFolder->method('get')->willReturn($this->createMock(File::class)); + } elseif ($reason === 'unreadable') { + $folder = $this->createMock(Folder::class); + $folder->method('isReadable')->willReturn(false); + $this->userFolder->method('get')->willReturn($folder); + } else { + $this->userFolder->method('get')->willThrowException(new $reason()); + } + } + + public function testSelectExistingReadableFolder(): void { + $folder = $this->createMock(Folder::class); + $folder->method('isReadable')->willReturn(true); + $folder->method('getPath')->willReturn('/alice/files/Team/Templates'); + $this->userFolder->method('get')->with('Team/Templates/')->willReturn($folder); + $this->userFolder->method('getRelativePath')->with('/alice/files/Team/Templates')->willReturn('/Team/Templates'); + $this->manager->expects(self::once())->method('setTemplatePath')->with('/Team/Templates'); + self::assertSame(['template_path' => '/Team/Templates', 'available' => true], $this->controller->setPath('Team/Templates/')->getData()); + } + + public function testRejectFolderOutsideUserRoot(): void { + $folder = $this->createMock(Folder::class); + $folder->method('isReadable')->willReturn(true); + $folder->method('getPath')->willReturn('/bob/files/Templates'); + $this->userFolder->method('get')->willReturn($folder); + $this->userFolder->method('getRelativePath')->willReturn(null); + $this->manager->expects(self::never())->method('setTemplatePath'); + $this->expectException(OCSBadRequestException::class); + $this->controller->setPath('../bob/files/Templates'); + } + + public function testClearSelection(): void { + $this->userFolder->expects(self::never())->method('get'); + $this->manager->expects(self::once())->method('setTemplatePath')->with(''); + self::assertSame(['template_path' => '', 'available' => false], $this->controller->setPath('')->getData()); + } +} diff --git a/openapi.json b/openapi.json index b9b1bee0f782a..b49bae45ee8b6 100644 --- a/openapi.json +++ b/openapi.json @@ -25639,6 +25639,279 @@ } } } + }, + "get": { + "operationId": "files-template-get-path", + "summary": "Get the personal template directory", + "tags": [ + "files/template" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Personal template directory returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "template_path", + "available" + ], + "properties": { + "template_path": { + "type": "string" + }, + "available": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "files-template-set-path", + "summary": "Select an existing personal template directory, or clear the selection", + "tags": [ + "files/template" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "templatePath" + ], + "properties": { + "templatePath": { + "type": "string", + "description": "User-relative folder path, or an empty string to clear the selection" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Personal template directory updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "template_path", + "available" + ], + "properties": { + "template_path": { + "type": "string" + }, + "available": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "The path does not refer to an existing folder", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "The folder is not readable", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } } }, "/ocs/v2.php/apps/files/api/v1/transferownership": { From 11468a60f0150f95b62eba706fbd3559999fc179 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Wed, 23 Sep 2026 14:45:48 +0200 Subject: [PATCH 2/4] feat(files): choose template folder in Files settings Assisted-by: Codex:gpt-6-astra --- .../FilesAppSettingsTemplates.spec.ts | 130 +++++++++++++++++ .../FilesAppSettingsTemplates.vue | 133 ++++++++++++++++++ .../src/newMenu/newTemplatesFolder.spec.ts | 58 ++++++++ apps/files/src/newMenu/newTemplatesFolder.ts | 16 +-- apps/files/src/store/templateDirectory.ts | 38 +++++ apps/files/src/views/FilesAppSettings.vue | 2 + 6 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts create mode 100644 apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue create mode 100644 apps/files/src/newMenu/newTemplatesFolder.spec.ts create mode 100644 apps/files/src/store/templateDirectory.ts diff --git a/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts new file mode 100644 index 0000000000000..f039951f1e25e --- /dev/null +++ b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts @@ -0,0 +1,130 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { FilePickerClosed, getFilePickerBuilder, showError } from '@nextcloud/dialogs' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import FilesAppSettingsTemplates from './FilesAppSettingsTemplates.vue' +import { templateDirectory } from '../../store/templateDirectory.ts' + +vi.mock('@nextcloud/vue/components/NcAppSettingsSection', async () => { + const { h } = await import('vue') + return { default: { setup: (_props, { slots }) => () => h('section', slots.default?.()) } } +}) +vi.mock('@nextcloud/axios', () => ({ default: { get: vi.fn(), put: vi.fn(), post: vi.fn(), delete: vi.fn() } })) +vi.mock('@nextcloud/dialogs', () => ({ + FilePickerClosed: class extends Error {}, + getFilePickerBuilder: vi.fn(), + showError: vi.fn(), +})) + +const pickNodes = vi.fn() +const builder = { + setMultiSelect: vi.fn().mockReturnThis(), + setMimeTypeFilter: vi.fn().mockReturnThis(), + allowDirectories: vi.fn().mockReturnThis(), + setCanPick: vi.fn().mockReturnThis(), + startAt: vi.fn().mockReturnThis(), + addButton: vi.fn().mockReturnThis(), + build: () => ({ pickNodes }), +} + +function response(path: string, available = true) { + return { data: { ocs: { data: { template_path: path, available } } } } +} + +async function mount() { + const component = render(FilesAppSettingsTemplates) + await waitFor(() => expect(component.getByRole('button', { name: 'Choose folder' })).not.toBeDisabled()) + return component +} + +describe('Personal template folder settings', () => { + beforeEach(() => { + cleanup() + vi.clearAllMocks() + Object.assign(templateDirectory, { template_path: '', available: false }) + vi.mocked(axios.get).mockResolvedValue(response('/Templates')) + vi.mocked(getFilePickerBuilder).mockReturnValue(builder as unknown as ReturnType) + pickNodes.mockResolvedValue([{ path: '/Documents/Templates' }]) + }) + + it('loads the current directory and links to it', async () => { + const component = await mount() + expect(component.getByText('/Templates')).toBeVisible() + expect(component.getByRole('button', { name: 'Open folder' })).toHaveAttribute('href', expect.stringContaining('dir=%2FTemplates')) + }) + + it('selects an existing folder without invoking initialization', async () => { + vi.mocked(axios.put).mockResolvedValue(response('/Documents/Templates')) + const component = await mount() + await fireEvent.click(component.getByRole('button', { name: 'Choose folder' })) + await waitFor(() => expect(component.getByText('/Documents/Templates')).toBeVisible()) + expect(axios.put).toHaveBeenCalledWith(expect.stringContaining('/templates/path'), { templatePath: '/Documents/Templates' }) + expect(axios.post).not.toHaveBeenCalled() + expect(builder.setMimeTypeFilter).toHaveBeenCalledWith(['httpd/unix-directory']) + expect(builder.startAt).toHaveBeenCalledWith('/Templates') + expect(builder.addButton).toHaveBeenCalledWith(expect.objectContaining({ label: 'Select folder' })) + const canPick = builder.setCanPick.mock.calls[0][0] + expect(canPick({ permissions: 1 })).toBe(true) + expect(canPick({ permissions: 0 })).toBe(false) + }) + + it('clears the preference without deleting files', async () => { + vi.mocked(axios.put).mockResolvedValue(response('', false)) + const component = await mount() + await fireEvent.click(component.getByRole('button', { name: 'Clear selection' })) + await waitFor(() => expect(component.getByText('No folder selected')).toBeVisible()) + expect(axios.put).toHaveBeenCalledWith(expect.any(String), { templatePath: '' }) + expect(axios.delete).not.toHaveBeenCalled() + expect(component.queryByRole('button', { name: 'Open folder' })).toBeNull() + }) + + it('keeps the old selection when saving fails', async () => { + vi.mocked(axios.put).mockRejectedValue(new Error('Forbidden')) + const component = await mount() + await fireEvent.click(component.getByRole('button', { name: 'Choose folder' })) + await waitFor(() => expect(showError).toHaveBeenCalledWith('Unable to update the template folder')) + expect(component.getByText('/Templates')).toBeVisible() + }) + + it('does not save or show an error when the picker is cancelled', async () => { + pickNodes.mockRejectedValue(new FilePickerClosed()) + const component = await mount() + await fireEvent.click(component.getByRole('button', { name: 'Choose folder' })) + await waitFor(() => expect(pickNodes).toHaveBeenCalled()) + expect(axios.put).not.toHaveBeenCalled() + expect(showError).not.toHaveBeenCalled() + }) + + it('reports picker failures', async () => { + pickNodes.mockRejectedValue(new Error('Picker failed')) + const component = await mount() + await fireEvent.click(component.getByRole('button', { name: 'Choose folder' })) + await waitFor(() => expect(showError).toHaveBeenCalledWith('Unable to choose a template folder')) + }) + + it('shows unavailable paths and starts the picker at the root', async () => { + vi.mocked(axios.get).mockResolvedValue(response('/Missing', false)) + pickNodes.mockResolvedValue([]) + const component = await mount() + expect(component.getByText(/This folder is no longer available/)).toBeVisible() + expect(component.queryByRole('button', { name: 'Open folder' })).toBeNull() + await fireEvent.click(component.getByRole('button', { name: 'Choose folder' })) + expect(builder.startAt).toHaveBeenCalledWith('/') + expect(axios.put).not.toHaveBeenCalled() + }) + + it('allows retrying a failed load', async () => { + vi.mocked(axios.get).mockRejectedValueOnce(new Error('Offline')) + const component = render(FilesAppSettingsTemplates) + await waitFor(() => expect(component.getByText('Unable to load the template folder')).toBeVisible()) + expect(component.getByRole('button', { name: 'Choose folder' })).toBeDisabled() + await fireEvent.click(component.getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(component.getByText('/Templates')).toBeVisible()) + expect(component.getByRole('button', { name: 'Choose folder' })).not.toBeDisabled() + }) +}) diff --git a/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue new file mode 100644 index 0000000000000..e58850df3234c --- /dev/null +++ b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue @@ -0,0 +1,133 @@ + + + + + + + diff --git a/apps/files/src/newMenu/newTemplatesFolder.spec.ts b/apps/files/src/newMenu/newTemplatesFolder.spec.ts new file mode 100644 index 0000000000000..ca671df7d58c6 --- /dev/null +++ b/apps/files/src/newMenu/newTemplatesFolder.spec.ts @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { Folder, Permission } from '@nextcloud/files' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { setTemplateDirectory, templateDirectory } from '../store/templateDirectory.ts' +import { newNodeName } from '../utils/newNodeDialog.ts' +import { entry } from './newTemplatesFolder.ts' + +vi.mock('@nextcloud/auth', () => ({ getCurrentUser: () => ({ uid: 'alice' }) })) +vi.mock('@nextcloud/axios', () => ({ default: { post: vi.fn(), put: vi.fn() } })) +vi.mock('@nextcloud/dialogs', () => ({ showError: vi.fn() })) +vi.mock('../utils/newNodeDialog.ts', () => ({ newNodeName: vi.fn() })) + +const folder = new Folder({ source: 'http://localhost/remote.php/dav/files/alice/', owner: 'alice', permissions: Permission.ALL, root: '/files/alice' }) + +describe('Create templates folder menu entry', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.assign(templateDirectory, { template_path: '', available: false }) + window.OCP.Files = { Router: { goToRoute: vi.fn() } } as unknown as typeof window.OCP.Files + }) + + it('reflects selection and clearing from settings without a reload', async () => { + expect(entry.enabled!(folder)).toBe(true) + vi.mocked(axios.put).mockResolvedValueOnce({ data: { ocs: { data: { template_path: '/Templates', available: true } } } }) + await setTemplateDirectory('/Templates') + expect(entry.enabled!(folder)).toBe(false) + vi.mocked(axios.put).mockResolvedValueOnce({ data: { ocs: { data: { template_path: '', available: false } } } }) + await setTemplateDirectory('') + expect(entry.enabled!(folder)).toBe(true) + }) + + it('updates the shared selection after initialization succeeds', async () => { + vi.mocked(newNodeName).mockResolvedValue('Templates') + vi.mocked(axios.post).mockResolvedValue({ data: { ocs: { data: { template_path: '/Templates' } } } }) + await entry.handler(folder, []) + expect(templateDirectory).toEqual({ template_path: '/Templates', available: true }) + expect(entry.enabled!(folder)).toBe(false) + }) + + it('keeps the entry available after initialization fails', async () => { + vi.mocked(newNodeName).mockResolvedValue('Templates') + vi.mocked(axios.post).mockRejectedValue(new Error('Failed')) + await entry.handler(folder, []) + expect(entry.enabled!(folder)).toBe(true) + expect(window.OCP.Files.Router.goToRoute).not.toHaveBeenCalled() + }) + + it('does not initialize when cancelled', async () => { + vi.mocked(newNodeName).mockResolvedValue(null) + await entry.handler(folder, []) + expect(axios.post).not.toHaveBeenCalled() + }) +}) diff --git a/apps/files/src/newMenu/newTemplatesFolder.ts b/apps/files/src/newMenu/newTemplatesFolder.ts index 2e8f3431f84e7..79e28d5f9fc1c 100644 --- a/apps/files/src/newMenu/newTemplatesFolder.ts +++ b/apps/files/src/newMenu/newTemplatesFolder.ts @@ -9,18 +9,18 @@ import PlusSvg from '@mdi/svg/svg/plus.svg?raw' import { getCurrentUser } from '@nextcloud/auth' import axios from '@nextcloud/axios' import { showError } from '@nextcloud/dialogs' -import { Permission, removeNewFileMenuEntry } from '@nextcloud/files' +import { Permission } from '@nextcloud/files' import { loadState } from '@nextcloud/initial-state' import { translate as t } from '@nextcloud/l10n' import { generateOcsUrl } from '@nextcloud/router' import { join } from 'path' +import { templateDirectory } from '../store/templateDirectory.ts' import { logger } from '../utils/logger.ts' import { newNodeName } from '../utils/newNodeDialog.ts' const templatesEnabled = loadState('files', 'templates_enabled', true) -let templatesPath = loadState('files', 'templates_path', false) logger.debug('Templates folder enabled', { templatesEnabled }) -logger.debug('Initial templates folder', { templatesPath }) +logger.debug('Initial templates folder', { templateDirectory }) /** * Init template folder @@ -47,7 +47,8 @@ async function initTemplatesFolder(directory: IFolder, name: string) { logger.info('Created new templates folder', { ...data.ocs.data, }) - templatesPath = data.ocs.data.templates_path as string + templateDirectory.template_path = data.ocs.data.template_path + templateDirectory.available = !!data.ocs.data.template_path } catch (error) { logger.error('Unable to initialize the templates directory', { error }) showError(t('files', 'Unable to initialize the templates directory')) @@ -61,7 +62,7 @@ export const entry: NewMenuEntry = { order: 30, enabled(context: IFolder): boolean { // Templates disabled or templates folder already initialized - if (!templatesEnabled || templatesPath) { + if (!templatesEnabled || templateDirectory.available) { return false } // Allow creation on your own folders only @@ -75,10 +76,7 @@ export const entry: NewMenuEntry = { if (name !== null) { // Create the template folder - initTemplatesFolder(context, name) - - // Remove the menu entry - removeNewFileMenuEntry('template-picker') + await initTemplatesFolder(context, name) } }, } diff --git a/apps/files/src/store/templateDirectory.ts b/apps/files/src/store/templateDirectory.ts new file mode 100644 index 0000000000000..d7428e55c4c19 --- /dev/null +++ b/apps/files/src/store/templateDirectory.ts @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { OCSResponse } from '@nextcloud/typings/ocs' + +import axios from '@nextcloud/axios' +import { loadState } from '@nextcloud/initial-state' +import { generateOcsUrl } from '@nextcloud/router' +import { reactive } from 'vue' + +interface TemplateDirectory { + template_path: string + available: boolean +} + +const initialPath = loadState('files', 'templates_path', false) +export const templateDirectory = reactive({ + template_path: initialPath || '', + available: !!initialPath, +}) + +/** Load the configured folder, including folders that are no longer available. */ +export async function loadTemplateDirectory(): Promise { + const { data } = await axios.get>(generateOcsUrl('apps/files/api/v1/templates/path')) + Object.assign(templateDirectory, data.ocs.data) +} + +/** + * Select an existing folder without creating or copying files. + * + * @param templatePath User-relative folder path, or empty to clear the selection + */ +export async function setTemplateDirectory(templatePath: string): Promise { + const { data } = await axios.put>(generateOcsUrl('apps/files/api/v1/templates/path'), { templatePath }) + Object.assign(templateDirectory, data.ocs.data) +} diff --git a/apps/files/src/views/FilesAppSettings.vue b/apps/files/src/views/FilesAppSettings.vue index 7772b3d89c220..59e405e719a7e 100644 --- a/apps/files/src/views/FilesAppSettings.vue +++ b/apps/files/src/views/FilesAppSettings.vue @@ -12,6 +12,7 @@ import FilesAppSettingsAppearance from '../components/FilesAppSettings/FilesAppS import FilesAppSettingsGeneral from '../components/FilesAppSettings/FilesAppSettingsGeneral.vue' import FilesAppSettingsLegacyApi from '../components/FilesAppSettings/FilesAppSettingsLegacyApi.vue' import FilesAppSettingsShortcuts from '../components/FilesAppSettings/FilesAppSettingsShortcuts.vue' +import FilesAppSettingsTemplates from '../components/FilesAppSettings/FilesAppSettingsTemplates.vue' import FilesAppSettingsWarnings from '../components/FilesAppSettings/FilesAppSettingsWarnings.vue' import FilesAppSettingsWebDav from '../components/FilesAppSettings/FilesAppSettingsWebDav.vue' @@ -54,6 +55,7 @@ async function showKeyboardShortcuts() { @update:open="emit('close')"> + From 306d4b6d77ad222e7ee39c23be1ea44ac3ca8e33 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Wed, 23 Sep 2026 14:52:38 +0200 Subject: [PATCH 3/4] refactor(files): simplify template folder settings actions Assisted-by: Codex:gpt-6-astra --- .../FilesAppSettingsTemplates.spec.ts | 18 ++++---- .../FilesAppSettingsTemplates.vue | 45 ++++++++++++------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts index f039951f1e25e..6706dc8ba4002 100644 --- a/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts +++ b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts @@ -38,7 +38,7 @@ function response(path: string, available = true) { async function mount() { const component = render(FilesAppSettingsTemplates) - await waitFor(() => expect(component.getByRole('button', { name: 'Choose folder' })).not.toBeDisabled()) + await waitFor(() => expect(component.getByRole('button', { name: 'Personal template folder' })).not.toBeDisabled()) return component } @@ -55,13 +55,14 @@ describe('Personal template folder settings', () => { it('loads the current directory and links to it', async () => { const component = await mount() expect(component.getByText('/Templates')).toBeVisible() + expect(component.queryByRole('button', { name: 'Choose folder' })).toBeNull() expect(component.getByRole('button', { name: 'Open folder' })).toHaveAttribute('href', expect.stringContaining('dir=%2FTemplates')) }) it('selects an existing folder without invoking initialization', async () => { vi.mocked(axios.put).mockResolvedValue(response('/Documents/Templates')) const component = await mount() - await fireEvent.click(component.getByRole('button', { name: 'Choose folder' })) + await fireEvent.click(component.getByRole('button', { name: 'Personal template folder' })) await waitFor(() => expect(component.getByText('/Documents/Templates')).toBeVisible()) expect(axios.put).toHaveBeenCalledWith(expect.stringContaining('/templates/path'), { templatePath: '/Documents/Templates' }) expect(axios.post).not.toHaveBeenCalled() @@ -80,13 +81,14 @@ describe('Personal template folder settings', () => { await waitFor(() => expect(component.getByText('No folder selected')).toBeVisible()) expect(axios.put).toHaveBeenCalledWith(expect.any(String), { templatePath: '' }) expect(axios.delete).not.toHaveBeenCalled() + expect(pickNodes).not.toHaveBeenCalled() expect(component.queryByRole('button', { name: 'Open folder' })).toBeNull() }) it('keeps the old selection when saving fails', async () => { vi.mocked(axios.put).mockRejectedValue(new Error('Forbidden')) const component = await mount() - await fireEvent.click(component.getByRole('button', { name: 'Choose folder' })) + await fireEvent.click(component.getByRole('button', { name: 'Personal template folder' })) await waitFor(() => expect(showError).toHaveBeenCalledWith('Unable to update the template folder')) expect(component.getByText('/Templates')).toBeVisible() }) @@ -94,7 +96,7 @@ describe('Personal template folder settings', () => { it('does not save or show an error when the picker is cancelled', async () => { pickNodes.mockRejectedValue(new FilePickerClosed()) const component = await mount() - await fireEvent.click(component.getByRole('button', { name: 'Choose folder' })) + await fireEvent.click(component.getByRole('button', { name: 'Personal template folder' })) await waitFor(() => expect(pickNodes).toHaveBeenCalled()) expect(axios.put).not.toHaveBeenCalled() expect(showError).not.toHaveBeenCalled() @@ -103,7 +105,7 @@ describe('Personal template folder settings', () => { it('reports picker failures', async () => { pickNodes.mockRejectedValue(new Error('Picker failed')) const component = await mount() - await fireEvent.click(component.getByRole('button', { name: 'Choose folder' })) + await fireEvent.click(component.getByRole('button', { name: 'Personal template folder' })) await waitFor(() => expect(showError).toHaveBeenCalledWith('Unable to choose a template folder')) }) @@ -113,7 +115,7 @@ describe('Personal template folder settings', () => { const component = await mount() expect(component.getByText(/This folder is no longer available/)).toBeVisible() expect(component.queryByRole('button', { name: 'Open folder' })).toBeNull() - await fireEvent.click(component.getByRole('button', { name: 'Choose folder' })) + await fireEvent.click(component.getByRole('button', { name: 'Personal template folder' })) expect(builder.startAt).toHaveBeenCalledWith('/') expect(axios.put).not.toHaveBeenCalled() }) @@ -122,9 +124,9 @@ describe('Personal template folder settings', () => { vi.mocked(axios.get).mockRejectedValueOnce(new Error('Offline')) const component = render(FilesAppSettingsTemplates) await waitFor(() => expect(component.getByText('Unable to load the template folder')).toBeVisible()) - expect(component.getByRole('button', { name: 'Choose folder' })).toBeDisabled() + expect(component.getByRole('button', { name: 'Personal template folder' })).toBeDisabled() await fireEvent.click(component.getByRole('button', { name: 'Retry' })) await waitFor(() => expect(component.getByText('/Templates')).toBeVisible()) - expect(component.getByRole('button', { name: 'Choose folder' })).not.toBeDisabled() + expect(component.getByRole('button', { name: 'Personal template folder' })).not.toBeDisabled() }) }) diff --git a/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue index e58850df3234c..eb6e769707f6a 100644 --- a/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue +++ b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue @@ -4,7 +4,7 @@ -->