diff --git a/cypress/component/ContextPageBar.cy.js b/cypress/component/ContextPageBar.cy.js new file mode 100644 index 0000000000..8a196d3f71 --- /dev/null +++ b/cypress/component/ContextPageBar.cy.js @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import ContextPageBar from '../../src/modules/grid/ContextPageBar.vue' + +const context = { + id: 1, + pages: { + 9: { id: 9, page_type: 'grid', title: 'Later', icon: null }, + 3: { id: 3, page_type: 'startpage', title: null, icon: null }, + 5: { id: 5, page_type: 'grid', title: 'Overview', icon: 'star' }, + }, +} + +/** + * @param {object} props props besides the application + */ +function mountBar(props = {}) { + cy.mount(ContextPageBar, { props: { context, selectedPageId: 3, panelId: 'panel', ...props } }) + .then(({ wrapper }) => cy.wrap(wrapper).as('wrapper')) +} + +describe('ContextPageBar', () => { + it('lists the start page first, then the grid pages in the order they were added', () => { + mountBar() + + cy.get('[role="tab"]').should('have.length', 3) + cy.get('[role="tab"]').eq(0).should('have.text', 'Home').and('have.attr', 'aria-selected', 'true') + cy.get('[role="tab"]').eq(1).should('contain.text', 'Overview').and('have.attr', 'aria-selected', 'false') + cy.get('[role="tab"]').eq(2).should('contain.text', 'Later') + cy.get('[role="tab"]').each(tab => expect(tab).to.have.attr('aria-controls', 'panel')) + }) + + it('has one tab stop and moves between tabs with the arrow keys, Home and End', () => { + mountBar({ selectedPageId: 5 }) + + cy.get('[role="tab"][tabindex="0"]').should('have.length', 1).and('contain.text', 'Overview').focus() + cy.focused().trigger('keydown', { key: 'ArrowRight' }) + cy.focused().should('contain.text', 'Later').and('have.attr', 'tabindex', '0') + cy.focused().trigger('keydown', { key: 'ArrowRight' }) + cy.focused().should('have.text', 'Home') + cy.focused().trigger('keydown', { key: 'End' }) + cy.focused().should('contain.text', 'Later') + cy.focused().trigger('keydown', { key: 'Home' }) + cy.focused().should('have.text', 'Home') + cy.focused().trigger('keydown', { key: 'ArrowLeft' }) + cy.focused().should('contain.text', 'Later') + cy.get('@wrapper').then(wrapper => expect(wrapper.emitted('select')).to.equal(undefined)) + }) + + it('opens a page on click', () => { + mountBar() + + cy.get('[role="tab"]').eq(1).click() + cy.get('@wrapper').then(wrapper => expect(wrapper.emitted('select')[0][0].id).to.equal(5)) + }) + + it('offers adding a page only to those who manage the application', () => { + mountBar() + cy.get('[data-cy="contextPageAdd"]').should('not.exist') + + mountBar({ canManage: true }) + cy.get('[data-cy="contextPageAdd"]').should('be.visible') + }) +}) diff --git a/cypress/component/ContextPageForm.cy.js b/cypress/component/ContextPageForm.cy.js new file mode 100644 index 0000000000..8fd77102e2 --- /dev/null +++ b/cypress/component/ContextPageForm.cy.js @@ -0,0 +1,56 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import ContextPageForm from '../../src/modules/modals/ContextPageForm.vue' + +/** + * @param {object|null} page the page to edit, or null for a new one + */ +function mountForm(page = null) { + cy.mount(ContextPageForm, { props: { showModal: false, page } }).then(({ wrapper }) => { + cy.wrap(wrapper).as('wrapper') + cy.wrap(wrapper.setProps({ showModal: true })) + }) +} + +describe('ContextPageForm', () => { + it('sends a new page without a technical name when none is typed', () => { + mountForm() + + cy.get('[data-cy="contextPageSubmit"]').should('be.disabled') + cy.get('[data-cy="contextPageTitle"]').type(' Overview ') + cy.get('[data-cy="contextPageDescription"]').type('All open work') + cy.get('[data-cy="contextPageSubmit"]').click() + cy.get('@wrapper').then(wrapper => { + expect(wrapper.emitted('submit')[0][0]).to.deep.equal({ title: 'Overview', icon: '', description: 'All open work' }) + }) + }) + + it('refuses a technical name of the wrong form or of the automatic form', () => { + mountForm() + + cy.get('[data-cy="contextPageTitle"]').type('Overview') + cy.get('[data-cy="contextPageTechnicalName"]').type('Open work') + cy.get('[data-cy="contextPageSubmit"]').should('be.disabled') + cy.contains('Use lower case letters, digits and underscores, starting with a letter.') + cy.get('[data-cy="contextPageTechnicalName"]').clear().type('page_12') + cy.get('[data-cy="contextPageSubmit"]').should('be.disabled') + cy.get('[data-cy="contextPageTechnicalName"]').clear().type('open_work') + cy.get('[data-cy="contextPageSubmit"]').should('be.enabled').click() + cy.get('@wrapper').then(wrapper => { + expect(wrapper.emitted('submit')[0][0].technicalName).to.equal('open_work') + }) + }) + + it('keeps the automatic name of a page being edited and does not send it again', () => { + mountForm({ id: 12, title: 'Overview', icon: 'star', technicalName: 'page_12', description: '' }) + + cy.get('[data-cy="contextPageTechnicalName"]').should('have.value', 'page_12') + cy.get('[data-cy="iconFieldClear"]').click() + cy.get('[data-cy="contextPageSubmit"]').should('be.enabled').click() + cy.get('@wrapper').then(wrapper => { + expect(wrapper.emitted('submit')[0][0]).to.deep.equal({ title: 'Overview', icon: '', description: '' }) + }) + }) +}) diff --git a/playwright/e2e/context-grid-pages.spec.ts b/playwright/e2e/context-grid-pages.spec.ts new file mode 100644 index 0000000000..8001ffe12d --- /dev/null +++ b/playwright/e2e/context-grid-pages.spec.ts @@ -0,0 +1,239 @@ +/** + * 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-selected', 'true') + 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: 'Technical name' }).fill('page_3') + await expect(page.locator('[data-cy="contextPageSubmit"]')).toBeDisabled() + await pageForm.getByRole('textbox', { name: 'Technical name' }).fill('overview') + await pageForm.locator('[data-cy="iconFieldPick"]').click() + await page.locator('.icon-picker__icons button').first().click() + 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-selected', 'true') + const created = Object.values((await getApplication(page.request, user, contextId)).pages as Record).find(entry => entry.title === 'Overview') + expect(created!.technical_name).toBe('overview') + expect(created!.icon).not.toBeNull() + 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() + const tasks = page.locator('[data-cy="gridWidget"]', { has: page.locator('h3', { hasText: 'Tasks' }) }) + await expect(tasks.getByText('No columns')).toBeVisible({ timeout: 30000 }) + // The widget has a title of its own, so the table does not repeat its name + await expect(page.getByRole('heading', { name: 'grid table in app' })).toHaveCount(0) + + // 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() + + const sharedTasks = shareePage.locator('[data-cy="gridWidget"]', { has: shareePage.locator('h3', { hasText: 'Shared tasks' }) }) + await expect(sharedTasks.getByText('No columns')).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"] h3', { 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() + }) + + test('A widget can be moved and resized from its menu', async ({ userPage: { page, user } }) => { + const table = await createTableViaApi(page.request, user, 'grid menu move table') + const contextId = await createApplicationViaApi(page.request, user, 'grid menu move app', [table]) + const pageId = await createPageViaApi(page.request, user, contextId, 'Arranged') + await ocsRequest(page.request, user, { + method: 'PATCH', + url: `${API}/contexts/${contextId}/pages/${pageId}?format=json`, + data: { widgets: [{ type: 'text', title: 'Notes', gridX: 0, gridY: 0, gridWidth: 6, gridHeight: 2, settings: { text: 'Hello' } }] }, + }) + await openApplication(page, contextId, `/page/${pageId}`) + + await page.locator('[data-cy="gridPageEdit"]').click() + await page.getByRole('button', { name: 'Widget actions' }).click() + // The steps keep the menu open, so a widget can be moved several steps in a row + await page.locator('[data-cy="widgetMoveRight"] button').click() + await page.locator('[data-cy="widgetWider"] button').click() + await page.keyboard.press('Escape') + + const saved = page.waitForResponse(r => /\/pages\/\d+/.test(r.url()) && r.request().method() === 'PATCH') + await page.locator('[data-cy="gridPageSave"]').click() + const widget = (await (await saved).json()).ocs.data.widgets[0] + expect(widget).toMatchObject({ gridX: 1, gridWidth: 7 }) + }) +}) diff --git a/src/modules/grid/ContextPageBar.vue b/src/modules/grid/ContextPageBar.vue new file mode 100644 index 0000000000..090c658c18 --- /dev/null +++ b/src/modules/grid/ContextPageBar.vue @@ -0,0 +1,230 @@ + + + + + + diff --git a/src/modules/grid/GridPage.vue b/src/modules/grid/GridPage.vue new file mode 100644 index 0000000000..99ac392ea2 --- /dev/null +++ b/src/modules/grid/GridPage.vue @@ -0,0 +1,426 @@ + + + + + + diff --git a/src/modules/grid/GridWidget.vue b/src/modules/grid/GridWidget.vue index 74a1fa7f97..2f5978e83b 100644 --- a/src/modules/grid/GridWidget.vue +++ b/src/modules/grid/GridWidget.vue @@ -10,10 +10,10 @@
-

+ {{ label }} -

+ @@ -57,8 +84,13 @@ 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 { contextPages, isStartPage } from '../modules/grid/gridLayout.js' +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 +99,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 +121,32 @@ 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 + }, + // The page shown: the grid page in the route, else the start page + selectedPageId() { + if (this.activePageId !== null) { + return this.activePageId + } + return contextPages(this.activeContext).find(isStartPage)?.id ?? null + }, + pagePanelId() { + return 'context-page-panel' + }, + canManage() { + return this.ownsContext(this.activeContext) + }, rows() { const rows = {} if (this.context && this.context.nodes) { @@ -142,6 +198,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 +222,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 +255,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 +335,109 @@ 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 + } + } + }, + /** + * Opens a page from the page bar. + * + * @param {object} page a page of the application + */ + openPage(page) { + if (isStartPage(page)) { + this.$router.push({ name: 'context', params: { contextId: this.activeContextId } }) + } else { + this.$router.push({ name: 'contextPage', params: { contextId: this.activeContextId, pageId: page.id } }) + } + }, + /** + * @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, icon: string, technicalName?: 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 }) }, @@ -346,7 +515,8 @@ export default { &__description { margin: calc(3 * var(--default-grid-baseline, 4px)); max-width: 790px; - margin-inline-start: 32px; + // Lines up with the page tabs and the widget texts + margin-inline-start: calc(32px + 3 * var(--default-grid-baseline)); } &:deep(.icon-vue) { 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 1a3ee499fd..1205f65d1b 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -534,6 +534,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, icon, technical name 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, icon, technical name, 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)) {