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..826a0ff68f 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 } }) => { @@ -123,7 +148,7 @@ test.describe('Grid pages of an application', () => { 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="gridWidget"]').getByRole('heading', { name: 'grid table in app' })).toBeVisible({ timeout: 30000 }) await expect(page.locator('[data-cy="gridPage"]')).toHaveCount(0) }) diff --git a/playwright/e2e/context.spec.ts b/playwright/e2e/context.spec.ts index 76fae80ef5..98d79e10d7 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('[data-cy="gridWidget"]').getByRole('heading', { name: 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,7 +143,8 @@ test.describe('Manage a context', () => { await loadContext(page, contextTitle) await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible({ timeout: 10000 }) - await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) + await addTableWidgetToStartPage(page, tableTitle) + await expect(page.locator('[data-cy="gridWidget"]').getByRole('heading', { name: tableTitle })).toBeVisible({ timeout: 10000 }) // verify context was shared properly await page.context().clearCookies() @@ -130,7 +153,7 @@ test.describe('Manage a context', () => { await loadContext(page, contextTitle) await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() - await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() + await expect(page.locator('[data-cy="gridWidget"]').getByRole('heading', { name: tableTitle })).toBeVisible() }) test('Transfer context', async ({ userPage: { page }, request }) => { @@ -221,7 +244,8 @@ 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 addTableWidgetToStartPage(page, tableTitle) + await expect(page.locator('[data-cy="gridWidget"]').getByRole('heading', { name: tableTitle })).toBeVisible() await openContextEditModal(page, contextTitle) await page.locator('[data-cy="contextResourceList"] button').filter({ hasText: 'Delete' }).click({ force: true }) @@ -230,7 +254,7 @@ 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 })).toBeHidden() + await expect(page.locator('[data-cy="gridWidget"]').getByRole('heading', { name: tableTitle })).toBeHidden() }) test('Modify resource rows and columns from context', async ({ userPage: { page } }) => { @@ -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,7 +318,8 @@ test.describe('Manage a context', () => { await updatePermsResponse await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible({ timeout: 10000 }) - await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) + await addTableWidgetToStartPage(page, tableTitle) + await expect(page.locator('[data-cy="gridWidget"]').getByRole('heading', { name: tableTitle })).toBeVisible({ timeout: 10000 }) // verify that shared user can modify and delete data in the resource await page.context().clearCookies() @@ -302,7 +328,7 @@ test.describe('Manage a context', () => { await loadContext(page, contextTitle) await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() - await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() + await expect(page.locator('[data-cy="gridWidget"]').getByRole('heading', { name: tableTitle })).toBeVisible() await page.locator('button').filter({ hasText: 'Create row' }).first().click() await fillInValueTextLine(page, 'title', 'first row') 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 @@