From ef8821af27aa1059a629aa585ce092dae6f9b337 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 16 Oct 2026 10:55:46 +0200 Subject: [PATCH] feat(applications): overview page to manage pages and menu Inside Tables an application gets an overview at /application/{id}/overview, linked from its title row: its address, settings, pages and menu. The pages come from the application (start page first), with their widgets and, on the start page, its tables and views. "Add page" uses the page form, adds the page to the menu and opens it in edit mode. The menu has a numbered preview and is edited in place with the editor of the create dialog, which now offers the pages of the application. Assisted-by: ClaudeCode:claude-opus-5-5 --- cypress/component/menuItems.cy.js | 22 + playwright/e2e/application.spec.ts | 91 +++ src/modules/grid/GridPage.vue | 8 + .../main/sections/ApplicationOverview.vue | 730 ++++++++++++++++++ src/pages/Context.vue | 38 +- src/router.js | 5 + .../ncContextResource/MenuItemsEditor.vue | 38 +- src/shared/utils/menuItems.js | 31 + src/store/store.js | 27 + 9 files changed, 977 insertions(+), 13 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 d04be9db92..9f113fd4da 100644 --- a/cypress/component/menuItems.cy.js +++ b/cypress/component/menuItems.cy.js @@ -16,8 +16,10 @@ import { newDashboardItem, opensNewStartPage, outdentItem, + pageTitle, parentOfPosition, removeItem, + toEditableMenuItems, toMenuItemPayload, } from '../../src/shared/utils/menuItems.js' @@ -185,6 +187,26 @@ 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: null, pageType: 'startpage' })).to.equal('Dashboard') + expect(pageTitle({ title: null, pageType: 'grid' })).to.equal('Untitled page') + 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 9c7d814efa..3f95958fda 100644 --- a/playwright/e2e/application.spec.ts +++ b/playwright/e2e/application.spec.ts @@ -131,4 +131,95 @@ test.describe('Applications', () => { await expect(startPage).toContainText('Dashboard') await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 30000 }) }) + + test('The overview lists the pages; a page added there gets a menu entry and opens in edit mode', async ({ userPage: { page, user } }) => { + const contextResponse = await ocsRequest(page.request, user, { method: 'POST', url: `${API}/contexts?format=json`, data: { name: 'overview application', iconName: 'star' } }) + const contextId = (await contextResponse.json()).ocs.data.id + + await page.goto(`/index.php/apps/tables/#/application/${contextId}`) + await page.locator('[data-cy="context-overview-link"]').click() + await expect(page).toHaveURL(new RegExp(`/application/${contextId}/overview$`)) + + const cards = page.locator('[data-cy="page-card"]') + await expect(cards).toHaveCount(1) + await expect(cards.first()).toContainText('Dashboard') + await expect(cards.first()).toContainText('Start page') + await expect(cards.first()).toContainText('0 widgets') + + 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: 'Add page' }) + await form.getByRole('textbox', { name: 'Title' }).fill('Intake home') + await form.getByRole('textbox', { name: 'Description' }).fill('Where new requests come in') + await page.locator('[data-cy="contextPageSubmit"]').click() + + await expect(page).toHaveURL(/\/application\/\d+\/page\/\d+\?edit=1$/) + await expect(page.locator('[data-cy="gridPageTitle"]')).toContainText('Intake home') + await expect(page.locator('[data-cy="gridPageSave"]')).toBeVisible() + + await page.locator('[data-cy="gridPageCancel"]').click() + await page.locator('[data-cy="context-overview-link"]').click() + const intake = cards.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() + + const menu = (await (await ocsRequest(page.request, user, { method: 'GET', url: `${API}/contexts/${contextId}?format=json` })).json()).ocs.data.menuItems + expect(menu.map((item: { label: string, technicalName: string }) => [item.label, item.technicalName])).toEqual([['Dashboard', 'dashboard'], ['Intake home', 'intake_home']]) + }) + + test('A menu entry is reordered with its buttons, sits under another one and keeps its place after a reload', async ({ userPage: { page, user } }) => { + const contextResponse = await ocsRequest(page.request, user, { method: 'POST', url: `${API}/contexts?format=json`, data: { name: 'nested menu application', iconName: 'star' } }) + const contextId = (await contextResponse.json()).ocs.data.id + await page.goto(`/index.php/apps/tables/#/application/${contextId}/overview`) + + 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}/overview`) + 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/src/modules/grid/GridPage.vue b/src/modules/grid/GridPage.vue index 26566ebe00..ac115c806f 100644 --- a/src/modules/grid/GridPage.vue +++ b/src/modules/grid/GridPage.vue @@ -158,6 +158,11 @@ export default { type: Boolean, default: false, }, + // Opens the page in edit mode, for a "Design" link + startInEditMode: { + type: Boolean, + default: false, + }, }, emits: ['saved', 'edit-details', 'delete'], setup(props) { @@ -226,6 +231,9 @@ export default { }, mounted() { window.addEventListener('beforeunload', this.warnBeforeUnload) + if (this.startInEditMode && this.canEdit) { + this.startEditing() + } }, beforeUnmount() { window.removeEventListener('beforeunload', this.warnBeforeUnload) diff --git a/src/modules/main/sections/ApplicationOverview.vue b/src/modules/main/sections/ApplicationOverview.vue new file mode 100644 index 0000000000..38a56aaa09 --- /dev/null +++ b/src/modules/main/sections/ApplicationOverview.vue @@ -0,0 +1,730 @@ + + + + + + diff --git a/src/pages/Context.vue b/src/pages/Context.vue index 345e482ce6..16554eca24 100644 --- a/src/pages/Context.vue +++ b/src/pages/Context.vue @@ -29,12 +29,26 @@ -
+ + +

  {{ activeContext.name }}

+ + + {{ t('tables', 'Overview') }} +
{{ activeContext.description }} @@ -60,6 +74,7 @@ :context="activeContext" :page="activePage" :can-edit="canManage" + :start-in-edit-mode="$route.query.edit === '1'" @saved="page => activePage = page" @edit-details="openPageForm" @delete="deletePage" /> @@ -72,6 +87,7 @@ :context="activeContext" :page="startPage" :can-edit="canManage" + :start-in-edit-mode="$route.query.edit === '1'" is-start-page @saved="page => startPage = page" /> @@ -111,6 +127,7 @@ import { NcButton, NcEmptyContent, NcIconSvgWrapper } from '@nextcloud/vue' import { loadState } from '@nextcloud/initial-state' import { generateUrl } from '@nextcloud/router' import ChevronLeft from 'vue-material-design-icons/ChevronLeft.vue' +import InformationOutline from 'vue-material-design-icons/InformationOutline.vue' import OpenInNew from 'vue-material-design-icons/OpenInNew.vue' import TableWrapper from '../modules/main/sections/TableWrapper.vue' import CustomView from '../modules/main/sections/View.vue' @@ -124,6 +141,7 @@ import ErrorMessage from '../modules/main/partials/ErrorMessage.vue' import ContextPageBar from '../modules/grid/ContextPageBar.vue' import GridPage from '../modules/grid/GridPage.vue' import DataWidget from '../modules/grid/widgets/DataWidget.vue' +import ApplicationOverview from '../modules/main/sections/ApplicationOverview.vue' import ContextPageForm from '../modules/modals/ContextPageForm.vue' import permissionsMixin from '../shared/components/ncTable/mixins/permissionsMixin.js' import displayError, { getNotFoundError, getGenericLoadError } from '../shared/utils/displayError.js' @@ -143,8 +161,10 @@ export default { NcEmptyContent, NcIconSvgWrapper, ChevronLeft, + InformationOutline, OpenInNew, DataWidget, + ApplicationOverview, ErrorMessage, TableWrapper, CustomView, @@ -200,7 +220,7 @@ export default { * @return {boolean} */ showsStartPage() { - if (this.activePageId !== null) { + if (this.activePageId !== null || this.isOverview) { return false } return !this.isMenuRoute || this.menuTarget?.startPage === true @@ -222,6 +242,14 @@ export default { isMenuRoute() { return !!this.$route.params.itemName }, + /** + * Whether the overview is shown, where the application is managed inside Tables. + * + * @return {boolean} + */ + isOverview() { + return this.$route.name === 'contextOverview' && !this.isStandalone + }, selectedMenuItem() { const technicalName = this.$route.params.itemName if (!technicalName) { @@ -656,6 +684,8 @@ export default { inset-inline-start: 0; top: 0; z-index: 15; + display: flex; + align-items: center; background-color: var(--color-main-background); width: var(--app-content-width, 100%); } @@ -664,6 +694,10 @@ export default { display: inline-flex; } + &__overview-link { + margin-inline: auto calc(4 * var(--default-grid-baseline)); + } + &__description { margin: calc(3 * var(--default-grid-baseline, 4px)); max-width: 790px; diff --git a/src/router.js b/src/router.js index 9cfb313453..adfe3c1cb7 100644 --- a/src/router.js +++ b/src/router.js @@ -28,6 +28,11 @@ export default createRouter({ component: Context, name: 'contextPage', }, + { + path: '/application/:contextId/overview', + component: Context, + name: 'contextOverview', + }, { path: '/application/:contextId/menu/:itemName', component: Context, diff --git a/src/shared/components/ncContextResource/MenuItemsEditor.vue b/src/shared/components/ncContextResource/MenuItemsEditor.vue index 502cf63864..f6cdc94301 100644 --- a/src/shared/components/ncContextResource/MenuItemsEditor.vue +++ b/src/shared/components/ncContextResource/MenuItemsEditor.vue @@ -46,8 +46,8 @@ ([]), }, + // The pages of the application an item can open; an application being created has none yet + pages: { + type: Array, + default: () => ([]), + }, }, emits: ['update:items'], data() { @@ -203,12 +209,23 @@ export default { computed: { ...mapState(useTablesStore, ['tables', 'views']), /** - * The tables and views the menu can open: only those the user can manage, because the server - * adds them to the application as resources, like the resource picker in the same dialog. + * The pages of the application, and the tables and views the menu can open: only those the + * user can manage, because the server adds them to the application as resources, like the + * resource picker in the same dialog. * * @return {Array} the options of the target select */ targetOptions() { + const pages = this.pages.map(page => { + const title = pageTitle(page) + return { + id: 'page-' + page.id, + targetType: 'page', + targetId: page.id, + title, + label: t('tables', '{title} (page)', { title }, undefined, { escape: false }), + } + }) const views = this.views.filter(view => this.canManageElement(view)).map(view => { const title = (view.emoji ? view.emoji + ' ' : '') + view.title return { @@ -216,9 +233,7 @@ export default { targetType: 'view', targetId: view.id, title, - label: view.type === 'grid' - ? t('tables', '{title} (page with widgets)', { title }, undefined, { escape: false }) - : t('tables', '{title} (view)', { title }, undefined, { escape: false }), + label: t('tables', '{title} (view)', { title }, undefined, { escape: false }), } }) const tables = this.tables.filter(table => this.canManageTable(table)).map(table => { @@ -231,7 +246,7 @@ export default { label: t('tables', '{title} (table)', { title }, undefined, { escape: false }), } }) - return [...views, ...tables, { id: URL_OPTION_ID, targetType: 'url', targetId: null, title: '', label: t('tables', 'External link') }] + return [...pages, ...views, ...tables, { id: URL_OPTION_ID, targetType: 'url', targetId: null, title: '', label: t('tables', 'External link') }] }, /** * What is missing or wrong in every item, by position. @@ -257,6 +272,7 @@ export default { indentItem, moveItem, newMenuItem, + opensNewStartPage, outdentItem, removeItem, /** @@ -275,7 +291,7 @@ export default { * @return {object|null} the selected option, or null when its target is not offered */ targetOption(item) { - if (item.targetType === 'page') { + if (item.targetType === 'page' && (opensNewStartPage(item) || !this.pages.some(page => page.id === item.targetId))) { return { id: 'page-' + (item.targetId ?? 'start'), targetType: 'page', @@ -313,7 +329,7 @@ export default { * @param {object|null} option the chosen option */ setTarget(index, option) { - if (!option || this.items[index].targetType === 'page') { + if (!option || opensNewStartPage(this.items[index])) { return } const changes = { targetType: option.targetType, targetId: option.targetId } diff --git a/src/shared/utils/menuItems.js b/src/shared/utils/menuItems.js index b44ce0816d..f93ef40e0c 100644 --- a/src/shared/utils/menuItems.js +++ b/src/shared/utils/menuItems.js @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { translate as t } from '@nextcloud/l10n' + /* * The menu of an application as the editor holds it: a flat, ordered list where a child carries * the key of its parent. A parent always precedes its children, there is one level of nesting, @@ -46,6 +48,25 @@ export function opensNewStartPage(item) { return item?.opensNewStartPage === true && item.targetType === 'page' } +/** + * Menu items as the API stores them, as items for the editor, with a stable key for list rendering. + * + * @param {Array|undefined|null} menuItems items from the application + * @return {Array} the items for the editor + */ +export function toEditableMenuItems(menuItems) { + return (menuItems ?? []).map((item, index) => ({ + key: 'item-' + (item.id ?? index), + label: item.label ?? '', + icon: item.icon ?? null, + targetType: item.targetType ?? 'url', + targetId: item.targetId ?? null, + url: item.url ?? '', + technicalName: item.technicalName ?? null, + parentKey: item.parentId != null ? 'item-' + item.parentId : null, + })) +} + /** * The payload the API expects for one menu item. * @@ -358,3 +379,13 @@ export function menuTree(menuItems) { const roots = sorted.filter(item => item.parentId == null) return roots.map(item => ({ item, children: sorted.filter(child => child.parentId === item.id) })) } + +/** + * The name of a page of an application: its title, or "Dashboard" for an untitled start page. + * + * @param {object} page a page as the context or the page API returns it + * @return {string} + */ +export function pageTitle(page) { + return page.title || ((page.page_type ?? page.pageType) === 'startpage' ? t('tables', 'Dashboard') : t('tables', 'Untitled page')) +} diff --git a/src/store/store.js b/src/store/store.js index 35362262f7..92f88aab0a 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -236,6 +236,33 @@ export const useTablesStore = defineStore('store', { return res.data.id }, + /** + * Replaces the menu of an application. + * + * @param {object} payload the application and its new menu + * @param {number} payload.id the id of the application + * @param {Array} payload.menuItems the menu as the API takes it + * @return {Promise} the updated application, or false when it could not be saved + */ + async updateContextMenuItems({ id, menuItems }) { + let res = null + try { + res = await axios.put(generateOcsUrl('/apps/tables/api/2/contexts/' + id), { menuItems }) + } catch (e) { + displayError(e, t('tables', 'Could not update application.')) + return false + } + + // The update answers with the application as stored; the read endpoint shapes its pages + // the way the rest of the app reads them + try { + await this.loadContext({ id }) + } catch { + // loadContext reported the error; the menu itself was saved + } + return this.contexts.find(c => c.id === id) ?? res.data.ocs.data + }, + async updateView({ id, data }) { let res = null