Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions cypress/component/AddWidget.cy.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
11 changes: 11 additions & 0 deletions cypress/component/gridLayout.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
GRID_BREAKPOINT_MEDIUM,
GRID_BREAKPOINT_SINGLE,
GRID_COLUMNS,
applicationNodeOptions,
applyNodePositions,
cloneGrid,
columnsForWidth,
Expand Down Expand Up @@ -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 })
Expand Down
151 changes: 151 additions & 0 deletions src/modules/grid/SchemaForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="schema-form">
<div v-for="(property, name) in properties" :key="name" class="schema-form__field" :data-cy="fieldDataCy(name)">
<NcTextField v-if="property.type === 'string'"
class="schema-form__input"
:model-value="value(name)"
:label="property.title"
@update:model-value="text => set(name, text)" />
<NcTextArea v-else-if="property.type === 'text'"
class="schema-form__input"
:model-value="value(name)"
:label="property.title"
:placeholder="t('tables', 'Leave an empty line between paragraphs')"
resize="vertical"
rows="6"
@update:model-value="text => set(name, text)" />
<NcCheckboxRadioSwitch v-else-if="property.type === 'boolean'"
type="switch"
:model-value="!!value(name)"
@update:model-value="checked => set(name, checked)">
{{ property.title }}
</NcCheckboxRadioSwitch>
<NcSelect v-else-if="property.type === 'enum'"
class="schema-form__input"
:model-value="enumOption(property, value(name))"
:input-label="property.title"
:options="property.options"
:clearable="false"
label="label"
@update:model-value="option => set(name, option?.value ?? property.default)" />
<div v-else-if="property.type === 'color'" class="schema-form__color">
<NcColorPicker :model-value="colorValue(name)"
advanced-fields
clearable
@update:model-value="color => set(name, color ?? '')">
<NcButton variant="secondary">
<template #icon>
<span class="schema-form__swatch" :style="{ backgroundColor: colorValue(name) ?? 'transparent' }" />
</template>
{{ property.title }}
</NcButton>
</NcColorPicker>
<span class="schema-form__hint">{{ colorValue(name) ?? t('tables', 'Theme color') }}</span>
</div>
</div>
</div>
</template>

<script>
import { NcButton, NcCheckboxRadioSwitch, NcColorPicker, NcSelect, NcTextArea, NcTextField } from '@nextcloud/vue'
import { isHexColor } from '../../shared/utils/gridColors.js'

/**
* Renders the settings form of a widget from the schema of its type, so a
* new widget type needs a schema on the server and a renderer, not a dialog.
*/
export default {
name: 'SchemaForm',
components: {
NcButton,
NcCheckboxRadioSwitch,
NcColorPicker,
NcSelect,
NcTextArea,
NcTextField,
},
props: {
properties: {
type: Object,
required: true,
},
modelValue: {
type: Object,
required: true,
},
},
emits: ['update:modelValue'],
methods: {
/**
* @param {string} name property name
* @return {string} the data-cy of the field, in camelCase
*/
fieldDataCy(name) {
return 'widgetField' + name.charAt(0).toUpperCase() + name.slice(1)
},
/**
* @param {string} name property name
* @return {string|number|boolean|object|null} the current value of the property
*/
value(name) {
return this.modelValue[name]
},
/**
* @param {string} name property name
* @param {string|number|boolean|object|null} value the new value of the property
*/
set(name, value) {
this.$emit('update:modelValue', { ...this.modelValue, [name]: value })
},
/**
* @param {string} name property name
* @return {string|undefined} the stored color, or undefined for the theme color
*/
colorValue(name) {
return isHexColor(this.value(name)) ? this.value(name) : undefined
},
/**
* @param {object} property the enum property
* @param {string} value the stored value
* @return {object|null} the option of the value, else the option of the default
*/
enumOption(property, value) {
return property.options.find(option => option.value === value) ?? property.options.find(option => option.value === property.default) ?? null
},
},
}
</script>

<style lang="scss" scoped>
.schema-form {
&__field {
margin-top: calc(2 * var(--default-grid-baseline));
}

&__input {
width: 100%;
}

&__color {
display: flex;
align-items: center;
gap: calc(3 * var(--default-grid-baseline));
}

&__swatch {
display: inline-block;
width: calc(5 * var(--default-grid-baseline));
height: calc(5 * var(--default-grid-baseline));
border: 1px solid var(--color-border-maxcontrast);
border-radius: var(--border-radius);
}

&__hint {
color: var(--color-text-maxcontrast);
}
}
</style>
36 changes: 36 additions & 0 deletions src/modules/grid/gridLayout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading