From 918dd899955e32b1cd0da04dbf54d83322a4943e Mon Sep 17 00:00:00 2001 From: Remko Date: Wed, 14 Oct 2026 10:30:32 +0200 Subject: [PATCH 1/3] feat(applications): standalone application shell with its own menu - an application opened at /apps/tables/app/{id} gets its own navigation: its menu items replace the Tables navigation - a menu item opens its view, table or page with widgets inside the application, at /application/{id}/menu/{technicalName} - opened on its own, the application starts on its first page - inside Tables, a menu item page shows a breadcrumb back to the application and a link to open it on its own - the content area only takes focus after a first interaction, so no focus ring is drawn around it on load Signed-off-by: Remko Assisted-by: ClaudeCode:claude-opus-5-5 --- cypress/component/menuItems.cy.js | 23 ++ playwright/e2e/application.spec.ts | 51 ++++ src/App.vue | 70 ++++-- .../sections/ApplicationNavigation.vue | 151 ++++++++++++ src/pages/Context.vue | 233 +++++++++++++++++- src/router.js | 5 + src/shared/components/MenuItemIcon.vue | 44 ++++ src/shared/utils/menuItems.js | 26 ++ 8 files changed, 573 insertions(+), 30 deletions(-) create mode 100644 playwright/e2e/application.spec.ts create mode 100644 src/modules/navigation/sections/ApplicationNavigation.vue create mode 100644 src/shared/components/MenuItemIcon.vue diff --git a/cypress/component/menuItems.cy.js b/cypress/component/menuItems.cy.js index ad68aa1952..7c3ba5f8b7 100644 --- a/cypress/component/menuItems.cy.js +++ b/cypress/component/menuItems.cy.js @@ -10,6 +10,8 @@ import { indentItem, isValidMenuLink, menuItemErrors, + menuItemRoute, + menuTree, moveItem, outdentItem, parentOfPosition, @@ -115,4 +117,25 @@ describe('menu items', () => { expect(dragItemOver(items, 0, 1)).to.equal(null, 'a block cannot be dropped on its own child') expect(dragItemOver(items, 2, 2)).to.equal(null) }) + + it('routes a view or table entry inside the app and leaves a link outside it', () => { + expect(menuItemRoute({ targetType: 'view', technicalName: 'home' }, 7)).to.equal('/application/7/menu/home') + expect(menuItemRoute({ targetType: 'table', technicalName: 'cases' }, 7)).to.equal('/application/7/menu/cases') + expect(menuItemRoute({ targetType: 'url', technicalName: 'docs' }, 7)).to.equal(null) + }) + + it('builds the stored menu into a tree in menu order', () => { + const stored = [ + { id: 3, order: 30, parentId: null, label: 'Docs' }, + { id: 2, order: 20, parentId: 1, label: 'Open cases' }, + { id: 1, order: 10, parentId: null, label: 'Cases' }, + ] + + const tree = menuTree(stored) + + expect(tree.map(branch => branch.item.label)).to.deep.equal(['Cases', 'Docs']) + expect(tree[0].children.map(child => child.label)).to.deep.equal(['Open cases']) + expect(tree[1].children).to.deep.equal([]) + expect(menuTree(undefined)).to.deep.equal([]) + }) }) diff --git a/playwright/e2e/application.spec.ts b/playwright/e2e/application.spec.ts new file mode 100644 index 0000000000..61e565ff88 --- /dev/null +++ b/playwright/e2e/application.spec.ts @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import { ocsRequest, type TestUser } from '../support/api' + +/** + * Creates a grid view and an application whose menu opens it, through the API. + * + * @param page the page of the test user + * @param user the test user + * @param name name of the application + * @param pageTitle title of the grid view, and label of its menu item + */ +async function createApplicationWithGridPage(page: Page, user: TestUser, name: string, pageTitle: string) { + const viewResponse = await ocsRequest(page.request, user, { + method: 'POST', + url: '/ocs/v2.php/apps/tables/api/2/views?format=json', + data: { title: pageTitle }, + }) + const view = (await viewResponse.json()).ocs.data + const contextResponse = await ocsRequest(page.request, user, { + method: 'POST', + url: '/ocs/v2.php/apps/tables/api/2/contexts?format=json', + data: { + name, + iconName: 'star', + menuItems: [{ label: pageTitle, targetType: 'view', targetId: view.id }], + }, + }) + return (await contextResponse.json()).ocs.data +} + +test.describe('Applications', () => { + test('An application opened on its own shows its menu and its first page', async ({ userPage: { page, user } }) => { + const context = await createApplicationWithGridPage(page, user, 'standalone application', 'Front page') + + await page.goto(`/index.php/apps/tables/app/${context.id}`) + + await expect(page.locator('[data-cy="application-nav-header"]')).toContainText('standalone application') + await expect(page.locator('[data-cy="application-nav-item"]').filter({ hasText: 'Front page' })).toBeVisible() + await expect(page).toHaveURL(/\/menu\/front_page$/) + await expect(page.locator('[data-cy="gridViewHeading"]')).toContainText('Front page') + await expect(page.locator('[data-cy="navigationCreateTableIcon"]')).toHaveCount(0) + // Nothing focused the content area before the person touched the page, so no focus ring is drawn around it + expect(await page.evaluate(() => document.activeElement?.id ?? '')).not.toBe('app-content-vue') + }) +}) diff --git a/src/App.vue b/src/App.vue index d294ae6c2c..39c01dd331 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,7 +4,8 @@ -->