From 74de0f3c78562403c36e03b07a8a95449f99dca8 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 15 Oct 2026 19:11:06 +0200 Subject: [PATCH 1/3] feat(applications): overview page to manage pages and menu Inside Tables an application now opens on an overview to manage it: its name, address, settings, pages and menu. The header shows the icon, name, description and the application's address with a copy button and "Open application"; the owner gets a menu to edit name, icon and members, import a scheme, transfer ownership or delete the application. The pages table lists the application's pages, the start page first and named "Dashboard" while untitled, with what each shows and a "Design" button for managers. "Add page" creates a grid page, adds it to the menu straight away and opens it in edit mode; "Table or view" and "Link" open the menu editor with a new entry. The menu is previewed and edited in place; the editor offers the pages of the application as targets. Every page opens at /application/{id}/page/{pageId}, below a breadcrumb back to the overview. Opening an application no longer collapses the Tables navigation. Assisted-by: ClaudeCode:claude-opus-5-5 --- cypress/component/menuItems.cy.js | 20 + playwright/e2e/application.spec.ts | 84 ++- playwright/e2e/column-relation.spec.ts | 5 +- playwright/e2e/context-grid-pages.spec.ts | 17 +- playwright/e2e/context.spec.ts | 46 +- .../main/sections/ApplicationOverview.vue | 687 ++++++++++++++++++ src/modules/modals/EditContext.vue | 4 +- src/pages/Context.vue | 158 ++-- .../ncContextResource/MenuItemsEditor.vue | 33 +- src/shared/utils/menuItems.js | 31 + src/store/store.js | 27 + 11 files changed, 994 insertions(+), 118 deletions(-) create mode 100644 src/modules/main/sections/ApplicationOverview.vue diff --git a/cypress/component/menuItems.cy.js b/cypress/component/menuItems.cy.js index e3ded0926d..cd18dcf42b 100644 --- a/cypress/component/menuItems.cy.js +++ b/cypress/component/menuItems.cy.js @@ -16,8 +16,10 @@ import { moveItem, newDashboardItem, outdentItem, + pageTitle, parentOfPosition, removeItem, + toEditableMenuItems, toMenuItemPayload, } from '../../src/shared/utils/menuItems.js' @@ -175,6 +177,24 @@ describe('menu items', () => { expect(menuItemErrors({ ...page, targetId: null }).target).to.equal(true) }) + it('names a page by its title, an untitled start page as the dashboard', () => { + expect(pageTitle({ title: 'Intake', page_type: 'grid' })).to.equal('Intake') + expect(pageTitle({ title: null, page_type: 'startpage' })).to.equal('Dashboard') + expect(pageTitle({ title: 'Home', page_type: 'startpage' })).to.equal('Home') + }) + + it('turns the stored menu into editor items that keep their parent', () => { + const editable = toEditableMenuItems([ + { id: 1, label: 'Cases', icon: 'star', targetType: 'table', targetId: 4, url: null, technicalName: 'cases', parentId: null }, + { id: 2, label: 'Docs', targetType: 'url', url: 'https://example.org', technicalName: 'docs', parentId: 1 }, + ]) + + expect(editable.map(entry => entry.key)).to.deep.equal(['item-1', 'item-2']) + expect(editable[0]).to.deep.include({ label: 'Cases', icon: 'star', targetType: 'table', targetId: 4, url: '', parentKey: null }) + expect(editable[1]).to.deep.include({ icon: null, targetId: null, url: 'https://example.org', parentKey: 'item-1' }) + expect(toEditableMenuItems(null)).to.deep.equal([]) + }) + 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') diff --git a/playwright/e2e/application.spec.ts b/playwright/e2e/application.spec.ts index cb2f97634d..ea5753c9f0 100644 --- a/playwright/e2e/application.spec.ts +++ b/playwright/e2e/application.spec.ts @@ -6,6 +6,7 @@ import type { APIRequestContext } from '@playwright/test' import { test, expect } from '../support/fixtures' import { ocsRequest, type TestUser } from '../support/api' +import { createContext, loadContext } from '../support/commands' test.use({ viewport: { width: 1600, height: 1000 } }) @@ -93,7 +94,7 @@ test.describe('Applications', () => { 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() + await expect(page.locator('[data-cy="page-card"]').filter({ hasText: 'Front page' })).toBeVisible() }) test('A menu item whose page is gone shows as broken', async ({ userPage: { page, user } }) => { @@ -122,4 +123,85 @@ test.describe('Applications', () => { await expect(startPage).toContainText('Dashboard') await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 30000 }) }) + + test('A page with widgets added on the overview gets a menu entry and opens in edit mode', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createContext(page, 'overview application') + await loadContext(page, 'overview application') + + await expect(page.locator('[data-cy="page-card"]')).toHaveCount(1) + await expect(page.locator('[data-cy="page-card"]').first()).toContainText('Dashboard') + + await page.locator('[data-cy="context-add-page"]').getByRole('button').first().click() + await page.getByRole('menuitem', { name: 'Page with widgets' }).click() + const form = page.getByRole('dialog', { name: 'New page' }) + await form.getByRole('textbox', { name: 'Title' }).fill('Intake home') + await page.locator('[data-cy="contextPageSubmit"]').click() + + await expect(page).toHaveURL(/\/application\/\d+\/page\/\d+\?edit=1$/) + await expect(page.locator('[data-cy="context-breadcrumb"]')).toContainText('Intake home') + await expect(page.locator('[data-cy="gridPageSave"]')).toBeVisible() + + await page.locator('[data-cy="gridPageCancel"]').click() + await page.locator('[data-cy="context-menu-overview"]').click() + const intake = page.locator('[data-cy="page-card"]').filter({ hasText: 'Intake home' }) + await expect(intake).toContainText('Page with widgets') + await expect(page.locator('[data-cy="context-menu-preview-item"]').filter({ hasText: 'Intake home' })).toBeVisible() + }) + + test('A menu entry is reordered with its buttons, sits under another one and keeps its place after a reload', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createContext(page, 'nested menu application') + await loadContext(page, 'nested menu application') + + await page.locator('[data-cy="context-menu-edit"]').click() + for (const [label, url] of [['Handbook', 'https://docs.nextcloud.com'], ['Forum', 'https://help.nextcloud.com']]) { + await page.locator('[data-cy="menuItemAdd"]').click() + const row = page.locator('[data-cy="menuItemRow"]').last() + await row.getByRole('textbox', { name: 'Label' }).fill(label) + await row.getByRole('textbox', { name: 'Link' }).fill(url) + } + + // Without a pointer: move Forum above Handbook, then put Handbook under it + const rows = page.locator('[data-cy="menuItemRow"]') + await rows.nth(2).locator('[data-cy="menuItemMoveUp"]').click() + await expect(rows.nth(1).getByRole('textbox', { name: 'Label' })).toHaveValue('Forum') + await rows.nth(2).locator('[data-cy="menuItemIndent"]').click() + await expect(rows.nth(2).locator('[data-cy="menuItemOutdent"]')).toBeVisible() + + const saveResponse = page.waitForResponse(response => response.url().includes('/apps/tables/api/2/contexts/') && response.request().method() === 'PUT') + await page.locator('[data-cy="context-menu-save"]').click() + expect((await saveResponse).status()).toBe(200) + + await page.reload() + const items = page.locator('[data-cy="context-menu-preview-item"]') + await expect(items).toHaveCount(3) + await expect(items.nth(0)).toContainText('Dashboard') + await expect(items.nth(1)).toContainText('Forum') + await expect(items.nth(2)).toContainText('2.1') + await expect(items.nth(2)).toContainText('Handbook') + }) + + test('A menu entry can be pointed at a page of the application', async ({ userPage: { page, user } }) => { + const { contextId } = await createApplicationWithMenu(page.request, user, 'page target application') + await ocsRequest(page.request, user, { method: 'POST', url: `${API}/contexts/${contextId}/pages?format=json`, data: { title: 'Reports' } }) + + await page.goto(`/index.php/apps/tables/#/application/${contextId}`) + await page.locator('[data-cy="context-menu-edit"]').click() + await page.locator('[data-cy="menuItemAdd"]').click() + const row = page.locator('[data-cy="menuItemRow"]').last() + await row.locator('[data-cy="menuItemTarget"] input').click() + await page.locator('.vs__dropdown-menu li').filter({ hasText: 'Reports (page)' }).click() + await expect(row.getByRole('textbox', { name: 'Label' })).toHaveValue('Reports') + + const saveResponse = page.waitForResponse(response => response.url().includes('/apps/tables/api/2/contexts/') && response.request().method() === 'PUT') + await page.locator('[data-cy="context-menu-save"]').click() + expect((await saveResponse).status()).toBe(200) + await expect(page.locator('[data-cy="context-menu-preview-item"]').filter({ hasText: 'Reports' }).last()).toBeVisible() + + await page.goto(`/index.php/apps/tables/app/${contextId}`) + await page.locator('[data-cy="application-nav-item"]').filter({ hasText: 'Reports' }).getByRole('link').click() + await expect(page).toHaveURL(/\/menu\/reports$/) + await expect(page.locator('[data-cy="gridPage"]')).toBeVisible() + }) }) diff --git a/playwright/e2e/column-relation.spec.ts b/playwright/e2e/column-relation.spec.ts index ab0e6f32d6..d7e2dc9dcc 100644 --- a/playwright/e2e/column-relation.spec.ts +++ b/playwright/e2e/column-relation.spec.ts @@ -158,8 +158,11 @@ test.describe('Test column relation', () => { await page.locator('[data-cy="editContextSubmitBtn"]').click() - // load the context and verify the relation label is rendered without crashing + // the tables of an application render in the application itself, opened on its own await loadContext(page, contextTitle) + const contextId = page.url().match(/\/application\/(\d+)/)?.[1] + await page.goto(`/index.php/apps/tables/app/${contextId}`) + // verify the relation label is rendered without crashing await expect( page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]').filter({ hasText: 'Bob' }), ).toBeVisible() diff --git a/playwright/e2e/context-grid-pages.spec.ts b/playwright/e2e/context-grid-pages.spec.ts index 1a4aae2bde..4c99361555 100644 --- a/playwright/e2e/context-grid-pages.spec.ts +++ b/playwright/e2e/context-grid-pages.spec.ts @@ -32,8 +32,13 @@ async function getApplication(request: APIRequestContext, user: TestUser, contex return (await res.json()).ocs.data } -async function openApplication(page: Page, contextId: number, path = '') { - await page.goto(`/index.php/apps/tables/#/application/${contextId}${path}`) +/** + * Opens the start page of an application inside Tables; the application itself opens on its overview. + */ +async function openApplication(page: Page, user: TestUser, contextId: number) { + const application = await getApplication(page.request, user, contextId) + const startPage = Object.values(application.pages as Record).find(entry => entry.page_type === 'startpage') + await page.goto(`/index.php/apps/tables/#/application/${contextId}/page/${startPage!.id}`) await expect(page.locator('[data-cy="gridPage"]')).toBeVisible({ timeout: 30000 }) } @@ -48,7 +53,7 @@ test.describe('Application pages on the grid', () => { const second = await createTableViaApi(page.request, user, 'grid second table') const contextId = await createApplicationViaApi(page.request, user, 'grid existing app', [first, second]) - await openApplication(page, contextId) + await openApplication(page, user, contextId) const items = page.locator('[data-cy="gridItem"]') await expect(items).toHaveCount(2) @@ -67,7 +72,7 @@ test.describe('Application pages on the grid', () => { test('A header added to the start page is saved with the order filled in', async ({ userPage: { page, user } }) => { const table = await createTableViaApi(page.request, user, 'grid header table') const contextId = await createApplicationViaApi(page.request, user, 'grid header app', [table]) - await openApplication(page, contextId) + await openApplication(page, user, contextId) await page.locator('[data-cy="gridPageEdit"]').click() await page.locator('[data-cy="gridPageAddWidget"]').click() @@ -96,7 +101,7 @@ test.describe('Application pages on the grid', () => { const inApp = await createTableViaApi(page.request, user, 'grid table in app') const notInApp = await createTableViaApi(page.request, user, 'grid table not in app') const contextId = await createApplicationViaApi(page.request, user, 'grid new page app', [inApp]) - await openApplication(page, contextId) + await openApplication(page, user, contextId) await page.locator('[data-cy="contextPageCreate"]').click() const pageForm = page.getByRole('dialog', { name: 'New page' }) @@ -149,7 +154,7 @@ test.describe('Application pages on the grid', () => { const shareeContext = await browser.newContext({ baseURL, viewport: { width: 1600, height: 1000 } }) const shareePage = await shareeContext.newPage() await login(shareePage, sharee) - await openApplication(shareePage, contextId) + await openApplication(shareePage, sharee, contextId) await expect(shareePage.locator('h1', { hasText: 'grid shared table' })).toBeVisible() await expect(shareePage.locator('[data-cy="contextPageLink"]', { hasText: 'Shared overview' })).toBeVisible() diff --git a/playwright/e2e/context.spec.ts b/playwright/e2e/context.spec.ts index 76fae80ef5..8afce5a67d 100644 --- a/playwright/e2e/context.spec.ts +++ b/playwright/e2e/context.spec.ts @@ -27,6 +27,20 @@ async function setupContextAndNavigate(page: Page, contextTitle: string) { await loadContext(page, contextTitle) } +/** + * Inside Tables the application page is where an application is configured; its tables and + * views render in the application itself. + */ +async function openApplication(page: Page, contextTitle: string) { + await loadContext(page, contextTitle) + const contextId = page.url().match(/\/application\/(\d+)/)?.[1] + if (!contextId) { + throw new Error(`Could not find the id of application ${contextTitle}`) + } + await page.goto(`/index.php/apps/tables/app/${contextId}`) + await expect(page.locator('[data-cy="application-nav-header"]')).toContainText(contextTitle, { timeout: 10000 }) +} + async function selectFromVueDropdown(page: Page, inputSelector: string, value: string) { const input = page.locator(inputSelector).first() await input.click() @@ -92,11 +106,10 @@ test.describe('Manage a context', () => { await expect(page.getByRole('dialog', { name: 'Edit application' })).toBeHidden({ timeout: 10000 }) await expect(page.locator('[data-cy="navigationContextItem"]').filter({ hasText: `updated ${contextTitle}` })).toBeVisible() - await loadContext(page, `updated ${contextTitle}`) + await openApplication(page, `updated ${contextTitle}`) - await expect(page.locator('h1', { hasText: `updated ${contextTitle}` })).toBeVisible() - await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() - await expect(page.locator('h1', { hasText: viewTitle })).toBeVisible() + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) + await expect(page.locator('h1', { hasText: viewTitle })).toBeVisible({ timeout: 10000 }) }) test('Share context with resources', async ({ userPage: { page }, request }) => { @@ -119,8 +132,7 @@ test.describe('Manage a context', () => { await updateResponse await expect(page.locator('[class*="toast_success"]').first()).toBeVisible({ timeout: 10000 }) - await loadContext(page, contextTitle) - await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible({ timeout: 10000 }) + await openApplication(page, contextTitle) await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) // verify context was shared properly @@ -128,9 +140,8 @@ test.describe('Manage a context', () => { await login(page, nonLocalUser) await page.goto('/index.php/apps/tables') - await loadContext(page, contextTitle) - await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() - await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() + await openApplication(page, contextTitle) + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) }) test('Transfer context', async ({ userPage: { page }, request }) => { @@ -221,8 +232,11 @@ test.describe('Manage a context', () => { await page.locator('[data-cy="editContextSubmitBtn"]').click() await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() - await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() + await openApplication(page, contextTitle) + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) + await page.goto('/index.php/apps/tables') + await loadContext(page, contextTitle) await openContextEditModal(page, contextTitle) await page.locator('[data-cy="contextResourceList"] button').filter({ hasText: 'Delete' }).click({ force: true }) @@ -230,6 +244,9 @@ test.describe('Manage a context', () => { await page.locator('[data-cy="editContextSubmitBtn"]').click() await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() + await openApplication(page, contextTitle) + // The application has loaded once its loading icon is gone; only then does a missing table mean anything + await expect(page.locator('.main-context-view > .icon-loading')).toHaveCount(0, { timeout: 10000 }) await expect(page.locator('h1', { hasText: tableTitle })).toBeHidden() }) @@ -248,7 +265,10 @@ test.describe('Manage a context', () => { await expect(page.locator('[data-cy="contextResourceList"]')).toContainText(tableTitle) await expect(page.locator('[data-cy="contextResourcePerms"]')).toContainText(tableTitle) await page.locator('[data-cy="editContextSubmitBtn"]').click() + await expect(page.getByRole('dialog', { name: 'Edit application' })).toBeHidden({ timeout: 10000 }) + await openApplication(page, contextTitle) + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) await createTextLineColumn(page, 'title', '', '', true) await page.locator('button').filter({ hasText: 'Create row' }).first().click() await fillInValueTextLine(page, 'title', 'first row') @@ -293,6 +313,7 @@ test.describe('Manage a context', () => { await updatePermsResponse await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible({ timeout: 10000 }) + await openApplication(page, contextTitle) await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) // verify that shared user can modify and delete data in the resource @@ -300,9 +321,8 @@ test.describe('Manage a context', () => { await login(page, nonLocalUser) await page.goto('/index.php/apps/tables') - await loadContext(page, contextTitle) - await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() - await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() + await openApplication(page, contextTitle) + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) await page.locator('button').filter({ hasText: 'Create row' }).first().click() await fillInValueTextLine(page, 'title', 'first row') diff --git a/src/modules/main/sections/ApplicationOverview.vue b/src/modules/main/sections/ApplicationOverview.vue new file mode 100644 index 0000000000..0ba04585cf --- /dev/null +++ b/src/modules/main/sections/ApplicationOverview.vue @@ -0,0 +1,687 @@ + + + + + + diff --git a/src/modules/modals/EditContext.vue b/src/modules/modals/EditContext.vue index cdc15acfa0..24640d4f94 100644 --- a/src/modules/modals/EditContext.vue +++ b/src/modules/modals/EditContext.vue @@ -287,8 +287,10 @@ export default { */ async arrangeStartPage() { const contextId = this.contextId + const startPage = Object.values(this.getContext(contextId)?.pages ?? {}).find(page => page.page_type === 'startpage') this.actionCancel() - await this.$router.push({ path: '/application/' + contextId, query: { edit: '1' } }).catch(err => err) + const path = '/application/' + contextId + (startPage ? '/page/' + startPage.id : '') + await this.$router.push({ path, query: { edit: '1' } }).catch(err => err) }, async actionDeleteContext() { this.prepareDeleteContext = false diff --git a/src/pages/Context.vue b/src/pages/Context.vue index 2475e7c9dd..fe08fac54e 100644 --- a/src/pages/Context.vue +++ b/src/pages/Context.vue @@ -7,7 +7,14 @@
-
+ + +