From 553bdbf60d0b47a61825a280547fe1a8987635e9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 15 Oct 2026 11:53:52 +0200 Subject: [PATCH] feat(grid): add the grid canvas and widgets for application pages The canvas places widgets on a 12 column grid with GridStack and lets them be moved and resized in edit mode, or through the widget menu with a keyboard. Header and text widgets render their content; a data widget shows a table or view of the application with the components the start page uses. The widget dialog builds its form from the widget type schemas and picks the table or view of a data widget. gridLayout.js reads a page into a grid and turns the grid back into the widgets the page API saves, by node relation when the application has the node. The store gains actions for the page endpoints. Assisted-by: ClaudeCode:claude-opus-5-5 --- cypress/component/gridLayout.cy.js | 138 ++++++++++++++ package-lock.json | 17 ++ package.json | 1 + src/modules/grid/GridCanvas.vue | 204 +++++++++++++++++++++ src/modules/grid/GridWidget.vue | 202 +++++++++++++++++++++ src/modules/grid/SchemaForm.vue | 151 +++++++++++++++ src/modules/grid/gridLayout.js | 212 ++++++++++++++++++++++ src/modules/grid/widgetRegistry.js | 30 +++ src/modules/grid/widgets/DataWidget.vue | 192 ++++++++++++++++++++ src/modules/grid/widgets/HeaderWidget.vue | 73 ++++++++ src/modules/grid/widgets/TextWidget.vue | 54 ++++++ src/modules/modals/AddWidget.vue | 206 +++++++++++++++++++++ src/shared/utils/gridColors.js | 14 ++ src/store/store.js | 106 +++++++++++ 14 files changed, 1600 insertions(+) create mode 100644 cypress/component/gridLayout.cy.js create mode 100644 src/modules/grid/GridCanvas.vue create mode 100644 src/modules/grid/GridWidget.vue create mode 100644 src/modules/grid/SchemaForm.vue create mode 100644 src/modules/grid/gridLayout.js create mode 100644 src/modules/grid/widgetRegistry.js create mode 100644 src/modules/grid/widgets/DataWidget.vue create mode 100644 src/modules/grid/widgets/HeaderWidget.vue create mode 100644 src/modules/grid/widgets/TextWidget.vue create mode 100644 src/modules/modals/AddWidget.vue create mode 100644 src/shared/utils/gridColors.js diff --git a/cypress/component/gridLayout.cy.js b/cypress/component/gridLayout.cy.js new file mode 100644 index 0000000000..29d8a0f9dd --- /dev/null +++ b/cypress/component/gridLayout.cy.js @@ -0,0 +1,138 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { reactive } from 'vue' +import { + GRID_BREAKPOINT_MEDIUM, + GRID_BREAKPOINT_SINGLE, + GRID_COLUMNS, + applyNodePositions, + cloneGrid, + columnsForWidth, + defaultConfiguration, + defaultContent, + gridToWidgets, + nextFreeRow, + pageToGrid, + widgetTarget, +} from '../../src/modules/grid/gridLayout.js' +import { isHexColor } from '../../src/shared/utils/gridColors.js' + +const headerType = { + type: 'header', + configuration: { + title: { type: 'string', default: '' }, + showTitle: { type: 'boolean', default: false }, + }, + properties: { + title: { type: 'string', default: '' }, + textAlign: { type: 'enum', default: 'left' }, + }, +} + +const nodes = { + 4: { id: 4, node_type: 0, node_id: 40, permissions: 1 }, + 5: { id: 5, node_type: 1, node_id: 50, permissions: 1 }, +} + +const startPage = { + id: 1, + page_type: 'startpage', + content: { + 12: { id: 12, order: 20, node_rel_id: 5, type: 'data', configuration: { title: '', showTitle: false }, content: [], grid_x: 0, grid_y: 5, grid_width: 12, grid_height: 5 }, + 11: { id: 11, order: 10, node_rel_id: 4, type: 'data', configuration: { title: '', showTitle: false }, content: [], grid_x: 0, grid_y: 0, grid_width: 12, grid_height: 5 }, + 13: { id: 13, order: 30, node_rel_id: null, type: 'header', configuration: { title: '', showTitle: false }, content: { title: 'Hi' }, grid_x: 0, grid_y: 10, grid_width: 12, grid_height: 2 }, + }, +} + +describe('grid layout', () => { + it('starts a widget with every configuration and content property at its default', () => { + expect(defaultConfiguration(headerType)).to.deep.equal({ title: '', showTitle: false }) + expect(defaultContent(headerType)).to.deep.equal({ title: '', textAlign: 'left' }) + expect(defaultContent(null)).to.deep.equal({}) + }) + + it('copies a grid deeply, also when it is reactive', () => { + const stored = reactive({ widgets: [{ id: 'w-1', content: { title: 'Hi' } }], layout: [{ widgetId: 'w-1', gridX: 0 }] }) + + const copy = cloneGrid(stored) + copy.widgets[0].content.title = 'Changed' + copy.layout[0].gridX = 4 + + expect(stored.widgets[0].content.title).to.equal('Hi') + expect(stored.layout[0].gridX).to.equal(0) + expect(cloneGrid(undefined)).to.deep.equal({ widgets: [], layout: [] }) + }) + + it('reads a page into widgets in order with the table or view of each data widget', () => { + const grid = pageToGrid(startPage, nodes) + + expect(grid.widgets.map(widget => widget.id)).to.deep.equal(['c11', 'c12', 'c13']) + expect(grid.widgets[0]).to.deep.include({ type: 'data', nodeRelId: 4, node: { nodeType: 0, nodeId: 40 }, content: {} }) + expect(grid.widgets[1].node).to.deep.equal({ nodeType: 1, nodeId: 50 }) + expect(grid.widgets[2]).to.deep.include({ type: 'header', nodeRelId: null, node: null, content: { title: 'Hi' } }) + expect(grid.layout[1]).to.deep.equal({ widgetId: 'c12', gridX: 0, gridY: 5, gridWidth: 12, gridHeight: 5 }) + expect(widgetTarget(grid.widgets[0])).to.deep.equal({ type: 'table', id: 40 }) + expect(widgetTarget(grid.widgets[1])).to.deep.equal({ type: 'view', id: 50 }) + expect(widgetTarget(grid.widgets[2])).to.equal(null) + }) + + it('marks a data widget whose node is gone as without a table or view', () => { + const grid = pageToGrid({ content: [{ id: 1, order: 10, node_rel_id: 9, type: 'data', configuration: {}, content: [], grid_x: 0, grid_y: 0, grid_width: 12, grid_height: 5 }] }, nodes) + + expect(grid.widgets[0].node).to.equal(null) + expect(widgetTarget(grid.widgets[0])).to.equal(null) + }) + + it('saves data widgets by node relation when the application has the node, else by the node', () => { + const grid = pageToGrid(startPage, nodes) + grid.widgets.push({ id: 'new-1', type: 'data', nodeRelId: null, node: { nodeType: 0, nodeId: 70 }, configuration: { title: 'New', showTitle: true }, content: {} }) + grid.layout.push({ widgetId: 'new-1', gridX: 6, gridY: 12, gridWidth: 6, gridHeight: 4 }) + grid.widgets.push({ id: 'new-2', type: 'data', nodeRelId: null, node: { nodeType: 1, nodeId: 50 }, configuration: {}, content: {} }) + grid.layout.push({ widgetId: 'new-2', gridX: 0, gridY: 12, gridWidth: 6, gridHeight: 4 }) + grid.widgets.push({ id: 'unplaced', type: 'text', configuration: {}, content: {} }) + + const widgets = gridToWidgets(grid, nodes) + + expect(widgets).to.have.length(5) + expect(widgets[0]).to.deep.equal({ type: 'data', configuration: { title: '', showTitle: false }, content: {}, gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 5, nodeRelId: 4 }) + expect(widgets[2]).to.not.have.property('nodeRelId') + expect(widgets[2]).to.not.have.property('node') + expect(widgets[3]).to.deep.include({ node: { nodeType: 0, nodeId: 70 }, gridX: 6, gridWidth: 6 }) + expect(widgets[3]).to.not.have.property('nodeRelId') + expect(widgets[4]).to.deep.include({ nodeRelId: 5 }) + }) + + it('places a new widget below the lowest one', () => { + expect(nextFreeRow([])).to.equal(0) + expect(nextFreeRow([{ gridY: 0, gridHeight: 2 }, { gridY: 3, gridHeight: 4 }, { gridY: 1 }])).to.equal(7) + }) + + it('shows 12, 6 or 1 columns by canvas width', () => { + expect(columnsForWidth(GRID_BREAKPOINT_SINGLE - 1)).to.equal(1) + expect(columnsForWidth(GRID_BREAKPOINT_SINGLE)).to.equal(6) + expect(columnsForWidth(GRID_BREAKPOINT_MEDIUM - 1)).to.equal(6) + expect(columnsForWidth(GRID_BREAKPOINT_MEDIUM)).to.equal(GRID_COLUMNS) + }) + + it('applies only the positions of the nodes that changed', () => { + const layout = [ + { id: 'w-1', widgetId: 'w-1', gridX: 0, gridY: 0, gridWidth: 6, gridHeight: 2 }, + { id: 'w-2', widgetId: 'w-2', gridX: 6, gridY: 0, gridWidth: 6, gridHeight: 2 }, + ] + + const updated = applyNodePositions(layout, [{ id: 'w-2', x: 0, y: 2, w: 12, h: 3 }]) + + expect(updated[0]).to.equal(layout[0]) + expect(updated[1]).to.deep.equal({ id: 'w-2', widgetId: 'w-2', gridX: 0, gridY: 2, gridWidth: 12, gridHeight: 3 }) + }) + + it('accepts only hex colors, like the server', () => { + expect(isHexColor('#abc')).to.equal(true) + expect(isHexColor('#a1b2c3')).to.equal(true) + expect(isHexColor('red')).to.equal(false) + expect(isHexColor('url(evil)')).to.equal(false) + expect(isHexColor(null)).to.equal(false) + }) +}) diff --git a/package-lock.json b/package-lock.json index 6055c7b7bb..9beaf4a1d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@vueuse/core": "^12.8.2", "debounce": "^3.0.0", "dompurify": "^3.4.15", + "gridstack": "^13.3.0", "papaparse": "^5.7.0", "pinia": "^2.3.1", "vue": "^3.5.13", @@ -8113,6 +8114,22 @@ "dev": true, "license": "ISC" }, + "node_modules/gridstack": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/gridstack/-/gridstack-13.3.0.tgz", + "integrity": "sha512-1tqFd3/hb3CSOJWFG8aFleCGWT6Gqx/hnjRT78ocnixp0XPk5Aa6Uxkm0qn9VHFwLrEdOFb+ZXtQZT+a/IdS3g==", + "funding": [ + { + "type": "paypal", + "url": "https://www.paypal.me/alaind831" + }, + { + "type": "venmo", + "url": "https://www.venmo.com/adumesny" + } + ], + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/package.json b/package.json index 368472e681..cd1b051521 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@vueuse/core": "^12.8.2", "debounce": "^3.0.0", "dompurify": "^3.4.15", + "gridstack": "^13.3.0", "papaparse": "^5.7.0", "pinia": "^2.3.1", "vue": "^3.5.13", diff --git a/src/modules/grid/GridCanvas.vue b/src/modules/grid/GridCanvas.vue new file mode 100644 index 0000000000..8d7f95cf0c --- /dev/null +++ b/src/modules/grid/GridCanvas.vue @@ -0,0 +1,204 @@ + + + + + + diff --git a/src/modules/grid/GridWidget.vue b/src/modules/grid/GridWidget.vue new file mode 100644 index 0000000000..c13eb56aaf --- /dev/null +++ b/src/modules/grid/GridWidget.vue @@ -0,0 +1,202 @@ + + + + + + diff --git a/src/modules/grid/SchemaForm.vue b/src/modules/grid/SchemaForm.vue new file mode 100644 index 0000000000..a7a4f7fd43 --- /dev/null +++ b/src/modules/grid/SchemaForm.vue @@ -0,0 +1,151 @@ + + + + + + diff --git a/src/modules/grid/gridLayout.js b/src/modules/grid/gridLayout.js new file mode 100644 index 0000000000..3015faf0bd --- /dev/null +++ b/src/modules/grid/gridLayout.js @@ -0,0 +1,212 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { NODE_TYPE_TABLE, NODE_TYPE_VIEW } from '../../shared/constants.ts' + +/** Number of columns of a grid page. */ +export const GRID_COLUMNS = 12 + +/** Smallest number of columns a widget spans. */ +export const GRID_MIN_WIDTH = 2 + +/** Height of one grid row. In pixels on purpose: GridStack takes a number, not a CSS variable. */ +export const GRID_CELL_HEIGHT = 80 + +/** Space between widgets in pixels: two grid baselines of 4px. */ +export const GRID_MARGIN = 8 + +/** Below this canvas width in pixels the grid shows one column. */ +export const GRID_BREAKPOINT_SINGLE = 560 + +/** Below this canvas width in pixels the grid shows six columns. */ +export const GRID_BREAKPOINT_MEDIUM = 960 + +/** + * Deep copy of plain data. A JSON round-trip also copies reactive proxies, which + * structuredClone refuses. + * + * @param {object|Array|string|number|boolean|null|undefined} value plain data + * @return {object|Array|string|number|boolean|null} the copy + */ +function copy(value) { + return JSON.parse(JSON.stringify(value ?? null)) +} + +/** + * @param {Array} widgetTypes the schemas loaded from the server + * @param {string} type widget type + * @return {object|null} the schema of that type, or null when the server does not know it + */ +export function findWidgetType(widgetTypes, type) { + return widgetTypes.find(widgetType => widgetType.type === type) ?? null +} + +/** + * The configuration a new widget of this type starts with: every property at its default. + * + * @param {object} widgetType a schema from the server + * @return {object} the configuration + */ +export function defaultConfiguration(widgetType) { + return Object.fromEntries(Object.entries(widgetType?.configuration ?? {}).map(([name, property]) => [name, copy(property.default)])) +} + +/** + * The content a new widget of this type starts with: every property at its default. + * + * @param {object} widgetType a schema from the server + * @return {object} the content + */ +export function defaultContent(widgetType) { + return Object.fromEntries(Object.entries(widgetType?.properties ?? {}).map(([name, property]) => [name, copy(property.default)])) +} + +/** + * Deep copy of a grid so edits never touch the stored page until saved. + * + * @param {object} grid grid as built by pageToGrid + * @return {{widgets: Array, layout: Array}} the copy + */ +export function cloneGrid(grid) { + return { + widgets: copy(grid?.widgets ?? []), + layout: copy(grid?.layout ?? []), + } +} + +/** + * The row below the lowest widget, where a new widget is placed. + * + * @param {Array} layout layout items + * @return {number} the row + */ +export function nextFreeRow(layout) { + return layout.reduce((max, item) => Math.max(max, (item.gridY ?? 0) + (item.gridHeight ?? 1)), 0) +} + +/** + * How many columns the grid shows on a canvas of this width: 12 on a wide one, 6 on a + * medium one and one on a phone. + * + * @param {number} width width of the canvas in pixels + * @return {number} the number of columns + */ +export function columnsForWidth(width) { + if (width < GRID_BREAKPOINT_SINGLE) { + return 1 + } + return width < GRID_BREAKPOINT_MEDIUM ? 6 : GRID_COLUMNS +} + +/** + * The layout with the positions of the GridStack nodes that changed applied to it. + * + * @param {Array} layout layout items + * @param {Array<{id: string, x: number, y: number, w: number, h: number}>} nodes the changed nodes + * @return {Array} the updated layout + */ +export function applyNodePositions(layout, nodes) { + const positions = Object.fromEntries(nodes.map(node => [node.id, node])) + return layout.map(item => { + const node = positions[item.widgetId] + return node ? { ...item, gridX: node.x, gridY: node.y, gridWidth: node.w, gridHeight: node.h } : item + }) +} + +/** + * The table or view a node relation of the application points at. + * + * @param {object|undefined} node a node of the application, as in context.nodes + * @return {{nodeType: number, nodeId: number}|null} the node, or null for an unknown one + */ +function nodeOf(node) { + if (!node) { + return null + } + const nodeType = parseInt(node.node_type) + return nodeType === NODE_TYPE_TABLE || nodeType === NODE_TYPE_VIEW ? { nodeType, nodeId: parseInt(node.node_id) } : null +} + +/** + * The grid of a page: its widgets in order and where they sit. A data widget carries the + * node relation it is stored with and the table or view behind it. + * + * @param {object} page a page as the API returns it + * @param {object} nodes the nodes of the application by relation id + * @return {{widgets: Array, layout: Array}} the grid + */ +export function pageToGrid(page, nodes) { + const content = Object.values(page?.content ?? {}) + .sort((a, b) => (a.order - b.order) || (a.id - b.id)) + return { + widgets: content.map(entry => ({ + id: 'c' + entry.id, + type: entry.type, + nodeRelId: entry.node_rel_id ?? null, + node: entry.node_rel_id ? nodeOf(nodes?.[entry.node_rel_id]) : null, + configuration: copy(entry.configuration ?? {}), + content: Array.isArray(entry.content) ? {} : copy(entry.content ?? {}), + })), + layout: content.map(entry => ({ + widgetId: 'c' + entry.id, + gridX: entry.grid_x, + gridY: entry.grid_y, + gridWidth: entry.grid_width, + gridHeight: entry.grid_height, + })), + } +} + +/** + * The table or view a data widget shows. + * + * @param {object} widget a widget of the grid + * @return {{type: string, id: number}|null} the target, or null when it has none + */ +export function widgetTarget(widget) { + if (!widget?.node) { + return null + } + return { type: widget.node.nodeType === NODE_TYPE_VIEW ? 'view' : 'table', id: widget.node.nodeId } +} + +/** + * The widgets of a grid as the API saves them. A data widget names the node relation of + * the application that points at its table or view, or the table or view itself when the + * application does not have it yet. + * + * @param {{widgets: Array, layout: Array}} grid the grid + * @param {object} nodes the nodes of the application by relation id + * @return {Array} the widgets to save + */ +export function gridToWidgets(grid, nodes) { + const positions = Object.fromEntries(grid.layout.map(item => [item.widgetId, item])) + return grid.widgets.filter(widget => positions[widget.id]).map(widget => { + const { gridX, gridY, gridWidth, gridHeight } = positions[widget.id] + const saved = { + type: widget.type, + configuration: copy(widget.configuration ?? {}), + content: copy(widget.content ?? {}), + gridX, + gridY, + gridWidth, + gridHeight, + } + if (widget.node) { + const relation = Object.values(nodes ?? {}).find(node => { + const known = nodeOf(node) + return known?.nodeType === widget.node.nodeType && known?.nodeId === widget.node.nodeId + }) + if (relation) { + saved.nodeRelId = parseInt(relation.id) + } else { + saved.node = { ...widget.node } + } + } else if (widget.nodeRelId) { + saved.nodeRelId = widget.nodeRelId + } + return saved + }) +} diff --git a/src/modules/grid/widgetRegistry.js b/src/modules/grid/widgetRegistry.js new file mode 100644 index 0000000000..bd958d46d1 --- /dev/null +++ b/src/modules/grid/widgetRegistry.js @@ -0,0 +1,30 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import HeaderWidget from './widgets/HeaderWidget.vue' +import TextWidget from './widgets/TextWidget.vue' +import DataWidget from './widgets/DataWidget.vue' + +export const WIDGET_TYPE_HEADER = 'header' +export const WIDGET_TYPE_TEXT = 'text' +export const WIDGET_TYPE_DATA = 'data' + +/** + * The component that renders each widget type. Everything else about a type, + * its title, default size and the schema of its content, comes from the server + * (see GridWidgetTypes.php) so the form and the validation share one definition. + */ +const widgetComponents = { + [WIDGET_TYPE_HEADER]: HeaderWidget, + [WIDGET_TYPE_TEXT]: TextWidget, + [WIDGET_TYPE_DATA]: DataWidget, +} + +/** + * @param {string} type widget type + * @return {object|null} the component that renders it + */ +export function getWidgetComponent(type) { + return widgetComponents[type] ?? null +} diff --git a/src/modules/grid/widgets/DataWidget.vue b/src/modules/grid/widgets/DataWidget.vue new file mode 100644 index 0000000000..9e4d5c197f --- /dev/null +++ b/src/modules/grid/widgets/DataWidget.vue @@ -0,0 +1,192 @@ + + + + + + diff --git a/src/modules/grid/widgets/HeaderWidget.vue b/src/modules/grid/widgets/HeaderWidget.vue new file mode 100644 index 0000000000..3caf3babd3 --- /dev/null +++ b/src/modules/grid/widgets/HeaderWidget.vue @@ -0,0 +1,73 @@ + + + + + + diff --git a/src/modules/grid/widgets/TextWidget.vue b/src/modules/grid/widgets/TextWidget.vue new file mode 100644 index 0000000000..11f4543a4b --- /dev/null +++ b/src/modules/grid/widgets/TextWidget.vue @@ -0,0 +1,54 @@ + + + + + + diff --git a/src/modules/modals/AddWidget.vue b/src/modules/modals/AddWidget.vue new file mode 100644 index 0000000000..2242343590 --- /dev/null +++ b/src/modules/modals/AddWidget.vue @@ -0,0 +1,206 @@ + + + + + + diff --git a/src/shared/utils/gridColors.js b/src/shared/utils/gridColors.js new file mode 100644 index 0000000000..9e54849ce3 --- /dev/null +++ b/src/shared/utils/gridColors.js @@ -0,0 +1,14 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Only hex colors are accepted in widget content, the same rule the backend applies. + * + * @param {string|null|undefined} value a color as entered by the user + * @return {boolean} + */ +export function isHexColor(value) { + return typeof value === 'string' && /^#[0-9a-fA-F]{3,6}$/.test(value) +} diff --git a/src/store/store.js b/src/store/store.js index c6d9e213df..0cdb4c7cca 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -11,6 +11,9 @@ import displayError from '../shared/utils/displayError.js' import { NODE_TYPE_TABLE, NODE_TYPE_VIEW } from '../shared/constants.ts' import { getCurrentUser } from '@nextcloud/auth' +// The one request for the widget types while it runs, so widgets that mount together share it +let widgetTypesRequest = null + export const useTablesStore = defineStore('store', { state: () => ({ loading: { @@ -27,6 +30,7 @@ export const useTablesStore = defineStore('store', { activeRowId: null, activeElementIsView: false, activeContextId: null, + widgetTypes: [], appNavCollapsed: false, }), @@ -499,6 +503,108 @@ export const useTablesStore = defineStore('store', { return true }, + /** + * Loads the widget types the first time a grid needs them. + */ + async loadWidgetTypes() { + if (this.widgetTypes.length > 0) { + return + } + widgetTypesRequest ??= axios.get(generateOcsUrl('/apps/tables/api/2/widget-types')) + .then(res => { this.widgetTypes = res.data.ocs.data }) + .catch(e => displayError(e, t('tables', 'Could not load widget types.'))) + .finally(() => { widgetTypesRequest = null }) + await widgetTypesRequest + }, + + /** + * @param {object} params parameters + * @param {number} params.contextId id of the application + * @return {Promise} the pages with their widgets, or null when loading failed + */ + async loadContextPages({ contextId }) { + try { + return (await axios.get(generateOcsUrl('/apps/tables/api/2/contexts/{contextId}/pages', { contextId }))).data.ocs.data + } catch (e) { + displayError(e, t('tables', 'Could not load the pages of the application.')) + return null + } + }, + + /** + * @param {object} params parameters + * @param {number} params.contextId id of the application + * @param {string} params.title title of the page + * @param {string|null} params.technicalName technical name of the page + * @return {Promise} the new page, or null when it could not be created + */ + async createContextPage({ contextId, title, technicalName = null }) { + try { + return (await axios.post(generateOcsUrl('/apps/tables/api/2/contexts/{contextId}/pages', { contextId }), { title, technicalName })).data.ocs.data + } catch (e) { + displayError(e, t('tables', 'Could not create the page.')) + return null + } + }, + + /** + * @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 and technicalName to change; null keeps a value + * @return {Promise} the page, or null when it could not be changed + */ + async updateContextPage({ contextId, pageId, data }) { + try { + return (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 change the page.')) + return null + } + }, + + /** + * @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 })) + return true + } catch (e) { + displayError(e, t('tables', 'Could not delete the page.')) + return false + } + }, + + /** + * Saves the widgets of a page. A table or view that was not part of the application + * becomes part of it, so the application is loaded again afterwards. + * + * @param {object} params parameters + * @param {number} params.contextId id of the application + * @param {number} params.pageId id of the page + * @param {Array} params.widgets the widgets as the API takes them + * @return {Promise} the saved page, or null when saving failed + */ + async saveContextPageWidgets({ contextId, pageId, widgets }) { + let page + try { + page = (await axios.put(generateOcsUrl('/apps/tables/api/2/contexts/{contextId}/pages/{pageId}/widgets', { contextId, pageId }), { widgets })).data.ocs.data + } catch (e) { + displayError(e, t('tables', 'Could not save the page.')) + return null + } + try { + await this.loadContext({ id: contextId }) + } catch { + // loadContext reported the error; the page itself was saved + } + return page + }, + async getContextsTablesAndViews() { for (const context of this.contexts) { for (const node of Object.values(context?.nodes)) {