From 658c130852cc230b39a2f36ce4c570bd2a33abae Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 2 Nov 2026 11:48:07 +0100 Subject: [PATCH 1/2] feat(pages): add the grid canvas and its widgets A grid page shows widgets on a 12 column GridStack canvas: a header, a text and a table or view. Widgets carry an optional named icon and description next to their title. In edit mode widgets can be dragged and resized, and moved and resized from their menu with a keyboard. On a narrower screen the grid reflows to 6 columns or one, and that reflow is never saved. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde --- cypress/component/GridCanvas.cy.js | 60 +++++ cypress/component/GridWidget.cy.js | 56 +++++ cypress/component/gridLayout.cy.js | 115 +++++++++ package-lock.json | 17 ++ package.json | 1 + src/modules/grid/GridCanvas.vue | 204 ++++++++++++++++ src/modules/grid/GridWidget.vue | 229 ++++++++++++++++++ src/modules/grid/gridLayout.js | 180 ++++++++++++++ src/modules/grid/widgetRegistry.js | 30 +++ src/modules/grid/widgets/DataWidget.vue | 208 ++++++++++++++++ src/modules/grid/widgets/HeaderWidget.vue | 73 ++++++ src/modules/grid/widgets/TextWidget.vue | 54 +++++ .../components/ncIconPicker/NamedIcon.vue | 54 +++++ src/shared/utils/gridColors.js | 14 ++ src/store/store.js | 18 ++ 15 files changed, 1313 insertions(+) create mode 100644 cypress/component/GridCanvas.cy.js create mode 100644 cypress/component/GridWidget.cy.js 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/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/shared/components/ncIconPicker/NamedIcon.vue create mode 100644 src/shared/utils/gridColors.js diff --git a/cypress/component/GridCanvas.cy.js b/cypress/component/GridCanvas.cy.js new file mode 100644 index 0000000000..80bdf91a26 --- /dev/null +++ b/cypress/component/GridCanvas.cy.js @@ -0,0 +1,60 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { h } from 'vue' +import GridCanvas from '../../src/modules/grid/GridCanvas.vue' + +const layout = [ + { widgetId: 'w1', gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 2 }, + { widgetId: 'w2', gridX: 0, gridY: 2, gridWidth: 6, gridHeight: 3 }, +] + +/** + * @param {object} props props of the canvas + */ +function mountCanvas(props) { + cy.mount(GridCanvas, { + props, + slots: { widget: ({ item }) => h('span', { 'data-cy': 'slotWidget' }, item.widgetId) }, + }).then(({ wrapper }) => cy.wrap(wrapper).as('wrapper')) +} + +describe('GridCanvas', () => { + it('places every widget of the layout where it is stored', () => { + cy.viewport(1400, 800) + mountCanvas({ layout, editable: false }) + + cy.get('[data-cy="gridItem"]').should('have.length', 2) + cy.get('[data-cy="gridItem"][gs-id="w2"]').should('have.attr', 'gs-w', '6') + cy.get('[data-cy="slotWidget"]').first().should('have.text', 'w1') + cy.get('.grid-canvas__stack--editing').should('not.exist') + }) + + it('can be arranged in edit mode on a wide screen', () => { + cy.viewport(1400, 800) + mountCanvas({ layout, editable: true }) + + cy.get('.grid-canvas__stack--editing').should('exist') + cy.get('[data-cy="gridNarrowHint"]').should('not.exist') + }) + + it('is only shown, not arranged, on a narrow screen', () => { + cy.viewport(420, 800) + mountCanvas({ layout, editable: true }) + + cy.get('[data-cy="gridNarrowHint"]').should('be.visible') + cy.get('.grid-canvas__stack--editing').should('not.exist') + }) + + it('adds a widget that appears in the layout', () => { + cy.viewport(1400, 800) + mountCanvas({ layout, editable: true }) + + cy.get('@wrapper').then(wrapper => wrapper.setProps({ layout: [...layout, { widgetId: 'w3', gridX: 6, gridY: 2, gridWidth: 6, gridHeight: 3 }] })) + cy.get('[data-cy="gridItem"]').should('have.length', 3) + cy.get('@wrapper').should(wrapper => { + expect(wrapper.vm.grid.engine.nodes.map(node => node.id)).to.include('w3') + }) + }) +}) diff --git a/cypress/component/GridWidget.cy.js b/cypress/component/GridWidget.cy.js new file mode 100644 index 0000000000..8db2af70da --- /dev/null +++ b/cypress/component/GridWidget.cy.js @@ -0,0 +1,56 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import GridWidget from '../../src/modules/grid/GridWidget.vue' + +const nodes = { 4: { id: 4, node_type: 0, node_id: 40, permissions: 1 } } + +/** + * @param {object} widget the widget to show + * @param {boolean} editable whether the page is edited + * @param {object|null} layoutItem where the widget sits + */ +function mountWidget(widget, editable, layoutItem = null) { + cy.intercept('GET', '**/apps/tables/api/2/widget-types', { ocs: { data: [] } }) + cy.mount(GridWidget, { props: { widget, nodes, editable, layoutItem } }).then(({ wrapper }) => cy.wrap(wrapper).as('wrapper')) +} + +describe('GridWidget', () => { + it('shows the title, icon and description of a widget', () => { + mountWidget({ key: 'w1', id: 1, type: 'header', title: 'Welcome', icon: 'star', description: 'What we agreed', settings: { heading: 'Projects', subheading: 'This quarter', textAlign: 'end' }, nodeRelId: null }, false) + + cy.get('[data-cy="gridWidget"] h2').should('contain.text', 'Welcome') + cy.get('[data-cy="gridWidget"] h2 svg').should('exist') + cy.get('[data-cy="gridWidgetDescription"]').should('have.text', 'What we agreed') + cy.get('.header-widget').should('contain.text', 'Projects').and('contain.text', 'This quarter').and('have.css', 'text-align', 'end') + }) + + it('shows no heading for a widget without a title outside edit mode', () => { + mountWidget({ key: 'w2', id: 2, type: 'text', title: '', settings: { text: 'First\n\nSecond' }, nodeRelId: null }, false) + + cy.get('[data-cy="gridWidget"] h2').should('not.exist') + cy.get('.text-widget__paragraph').should('have.length', 2) + }) + + it('asks editors to pick a table or view for a data widget without one, and shows viewers an empty state', () => { + mountWidget({ key: 'w3', id: 3, type: 'data', title: '', settings: {}, nodeRelId: 9 }, true) + cy.get('[data-cy="dataWidgetPickSource"]').should('be.visible') + cy.get('[data-cy="dataWidgetNoSource"]').should('not.exist') + + mountWidget({ key: 'w4', id: 4, type: 'data', title: 'Tasks', settings: {}, nodeRelId: null }, false) + cy.get('[data-cy="dataWidgetNoSource"]').should('contain.text', 'No table or view to show') + cy.get('[data-cy="dataWidgetPickSource"]').should('not.exist') + }) + + it('moves and resizes a widget from its menu, so the grid can be arranged with a keyboard', () => { + mountWidget({ key: 'w6', id: 6, type: 'text', title: 'Notes', settings: { text: '' }, nodeRelId: null }, true, { widgetId: 'w6', gridX: 0, gridY: 0, gridWidth: 6, gridHeight: 2 }) + + cy.get('[data-cy="gridWidget"] button[aria-label="Widget actions"]').click() + cy.get('[data-cy="widgetMoveLeft"]').should('have.class', 'action--disabled') + cy.get('[data-cy="widgetMoveRight"] button').click() + cy.get('@wrapper').then(wrapper => { + expect(wrapper.emitted('move')[0][1]).to.deep.equal({ gridX: 1 }) + }) + }) +}) diff --git a/cypress/component/gridLayout.cy.js b/cypress/component/gridLayout.cy.js new file mode 100644 index 0000000000..ac3cd47b4d --- /dev/null +++ b/cypress/component/gridLayout.cy.js @@ -0,0 +1,115 @@ +/** + * 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, + gridToWidgets, + nextFreeRow, + pageToGrid, + widgetTarget, +} from '../../src/modules/grid/gridLayout.js' +import { isHexColor } from '../../src/shared/utils/gridColors.js' + +const widgetTypes = [ + { type: 'header', hasSource: false, settings: { heading: { type: 'string', default: '' }, textAlign: { type: 'enum', default: 'start' } } }, + { type: 'text', hasSource: false, settings: { text: { type: 'text', default: '' } } }, + { type: 'data', hasSource: true, settings: {} }, +] + +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 page = { + id: 2, + pageType: 'grid', + widgets: [ + { id: 11, type: 'header', title: null, gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 2, settings: { heading: 'Hi', textAlign: 'center' }, source: null }, + { id: 12, type: 'data', title: 'Tasks', icon: 'check', description: 'Open work', gridX: 0, gridY: 2, gridWidth: 6, gridHeight: 5, settings: [], source: { nodeRelId: 4 } }, + { id: 13, type: 'data', title: null, gridX: 6, gridY: 2, gridWidth: 6, gridHeight: 5, settings: [], source: { nodeRelId: 5 } }, + { id: 14, type: 'data', title: null, gridX: 0, gridY: 7, gridWidth: 12, gridHeight: 5, settings: [], source: null }, + ], +} + +describe('grid layout', () => { + it('copies a grid deeply, also when it is reactive', () => { + const stored = reactive({ widgets: [{ key: 'w1', settings: { heading: 'Hi' } }], layout: [{ widgetId: 'w1', gridX: 0 }] }) + + const copy = cloneGrid(stored) + copy.widgets[0].settings.heading = 'Changed' + copy.layout[0].gridX = 4 + + expect(stored.widgets[0].settings.heading).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 with the source of each data widget', () => { + const grid = pageToGrid(page) + + expect(grid.widgets.map(widget => widget.key)).to.deep.equal(['w11', 'w12', 'w13', 'w14']) + expect(grid.widgets[0]).to.deep.equal({ key: 'w11', id: 11, type: 'header', title: '', icon: null, description: '', settings: { heading: 'Hi', textAlign: 'center' }, nodeRelId: null }) + expect(grid.widgets[1]).to.deep.include({ title: 'Tasks', icon: 'check', description: 'Open work', settings: {}, nodeRelId: 4 }) + expect(grid.layout[2]).to.deep.equal({ widgetId: 'w13', gridX: 6, gridY: 2, gridWidth: 6, gridHeight: 5 }) + expect(widgetTarget(grid.widgets[1], nodes)).to.deep.equal({ type: 'table', id: 40 }) + expect(widgetTarget(grid.widgets[2], nodes)).to.deep.equal({ type: 'view', id: 50 }) + expect(widgetTarget(grid.widgets[3], nodes)).to.equal(null) + // The table or view left the application + expect(widgetTarget(grid.widgets[1], { 5: nodes[5] })).to.equal(null) + }) + + it('saves widgets with their id, and a source only for types that have one', () => { + const grid = pageToGrid(page) + grid.widgets.push({ key: 'new-1', id: null, type: 'text', title: ' Notes ', icon: 'star', description: ' Read me ', settings: { text: 'A' }, nodeRelId: null }) + grid.layout.push({ widgetId: 'new-1', gridX: 0, gridY: 12, gridWidth: 6, gridHeight: 2 }) + grid.widgets.push({ key: 'unplaced', id: null, type: 'text', title: '', settings: {}, nodeRelId: null }) + + const widgets = gridToWidgets(grid, widgetTypes) + + expect(widgets).to.have.length(5) + expect(widgets[0]).to.deep.equal({ id: 11, type: 'header', title: null, icon: null, description: null, gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 2, settings: { heading: 'Hi', textAlign: 'center' } }) + expect(widgets[1]).to.deep.include({ id: 12, title: 'Tasks', icon: 'check', description: 'Open work', source: { nodeRelId: 4 } }) + expect(widgets[3]).to.deep.include({ id: 14, source: null }) + expect(widgets[4]).to.deep.equal({ type: 'text', title: 'Notes', icon: 'star', description: 'Read me', gridX: 0, gridY: 12, gridWidth: 6, gridHeight: 2, settings: { text: 'A' } }) + }) + + 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 = [ + { widgetId: 'w1', gridX: 0, gridY: 0, gridWidth: 6, gridHeight: 2 }, + { widgetId: 'w2', gridX: 6, gridY: 0, gridWidth: 6, gridHeight: 2 }, + ] + + const updated = applyNodePositions(layout, [{ id: 'w2', x: 0, y: 2, w: 12, h: 3 }]) + + expect(updated[0]).to.equal(layout[0]) + expect(updated[1]).to.deep.equal({ widgetId: 'w2', 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 fb673f8071..60afeb619f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@vueuse/core": "^15.0.0", "debounce": "^3.0.0", "dompurify": "^3.4.17", + "gridstack": "^13.3.0", "papaparse": "^5.7.0", "pinia": "^4.1.0", "vue": "^3.5.44", @@ -8189,6 +8190,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 0f0d24f5cb..e67632f3e8 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@vueuse/core": "^15.0.0", "debounce": "^3.0.0", "dompurify": "^3.4.17", + "gridstack": "^13.3.0", "papaparse": "^5.7.0", "pinia": "^4.1.0", "vue": "^3.5.44", 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..fd79d85111 --- /dev/null +++ b/src/modules/grid/GridWidget.vue @@ -0,0 +1,229 @@ + + + + + + diff --git a/src/modules/grid/gridLayout.js b/src/modules/grid/gridLayout.js new file mode 100644 index 0000000000..7e7c04282a --- /dev/null +++ b/src/modules/grid/gridLayout.js @@ -0,0 +1,180 @@ +/** + * 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 types loaded from the server + * @param {string} type widget type + * @return {object|null} that type, or null when the server does not know it + */ +export function findWidgetType(widgetTypes, type) { + return widgetTypes.find(widgetType => widgetType.type === type) ?? null +} + +/** + * 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 grid of a page: its widgets and where they sit. Widgets are keyed by `key`, which + * is their id for saved widgets; a data widget keeps the node relation of its source. + * + * @param {object} page a page as the API returns it + * @return {{widgets: Array, layout: Array}} the grid + */ +export function pageToGrid(page) { + const widgets = page?.widgets ?? [] + return { + widgets: widgets.map(widget => ({ + key: 'w' + widget.id, + id: widget.id, + type: widget.type, + title: widget.title ?? '', + icon: widget.icon ?? null, + description: widget.description ?? '', + // An empty settings map arrives as a JSON array + settings: Array.isArray(widget.settings) ? {} : copy(widget.settings ?? {}), + nodeRelId: widget.source?.nodeRelId ?? null, + })), + layout: widgets.map(widget => ({ + widgetId: 'w' + widget.id, + gridX: widget.gridX, + gridY: widget.gridY, + gridWidth: widget.gridWidth, + gridHeight: widget.gridHeight, + })), + } +} + +/** + * The table or view a data widget shows. + * + * @param {object} widget a widget of the grid + * @param {object} nodes the nodes of the application by relation id, as in context.nodes + * @return {{type: string, id: number}|null} the target, or null when the widget has no source + * or its table or view left the application + */ +export function widgetTarget(widget, nodes) { + const node = widget?.nodeRelId ? nodes?.[widget.nodeRelId] : null + if (!node) { + return null + } + const nodeType = parseInt(node.node_type) + if (nodeType !== NODE_TYPE_TABLE && nodeType !== NODE_TYPE_VIEW) { + return null + } + return { type: nodeType === NODE_TYPE_VIEW ? 'view' : 'table', id: parseInt(node.node_id) } +} + +/** + * The widgets of a grid as the API saves them. A saved widget keeps its id; a widget of + * a type with a source names the node relation it shows, or null. + * + * @param {{widgets: Array, layout: Array}} grid the grid + * @param {Array} widgetTypes the types loaded from the server + * @return {Array} the widgets to save + */ +export function gridToWidgets(grid, widgetTypes) { + const positions = Object.fromEntries(grid.layout.map(item => [item.widgetId, item])) + return grid.widgets.filter(widget => positions[widget.key]).map(widget => { + const { gridX, gridY, gridWidth, gridHeight } = positions[widget.key] + const saved = { + type: widget.type, + title: widget.title?.trim() || null, + icon: widget.icon || null, + description: widget.description?.trim() || null, + gridX, + gridY, + gridWidth, + gridHeight, + settings: copy(widget.settings ?? {}), + } + if (widget.id) { + saved.id = widget.id + } + if (findWidgetType(widgetTypes, widget.type)?.hasSource) { + saved.source = widget.nodeRelId ? { nodeRelId: widget.nodeRelId } : null + } + 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..57348c9c8b --- /dev/null +++ b/src/modules/grid/widgets/DataWidget.vue @@ -0,0 +1,208 @@ + + + + + + diff --git a/src/modules/grid/widgets/HeaderWidget.vue b/src/modules/grid/widgets/HeaderWidget.vue new file mode 100644 index 0000000000..3159f3d1a2 --- /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..8ca3a64853 --- /dev/null +++ b/src/modules/grid/widgets/TextWidget.vue @@ -0,0 +1,54 @@ + + + + + + diff --git a/src/shared/components/ncIconPicker/NamedIcon.vue b/src/shared/components/ncIconPicker/NamedIcon.vue new file mode 100644 index 0000000000..c893b8f118 --- /dev/null +++ b/src/shared/components/ncIconPicker/NamedIcon.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 ce22251a6c..1a3ee499fd 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, }), @@ -516,6 +520,20 @@ 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 + }, + async getContextsTablesAndViews() { for (const context of this.contexts) { for (const node of Object.values(context?.nodes)) { From 4be217bd81e48fd873dd5ff9d89f6280d44035e7 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 5 Nov 2026 10:55:09 +0100 Subject: [PATCH 2/2] fix(pages): show a widget the same in view and edit mode, with its title once A data widget without a title of its own takes the title of its table or view, and the table or view inside it no longer repeats it. A widget without a title gets no header in edit mode either: its menu floats over it, so switching edit mode no longer moves the widgets. The edit mode outline uses the regular border colour. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde --- cypress/component/GridWidget.cy.js | 18 +++++++++- src/modules/grid/GridCanvas.vue | 2 +- src/modules/grid/GridWidget.vue | 42 +++++++++++++++++----- src/modules/grid/widgets/DataWidget.vue | 24 ++++++++++++- src/modules/main/sections/ElementTitle.vue | 9 +++-- src/modules/main/sections/TableWrapper.vue | 6 +++- src/modules/main/sections/View.vue | 6 +++- 7 files changed, 92 insertions(+), 15 deletions(-) diff --git a/cypress/component/GridWidget.cy.js b/cypress/component/GridWidget.cy.js index 8db2af70da..85612b444c 100644 --- a/cypress/component/GridWidget.cy.js +++ b/cypress/component/GridWidget.cy.js @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import GridWidget from '../../src/modules/grid/GridWidget.vue' +import { useTablesStore } from '../../src/store/store.js' const nodes = { 4: { id: 4, node_type: 0, node_id: 40, permissions: 1 } } @@ -26,11 +27,26 @@ describe('GridWidget', () => { cy.get('.header-widget').should('contain.text', 'Projects').and('contain.text', 'This quarter').and('have.css', 'text-align', 'end') }) - it('shows no heading for a widget without a title outside edit mode', () => { + it('shows no heading for a widget without a title, also in edit mode, where its menu floats', () => { mountWidget({ key: 'w2', id: 2, type: 'text', title: '', settings: { text: 'First\n\nSecond' }, nodeRelId: null }, false) cy.get('[data-cy="gridWidget"] h2').should('not.exist') cy.get('.text-widget__paragraph').should('have.length', 2) + + mountWidget({ key: 'w2', id: 2, type: 'text', title: '', settings: { text: 'First' }, nodeRelId: null }, true) + cy.get('[data-cy="gridWidget"] h2').should('not.exist') + cy.get('.grid-widget__header--floating button[aria-label="Widget actions"]').should('be.visible') + }) + + it('titles a data widget without a title after its table, which then shows no title of its own', () => { + cy.intercept('GET', '**/apps/tables/**', { statusCode: 404, body: {} }) + mountWidget({ key: 'w5', id: 5, type: 'data', title: '', settings: {}, nodeRelId: 4 }, false) + cy.then(() => { + useTablesStore().tables = [{ id: 40, title: 'Tasks', emoji: '✅' }] + }) + + cy.get('[data-cy="gridWidget"] h2').should('contain.text', '✅ Tasks') + cy.get('[data-cy="gridWidget"] h1').should('not.exist') }) it('asks editors to pick a table or view for a data widget without one, and shows viewers an empty state', () => { diff --git a/src/modules/grid/GridCanvas.vue b/src/modules/grid/GridCanvas.vue index 8d7f95cf0c..9394a0856f 100644 --- a/src/modules/grid/GridCanvas.vue +++ b/src/modules/grid/GridCanvas.vue @@ -197,7 +197,7 @@ export default { } .grid-canvas__stack--editing .grid-stack-item-content { - outline: 2px dashed var(--color-border-maxcontrast); + outline: 2px dashed var(--color-border); outline-offset: -2px; cursor: move; } diff --git a/src/modules/grid/GridWidget.vue b/src/modules/grid/GridWidget.vue index fd79d85111..74a1fa7f97 100644 --- a/src/modules/grid/GridWidget.vue +++ b/src/modules/grid/GridWidget.vue @@ -7,8 +7,10 @@ :aria-labelledby="hasHeading ? headingId : undefined" :aria-label="hasHeading ? undefined : label" data-cy="gridWidget"> -
-

+
+

{{ label }}

@@ -126,16 +128,27 @@ export default { }, emits: ['configure', 'remove', 'move'], computed: { - ...mapState(useTablesStore, ['widgetTypes']), - // A widget shows its title when it has one; while editing every card has a heading for its menu + ...mapState(useTablesStore, ['widgetTypes', 'tables', 'views']), + // The same in view and edit mode, so the widgets keep their size; without a title the edit + // menu floats over the widget hasHeading() { - return !!this.widget.title || this.editable + return !!this.widget.title || !!this.sourceTitle + }, + // A data widget without a title of its own takes the title of its table or view + sourceTitle() { + if (this.widget.type !== WIDGET_TYPE_DATA) { + return '' + } + const target = widgetTarget(this.widget, this.nodes) + const elements = target?.type === 'view' ? this.views : this.tables + const element = target ? elements.find(element => element.id === target.id) : null + return element ? [element.emoji, element.title].filter(Boolean).join(' ') : '' }, headingId() { return 'grid-widget-title-' + this.widget.key }, label() { - return this.widget.title || this.typeLabel + return this.widget.title || this.sourceTitle || this.typeLabel }, widgetComponent() { return getWidgetComponent(this.widget.type) @@ -143,7 +156,7 @@ export default { // A data widget shows a table or view of the application, the others their own settings componentProps() { return this.widget.type === WIDGET_TYPE_DATA - ? { target: widgetTarget(this.widget, this.nodes), editable: this.editable } + ? { target: widgetTarget(this.widget, this.nodes), editable: this.editable, showTitle: !this.sourceTitle } : { settings: this.widget.settings, editable: this.editable } }, // A widget of a type the server does not know is shown, not configured: its schema is unknown @@ -179,6 +192,7 @@ export default { diff --git a/src/modules/main/sections/ElementTitle.vue b/src/modules/main/sections/ElementTitle.vue index a7d2f856be..1bd3e486a7 100644 --- a/src/modules/main/sections/ElementTitle.vue +++ b/src/modules/main/sections/ElementTitle.vue @@ -3,8 +3,8 @@ - SPDX-License-Identifier: AGPL-3.0-or-later -->