From 64b65196e3a7b26787e632bb9cdea9797889286a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 2 Nov 2026 11:52:35 +0100 Subject: [PATCH 1/2] feat(pages): show the start page of an application as a grid page The start page of an application is shown as the grid of its widgets, with Edit page and Edit page details for its owner and no Delete. The old list of tables is no longer shown; the table or view widget takes over its CSV download, scheme import and saved sorting. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde --- playwright/e2e/column-relation.spec.ts | 13 +- playwright/e2e/context-grid-pages.spec.ts | 37 ++- playwright/e2e/context.spec.ts | 30 ++- playwright/support/commands.ts | 17 ++ src/modules/grid/GridPage.vue | 28 ++- src/modules/grid/widgets/DataWidget.vue | 35 ++- src/pages/Context.vue | 261 +++------------------- 7 files changed, 159 insertions(+), 262 deletions(-) diff --git a/playwright/e2e/column-relation.spec.ts b/playwright/e2e/column-relation.spec.ts index ab0e6f32d6..a9189522a9 100644 --- a/playwright/e2e/column-relation.spec.ts +++ b/playwright/e2e/column-relation.spec.ts @@ -11,7 +11,6 @@ import { createTextLineColumn, loadContext, loadTable, - openContextEditModal, openCreateColumnModal, openCreateRowModal, fillInValueTextLine, @@ -147,16 +146,8 @@ test.describe('Test column relation', () => { await selectFromVueDropdown(page, 'Select relation value', 'Bob') await page.locator('[data-cy="createRowSaveButton"]').click() - // add the target table to an application context - await createContext(page, contextTitle) - await openContextEditModal(page, contextTitle) - - const resourceInput = page.locator('[data-cy="contextResourceForm"] input').first() - await resourceInput.click() - await resourceInput.fill(contextTargetTitle) - await page.getByRole('option', { name: new RegExp(contextTargetTitle, 'i') }).first().click() - - await page.locator('[data-cy="editContextSubmitBtn"]').click() + // an application created with the target table shows it on its start page + await createContext(page, contextTitle, false, [contextTargetTitle]) // load the context and verify the relation label is rendered without crashing await loadContext(page, contextTitle) diff --git a/playwright/e2e/context-grid-pages.spec.ts b/playwright/e2e/context-grid-pages.spec.ts index 8001ffe12d..1b8e626fba 100644 --- a/playwright/e2e/context-grid-pages.spec.ts +++ b/playwright/e2e/context-grid-pages.spec.ts @@ -48,21 +48,46 @@ async function pickOption(page: Page, selectCy: string, option: string) { } 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 } }) => { + test('The start page of a new application shows its name and its tables as widgets', 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() + const startPage = page.locator('[data-cy="startPageGrid"]') + await expect(startPage).toBeVisible() + await expect(startPage.locator('.header-widget')).toContainText('grid existing app') + const firstHeading = page.locator('[data-cy="gridWidget"]').getByRole('heading', { name: 'grid first table' }) + const secondHeading = page.locator('[data-cy="gridWidget"]').getByRole('heading', { name: 'grid second table' }) + await expect(firstHeading).toBeVisible({ timeout: 30000 }) + await expect(secondHeading).toBeVisible({ timeout: 30000 }) expect((await firstHeading.boundingBox())!.y).toBeLessThan((await secondHeading.boundingBox())!.y) + await expect(page.locator('[data-cy="gridWidget"]')).toHaveCount(3) await expect(page.locator('[data-cy="contextStartPageLink"]')).toHaveAttribute('aria-selected', 'true') await expect(page.locator('[data-cy="contextPageAdd"]')).toBeVisible() - await expect(page.locator('[data-cy="gridPage"]')).toHaveCount(0) + await expect(page.locator('.resources')).toHaveCount(0) + }) + + test('The start page can be edited like a grid page, but not deleted', async ({ userPage: { page, user } }) => { + const table = await createTableViaApi(page.request, user, 'grid start edit table') + const contextId = await createApplicationViaApi(page.request, user, 'grid start edit app', [table]) + await openApplication(page, contextId) + + await page.locator('[data-cy="gridPageEdit"]').click() + await page.locator('[data-cy="gridPageAddWidget"]').click() + await pickOption(page, 'addWidgetType', 'Description') + await page.locator('[data-cy="widgetFieldText"] textarea').fill('Read this first') + await page.locator('[data-cy="addWidgetSubmit"]').click() + 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 page.reload() + await expect(page.getByText('Read this first')).toBeVisible({ timeout: 30000 }) + // With only one action left the menu shows it as a button of its own + await expect(page.getByRole('button', { name: 'Edit page details' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Page actions' })).toHaveCount(0) }) test('A new page holds a header and a table of the application after a reload', async ({ userPage: { page, user } }) => { diff --git a/playwright/e2e/context.spec.ts b/playwright/e2e/context.spec.ts index 76fae80ef5..88e2be0fd5 100644 --- a/playwright/e2e/context.spec.ts +++ b/playwright/e2e/context.spec.ts @@ -56,6 +56,21 @@ async function expectSelectedShare(page: Page, userId: string) { ).toBeVisible() } +/** + * Places a table of the application on its start page. Tables added after the application was + * created are not placed there by themselves. + */ +async function addTableWidgetToStartPage(page: Page, tableTitle: string) { + await page.locator('[data-cy="gridPageEdit"]').click() + await page.locator('[data-cy="gridPageAddWidget"]').click() + await selectFromVueDropdown(page, '[data-cy="addWidgetType"] input', 'Table or view') + await selectFromVueDropdown(page, '[data-cy="addWidgetNode"] input', tableTitle) + await page.locator('[data-cy="addWidgetSubmit"]').click() + 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) +} + test.describe('Manage a context', () => { test.describe.configure({ mode: 'serial' }) @@ -95,8 +110,15 @@ test.describe('Manage a context', () => { await loadContext(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() + // Tables and views added after the application was created are not placed on its start page + await expect(page.locator('[data-cy="startPageGrid"]')).toBeVisible() + await expect(page.locator('h1', { hasText: tableTitle })).toHaveCount(0) + await page.locator('[data-cy="gridPageEdit"]').click() + await page.locator('[data-cy="gridPageAddWidget"]').click() + await selectFromVueDropdown(page, '[data-cy="addWidgetType"] input', 'Table or view') + await page.locator('[data-cy="addWidgetNode"] input').click() + await expect(page.locator('.vs__dropdown-menu li').filter({ hasText: tableTitle })).toBeVisible() + await expect(page.locator('.vs__dropdown-menu li').filter({ hasText: viewTitle })).toBeVisible() }) test('Share context with resources', async ({ userPage: { page }, request }) => { @@ -121,6 +143,7 @@ test.describe('Manage a context', () => { await loadContext(page, contextTitle) await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible({ timeout: 10000 }) + await addTableWidgetToStartPage(page, tableTitle) await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) // verify context was shared properly @@ -221,6 +244,7 @@ test.describe('Manage a context', () => { await page.locator('[data-cy="editContextSubmitBtn"]').click() await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() + await addTableWidgetToStartPage(page, tableTitle) await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() await openContextEditModal(page, contextTitle) @@ -248,6 +272,7 @@ 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 addTableWidgetToStartPage(page, tableTitle) await createTextLineColumn(page, 'title', '', '', true) await page.locator('button').filter({ hasText: 'Create row' }).first().click() @@ -293,6 +318,7 @@ test.describe('Manage a context', () => { await updatePermsResponse await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible({ timeout: 10000 }) + await addTableWidgetToStartPage(page, tableTitle) await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) // verify that shared user can modify and delete data in the resource diff --git a/playwright/support/commands.ts b/playwright/support/commands.ts index fe66200ba2..c8cc3f2829 100644 --- a/playwright/support/commands.ts +++ b/playwright/support/commands.ts @@ -370,10 +370,20 @@ export async function openCreateColumnModal( await expect(page.locator('[data-cy="columnTypeFormInput"]')).toBeVisible() } +/** + * Creates an application. Tables and views given by title are its resources from the start, so + * its start page shows them. + * + * @param page the page + * @param title the title of the application + * @param showInNav whether it shows in the app list + * @param resourceTitles titles of its tables and views + */ export async function createContext( page: Page, title: string, showInNav: boolean = false, + resourceTitles: string[] = [], ) { await ensureNavigationOpen(page) @@ -404,6 +414,13 @@ export async function createContext( .locator('[data-cy="createContextShowInNavSwitch"] input') .check({ force: true }) } + for (const resourceTitle of resourceTitles) { + const resourceInput = createContextModal.locator('[data-cy="contextResourceForm"] input').first() + await resourceInput.click() + await resourceInput.fill(resourceTitle) + await page.getByRole('option', { name: new RegExp(resourceTitle, 'i') }).first().click() + await expect(createContextModal.locator('[data-cy="contextResourceList"]')).toContainText(resourceTitle) + } await page.locator('[data-cy="createContextSubmitBtn"]').click() await expect( diff --git a/src/modules/grid/GridPage.vue b/src/modules/grid/GridPage.vue index 99ac392ea2..3e9b830b32 100644 --- a/src/modules/grid/GridPage.vue +++ b/src/modules/grid/GridPage.vue @@ -3,9 +3,9 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> {{ t('tables', 'Edit page details') }} - + @@ -110,7 +113,7 @@ import GridWidget from './GridWidget.vue' import AddWidget from '../modals/AddWidget.vue' import NamedIcon from '../../shared/components/ncIconPicker/NamedIcon.vue' import { useTablesStore } from '../../store/store.js' -import { GRID_COLUMNS, GRID_MIN_WIDTH, cloneGrid, findWidgetType, gridToWidgets, nextFreeRow, pageTitle, pageToGrid } from './gridLayout.js' +import { GRID_COLUMNS, GRID_MIN_WIDTH, cloneGrid, findWidgetType, gridToWidgets, isStartPage, nextFreeRow, pageTitle, pageToGrid } from './gridLayout.js' /** * A grid page of an application: its widgets on a grid, with an edit mode that saves the @@ -134,9 +137,11 @@ export default { AddWidget, NamedIcon, }, - provide: { - // Widget titles sit below the title of the page - gridHeadingLevel: 3, + provide() { + return { + // Widget titles sit below the title of the page, or of the application on a start page without one + gridHeadingLevel: computed(() => this.showHeading ? 3 : 2), + } }, props: { // The application, for its id and its nodes @@ -201,6 +206,13 @@ export default { title() { return pageTitle(this.page) }, + isStartPage() { + return isStartPage(this.page) + }, + // The start page sits under the title of its application, so it only has a heading of its own once it gets a title + showHeading() { + return !this.isStartPage || !!this.page.title || !!this.page.description + }, activeGrid() { return this.draftGrid ?? this.storedGrid }, diff --git a/src/modules/grid/widgets/DataWidget.vue b/src/modules/grid/widgets/DataWidget.vue index 67d2cde917..8ba66c314a 100644 --- a/src/modules/grid/widgets/DataWidget.vue +++ b/src/modules/grid/widgets/DataWidget.vue @@ -28,6 +28,8 @@ :view-setting="viewSetting" @create-column="createColumn" @import="openImportModal" + @download-csv="downloadCSV(rows)" + @download-filtered-csv="downloadCSV" @update:view-setting="setting => viewSetting = setting" /> @@ -47,10 +52,12 @@ import { NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue' import TableOff from 'vue-material-design-icons/TableOff.vue' import { mapActions, mapState, storeToRefs } from 'pinia' import { emit } from '@nextcloud/event-bus' +import { showError } from '@nextcloud/dialogs' import TableWrapper from '../../main/sections/TableWrapper.vue' import CustomView from '../../main/sections/View.vue' import { useTablesStore } from '../../../store/store.js' import { useDataStore } from '../../../store/data.js' +import exportTableMixin from '../../../shared/components/ncTable/mixins/exportTableMixin.js' /** * Shows the rows of one table or view of the application on a grid page, with the @@ -65,6 +72,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, // also when its table or view left the application @@ -134,7 +142,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. @@ -162,6 +170,10 @@ export default { await this.loadRowsFromBE({ viewId: null, tableId: this.targetId }) await this.loadRelationsFromBE({ tableId: this.targetId }) } + if (request === this.loadRequest) { + // The rows start in the sorting the table or view was saved with + this.viewSetting = this.element?.sort?.length ? { presetSorting: [...this.element.sort] } : {} + } } catch { // The store already reported the error if (request === this.loadRequest) { @@ -185,6 +197,27 @@ export default { openImportModal() { emit('tables:modal:import', { element: this.element, isView: this.isView }) }, + /** + * Asks the app to open the scheme import dialog for the shown table. + */ + openImportSchemeModal() { + emit('tables:modal:table-scheme-import', { table: this.element }) + }, + /** + * Downloads rows of the shown table or view as CSV, after checking the user may still read it. + * + * @param {Array} rows the rows to download + */ + 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) + }, }, } diff --git a/src/pages/Context.vue b/src/pages/Context.vue index 250cc4f79a..e44f557f5a 100644 --- a/src/pages/Context.vue +++ b/src/pages/Context.vue @@ -28,36 +28,16 @@
- - -
-
-
- -
-
- -
-
-
+ +
+
@@ -73,16 +53,12 @@