From ac7b3d3c9315e5d11dd0621caaeb6bd9f1ab94a0 Mon Sep 17 00:00:00 2001 From: Remko Date: Mon, 12 Oct 2026 10:28:47 +0200 Subject: [PATCH 1/4] feat(grid): add schema-driven widget forms and the table or view widget The form to add or configure a widget is rendered from the schema the server defines for its type, so a new widget type needs a schema and a renderer rather than its own dialog. The data widget shows the rows of a table or view inside a grid. Co-authored-by: Ruben van der Linde Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Remko --- src/modules/grid/SchemaForm.vue | 176 ++++++++++++++++++++++++ src/modules/grid/widgetRegistry.js | 2 + src/modules/grid/widgets/DataWidget.vue | 170 +++++++++++++++++++++++ src/modules/modals/AddWidget.vue | 155 +++++++++++++++++++++ 4 files changed, 503 insertions(+) create mode 100644 src/modules/grid/SchemaForm.vue create mode 100644 src/modules/grid/widgets/DataWidget.vue create mode 100644 src/modules/modals/AddWidget.vue diff --git a/src/modules/grid/SchemaForm.vue b/src/modules/grid/SchemaForm.vue new file mode 100644 index 0000000000..e9fdb6f5fb --- /dev/null +++ b/src/modules/grid/SchemaForm.vue @@ -0,0 +1,176 @@ + + + + + + diff --git a/src/modules/grid/widgetRegistry.js b/src/modules/grid/widgetRegistry.js index 49bf755b94..bd958d46d1 100644 --- a/src/modules/grid/widgetRegistry.js +++ b/src/modules/grid/widgetRegistry.js @@ -4,6 +4,7 @@ */ 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' @@ -17,6 +18,7 @@ export const WIDGET_TYPE_DATA = 'data' const widgetComponents = { [WIDGET_TYPE_HEADER]: HeaderWidget, [WIDGET_TYPE_TEXT]: TextWidget, + [WIDGET_TYPE_DATA]: DataWidget, } /** diff --git a/src/modules/grid/widgets/DataWidget.vue b/src/modules/grid/widgets/DataWidget.vue new file mode 100644 index 0000000000..6ae13d5195 --- /dev/null +++ b/src/modules/grid/widgets/DataWidget.vue @@ -0,0 +1,170 @@ + + + + + + diff --git a/src/modules/modals/AddWidget.vue b/src/modules/modals/AddWidget.vue new file mode 100644 index 0000000000..2beb2669ad --- /dev/null +++ b/src/modules/modals/AddWidget.vue @@ -0,0 +1,155 @@ + + + + + + From bdf947d36ae8d6ef1b250b9b86194bc8277c687e Mon Sep 17 00:00:00 2001 From: Remko Date: Mon, 12 Oct 2026 15:30:51 +0200 Subject: [PATCH 2/4] fix(grid): keep a configured widget's content and show why a data widget is empty - configuring a widget no longer resets its content to the defaults of the type picked before; only a type the user picks starts from defaults, and a widget of an unknown type is neither configured nor converted - a data widget whose target the server marked broken says it no longer exists instead of loading it, a failed load has its own message, and the prompt to pick a target only shows while editing - an empty widget title stays empty, the card shows the type name in the viewer's language - the dialog loads the widget types itself and imports the helpers from gridLayout.js - integer fields refuse fractions, booleans render as a switch, the colour swatch is empty for the theme colour and the view label is translatable Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Remko --- src/modules/grid/GridWidget.vue | 16 +++++-- src/modules/grid/SchemaForm.vue | 37 +++++++++++++--- src/modules/grid/widgets/DataWidget.vue | 59 ++++++++++++++++++------- src/modules/modals/AddWidget.vue | 52 +++++++++++++--------- 4 files changed, 115 insertions(+), 49 deletions(-) diff --git a/src/modules/grid/GridWidget.vue b/src/modules/grid/GridWidget.vue index 8c26456949..1fb297e980 100644 --- a/src/modules/grid/GridWidget.vue +++ b/src/modules/grid/GridWidget.vue @@ -9,10 +9,10 @@ data-cy="gridWidget">

- {{ widget.configuration.showTitle ? widget.configuration.title : typeLabel }} + {{ widget.configuration.showTitle ? label : typeLabel }}

- + @@ -86,8 +86,12 @@ export default { TrashCanOutline, }, provide() { - // A widget that renders its own heading sits one level below the card's heading - return { widgetHeadingLevel: computed(() => this.hasHeading ? 3 : 2) } + return { + // A widget that renders its own heading sits one level below the card's heading + widgetHeadingLevel: computed(() => this.hasHeading ? 3 : 2), + // Set when the server marked the widget's stored target as gone + widgetBroken: computed(() => this.widget.broken === true), + } }, props: { widget: { @@ -124,6 +128,10 @@ export default { widgetComponent() { return getWidgetComponent(this.widget.type) }, + // A widget of a type the server does not know is shown, not configured: its schema is unknown + isKnownType() { + return findWidgetType(this.widgetTypes, this.widget.type) !== null + }, typeLabel() { return findWidgetType(this.widgetTypes, this.widget.type)?.title ?? this.widget.type }, diff --git a/src/modules/grid/SchemaForm.vue b/src/modules/grid/SchemaForm.vue index e9fdb6f5fb..3d99c91212 100644 --- a/src/modules/grid/SchemaForm.vue +++ b/src/modules/grid/SchemaForm.vue @@ -22,9 +22,12 @@ class="schema-form__input" :model-value="String(value(name) ?? '')" :label="label(property)" + :error="invalidIntegers.includes(name)" + :helper-text="invalidIntegers.includes(name) ? t('tables', 'Enter a whole number') : ''" type="number" - @update:model-value="text => set(name, text === '' ? null : parseInt(text))" /> + @update:model-value="text => setInteger(name, text)" /> {{ label(property) }} @@ -53,7 +56,7 @@ @update:model-value="color => set(name, color ?? '')"> {{ label(property) }} @@ -95,6 +98,11 @@ export default { }, }, emits: ['update:modelValue'], + data() { + return { + invalidIntegers: [], + } + }, computed: { ...mapState(useTablesStore, ['tables', 'views']), targetOptions() { @@ -110,7 +118,7 @@ export default { id: 'view-' + view.id, targetType: 'view', targetId: view.id, - label: (view.emoji ? view.emoji + ' ' : '') + view.title + ' (' + t('tables', 'view') + ')', + label: t('tables', '{title} (view)', { title: (view.emoji ? view.emoji + ' ' : '') + view.title }), })) return [...tables, ...views] }, @@ -122,6 +130,21 @@ export default { set(name, value) { this.$emit('update:modelValue', { ...this.modelValue, [name]: value }) }, + /** + * Stores a whole number, or nothing for an empty field. Anything else is marked + * invalid instead of being rounded. + * + * @param {string} name property name + * @param {string} text what was typed + */ + setInteger(name, text) { + const number = Number(text) + const isValid = text === '' || Number.isInteger(number) + this.invalidIntegers = isValid ? this.invalidIntegers.filter(invalid => invalid !== name) : [...new Set([...this.invalidIntegers, name])] + if (isValid) { + this.set(name, text === '' ? null : number) + } + }, label(property) { return property.title }, @@ -148,7 +171,7 @@ export default {