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/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..4247e95cd6 --- /dev/null +++ b/src/services/officeFiles.js @@ -0,0 +1,115 @@ +/** + * 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' + +export const OFFICE_MIME_FILTERS = { + documents: [ + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.text-template', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', + 'application/msword', + ], + presentations: [ + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.oasis.opendocument.presentation-template', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.openxmlformats-officedocument.presentationml.template', + 'application/vnd.ms-powerpoint', + ], + spreadsheets: [ + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.spreadsheet-template', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', + 'application/vnd.ms-excel', + 'text/csv', + ], + diagrams: [ + 'application/vnd.oasis.opendocument.graphics', + 'application/vnd.oasis.opendocument.graphics-template', + 'application/vnd.visio', + 'application/vnd.ms-visio.drawing', + ], +} + +const ALL_OFFICE_MIMES = Object.values(OFFICE_MIME_FILTERS).flat() + +// Pre-computed at module load time — ALL_OFFICE_MIMES is static so this never changes. +const OFFICE_MIME_CONDITIONS = ALL_OFFICE_MIMES + .map(mime => `\t\t\t\t${mime}`) + .join('\n') + +/** + * Build a DAV SEARCH request body that matches files of any office MIME type + * across all subdirectories (depth: infinity). + * + * @return {string} XML string for the SEARCH request body + */ +function getOfficeMimeSearch() { + return ` + + + + + ${getDavProperties()} + + + + + ${getRootPath()}/ + infinity + + + + +${OFFICE_MIME_CONDITIONS} + + + +` +} + +/** @type {import('@nextcloud/files').Node[]|null} */ +let cachedNodes = null + +/** + * Fetch all office files once and cache the result. + * Subsequent calls return the cached array. + * Uses DAV SEARCH to recursively find office files across all subdirectories. + * + * @return {Promise} + */ +export async function getAllOfficeFiles() { + if (cachedNodes) { + return cachedNodes + } + + const client = getClient() + + const response = await client.search('/', { + details: true, + data: getOfficeMimeSearch(), + }) + + cachedNodes = response.data.results + .map(item => resultToNode(item)) + .filter(node => node.type === 'file') + + return cachedNodes +} + +/** + * Filter a list of file nodes by an office category. + * + * @param {import('@nextcloud/files').Node[]} files + * @param {string} category - One of 'documents', 'presentations', 'spreadsheets', 'diagrams' + * @return {import('@nextcloud/files').Node[]} + */ +export function filterByCategory(files, category) { + const mimes = OFFICE_MIME_FILTERS[category] + return files.filter(file => mimes.includes(file.mime)) +} diff --git a/src/views/OfficeOverview.vue b/src/views/OfficeOverview.vue new file mode 100644 index 0000000000..5a8c9053c6 --- /dev/null +++ b/src/views/OfficeOverview.vue @@ -0,0 +1,243 @@ + + + + + + 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'),