diff --git a/cypress/component/gridLayout.cy.js b/cypress/component/gridLayout.cy.js new file mode 100644 index 0000000000..919b38820c --- /dev/null +++ b/cypress/component/gridLayout.cy.js @@ -0,0 +1,82 @@ +/** + * 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, + nextFreeRow, +} 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' }, + target: { type: 'target', default: null }, + }, +} + +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', target: null }) + 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('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..8c26456949 --- /dev/null +++ b/src/modules/grid/GridWidget.vue @@ -0,0 +1,190 @@ + + + + + + diff --git a/src/modules/grid/gridLayout.js b/src/modules/grid/gridLayout.js new file mode 100644 index 0000000000..51608cd5af --- /dev/null +++ b/src/modules/grid/gridLayout.js @@ -0,0 +1,123 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** Number of columns of a grid view. */ +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)])) +} + +/** + * An empty grid, the value of a view that was never designed. + * + * @return {{widgets: Array, layout: Array}} the grid + */ +export function emptyGrid() { + return { widgets: [], layout: [] } +} + +/** + * Deep copy of a grid so edits never touch the stored view until saved. + * + * @param {object} grid grid as stored on the view + * @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 + }) +} diff --git a/src/modules/grid/widgetRegistry.js b/src/modules/grid/widgetRegistry.js new file mode 100644 index 0000000000..49bf755b94 --- /dev/null +++ b/src/modules/grid/widgetRegistry.js @@ -0,0 +1,28 @@ +/** + * 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' + +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, +} + +/** + * @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/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/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..e7c7a416cf 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, }), @@ -232,6 +236,20 @@ export const useTablesStore = defineStore('store', { return res.data.id }, + /** + * 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/views/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 + }, + async updateView({ id, data }) { let res = null