From 929306dd3e75830fad88d4c10a96f94f1f38bf75 Mon Sep 17 00:00:00 2001 From: Remko Date: Mon, 12 Oct 2026 10:28:27 +0200 Subject: [PATCH 1/3] feat(grid): render grid widgets on a gridstack canvas Adds the building blocks of a grid view: a canvas that places widgets on a 12 column grid with gridstack, the card around each widget, and the header and text widgets. The widget types are loaded from the server. - the grid reflows to 6 and 1 columns on narrower screens, while the stored layout keeps the 12 column positions - in edit mode widgets can be dragged and resized, or moved and resized from the widget menu with the keyboard - colors from widget content are applied only when they are hex values Co-authored-by: Ruben van der Linde Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Remko --- package-lock.json | 17 ++ package.json | 1 + src/App.vue | 1 + src/modules/grid/GridCanvas.vue | 193 ++++++++++++++++++++++ src/modules/grid/GridWidget.vue | 145 ++++++++++++++++ src/modules/grid/widgetRegistry.js | 98 +++++++++++ src/modules/grid/widgets/HeaderWidget.vue | 66 ++++++++ src/modules/grid/widgets/TextWidget.vue | 49 ++++++ src/shared/utils/gridColors.js | 14 ++ src/store/store.js | 10 ++ 10 files changed, 594 insertions(+) create mode 100644 src/modules/grid/GridCanvas.vue create mode 100644 src/modules/grid/GridWidget.vue create mode 100644 src/modules/grid/widgetRegistry.js create mode 100644 src/modules/grid/widgets/HeaderWidget.vue create mode 100644 src/modules/grid/widgets/TextWidget.vue create mode 100644 src/shared/utils/gridColors.js diff --git a/package-lock.json b/package-lock.json index daa7b4c41f..ad3ab70f7d 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", @@ -9759,6 +9760,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 5b6323de10..efc14fe436 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/App.vue b/src/App.vue index d294ae6c2c..9a564e14be 100644 --- a/src/App.vue +++ b/src/App.vue @@ -67,6 +67,7 @@ export default { store.getAllContexts(), store.loadViewsSharedWithMeFromBE(), store.loadTemplatesFromBE(), + store.loadWidgetTypes(), ]) } else { store.setLoading({ key: 'tables', value: false }) diff --git a/src/modules/grid/GridCanvas.vue b/src/modules/grid/GridCanvas.vue new file mode 100644 index 0000000000..4ec283f7b8 --- /dev/null +++ b/src/modules/grid/GridCanvas.vue @@ -0,0 +1,193 @@ + + + + + + diff --git a/src/modules/grid/GridWidget.vue b/src/modules/grid/GridWidget.vue new file mode 100644 index 0000000000..eff3ad7521 --- /dev/null +++ b/src/modules/grid/GridWidget.vue @@ -0,0 +1,145 @@ + + + + + + diff --git a/src/modules/grid/widgetRegistry.js b/src/modules/grid/widgetRegistry.js new file mode 100644 index 0000000000..7aad4721f0 --- /dev/null +++ b/src/modules/grid/widgetRegistry.js @@ -0,0 +1,98 @@ +/** + * 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' + +/** 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. */ +export const GRID_CELL_HEIGHT = 80 + +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 +} + +/** + * @param {Array} widgetTypes the schemas loaded from the server + * @param {string} type widget type + * @return {object|null} + */ +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} + */ +export function defaultConfiguration(widgetType) { + return Object.fromEntries(Object.entries(widgetType?.configuration ?? {}).map(([name, property]) => [name, structuredClone(property.default ?? null)])) +} + +/** + * 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} + */ +export function defaultContent(widgetType) { + return Object.fromEntries(Object.entries(widgetType?.properties ?? {}).map(([name, property]) => [name, structuredClone(property.default ?? null)])) +} + +/** + * An empty grid, the value of a view that was never designed. + * + * @return {{widgets: Array, layout: Array}} + */ +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}} + */ +export function cloneGrid(grid) { + return { + widgets: JSON.parse(JSON.stringify(grid?.widgets ?? [])), + layout: JSON.parse(JSON.stringify(grid?.layout ?? [])), + } +} + +/** + * The row below the lowest widget, where a new widget is placed. + * + * @param {Array} layout layout items + * @return {number} + */ +export function nextFreeRow(layout) { + return layout.reduce((max, item) => Math.max(max, (item.gridY ?? 0) + (item.gridHeight ?? 1)), 0) +} diff --git a/src/modules/grid/widgets/HeaderWidget.vue b/src/modules/grid/widgets/HeaderWidget.vue new file mode 100644 index 0000000000..f862ca8095 --- /dev/null +++ b/src/modules/grid/widgets/HeaderWidget.vue @@ -0,0 +1,66 @@ + + + + + + diff --git a/src/modules/grid/widgets/TextWidget.vue b/src/modules/grid/widgets/TextWidget.vue new file mode 100644 index 0000000000..adba977c83 --- /dev/null +++ b/src/modules/grid/widgets/TextWidget.vue @@ -0,0 +1,49 @@ + + + + + + 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 3bfa80586c..92ab9d38ab 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -27,6 +27,7 @@ export const useTablesStore = defineStore('store', { activeRowId: null, activeElementIsView: false, activeContextId: null, + widgetTypes: [], appNavCollapsed: false, }), @@ -215,6 +216,15 @@ export const useTablesStore = defineStore('store', { return res.data.id }, + async loadWidgetTypes() { + try { + const res = await axios.get(generateOcsUrl('/apps/tables/api/2/views/widget-types')) + this.widgetTypes = res.data.ocs.data + } catch (e) { + displayError(e, t('tables', 'Could not load widget types.')) + } + }, + async updateView({ id, data }) { let res = null From 0d46b6c1ed2f2c1b082eb8dd59980bf141912465 Mon Sep 17 00:00:00 2001 From: Remko Date: Mon, 12 Oct 2026 14:15:46 +0200 Subject: [PATCH 2/3] fix(grid): arrange widgets only at full width and load widget types lazily - on a canvas narrower than 12 columns drag, resize and the move actions are off with a hint, since that reflow is only shown and never stored - widget types load the first time a widget needs them, in one request, instead of on every app start - the column choice and the layout mapping are pure, named functions with component tests, together with the widget defaults, the grid copy and the hex color check - one deep copy for defaults and grids, which also copies reactive data - a header widget's heading sits below the card's own heading - widgets no longer get an editable attribute they do not declare, and the grid baseline variable is used without a fallback Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Remko --- cypress/component/gridWidgetRegistry.cy.js | 82 +++++++++++++++++++++ src/App.vue | 1 - src/modules/grid/GridCanvas.vue | 83 ++++++++++++---------- src/modules/grid/GridWidget.vue | 36 +++++++--- src/modules/grid/widgetRegistry.js | 54 ++++++++++++-- src/modules/grid/widgets/HeaderWidget.vue | 11 +-- src/modules/grid/widgets/TextWidget.vue | 4 +- src/store/store.js | 20 ++++-- 8 files changed, 230 insertions(+), 61 deletions(-) create mode 100644 cypress/component/gridWidgetRegistry.cy.js diff --git a/cypress/component/gridWidgetRegistry.cy.js b/cypress/component/gridWidgetRegistry.cy.js new file mode 100644 index 0000000000..4b8b941273 --- /dev/null +++ b/cypress/component/gridWidgetRegistry.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/widgetRegistry.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 widget registry', () => { + 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/src/App.vue b/src/App.vue index 9a564e14be..d294ae6c2c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -67,7 +67,6 @@ export default { store.getAllContexts(), store.loadViewsSharedWithMeFromBE(), store.loadTemplatesFromBE(), - store.loadWidgetTypes(), ]) } else { store.setLoading({ key: 'tables', value: false }) diff --git a/src/modules/grid/GridCanvas.vue b/src/modules/grid/GridCanvas.vue index 4ec283f7b8..9ab708361d 100644 --- a/src/modules/grid/GridCanvas.vue +++ b/src/modules/grid/GridCanvas.vue @@ -3,19 +3,24 @@ - SPDX-License-Identifier: AGPL-3.0-or-later -->