diff --git a/appinfo/info.xml b/appinfo/info.xml index 8024e57703..826a042ea2 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -55,4 +55,12 @@ You can also edit your documents off-line with the Collabora Office app from the OCA\Richdocuments\Settings\Personal OCA\Richdocuments\Settings\Section + + + Office + richdocuments.overview.index + app.svg + 10 + + diff --git a/appinfo/routes.php b/appinfo/routes.php index e17267f6ce..6646968513 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -24,6 +24,9 @@ // external api access ['name' => 'document#extAppGetData', 'url' => '/ajax/extapp/data/{fileId}', 'verb' => 'POST'], + // Office overview page + ['name' => 'overview#index', 'url' => '/overview', 'verb' => 'GET'], + // Settings ['name' => 'settings#setPersonalSettings', 'url' => 'ajax/personal.php', 'verb' => 'POST'], ['name' => 'settings#setSettings', 'url' => 'ajax/admin.php', 'verb' => 'POST'], diff --git a/cypress/e2e/overview.spec.js b/cypress/e2e/overview.spec.js new file mode 100644 index 0000000000..06acc8c840 --- /dev/null +++ b/cypress/e2e/overview.spec.js @@ -0,0 +1,158 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +const CATEGORY_FILES = [ + { + category: 'Documents', + emptyMessage: 'No documents found', + fixture: 'document.odt', + mimeType: 'application/vnd.oasis.opendocument.text', + }, + { + category: 'Presentations', + emptyMessage: 'No presentations found', + fixture: 'presentation.odp', + mimeType: 'application/vnd.oasis.opendocument.presentation', + }, + { + category: 'Spreadsheets', + emptyMessage: 'No spreadsheets found', + fixture: 'spreadsheet.ods', + mimeType: 'application/vnd.oasis.opendocument.spreadsheet', + }, + { + category: 'Diagrams', + emptyMessage: 'No diagrams found', + fixture: 'drawing.odg', + mimeType: 'application/vnd.oasis.opendocument.graphics', + }, +] + +describe('Office overview page', function() { + describe('without files', function() { + let randUser + + before(function() { + cy.createRandomUser().then(user => { + randUser = user + }) + }) + + beforeEach(function() { + cy.login(randUser) + cy.visit('/apps/richdocuments/overview') + }) + + it('Shows the navigation sidebar with appropriate entries', function() { + CATEGORY_FILES.forEach(({ category }) => { + cy.contains('.app-navigation-entry', category).should('exist') + }) + }) + + it('Highlights the active navigation item and shows empty state on click', function() { + CATEGORY_FILES.forEach(({ category, emptyMessage }) => { + cy.contains('.app-navigation-entry', category).click() + cy.contains('.app-navigation-entry', category) + .should('have.class', 'active') + cy.get('.empty-content') + .should('be.visible') + .and('contain', emptyMessage) + }) + }) + }) + + describe('with files', function() { + let randUser + + before(function() { + cy.createRandomUser().then(user => { + randUser = user + cy.login(user) + + CATEGORY_FILES.forEach(({ fixture, mimeType }) => { + cy.uploadFile(user, fixture, mimeType, `/${fixture}`) + }) + + cy.createFolder(user, 'subfolder').then(() => { + CATEGORY_FILES.forEach(({ fixture, mimeType }) => { + cy.uploadFile(user, fixture, mimeType, `/subfolder/${fixture}`) + }) + }) + }) + }) + + beforeEach(function() { + cy.login(randUser) + cy.visit('/apps/richdocuments/overview', { + onBeforeLoad(win) { + cy.spy(win, 'postMessage').as('postMessage') + }, + }) + }) + + CATEGORY_FILES.forEach(({ category, fixture }) => { + it(`Shows ${category} file cards in the correct category`, function() { + cy.contains('.app-navigation-entry', category).click() + + cy.contains('.file-card__name', fixture) + .should('be.visible') + + cy.get('.file-card__preview img') + .should('exist') + + cy.get('.input-field__label') + .should('contain', `Search ${category.toLowerCase()}`) + }) + + it(`Opens the viewer when clicking a ${category} file card`, function() { + cy.contains('.app-navigation-entry', category).click() + cy.contains('.file-card', fixture).click() + + cy.waitForViewer() + cy.waitForCollabora() + + cy.closeDocument() + }) + }) + + it('Shows file cards for files in subdirectories', function() { + CATEGORY_FILES.forEach(({ category }) => { + cy.contains('.app-navigation-entry', category).click() + cy.get('.file-card').should('have.length.at.least', 2) + }) + }) + + it('Filters file cards by search query', function() { + const { category, fixture } = CATEGORY_FILES[0] + const stem = fixture.split('.')[0] + + cy.contains('.app-navigation-entry', category).click() + + cy.get('.office-overview__search [type="search"]').type(stem) + cy.contains('.file-card__name', fixture).should('be.visible') + }) + + it('Shows empty state when search matches nothing', function() { + const { category } = CATEGORY_FILES[0] + + cy.contains('.app-navigation-entry', category).click() + + cy.get('.office-overview__search [type="search"]').type('xyz123noresults') + cy.get('.empty-content').should('be.visible') + }) + + it('Resets search when switching categories', function() { + const [first, second] = CATEGORY_FILES + + cy.contains('.app-navigation-entry', first.category).click() + cy.get('.office-overview__search [type="search"]').type('xyz123noresults') + + cy.contains('.app-navigation-entry', second.category).click() + + cy.get('.office-overview__search [type="search"]').should('have.value', '') + cy.contains('.file-card__name', second.fixture).should('be.visible') + }) + }) +}) diff --git a/cypress/e2e/templates.spec.js b/cypress/e2e/templates.spec.js index e8da9e890f..e5187bf2a6 100644 --- a/cypress/e2e/templates.spec.js +++ b/cypress/e2e/templates.spec.js @@ -52,7 +52,7 @@ describe('Global templates', function() { .scrollIntoView() cy.intercept('DELETE', '**/richdocuments/template/*').as('templateDeleteRequest') - cy.get('.template-btn[data-cy-template-btn-name="systemtemplate"]').click() + cy.get('.file-card[data-cy-template-btn-name="systemtemplate"]').click() cy.wait('@templateDeleteRequest').then(({ response }) => { expect(response.statusCode).to.equal(204) diff --git a/lib/Controller/OverviewController.php b/lib/Controller/OverviewController.php new file mode 100644 index 0000000000..a6d1e14708 --- /dev/null +++ b/lib/Controller/OverviewController.php @@ -0,0 +1,54 @@ +initialState->provideInitialState('previewEnabled', $this->preview->isMimeSupported('application/vnd.oasis.opendocument.text')); + + // Viewer is pre-installed in production but may not be available in other environments + if (class_exists(LoadViewer::class)) { + $this->eventDispatcher->dispatchTyped(new LoadViewer()); + } + + return new TemplateResponse('richdocuments', 'overview', [ + 'id-app-content' => '#app-content-vue', + 'id-app-navigation' => '#app-navigation-vue', + ]); + } +} diff --git a/src/components/AdminSettings/GlobalTemplates.vue b/src/components/AdminSettings/GlobalTemplates.vue index 5b1a82c7b3..2dbbc389c2 100644 --- a/src/components/AdminSettings/GlobalTemplates.vue +++ b/src/components/AdminSettings/GlobalTemplates.vue @@ -14,37 +14,37 @@ @change="selectFile">
- -
-
- -
- {{ t('richdocuments', 'New') }} -
-
- -
- -
-
-
- -
+ + + + + + + + +
+ + diff --git a/src/components/TemplateSection.vue b/src/components/TemplateSection.vue new file mode 100644 index 0000000000..fbb5e389fb --- /dev/null +++ b/src/components/TemplateSection.vue @@ -0,0 +1,165 @@ + + + + + + diff --git a/src/overview.js b/src/overview.js new file mode 100644 index 0000000000..2eca428705 --- /dev/null +++ b/src/overview.js @@ -0,0 +1,15 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import './init-shared.js' +import '../css/filetypes.scss' +import Vue from 'vue' +import OfficeOverview from './views/OfficeOverview.vue' + +Vue.prototype.t = t +Vue.prototype.n = n + +new Vue({ + render: h => h(OfficeOverview), +}).$mount('#content') diff --git a/src/services/officeFiles.js b/src/services/officeFiles.js new file mode 100644 index 0000000000..fe2a07833d --- /dev/null +++ b/src/services/officeFiles.js @@ -0,0 +1,89 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getClient, getDavNameSpaces, getDavProperties, getRootPath, resultToNode } from '@nextcloud/files/dav' + +/** + * Build a DAV SEARCH request body that matches files of any of the given MIME types + * across all subdirectories (depth: infinity). + * + * @param {string[]} mimes List of MIME type strings to search for + * @return {string} XML string for the SEARCH request body + */ +function buildOfficeMimeSearch(mimes) { + const conditions = mimes + .map(mime => `\t\t\t\t${mime}`) + .join('\n') + + return ` + + + + + ${getDavProperties()} + + + + + ${getRootPath()}/ + infinity + + + + +${conditions} + + + +` +} + +/** @type {import('@nextcloud/files').Node[]|null} */ +let cachedNodes = null + +/** + * Fetch all office files matching the given MIME types and cache the result. + * Subsequent calls with the same set of MIMEs return the cached array. + * Pass an empty array to invalidate and re-fetch. + * + * @param {string[]} mimes MIME types to search for, derived from template creators + * @return {Promise} + */ +export async function getAllOfficeFiles(mimes) { + if (cachedNodes) { + return cachedNodes + } + + const client = getClient() + + const response = await client.search('/', { + details: true, + data: buildOfficeMimeSearch(mimes), + }) + + cachedNodes = response.data.results + .map(item => resultToNode(item)) + .filter(node => node.type === 'file') + + return cachedNodes +} + +/** + * Discard the cached file list so the next getAllOfficeFiles() call re-fetches. + */ +export function invalidateOfficeFilesCache() { + cachedNodes = null +} + +/** + * Filter a list of file nodes to those whose MIME type is in the given set. + * + * @param {import('@nextcloud/files').Node[]} files + * @param {string[]} mimes MIME types for the active category + * @return {import('@nextcloud/files').Node[]} + */ +export function filterByMimes(files, mimes) { + return files.filter(file => mimes.includes(file.mime)) +} diff --git a/src/services/templates.js b/src/services/templates.js new file mode 100644 index 0000000000..f11cb68a1d --- /dev/null +++ b/src/services/templates.js @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// Mirrors apps/files/src/services/Templates.js — uses NC core Files API, not richdocuments OCS. + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +/** + * Fetch all template creators registered with the NC Files API. + * Returns an array of TemplateFileCreator objects, each with: + * app, label, extension, mimetypes[], templates[] + * + * @return {Promise} + */ +export async function getTemplates() { + const response = await axios.get(generateOcsUrl('apps/files/api/v1/templates')) + return response.data.ocs.data +} + +/** + * Create a new file from a template via the NC Files API. + * + * @param {string} filePath Destination path for the new file + * @param {string} templatePath Source template path + * @param {string} templateType Template type e.g. 'user' + * @return {Promise} + */ +export async function createFromTemplate(filePath, templatePath, templateType) { + const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates/create'), { + filePath, + templatePath, + templateType, + }) + return response.data.ocs.data +} diff --git a/src/views/OfficeOverview.vue b/src/views/OfficeOverview.vue new file mode 100644 index 0000000000..c0c7e15515 --- /dev/null +++ b/src/views/OfficeOverview.vue @@ -0,0 +1,315 @@ + + + + + + diff --git a/templates/overview.php b/templates/overview.php new file mode 100644 index 0000000000..0024bd549b --- /dev/null +++ b/templates/overview.php @@ -0,0 +1,8 @@ + +
diff --git a/tests/lib/Controller/OverviewControllerTest.php b/tests/lib/Controller/OverviewControllerTest.php new file mode 100644 index 0000000000..8ef5d5a911 --- /dev/null +++ b/tests/lib/Controller/OverviewControllerTest.php @@ -0,0 +1,76 @@ +eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->initialState = $this->createMock(IInitialState::class); + $this->preview = $this->createMock(IPreview::class); + + $this->controller = new OverviewController( + 'richdocuments', + $this->createMock(IRequest::class), + $this->eventDispatcher, + $this->initialState, + $this->preview, + ); + } + + public function testIndexReturnsTemplateResponse(): void { + $response = $this->controller->index(); + + $this->assertInstanceOf(TemplateResponse::class, $response); + $this->assertSame('richdocuments', $response->getApp()); + $this->assertSame('overview', $response->getTemplateName()); + $this->assertSame('#app-content-vue', $response->getParams()['id-app-content']); + $this->assertSame('#app-navigation-vue', $response->getParams()['id-app-navigation']); + } + + public function testIndexSetsPreviewEnabledTrue(): void { + $this->preview->expects($this->once()) + ->method('isMimeSupported') + ->with('application/vnd.oasis.opendocument.text') + ->willReturn(true); + + $this->initialState->expects($this->once()) + ->method('provideInitialState') + ->with('previewEnabled', true); + + $this->controller->index(); + } + + public function testIndexSetsPreviewEnabledFalse(): void { + $this->preview->expects($this->once()) + ->method('isMimeSupported') + ->with('application/vnd.oasis.opendocument.text') + ->willReturn(false); + + $this->initialState->expects($this->once()) + ->method('provideInitialState') + ->with('previewEnabled', false); + + $this->controller->index(); + } +} diff --git a/webpack.js b/webpack.js index c2d77b49ae..3ce6d64113 100644 --- a/webpack.js +++ b/webpack.js @@ -13,6 +13,7 @@ webpackConfig.entry = { 'init-viewer': path.join(__dirname, 'src', 'init-viewer.js'), fileActions: path.join(__dirname, 'src', 'file-actions.js'), document: path.join(__dirname, 'src', 'document.js'), + overview: path.join(__dirname, 'src', 'overview.js'), admin: path.join(__dirname, 'src', 'admin.js'), personal: path.join(__dirname, 'src', 'personal.js'), reference: path.join(__dirname, 'src', 'reference.js'),