From 0e7e5c7e058d98c1730b4a0907b2f2d8dd49fbf9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 16 Oct 2026 09:54:59 +0200 Subject: [PATCH] feat(pages): show grid pages of an application Adds the page bar under the application description (start page, one tab per grid page, "Add page" for managers), a route per grid page, the add and edit page dialog (title, emoji, description) and the grid page with an edit mode: drag, resize, configure and remove widgets, Save stores the whole layout, Cancel drops it. People who cannot manage the application see the pages read-only with live data. The start page is unchanged. Assisted-by: ClaudeCode:claude-opus-5-5 --- playwright/e2e/context-grid-pages.spec.ts | 205 +++++++++++ src/modules/grid/ContextPageBar.vue | 113 ++++++ src/modules/grid/GridPage.vue | 411 ++++++++++++++++++++++ src/modules/grid/gridLayout.js | 3 + src/modules/modals/ContextPageForm.vue | 125 +++++++ src/pages/Context.vue | 150 +++++++- src/router.js | 5 + src/store/store.js | 82 +++++ 8 files changed, 1089 insertions(+), 5 deletions(-) create mode 100644 playwright/e2e/context-grid-pages.spec.ts create mode 100644 src/modules/grid/ContextPageBar.vue create mode 100644 src/modules/grid/GridPage.vue create mode 100644 src/modules/modals/ContextPageForm.vue diff --git a/playwright/e2e/context-grid-pages.spec.ts b/playwright/e2e/context-grid-pages.spec.ts new file mode 100644 index 0000000000..d96dc93daa --- /dev/null +++ b/playwright/e2e/context-grid-pages.spec.ts @@ -0,0 +1,205 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { type APIRequestContext, type Page } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import { createRandomUser, ocsRequest, type TestUser } from '../support/api' +import { login } from '../support/login' + +// The app navigation takes width from the canvas; the grid only arranges at 12 columns +test.use({ viewport: { width: 1600, height: 1000 } }) + +const API = '/ocs/v2.php/apps/tables/api/2' + +async function createTableViaApi(request: APIRequestContext, user: TestUser, title: string): Promise { + const res = await ocsRequest(request, user, { method: 'POST', url: '/index.php/apps/tables/api/1/tables', data: { title } }) + return (await res.json()).id +} + +async function createApplicationViaApi(request: APIRequestContext, user: TestUser, name: string, tableIds: number[]): Promise { + const res = await ocsRequest(request, user, { + method: 'POST', + url: `${API}/contexts?format=json`, + data: { name, iconName: 'equalizer', description: '', nodes: tableIds.map(id => ({ id, type: 0, permissions: 1 })) }, + }) + return (await res.json()).ocs.data.id +} + +async function getApplication(request: APIRequestContext, user: TestUser, contextId: number) { + const res = await ocsRequest(request, user, { method: 'GET', url: `${API}/contexts/${contextId}?format=json` }) + return (await res.json()).ocs.data +} + +async function createPageViaApi(request: APIRequestContext, user: TestUser, contextId: number, title: string): Promise { + const res = await ocsRequest(request, user, { method: 'POST', url: `${API}/contexts/${contextId}/pages?format=json`, data: { title } }) + return (await res.json()).ocs.data.id +} + +async function openApplication(page: Page, contextId: number, path = '') { + await page.goto(`/index.php/apps/tables/#/application/${contextId}${path}`) + await expect(page.locator('[data-cy="contextPageBar"]')).toBeVisible({ timeout: 30000 }) +} + +async function pickOption(page: Page, selectCy: string, option: string) { + await page.locator(`[data-cy="${selectCy}"] input`).click() + await page.locator('.vs__dropdown-menu li').filter({ hasText: option }).click() +} + +test.describe('Grid pages of an application', () => { + test('The start page lists the tables as before, with the page bar under the description', async ({ userPage: { page, user } }) => { + const first = await createTableViaApi(page.request, user, 'grid first table') + 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) + + const firstHeading = page.locator('h1', { hasText: 'grid first table' }) + const secondHeading = page.locator('h1', { hasText: 'grid second table' }) + await expect(firstHeading).toBeVisible() + await expect(secondHeading).toBeVisible() + expect((await firstHeading.boundingBox())!.y).toBeLessThan((await secondHeading.boundingBox())!.y) + await expect(page.locator('[data-cy="contextStartPageLink"]')).toHaveAttribute('aria-current', 'page') + await expect(page.locator('[data-cy="contextPageAdd"]')).toBeVisible() + await expect(page.locator('[data-cy="gridPage"]')).toHaveCount(0) + }) + + test('A new page holds a header and a table of the application after a reload', async ({ userPage: { page, user } }) => { + const inApp = await createTableViaApi(page.request, user, 'grid table in app') + await createTableViaApi(page.request, user, 'grid table outside app') + const contextId = await createApplicationViaApi(page.request, user, 'grid new page app', [inApp]) + await openApplication(page, contextId) + + await page.locator('[data-cy="contextPageAdd"]').click() + const pageForm = page.locator('[data-cy="contextPageForm"]') + await pageForm.getByRole('textbox', { name: 'Title' }).fill('Overview') + await pageForm.getByRole('textbox', { name: 'Description' }).fill('Open work at a glance') + await page.locator('[data-cy="contextPageSubmit"]').click() + await expect(page).toHaveURL(/\/application\/\d+\/page\/\d+/) + await expect(page.locator('[data-cy="contextPageLink"]', { hasText: 'Overview' })).toHaveAttribute('aria-current', 'page') + await expect(page.locator('[data-cy="gridPageTitle"]')).toHaveText('Overview') + await expect(page.getByText('Open work at a glance')).toBeVisible() + + await page.locator('[data-cy="gridPageEdit"]').click() + await page.locator('[data-cy="gridPageAddWidget"]').click() + const dialog = page.locator('[data-cy="addWidgetModal"]') + await dialog.locator('[data-cy="widgetFieldHeading"] input').fill('Welcome to the overview') + await page.locator('[data-cy="addWidgetSubmit"]').click() + await expect(dialog).toBeHidden() + + await page.locator('[data-cy="gridPageAddWidget"]').click() + await pickOption(page, 'addWidgetType', 'Table or view') + await dialog.locator('[data-cy="addWidgetNode"] input').click() + await expect(page.locator('.vs__dropdown-menu li').filter({ hasText: 'grid table outside app' })).toHaveCount(0) + await page.locator('.vs__dropdown-menu li').filter({ hasText: 'grid table in app' }).click() + await dialog.getByRole('textbox', { name: 'Title' }).fill('Tasks') + await page.locator('[data-cy="addWidgetSubmit"]').click() + await expect(dialog).toBeHidden() + + const saved = page.waitForResponse(r => /\/pages\/\d+/.test(r.url()) && r.request().method() === 'PATCH') + await page.locator('[data-cy="gridPageSave"]').click() + expect((await saved).ok()).toBe(true) + await expect(page.locator('[data-cy="gridPageEdit"]')).toBeVisible() + + await page.reload() + await expect(page.locator('[data-cy="gridPage"]')).toBeVisible({ timeout: 30000 }) + await expect(page.getByText('Welcome to the overview')).toBeVisible() + await expect(page.locator('[data-cy="gridWidget"] h2', { hasText: 'Tasks' })).toBeVisible() + await expect(page.locator('h1', { hasText: 'grid table in app' })).toBeVisible({ timeout: 30000 }) + + // The start page still lists its table and nothing else + const application = await getApplication(page.request, user, contextId) + const startPage = Object.values(application.pages as Record).find(entry => entry.page_type === 'startpage') + expect(Object.keys(startPage!.content)).toHaveLength(1) + await page.locator('[data-cy="contextStartPageLink"]').click() + await expect(page.locator('h1', { hasText: 'grid table in app' })).toBeVisible({ timeout: 30000 }) + await expect(page.locator('[data-cy="gridPage"]')).toHaveCount(0) + }) + + test('Cancel drops every change made in edit mode', async ({ userPage: { page, user } }) => { + const table = await createTableViaApi(page.request, user, 'grid cancel table') + const contextId = await createApplicationViaApi(page.request, user, 'grid cancel app', [table]) + const pageId = await createPageViaApi(page.request, user, contextId, 'Drafts') + await openApplication(page, contextId, `/page/${pageId}`) + + await page.locator('[data-cy="gridPageEdit"]').click() + await page.locator('[data-cy="gridPageAddWidget"]').click() + await page.locator('[data-cy="widgetFieldHeading"] input').fill('Never saved') + await page.locator('[data-cy="addWidgetSubmit"]').click() + await expect(page.locator('[data-cy="gridItem"]')).toHaveCount(1) + + let patched = false + page.on('request', request => { + if (request.method() === 'PATCH') { + patched = true + } + }) + await page.locator('[data-cy="gridPageCancel"]').click() + await expect(page.locator('[data-cy="gridItem"]')).toHaveCount(0) + expect(patched).toBe(false) + }) + + test('A sharee who cannot manage the application sees its pages read-only with live data', async ({ userPage: { page, user }, browser, baseURL, request }) => { + const sharee = await createRandomUser(request) + const table = await createTableViaApi(page.request, user, 'grid shared table') + const contextId = await createApplicationViaApi(page.request, user, 'grid shared app', [table]) + const pageId = await createPageViaApi(page.request, user, contextId, 'Shared overview') + const nodeRelId = Number(Object.keys((await getApplication(page.request, user, contextId)).nodes)[0]) + await ocsRequest(page.request, user, { + method: 'PATCH', + url: `${API}/contexts/${contextId}/pages/${pageId}?format=json`, + data: { widgets: [{ type: 'data', title: 'Shared tasks', gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 5, source: { nodeRelId } }] }, + }) + await ocsRequest(page.request, user, { + method: 'POST', + url: '/index.php/apps/tables/api/1/shares', + data: { nodeId: contextId, nodeType: 'context', receiver: sharee.userId, receiverType: 'user', permissionRead: true, displayMode: 2 }, + }) + + 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 expect(shareePage.locator('[data-cy="contextPageAdd"]')).toHaveCount(0) + await shareePage.locator('[data-cy="contextPageLink"]', { hasText: 'Shared overview' }).click() + + await expect(shareePage.locator('[data-cy="gridWidget"] h2', { hasText: 'Shared tasks' })).toBeVisible({ timeout: 30000 }) + await expect(shareePage.locator('h1', { hasText: 'grid shared table' })).toBeVisible({ timeout: 30000 }) + await expect(shareePage.locator('[data-cy="gridPageEdit"]')).toHaveCount(0) + await expect(shareePage.locator('[data-cy="gridPageActions"]')).toHaveCount(0) + + const forbidden = await shareePage.request.fetch(`${API}/contexts/${contextId}/pages/${pageId}?format=json`, { + method: 'PATCH', + headers: { 'OCS-APIRequest': 'true', 'Content-Type': 'application/json', Authorization: 'Basic ' + Buffer.from(`${sharee.userId}:${sharee.password}`).toString('base64') }, + data: { widgets: [] }, + }) + expect(forbidden.status()).toBe(403) + + await shareeContext.close() + }) + + test('A table removed from the application leaves its widget asking for a new one', async ({ userPage: { page, user } }) => { + const table = await createTableViaApi(page.request, user, 'grid removed table') + const contextId = await createApplicationViaApi(page.request, user, 'grid removal app', [table]) + const pageId = await createPageViaApi(page.request, user, contextId, 'Board') + const nodeRelId = Number(Object.keys((await getApplication(page.request, user, contextId)).nodes)[0]) + await ocsRequest(page.request, user, { + method: 'PATCH', + url: `${API}/contexts/${contextId}/pages/${pageId}?format=json`, + data: { widgets: [{ type: 'data', title: 'Gone soon', gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 5, source: { nodeRelId } }] }, + }) + await ocsRequest(page.request, user, { + method: 'PUT', + url: `${API}/contexts/${contextId}?format=json`, + data: { nodes: [] }, + }) + + await openApplication(page, contextId, `/page/${pageId}`) + await expect(page.locator('[data-cy="gridWidget"] h2', { hasText: 'Gone soon' })).toBeVisible({ timeout: 30000 }) + await expect(page.locator('[data-cy="dataWidgetNoSource"]')).toBeVisible() + + await page.locator('[data-cy="gridPageEdit"]').click() + await expect(page.locator('[data-cy="dataWidgetPickSource"]')).toBeVisible() + }) +}) diff --git a/src/modules/grid/ContextPageBar.vue b/src/modules/grid/ContextPageBar.vue new file mode 100644 index 0000000000..6483d235b1 --- /dev/null +++ b/src/modules/grid/ContextPageBar.vue @@ -0,0 +1,113 @@ + + + + + + diff --git a/src/modules/grid/GridPage.vue b/src/modules/grid/GridPage.vue new file mode 100644 index 0000000000..4832a472ba --- /dev/null +++ b/src/modules/grid/GridPage.vue @@ -0,0 +1,411 @@ + + + + + + diff --git a/src/modules/grid/gridLayout.js b/src/modules/grid/gridLayout.js index 132cf9f66f..462c9bf85f 100644 --- a/src/modules/grid/gridLayout.js +++ b/src/modules/grid/gridLayout.js @@ -6,6 +6,9 @@ import { translate as t } from '@nextcloud/l10n' import { NODE_TYPE_TABLE, NODE_TYPE_VIEW } from '../../shared/constants.ts' +/** The page type of a grid page, as the API returns it. */ +export const PAGE_TYPE_GRID = 'grid' + /** Number of columns of a grid page. */ export const GRID_COLUMNS = 12 diff --git a/src/modules/modals/ContextPageForm.vue b/src/modules/modals/ContextPageForm.vue new file mode 100644 index 0000000000..ec1f829885 --- /dev/null +++ b/src/modules/modals/ContextPageForm.vue @@ -0,0 +1,125 @@ + + + + + + diff --git a/src/pages/Context.vue b/src/pages/Context.vue index 420961d6e7..3e52f69e9f 100644 --- a/src/pages/Context.vue +++ b/src/pages/Context.vue @@ -17,9 +17,26 @@
{{ activeContext.description }}
+ -
+ + +
+
@@ -57,8 +78,12 @@ import svgHelper from '../shared/components/ncIconPicker/mixins/svgHelper.js' import { useTablesStore } from '../store/store.js' import { useDataStore } from '../store/data.js' import ErrorMessage from '../modules/main/partials/ErrorMessage.vue' +import ContextPageBar from '../modules/grid/ContextPageBar.vue' +import GridPage from '../modules/grid/GridPage.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' -import { showError } from '@nextcloud/dialogs' +import { showConfirmation, showError } from '@nextcloud/dialogs' export default { components: { @@ -67,9 +92,12 @@ export default { ErrorMessage, TableWrapper, CustomView, + ContextPageBar, + GridPage, + ContextPageForm, }, - mixins: [exportTableMixin, svgHelper], + mixins: [exportTableMixin, svgHelper, permissionsMixin], setup() { const store = useDataStore() const { getColumns, getRows } = storeToRefs(store) @@ -86,11 +114,22 @@ export default { errorMessage: null, loadedSignature: null, isReloading: false, + activePage: null, + pageLoading: false, + pageFormOpen: false, + pageBeingEdited: null, } }, computed: { ...mapState(useTablesStore, ['tables', 'contexts', 'activeContextId', 'views', 'activeContext']), + // The grid page in the route; null on the start page + activePageId() { + return this.$route.params.pageId ? parseInt(this.$route.params.pageId) : null + }, + canManage() { + return this.ownsContext(this.activeContext) + }, rows() { const rows = {} if (this.context && this.context.nodes) { @@ -142,6 +181,14 @@ export default { this.reload() }, }, + activePageId(pageId, previousPageId) { + if (pageId !== null) { + this.loadActivePage() + } else if (previousPageId !== null) { + // The start page resources are only loaded while the start page is shown + this.reload() + } + }, 'context.iconName': { async handler(value) { this.icon = value ? await this.getContextIcon(value) : '' @@ -158,7 +205,7 @@ export default { }, methods: { - ...mapActions(useTablesStore, ['loadContext', 'validateExportAccess', 'loadContextTable', 'loadContextView']), + ...mapActions(useTablesStore, ['loadContext', 'validateExportAccess', 'loadContextTable', 'loadContextView', 'loadContextPage', 'createContextPage', 'updateContextPage', 'deleteContextPage']), ...mapActions(useDataStore, ['loadColumnsFromBE', 'loadRowsFromBE', 'loadRelationsFromBE']), contextSignature() { const ctx = this.activeContext @@ -191,7 +238,9 @@ export default { this.icon = await this.getContextIcon(this.activeContext.iconName) - if (this.context && this.context.pages) { + if (this.activePageId !== null) { + await this.loadActivePage() + } else if (this.context && this.context.pages) { const pages = Object.values(this.context.pages) const startPage = pages.find(p => p.page_type === 'startpage') @@ -269,6 +318,97 @@ export default { this.isReloading = false } }, + /** + * Loads the grid page in the route with its widgets, and the tables and views of the + * application so data widgets can show their titles. + */ + async loadActivePage() { + const pageId = this.activePageId + this.pageLoading = true + this.activePage = null + try { + const page = await this.loadContextPage({ contextId: this.activeContextId, pageId }) + if (pageId === this.activePageId) { + this.activePage = page + } + } catch (e) { + if (e?.response?.status !== 404) { + displayError(e, t('tables', 'Could not load the page.')) + } + } finally { + this.pageLoading = false + } + await this.loadApplicationNodes() + }, + /** + * Puts the tables and views of the application in the store, without their rows. + */ + async loadApplicationNodes() { + for (const node of Object.values(this.activeContext?.nodes ?? {})) { + try { + if (parseInt(node.node_type) === NODE_TYPE_TABLE) { + await this.loadContextTable({ id: node.node_id }) + } else if (parseInt(node.node_type) === NODE_TYPE_VIEW) { + await this.loadContextView({ id: node.node_id }) + } + } catch { + // A table or view that cannot be loaded is shown by id in the widget dialog + } + } + }, + /** + * @param {object|null} page the grid page to edit, or null to add one + */ + openPageForm(page) { + this.pageBeingEdited = page + this.pageFormOpen = true + }, + closePageForm() { + this.pageFormOpen = false + this.pageBeingEdited = null + }, + /** + * Adds a grid page and opens it, or saves the details of the page being edited. + * + * @param {{title: string, emoji: string, description: string}} data the page fields + */ + async submitPageForm(data) { + const editing = this.pageBeingEdited + const page = editing + ? await this.updateContextPage({ contextId: this.activeContextId, pageId: editing.id, data }) + : await this.createContextPage({ contextId: this.activeContextId, data }) + if (!page) { + return + } + this.closePageForm() + if (editing) { + this.activePage = page + return + } + await this.$router.push({ name: 'contextPage', params: { contextId: this.activeContextId, pageId: page.id } }) + }, + /** + * Deletes a grid page after confirmation and goes back to the start page. + * + * @param {object} page the page to delete + */ + async deletePage(page) { + let confirmed = false + try { + confirmed = await showConfirmation({ + name: t('tables', 'Delete page'), + text: t('tables', 'Delete the page "{title}" and its widgets? The tables and views stay in the application.', { title: page.title }), + labelConfirm: t('tables', 'Delete page'), + labelReject: t('tables', 'Cancel'), + severity: 'warning', + }) + } catch { + // Closing the dialog keeps the page + } + if (confirmed && await this.deleteContextPage({ contextId: this.activeContextId, pageId: page.id })) { + await this.$router.push({ name: 'context', params: { contextId: this.activeContextId } }) + } + }, createColumn(isView, element) { emit('tables:column:create', { isView, element }) }, diff --git a/src/router.js b/src/router.js index 9138e4d903..6d4e52b5d1 100644 --- a/src/router.js +++ b/src/router.js @@ -23,6 +23,11 @@ export default createRouter({ component: Context, name: 'context', }, + { + path: '/application/:contextId/page/:pageId', + component: Context, + name: 'contextPage', + }, { path: '/application/:contextId/row/:rowId', component: Context, diff --git a/src/store/store.js b/src/store/store.js index 1b8613e78d..35362262f7 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -517,6 +517,88 @@ export const useTablesStore = defineStore('store', { await widgetTypesRequest }, + /** + * @param {object} params parameters + * @param {number} params.contextId id of the application + * @param {number} params.pageId id of the page + * @return {Promise} the page with its widgets + */ + async loadContextPage({ contextId, pageId }) { + return (await axios.get(generateOcsUrl('/apps/tables/api/2/contexts/{contextId}/pages/{pageId}', { contextId, pageId }))).data.ocs.data + }, + + /** + * Adds a grid page and loads the application again, so its page bar shows it. + * + * @param {object} params parameters + * @param {number} params.contextId id of the application + * @param {object} params.data title, emoji and description of the page + * @return {Promise} the new page, or null when it could not be added + */ + async createContextPage({ contextId, data }) { + let page + try { + page = (await axios.post(generateOcsUrl('/apps/tables/api/2/contexts/{contextId}/pages', { contextId }), data)).data.ocs.data + } catch (e) { + displayError(e, t('tables', 'Could not add the page.')) + return null + } + await this.reloadContextQuietly(contextId) + return page + }, + + /** + * Changes the fields of a page and, when given, saves all its widgets at once. + * + * @param {object} params parameters + * @param {number} params.contextId id of the application + * @param {number} params.pageId id of the page + * @param {object} params.data title, emoji, description and widgets; left out keeps a value + * @return {Promise} the page, or null when it could not be saved + */ + async updateContextPage({ contextId, pageId, data }) { + let page + try { + page = (await axios.patch(generateOcsUrl('/apps/tables/api/2/contexts/{contextId}/pages/{pageId}', { contextId, pageId }), data)).data.ocs.data + } catch (e) { + displayError(e, t('tables', 'Could not save the page.')) + return null + } + await this.reloadContextQuietly(contextId) + return page + }, + + /** + * @param {object} params parameters + * @param {number} params.contextId id of the application + * @param {number} params.pageId id of the page + * @return {Promise} whether the page was deleted + */ + async deleteContextPage({ contextId, pageId }) { + try { + await axios.delete(generateOcsUrl('/apps/tables/api/2/contexts/{contextId}/pages/{pageId}', { contextId, pageId })) + } catch (e) { + displayError(e, t('tables', 'Could not delete the page.')) + return false + } + await this.reloadContextQuietly(contextId) + return true + }, + + /** + * Loads an application again after one of its pages changed. The change itself + * already succeeded, so a failure here is only reported by loadContext. + * + * @param {number} contextId id of the application + */ + async reloadContextQuietly(contextId) { + try { + await this.loadContext({ id: contextId }) + } catch { + // loadContext reported the error + } + }, + async getContextsTablesAndViews() { for (const context of this.contexts) { for (const node of Object.values(context?.nodes)) {