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)) {