From aad81ef0750ce9cd397ea53ad0c75202efa9d0f9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 16 Oct 2026 09:41:57 +0200 Subject: [PATCH] feat(grid): add the grid canvas and widget components Carries the grid frontend over from the earlier stack onto the widget shape of the #3100 API: a settings map per widget and a source node relation for data widgets. The canvas places widgets on 12 columns and reflows to 6 and 1 on narrow screens; the add widget dialog renders the settings form from the type schema and offers only the application's own tables and views. A data widget without a source asks the editor to pick one and shows an empty state to viewers. Assisted-by: ClaudeCode:claude-opus-5-5 --- cypress/component/GridWidget.cy.js | 46 +++++ cypress/component/gridLayout.cy.js | 137 ++++++++++++++ package-lock.json | 17 ++ package.json | 1 + src/modules/grid/GridCanvas.vue | 204 +++++++++++++++++++++ src/modules/grid/GridWidget.vue | 209 +++++++++++++++++++++ src/modules/grid/SchemaForm.vue | 151 +++++++++++++++ src/modules/grid/gridLayout.js | 212 ++++++++++++++++++++++ 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 ++++++ src/modules/modals/AddWidget.vue | 199 ++++++++++++++++++++ src/shared/utils/gridColors.js | 14 ++ src/store/store.js | 18 ++ 15 files changed, 1573 insertions(+) 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/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/GridWidget.cy.js b/cypress/component/GridWidget.cy.js new file mode 100644 index 0000000000..3c8d4c3f46 --- /dev/null +++ b/cypress/component/GridWidget.cy.js @@ -0,0 +1,46 @@ +/** + * 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 + */ +function mountWidget(widget, editable) { + cy.intercept('GET', '**/apps/tables/api/2/widget-types', { ocs: { data: [] } }) + cy.mount(GridWidget, { props: { widget, nodes, editable } }) +} + +describe('GridWidget', () => { + it('shows the title of a widget that has one', () => { + mountWidget({ key: 'w1', id: 1, type: 'header', title: 'Welcome', settings: { heading: 'Projects', subheading: 'This quarter' }, nodeRelId: null }, false) + + cy.get('[data-cy="gridWidget"] h2').should('contain.text', 'Welcome') + cy.get('[data-cy="gridWidget"]').should('contain.text', 'Projects').and('contain.text', 'This quarter') + }) + + 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 the editor to pick a table or view when a data widget has none', () => { + 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') + }) + + it('shows an empty state to viewers when a data widget has no table or view', () => { + 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') + }) +}) diff --git a/cypress/component/gridLayout.cy.js b/cypress/component/gridLayout.cy.js new file mode 100644 index 0000000000..1ce1160a5a --- /dev/null +++ b/cypress/component/gridLayout.cy.js @@ -0,0 +1,137 @@ +/** + * 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, + applicationNodeOptions, + applyNodePositions, + cloneGrid, + columnsForWidth, + defaultSettings, + 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: 'left' } } }, + { 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', 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('starts a widget with every setting at its default', () => { + expect(defaultSettings(widgetTypes[0])).to.deep.equal({ heading: '', textAlign: 'left' }) + expect(defaultSettings(widgetTypes[2])).to.deep.equal({}) + expect(defaultSettings(null)).to.deep.equal({}) + }) + + 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: '', settings: { heading: 'Hi', textAlign: 'center' }, nodeRelId: null }) + expect(grid.widgets[1]).to.deep.include({ title: 'Tasks', 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) + }) + + it('has no target when the table or view left the application', () => { + const grid = pageToGrid(page) + + expect(widgetTarget(grid.widgets[1], { 5: nodes[5] })).to.equal(null) + }) + + it('offers only the tables and views of the application', () => { + const tables = [{ id: 40, title: 'Tasks', emoji: '✅' }, { id: 41, title: 'Salaries', emoji: '' }] + const views = [{ id: 50, title: 'Open', emoji: '' }] + + expect(applicationNodeOptions(nodes, tables, views)).to.deep.equal([ + { id: 4, label: '✅ Tasks' }, + { id: 5, label: 'Open (view)' }, + ]) + }) + + 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 ', 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, gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 2, settings: { heading: 'Hi', textAlign: 'center' } }) + expect(widgets[1]).to.deep.include({ id: 12, title: 'Tasks', source: { nodeRelId: 4 } }) + expect(widgets[3]).to.deep.include({ id: 14, source: null }) + expect(widgets[4]).to.deep.equal({ type: 'text', title: 'Notes', 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 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..6bf0496d20 --- /dev/null +++ b/src/modules/grid/GridWidget.vue @@ -0,0 +1,209 @@ + + + + + + diff --git a/src/modules/grid/SchemaForm.vue b/src/modules/grid/SchemaForm.vue new file mode 100644 index 0000000000..04a777ee66 --- /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..132cf9f66f --- /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 { translate as t } from '@nextcloud/l10n' +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 +} + +/** + * The settings a new widget of this type starts with: every setting at its default. + * + * @param {object|null} widgetType a type from the server + * @return {object} the settings + */ +export function defaultSettings(widgetType) { + return Object.fromEntries(Object.entries(widgetType?.settings ?? {}).map(([name, setting]) => [name, copy(setting.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 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 ?? '', + // 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 tables and views of the application a data widget can show, with their titles. + * + * @param {object} nodes the nodes of the application by relation id + * @param {Array} tables tables known to the store + * @param {Array} views views known to the store + * @return {Array<{id: number, label: string}>} one option per node relation, by relation id + */ +export function applicationNodeOptions(nodes, tables, views) { + return Object.values(nodes ?? {}).map(node => { + const nodeType = parseInt(node.node_type) + const nodeId = parseInt(node.node_id) + const element = nodeType === NODE_TYPE_VIEW + ? views.find(view => view.id === nodeId) + : tables.find(table => table.id === nodeId) + const title = element ? (element.emoji ? element.emoji + ' ' : '') + element.title : '#' + nodeId + return { + id: parseInt(node.id), + label: nodeType === NODE_TYPE_VIEW + ? t('tables', '{title} (view)', { title }, undefined, { escape: false }) + : title, + } + }) +} + +/** + * 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, + 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..1cc861c452 --- /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/modules/modals/AddWidget.vue b/src/modules/modals/AddWidget.vue new file mode 100644 index 0000000000..2289cb9e31 --- /dev/null +++ b/src/modules/modals/AddWidget.vue @@ -0,0 +1,199 @@ + + + + + + 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..1b8613e78d 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,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)) {