From 0c57e65dcf9cffa2600f4356e869bd03a8f2e264 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 15 Oct 2026 18:27:08 +0200 Subject: [PATCH 1/2] feat(pages): show every application page on the grid The application shows its pages: the start page is the dashboard, read from the page API so an existing start page shows its tables and views full width in their order, and grid pages get a route of their own. Managers edit a page in place (add, move, resize, configure and remove widgets, save or cancel) and create, rename and delete grid pages. The edit dialog no longer sorts the tables and views: it lists every node of the application, also those only on other pages, and links to the dashboard editor. Updating an application keeps the order of a start page arranged on the grid. Data widgets offer the CSV export and scheme import the start page had. Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Service/ContextService.php | 16 +- playwright/e2e/context-grid-pages.spec.ts | 168 ++++++++ src/modules/grid/GridPage.vue | 368 +++++++++++++++++ src/modules/grid/widgets/DataWidget.vue | 31 +- src/modules/modals/ContextPageForm.vue | 97 +++++ src/modules/modals/EditContext.vue | 78 ++-- src/pages/Context.vue | 376 ++++++++---------- src/router.js | 5 + .../ncContextResource/NcContextResource.vue | 6 +- .../ncContextResource/ResourceList.vue | 11 +- tests/unit/Service/ContextServiceTest.php | 26 ++ 11 files changed, 938 insertions(+), 244 deletions(-) create mode 100644 playwright/e2e/context-grid-pages.spec.ts create mode 100644 src/modules/grid/GridPage.vue create mode 100644 src/modules/modals/ContextPageForm.vue diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index a14bb14583..569036a85a 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -258,7 +258,9 @@ public function update(int $contextId, string $userId, ?string $name, ?string $i } } - if ($startPageId !== null && isset($currentPages[$startPageId]['content'])) { + // a start page arranged on the grid takes its order from the positions + if ($startPageId !== null && isset($currentPages[$startPageId]['content']) + && !$this->hasPositionedContent($currentPages[$startPageId]['content'])) { $nodeTypeIdToRelId = []; foreach ($currentNodes as $relId => $nodeData) { $key = sprintf('t%di%d', $nodeData['node_type'], $nodeData['node_id']); @@ -309,6 +311,18 @@ public function update(int $contextId, string $userId, ?string $name, ?string $i return $context; } + /** + * @param array> $content + */ + private function hasPositionedContent(array $content): bool { + foreach ($content as $entry) { + if (($entry['grid_y'] ?? $entry['gridY'] ?? null) !== null) { + return true; + } + } + return false; + } + /** * @throws NotFoundError * @throws Exception diff --git a/playwright/e2e/context-grid-pages.spec.ts b/playwright/e2e/context-grid-pages.spec.ts new file mode 100644 index 0000000000..1a4aae2bde --- /dev/null +++ b/playwright/e2e/context-grid-pages.spec.ts @@ -0,0 +1,168 @@ +/** + * 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', 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 openApplication(page: Page, contextId: number, path = '') { + await page.goto(`/index.php/apps/tables/#/application/${contextId}${path}`) + await expect(page.locator('[data-cy="gridPage"]')).toBeVisible({ timeout: 30000 }) +} + +function startPageContent(application: { pages: Record }> }) { + const startPage = Object.values(application.pages).find(page => page.page_type === 'startpage') + return Object.values(startPage?.content ?? {}) +} + +test.describe('Application pages on the grid', () => { + test('An existing start page shows its tables in order before any edit', 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 items = page.locator('[data-cy="gridItem"]') + await expect(items).toHaveCount(2) + 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() + const firstBox = await firstHeading.boundingBox() + const secondBox = await secondHeading.boundingBox() + expect(firstBox!.y).toBeLessThan(secondBox!.y) + // Both full width, one below the other + const [firstItem, secondItem] = [await items.nth(0).boundingBox(), await items.nth(1).boundingBox()] + expect(Math.round(firstItem!.width)).toBe(Math.round(secondItem!.width)) + }) + + 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 page.locator('[data-cy="gridPageEdit"]').click() + await page.locator('[data-cy="gridPageAddWidget"]').click() + const dialog = page.locator('[data-cy="addWidgetModal"]') + await expect(dialog).toBeVisible() + await dialog.locator('[data-cy="widgetFieldTitle"] input').fill('Welcome to the dashboard') + await page.locator('[data-cy="addWidgetSubmit"]').click() + await expect(dialog).toBeHidden() + + const saved = page.waitForResponse(r => r.url().includes('/widgets') && r.request().method() === 'PUT') + await page.locator('[data-cy="gridPageSave"]').click() + expect((await saved).ok()).toBe(true) + + await page.reload() + await expect(page.locator('[data-cy="gridPage"]')).toBeVisible({ timeout: 30000 }) + await expect(page.getByText('Welcome to the dashboard')).toBeVisible() + await expect(page.locator('h1', { hasText: 'grid header table' })).toBeVisible() + + const content = startPageContent(await getApplication(page.request, user, contextId)) + .sort((a, b) => a.grid_y - b.grid_y) + expect(content.map(entry => entry.type)).toEqual(['data', 'header']) + expect(content.map(entry => entry.order)).toEqual([10, 20]) + }) + + test('A data widget on a new page adds its table to the application', async ({ userPage: { page, user } }) => { + 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 page.locator('[data-cy="contextPageCreate"]').click() + const pageForm = page.getByRole('dialog', { name: 'New page' }) + await pageForm.getByRole('textbox', { name: 'Title' }).fill('Overview') + await pageForm.getByRole('textbox', { name: 'Technical name' }).fill('overview') + await page.locator('[data-cy="contextPageSubmit"]').click() + await expect(page).toHaveURL(/\/application\/\d+\/page\/\d+/) + await expect(page.locator('[data-cy="contextPageLink"]', { hasText: 'Overview' })).toBeVisible() + await expect(page.locator('[data-cy="gridItem"]')).toHaveCount(0) + + 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="addWidgetType"] input').click() + await page.locator('.vs__dropdown-menu li').filter({ hasText: 'Table or view' }).click() + await dialog.locator('[data-cy="addWidgetNode"] input').click() + await page.locator('.vs__dropdown-menu li').filter({ hasText: 'grid table not in app' }).click() + await page.locator('[data-cy="addWidgetSubmit"]').click() + await expect(dialog).toBeHidden() + + const saved = page.waitForResponse(r => r.url().includes('/widgets') && r.request().method() === 'PUT') + await page.locator('[data-cy="gridPageSave"]').click() + expect((await saved).ok()).toBe(true) + await expect(page.locator('h1', { hasText: 'grid table not in app' })).toBeVisible() + + const application = await getApplication(page.request, user, contextId) + const nodeIds = Object.values(application.nodes as Record) + .filter(node => node.node_type === 0) + .map(node => node.node_id) + expect(nodeIds.sort()).toEqual([inApp, notInApp].sort()) + // The start page keeps only its own table + expect(startPageContent(application)).toHaveLength(1) + }) + + test('A sharee who cannot manage the application sees its pages read-only', 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]) + await ocsRequest(page.request, user, { + method: 'POST', + url: `${API}/contexts/${contextId}/pages?format=json`, + data: { title: 'Shared overview' }, + }) + 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('h1', { hasText: 'grid shared table' })).toBeVisible() + await expect(shareePage.locator('[data-cy="contextPageLink"]', { hasText: 'Shared overview' })).toBeVisible() + await expect(shareePage.locator('[data-cy="gridPageEdit"]')).toHaveCount(0) + await expect(shareePage.locator('[data-cy="contextPageCreate"]')).toHaveCount(0) + + const forbidden = await shareePage.request.fetch(`${API}/contexts/${contextId}/pages?format=json`, { + method: 'POST', + headers: { 'OCS-APIRequest': 'true', 'Content-Type': 'application/json', Authorization: 'Basic ' + Buffer.from(`${sharee.userId}:${sharee.password}`).toString('base64') }, + data: { title: 'Not allowed' }, + }) + expect(forbidden.status()).toBe(403) + + await shareeContext.close() + }) +}) diff --git a/src/modules/grid/GridPage.vue b/src/modules/grid/GridPage.vue new file mode 100644 index 0000000000..8f92cd3a20 --- /dev/null +++ b/src/modules/grid/GridPage.vue @@ -0,0 +1,368 @@ + + + + + + diff --git a/src/modules/grid/widgets/DataWidget.vue b/src/modules/grid/widgets/DataWidget.vue index 9e4d5c197f..5673ab68de 100644 --- a/src/modules/grid/widgets/DataWidget.vue +++ b/src/modules/grid/widgets/DataWidget.vue @@ -19,6 +19,8 @@ :view-setting="viewSetting" @create-column="createColumn" @import="openImportModal" + @download-csv="downloadCSV(rows)" + @download-filtered-csv="filteredRows => downloadCSV(filteredRows)" @update:view-setting="setting => viewSetting = setting" /> @@ -36,6 +41,8 @@ import { NcLoadingIcon } from '@nextcloud/vue' import { mapActions, mapState, storeToRefs } from 'pinia' import { emit } from '@nextcloud/event-bus' +import { showError } from '@nextcloud/dialogs' +import exportTableMixin from '../../../shared/components/ncTable/mixins/exportTableMixin.js' import TableWrapper from '../../main/sections/TableWrapper.vue' import CustomView from '../../main/sections/View.vue' import { useTablesStore } from '../../../store/store.js' @@ -52,6 +59,7 @@ export default { TableWrapper, CustomView, }, + mixins: [exportTableMixin], props: { // The table or view to show, as {type: 'table'|'view', id}; null when the widget has none target: { @@ -115,7 +123,7 @@ export default { this.load() }, methods: { - ...mapActions(useTablesStore, ['loadContextTable', 'loadContextView']), + ...mapActions(useTablesStore, ['loadContextTable', 'loadContextView', 'validateExportAccess']), ...mapActions(useDataStore, ['loadColumnsFromBE', 'loadRowsFromBE', 'loadRelationsFromBE']), /** * Loads the table or view with its columns, rows and relations. @@ -160,6 +168,27 @@ export default { createColumn() { emit('tables:column:create', { isView: this.isView, element: this.element }) }, + /** + * Downloads the given rows as CSV, once the server confirms the access is still there. + * + * @param {Array} rows the rows to export + */ + async downloadCSV(rows) { + const access = await this.validateExportAccess({ id: this.element.id, isView: this.isView }) + if (!access?.ok) { + if (access?.reason === 'NO_ACCESS') { + showError(t('tables', 'Your access was revoked. Reload the page to update your permissions.')) + } + return + } + this.downloadCsv(rows, this.columns, this.element.title) + }, + /** + * Asks the app to open the scheme import dialog for the shown table. + */ + openImportSchemeModal() { + emit('tables:modal:table-scheme-import', { table: this.element }) + }, /** * Asks the app to open the import dialog for the shown table or view. */ diff --git a/src/modules/modals/ContextPageForm.vue b/src/modules/modals/ContextPageForm.vue new file mode 100644 index 0000000000..22b77f5f24 --- /dev/null +++ b/src/modules/modals/ContextPageForm.vue @@ -0,0 +1,97 @@ + + + + + + diff --git a/src/modules/modals/EditContext.vue b/src/modules/modals/EditContext.vue index b096360b74..cdc15acfa0 100644 --- a/src/modules/modals/EditContext.vue +++ b/src/modules/modals/EditContext.vue @@ -34,7 +34,13 @@
{{ t('tables', 'Resources') }}
- + + + + {{ t('tables', 'Arrange the dashboard') }} +
@@ -69,6 +75,7 @@