diff --git a/cypress/component/AddWidget.cy.js b/cypress/component/AddWidget.cy.js new file mode 100644 index 0000000000..1c803614f8 --- /dev/null +++ b/cypress/component/AddWidget.cy.js @@ -0,0 +1,88 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import AddWidget from '../../src/modules/modals/AddWidget.vue' +import { useTablesStore } from '../../src/store/store.js' + +const widgetTypes = [ + { + type: 'header', + title: 'Header', + defaultWidth: 12, + defaultHeight: 2, + hasSource: false, + settings: { + heading: { type: 'string', title: 'Heading', default: '', required: true }, + textAlign: { type: 'enum', title: 'Text alignment', default: 'start', options: [{ value: 'start', label: 'Start' }, { value: 'center', label: 'Centered' }] }, + }, + }, + { type: 'data', title: 'Table or view', defaultWidth: 12, defaultHeight: 5, hasSource: true, settings: {} }, + { type: 'unrendered', title: 'Not shown yet', defaultWidth: 12, defaultHeight: 2, hasSource: false, settings: {} }, +] + +const nodes = { 4: { id: 4, node_type: 0, node_id: 40, permissions: 1 } } + +/** + * @param {object} props props of the dialog besides showModal + */ +function mountDialog(props = {}) { + cy.intercept('GET', '**/apps/tables/api/2/widget-types', { ocs: { data: widgetTypes } }) + cy.mount(AddWidget, { props: { showModal: false, nodes, ...props } }).then(({ wrapper }) => { + const store = useTablesStore() + store.widgetTypes = [] + store.tables = [{ id: 40, title: 'Tasks', emoji: '' }] + cy.wrap(wrapper).as('wrapper') + cy.wrap(wrapper.setProps({ showModal: true })) + }) +} + +describe('AddWidget', () => { + it('offers only the widget types it can show', () => { + mountDialog() + + cy.get('[data-cy="addWidgetType"] input').click() + cy.get('.vs__dropdown-menu li').should('have.length', 2) + cy.get('.vs__dropdown-menu').should('contain.text', 'Table or view').and('not.contain.text', 'Not shown yet') + }) + + it('builds the settings form from the server schema and hands the widget to the parent', () => { + mountDialog() + + cy.get('[data-cy="widgetFieldTextAlign"]').should('contain.text', 'Start') + cy.get('[data-cy="addWidgetSubmit"]').should('be.disabled') + cy.get('[data-cy="addWidgetTitle"]').type('Welcome') + cy.get('[data-cy="addWidgetDescription"]').type('Read this first') + cy.get('[data-cy="widgetFieldHeading"] input').type('Projects') + cy.get('[data-cy="addWidgetSubmit"]').should('be.enabled').click() + cy.get('@wrapper').then(wrapper => { + expect(wrapper.emitted('submit')[0][0]).to.deep.equal({ + type: 'header', + title: 'Welcome', + icon: null, + description: 'Read this first', + settings: { heading: 'Projects', textAlign: 'start' }, + nodeRelId: null, + }) + }) + }) + + it('keeps the icon, description and settings of a widget being configured', () => { + mountDialog({ editingWidget: { key: 'w1', id: 1, type: 'header', title: 'Hi', icon: 'star', description: 'About', settings: { heading: 'Old' }, nodeRelId: null } }) + + cy.get('[data-cy="addWidgetDescription"]').should('have.value', 'About') + cy.get('[data-cy="widgetFieldHeading"] input').should('have.value', 'Old') + cy.get('[data-cy="iconFieldClear"]').should('exist') + cy.get('[data-cy="addWidgetSubmit"]').click() + cy.get('@wrapper').then(wrapper => { + expect(wrapper.emitted('submit')[0][0]).to.deep.include({ icon: 'star', description: 'About', settings: { heading: 'Old', textAlign: 'start' } }) + }) + }) + + it('asks for a table or view of the application for a data widget', () => { + mountDialog({ editingWidget: { key: 'w2', id: 2, type: 'data', title: '', settings: {}, nodeRelId: null } }) + + cy.get('[data-cy="addWidgetNode"]').should('exist') + cy.get('[data-cy="addWidgetSubmit"]').should('be.disabled') + }) +}) diff --git a/cypress/component/gridLayout.cy.js b/cypress/component/gridLayout.cy.js index ac3cd47b4d..f3e5d9bbc6 100644 --- a/cypress/component/gridLayout.cy.js +++ b/cypress/component/gridLayout.cy.js @@ -7,6 +7,7 @@ import { GRID_BREAKPOINT_MEDIUM, GRID_BREAKPOINT_SINGLE, GRID_COLUMNS, + applicationNodeOptions, applyNodePositions, cloneGrid, columnsForWidth, @@ -66,6 +67,16 @@ describe('grid layout', () => { 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 ', icon: 'star', description: ' Read me ', settings: { text: 'A' }, nodeRelId: null }) 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 index 7e7c04282a..dff8f1f2df 100644 --- a/src/modules/grid/gridLayout.js +++ b/src/modules/grid/gridLayout.js @@ -3,6 +3,7 @@ * 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. */ @@ -43,6 +44,16 @@ 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. * @@ -146,6 +157,31 @@ export function widgetTarget(widget, nodes) { 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. diff --git a/src/modules/modals/AddWidget.vue b/src/modules/modals/AddWidget.vue new file mode 100644 index 0000000000..7d17aaaca4 --- /dev/null +++ b/src/modules/modals/AddWidget.vue @@ -0,0 +1,227 @@ + + + + + + diff --git a/src/shared/components/ncIconPicker/IconField.vue b/src/shared/components/ncIconPicker/IconField.vue new file mode 100644 index 0000000000..09929f257e --- /dev/null +++ b/src/shared/components/ncIconPicker/IconField.vue @@ -0,0 +1,74 @@ + + + + + +