From f88817055abeb212553d5b02d4444675ba374b5c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 16 Oct 2026 10:34:42 +0200 Subject: [PATCH] feat(applications): standalone application shell with its own menu An application opened on its own, at /apps/tables/app/{id}, shows its menu as the navigation and starts on its first menu item. A menu item route opens the start page, a grid page, or a table or view; inside Tables it sits below a breadcrumb back to the application. The start page keeps its own entry when no menu item opens it. Assisted-by: ClaudeCode:claude-opus-5-5 --- cypress/component/menuItems.cy.js | 24 ++ playwright/e2e/application.spec.ts | 134 ++++++++++ src/App.vue | 70 ++++-- src/modules/grid/gridLayout.js | 3 + .../sections/ApplicationNavigation.vue | 159 ++++++++++++ src/pages/Context.vue | 230 ++++++++++++++++-- src/router.js | 5 + src/shared/components/MenuItemIcon.vue | 44 ++++ src/shared/utils/menuItems.js | 26 ++ 9 files changed, 656 insertions(+), 39 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 884f824e66..d04be9db92 100644 --- a/cypress/component/menuItems.cy.js +++ b/cypress/component/menuItems.cy.js @@ -10,6 +10,8 @@ import { indentItem, isValidMenuLink, menuItemErrors, + menuItemRoute, + menuTree, moveItem, newDashboardItem, opensNewStartPage, @@ -182,4 +184,26 @@ describe('menu items', () => { // without an id, only the dashboard item of a new application is valid expect(menuItemErrors({ ...page, targetId: null }).target).to.equal(true) }) + + it('routes a page, view or table entry inside the app and leaves a link outside it', () => { + expect(menuItemRoute({ targetType: 'page', targetId: 12, technicalName: 'dashboard' }, 7)).to.equal('/application/7/menu/dashboard') + 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..9c7d814efa --- /dev/null +++ b/playwright/e2e/application.spec.ts @@ -0,0 +1,134 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { APIRequestContext } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import { ocsRequest, type TestUser } from '../support/api' + +test.use({ viewport: { width: 1600, height: 1000 } }) + +const API = '/ocs/v2.php/apps/tables/api/2' + +/** + * Creates an application with a table and a header on its start page, a grid page with a header, + * and a menu that opens the start page, the grid page, the table and an external link, through the API. + * + * @param request the request context of the test user + * @param user the test user + * @param name name of the application + */ +async function createApplicationWithMenu(request: APIRequestContext, user: TestUser, name: string) { + const tableResponse = await ocsRequest(request, user, { method: 'POST', url: '/index.php/apps/tables/api/1/tables', data: { title: name + ' cases' } }) + const tableId = (await tableResponse.json()).id + const contextResponse = await ocsRequest(request, user, { + method: 'POST', + url: `${API}/contexts?format=json`, + data: { name, iconName: 'star', nodes: [{ id: tableId, type: 0, permissions: 1 }] }, + }) + const context = (await contextResponse.json()).ocs.data + // A new application's dashboard item opens its start page + const startPageId = context.menuItems.find((item: { technicalName: string }) => item.technicalName === 'dashboard').targetId + const pageResponse = await ocsRequest(request, user, { method: 'POST', url: `${API}/contexts/${context.id}/pages?format=json`, data: { title: 'Front page' } }) + const frontPageId = (await pageResponse.json()).ocs.data.id + await ocsRequest(request, user, { + method: 'PATCH', + url: `${API}/contexts/${context.id}/pages/${frontPageId}?format=json`, + data: { widgets: [{ type: 'header', settings: { heading: 'Welcome on the front page' }, gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 2 }] }, + }) + await ocsRequest(request, user, { + method: 'PATCH', + url: `${API}/contexts/${context.id}/pages/${startPageId}?format=json`, + data: { widgets: [{ type: 'header', settings: { heading: 'Welcome on the dashboard' }, gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 2 }] }, + }) + await ocsRequest(request, user, { + method: 'PUT', + url: `${API}/contexts/${context.id}?format=json`, + data: { + menuItems: [ + { label: 'Dashboard', targetType: 'page', targetId: startPageId, technicalName: 'dashboard' }, + { label: 'Front page', targetType: 'page', targetId: frontPageId }, + { label: 'Cases', targetType: 'table', targetId: tableId }, + { label: 'Handbook', targetType: 'url', url: 'https://docs.nextcloud.com' }, + ], + }, + }) + return { contextId: context.id, frontPageId, tableTitle: name + ' cases' } +} + +test.describe('Applications', () => { + test('An application opened on its own shows its menu and opens what each item points at', async ({ userPage: { page, user } }) => { + const { contextId, tableTitle } = await createApplicationWithMenu(page.request, user, 'standalone application') + + await page.goto(`/index.php/apps/tables/app/${contextId}`) + + await expect(page.locator('[data-cy="application-nav-header"]')).toContainText('standalone application') + const items = page.locator('[data-cy="application-nav-item"]') + await expect(items).toHaveCount(4) + // The first menu item is the dashboard: the start page with its widgets and the application's table + await expect(page).toHaveURL(/\/menu\/dashboard$/) + await expect(page.getByText('Welcome on the dashboard')).toBeVisible({ timeout: 30000 }) + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 30000 }) + // The page bar is for designing inside Tables; opened on its own, the menu is the navigation + await expect(page.locator('[data-cy="contextPageBar"]')).toHaveCount(0) + await expect(page.locator('[data-cy="navigationCreateTableIcon"]')).toHaveCount(0) + await expect(page.locator('[data-cy="application-nav-overview"]')).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') + + await items.filter({ hasText: 'Front page' }).getByRole('link').click() + await expect(page).toHaveURL(/\/menu\/front_page$/) + await expect(page.getByText('Welcome on the front page')).toBeVisible() + await expect(page.locator('h1', { hasText: tableTitle })).toHaveCount(0) + + await items.filter({ hasText: 'Cases' }).getByRole('link').click() + await expect(page).toHaveURL(/\/menu\/cases$/) + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() + + await expect(items.filter({ hasText: 'Handbook' }).getByRole('link')).toHaveAttribute('href', 'https://docs.nextcloud.com') + }) + + test('Inside Tables a menu item opens below a breadcrumb back to the application', async ({ userPage: { page, user } }) => { + const { contextId } = await createApplicationWithMenu(page.request, user, 'breadcrumb application') + + await page.goto(`/index.php/apps/tables/#/application/${contextId}/menu/front_page`) + + const breadcrumb = page.locator('[data-cy="context-breadcrumb"]') + await expect(breadcrumb).toContainText('breadcrumb application') + await expect(breadcrumb).toContainText('Front page') + await expect(page.getByText('Welcome on the front page')).toBeVisible() + await expect(page.locator('[data-cy="context-open-application"]')).toHaveAttribute('href', new RegExp(`/apps/tables/app/${contextId}$`)) + + await page.locator('[data-cy="context-menu-overview"]').click() + await expect(page).toHaveURL(new RegExp(`/application/${contextId}$`)) + await expect(page.locator('[data-cy="contextPageLink"]', { hasText: 'Front page' })).toBeVisible() + }) + + test('A menu item whose page is gone shows as broken', async ({ userPage: { page, user } }) => { + const { contextId, frontPageId } = await createApplicationWithMenu(page.request, user, 'broken item application') + await ocsRequest(page.request, user, { method: 'DELETE', url: `${API}/contexts/${contextId}/pages/${frontPageId}?format=json` }) + + await page.goto(`/index.php/apps/tables/app/${contextId}`) + await expect(page).toHaveURL(/\/menu\/dashboard$/) + await page.locator('[data-cy="application-nav-item"]').filter({ hasText: 'Front page' }).getByRole('link').click() + + await expect(page.locator('[data-cy="context-menu-target-missing"]')).toBeVisible() + await expect(page.locator('[data-cy="application-nav-item"]').filter({ hasText: 'Front page' }).locator('.alert-circle-outline-icon')).toHaveCount(1) + }) + + test('An application whose menu has no dashboard item keeps its start page in the navigation', async ({ userPage: { page, user } }) => { + const { contextId, tableTitle } = await createApplicationWithMenu(page.request, user, 'no dashboard application') + await ocsRequest(page.request, user, { + method: 'PUT', + url: `${API}/contexts/${contextId}?format=json`, + data: { menuItems: [{ label: 'Handbook', targetType: 'url', url: 'https://docs.nextcloud.com' }] }, + }) + + await page.goto(`/index.php/apps/tables/app/${contextId}`) + + const startPage = page.locator('[data-cy="application-nav-overview"]') + await expect(startPage).toContainText('Dashboard') + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 30000 }) + }) +}) 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 @@ -->