From 940595ec695a808d0ce018dac3f4fa59b3b14d6c Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Wed, 30 Sep 2026 14:23:31 +0200 Subject: [PATCH 01/18] Migrate JSON forms to Vaadin --- .../src/renderers/agent-link.ts | 25 ++++---- .../src/controls/control-input-element.ts | 58 ++++++++++++++++++- .../src/or-vaadin-input.ts | 20 +++++-- 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/ui/component/or-attribute-input/src/renderers/agent-link.ts b/ui/component/or-attribute-input/src/renderers/agent-link.ts index d89840f97e..c922f55c47 100644 --- a/ui/component/or-attribute-input/src/renderers/agent-link.ts +++ b/ui/component/or-attribute-input/src/renderers/agent-link.ts @@ -31,9 +31,9 @@ import { uiTypeIs, formatIs, } from "@openremote/or-json-forms"; -import { InputType, type OrInputChangedEvent } from "@openremote/or-mwc-components/or-mwc-input"; +import type { OrVaadinSelect, SelectItem } from "@openremote/or-vaadin-components/or-vaadin-select"; import { html } from "lit"; -import "@openremote/or-mwc-components/or-mwc-input"; +import "@openremote/or-vaadin-components/or-vaadin-select"; import { i18next } from "@openremote/or-translate"; import { until } from "lit/directives/until.js"; @@ -118,19 +118,22 @@ const agentIdRenderer = (state: JsonFormsStateContext, props: ControlProps) => { }; const loadedTemplatePromise = loadAgents().then((agents) => { - const options: [string, string][] = agents.map((agent) => [agent.id!, agent.name + " (" + agent.id + ")"]); + const options: SelectItem[] = agents.map((agent) => ({ + value: agent.id!, + label: agent.name + " (" + agent.id + ")", + })); return html` - + placeholder="${i18next.t("selectAgent")}" + .items="${options}" + > `; }); @@ -142,7 +145,7 @@ const agentIdRenderer = (state: JsonFormsStateContext, props: ControlProps) => { width: 100%; } - ${until(loadedTemplatePromise, html``)} + ${until(loadedTemplatePromise, html``)} `; return getTemplateWrapper(template, undefined); diff --git a/ui/component/or-json-forms/src/controls/control-input-element.ts b/ui/component/or-json-forms/src/controls/control-input-element.ts index 8f82b111b7..56838cf408 100644 --- a/ui/component/or-json-forms/src/controls/control-input-element.ts +++ b/ui/component/or-json-forms/src/controls/control-input-element.ts @@ -16,9 +16,12 @@ * * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { InputType, type OrInputChangedEvent } from "@openremote/or-mwc-components/or-mwc-input"; +import type { OrInputChangedEvent } from "@openremote/or-mwc-components/or-mwc-input"; +import { InputType } from "@openremote/or-vaadin-components/util"; +import { OrVaadinInput } from "@openremote/or-vaadin-components/or-vaadin-input"; import { css, html } from "lit"; import { customElement } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; import { ControlBaseElement } from "./control-base-element"; import { baseStyle } from "../styles"; import { @@ -36,9 +39,14 @@ let defaultTz: string; // language=CSS const style = css` - or-mwc-input { + or-mwc-input, + or-vaadin-input { width: 100%; } + + or-vaadin-input { + display: block; + } `; @customElement("or-json-forms-input-control") @@ -164,6 +172,29 @@ export class ControlInputElement extends ControlBaseElement { } } + // or-vaadin-select offers neither multi select nor search, so those stay on or-mwc-input + if (OrVaadinInput.TEMPLATES.has(this.inputType) && !multiple && !searchable) { + const isCheckbox = this.inputType === InputType.CHECKBOX; + return html``; + } + return html``; } + /** + * Vaadin inputs expose their value as a string, so it is parsed back into the type the schema expects. + */ + protected onVaadinValueChanged(e: Event) { + const value = (e.currentTarget as OrVaadinInput).nativeValue; + const empty = value === undefined || value === ""; + + switch (this.inputType) { + case InputType.SELECT: { + this.handleChange(this.path!, empty ? undefined : JSON.parse(value)); + break; + } + case InputType.NUMBER: + case InputType.RANGE: { + this.handleChange(this.path!, empty ? undefined : Number(value)); + break; + } + default: { + this.handleChange(this.path!, value); + } + } + } + protected onValueChanged(e: OrInputChangedEvent) { if (this.inputType === InputType.SELECT) { if (Array.isArray(e.detail.value)) { diff --git a/ui/component/or-vaadin-components/src/or-vaadin-input.ts b/ui/component/or-vaadin-components/src/or-vaadin-input.ts index 4af29a6b4f..5949aaf622 100644 --- a/ui/component/or-vaadin-components/src/or-vaadin-input.ts +++ b/ui/component/or-vaadin-components/src/or-vaadin-input.ts @@ -143,15 +143,16 @@ export class OrVaadinInput extends OrElement { } updated(changedProps: PropertyValues) { + // A new type renders a different Vaadin element, which starts out without any of the attributes + if (changedProps.has("type")) { + this._applyAttributes(); + } changedProps.forEach((_, key) => this._onPropertyChange(String(key), this[String(key) as keyof OrVaadinInput])); return super.updated(changedProps); } firstUpdated(_changedProps: PropertyValues) { - for (const name of this.getAttributeNames()) { - // console.debug(this._getLoggingPrefix() + `firstUpdated for ${name} (${typeof this.getAttribute(name)}) to`, this.getAttribute(name)); - this._applyAttribute(name, this.getAttribute(name), this._elem); - } + this._applyAttributes(); return super.firstUpdated(_changedProps); } @@ -230,6 +231,17 @@ export class OrVaadinInput extends OrElement { this.dispatchEvent(new CustomEvent("change", { bubbles: true })); } + /** + * Internal function to apply every attribute of the root element to the child Vaadin element. + * @protected + */ + protected _applyAttributes(elem = this._elem) { + for (const name of this.getAttributeNames()) { + // console.debug(this._getLoggingPrefix() + `applying ${name} (${typeof this.getAttribute(name)}) to`, this.getAttribute(name)); + this._applyAttribute(name, this.getAttribute(name), elem); + } + } + /** * Internal function to apply an attribute to the child Vaadin element. * @param name - Attribute name From 3b72ed6af6b713600a3a90dcf297b53e8512f39d Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Wed, 30 Sep 2026 14:53:53 +0200 Subject: [PATCH 02/18] Migrate JSON forms dialogs to Vaadin --- .../src/controls/control-array-element.ts | 101 ++++--- .../src/layouts/layout-vertical-element.ts | 260 ++++++++---------- ui/component/or-json-forms/src/styles.ts | 87 +++--- ui/component/or-json-forms/src/util.ts | 87 +++--- .../or-json-forms/test/fixtures/index.ts | 16 +- 5 files changed, 251 insertions(+), 300 deletions(-) diff --git a/ui/component/or-json-forms/src/controls/control-array-element.ts b/ui/component/or-json-forms/src/controls/control-array-element.ts index 420ec895a8..07940227d8 100644 --- a/ui/component/or-json-forms/src/controls/control-array-element.ts +++ b/ui/component/or-json-forms/src/controls/control-array-element.ts @@ -36,11 +36,14 @@ import { showJsonEditor, } from "../util"; import { i18next } from "@openremote/or-translate"; -import { OrMwcDialog, showDialog } from "@openremote/or-mwc-components/or-mwc-dialog"; -import "@openremote/or-mwc-components/or-mwc-list"; +import { type OrVaadinDialog, showDialog } from "@openremote/or-vaadin-components/or-vaadin-dialog"; import { addItemOrParameterDialogStyle, baseStyle, panelStyle } from "../styles"; -import type { ListItem, OrMwcListChangedEvent } from "@openremote/or-mwc-components/or-mwc-list"; +import type { ListItem } from "@openremote/or-vaadin-components/or-vaadin-list-box"; import type { OrVaadinButton } from "@openremote/or-vaadin-components/or-vaadin-button"; +import { createRef, type Ref, ref } from "lit/directives/ref.js"; +import "@openremote/or-vaadin-components/or-vaadin-dialog"; +import "@openremote/or-vaadin-components/or-vaadin-list-box"; +import "@openremote/or-vaadin-components/or-vaadin-item"; import "@openremote/or-vaadin-components/or-vaadin-button"; import { DefaultColor4, DefaultColor5 } from "@openremote/core"; import { ControlBaseElement } from "./control-base-element"; @@ -133,7 +136,7 @@ export class ControlArrayElement extends ControlBaseElement { protected moveItem!: (fromIndex: number, toIndex: number) => void; public static get styles() { - return [baseStyle, panelStyle, style]; + return [baseStyle, panelStyle, addItemOrParameterDialogStyle, style]; } shouldUpdate(_changedProperties: PropertyValues): boolean { @@ -319,7 +322,7 @@ export class ControlArrayElement extends ControlBaseElement { protected _showJson(ev: Event) { ev.stopPropagation(); - showJsonEditor(this.title || this.schema.title || "", this.data, (newValue) => { + showJsonEditor(this.shadowRoot!, this.title || this.schema.title || "", this.data, (newValue) => { this.handleChange(this.path || "", newValue); }); } @@ -338,65 +341,61 @@ export class ControlArrayElement extends ControlBaseElement { protected showAddDialog() { let selectedItemInfo: CombinatorInfo | undefined; + let dialog: OrVaadinDialog | undefined; + const descRef: Ref = createRef(); + const addBtnRef: Ref = createRef(); - const listItems: ListItem[] = this.itemInfos!.map((itemInfo, index) => { + const listItems: ListItem[] = this.itemInfos!.map((itemInfo) => { const labelStr = itemInfo.title ? computeLabel(itemInfo.title, false, true) : ""; return { text: labelStr, value: labelStr, data: itemInfo, }; - }); + }).sort((a, b) => a.text!.localeCompare(b.text!)); const onParamChanged = (itemInfo: CombinatorInfo) => { selectedItemInfo = itemInfo; - const descElem = dialog.shadowRoot!.getElementById("parameter-desc") as HTMLDivElement; - descElem.innerHTML = itemInfo.description || ""; - (dialog.shadowRoot!.getElementById("add-btn") as OrVaadinButton).disabled = false; + descRef.value!.innerHTML = itemInfo.description || ""; + addBtnRef.value!.disabled = false; + }; + + const onAdd = () => { + if (selectedItemInfo) { + this.addItem(selectedItemInfo.defaultValueCreator()); + } + dialog?.close(); }; - const dialog = showDialog( - new OrMwcDialog() - .setContent(html` -
-
-
- -
-
-
+ dialog = showDialog( + this.shadowRoot!, + html` + +

+ ${(this.label ? computeLabel(this.label, this.required, false) + " - " : "") + i18next.t("addItem")} +

+
+ + ${listItems.map((item) => html`${item.text}`)} + +
- `) - .setStyles(addItemOrParameterDialogStyle) - .setHeading((this.label ? computeLabel(this.label, this.required, false) + " - " : "") + i18next.t("addItem")) - .setActions([ - { - actionName: "cancel", - content: "cancel", - }, - { - default: true, - actionName: "add", - action: () => { - if (selectedItemInfo) { - const value = selectedItemInfo.defaultValueCreator(); - this.addItem(value); - } - }, - content: html` - - - - `, - }, - ]) - .setDismissAction(null) + +
+ ` ); } } diff --git a/ui/component/or-json-forms/src/layouts/layout-vertical-element.ts b/ui/component/or-json-forms/src/layouts/layout-vertical-element.ts index b0fdfc4766..8e51799d3e 100644 --- a/ui/component/or-json-forms/src/layouts/layout-vertical-element.ts +++ b/ui/component/or-json-forms/src/layouts/layout-vertical-element.ts @@ -32,7 +32,7 @@ import { type StatePropsOfControl, type VerticalLayout, } from "@jsonforms/core"; -import { css, html, PropertyValues, type TemplateResult } from "lit"; +import { css, html, PropertyValues, render, type TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import { LayoutBaseElement } from "./layout-base-element"; import { @@ -43,16 +43,21 @@ import { getTemplateFromProps, showJsonEditor, } from "../util"; -import { InputType, OrInputChangedEvent, type OrMwcInput } from "@openremote/or-mwc-components/or-mwc-input"; import type { OrVaadinTextField } from "@openremote/or-vaadin-components/or-vaadin-text-field"; +import type { OrVaadinButton } from "@openremote/or-vaadin-components/or-vaadin-button"; +import type { ListItem } from "@openremote/or-vaadin-components/or-vaadin-list-box"; +import { type OrVaadinDialog, showDialog } from "@openremote/or-vaadin-components/or-vaadin-dialog"; import "@openremote/or-vaadin-components/or-vaadin-text-field"; import "@openremote/or-vaadin-components/or-vaadin-button"; +import "@openremote/or-vaadin-components/or-vaadin-dialog"; +import "@openremote/or-vaadin-components/or-vaadin-list-box"; +import "@openremote/or-vaadin-components/or-vaadin-item"; import { i18next } from "@openremote/or-translate"; -import { OrMwcDialog, showDialog } from "@openremote/or-mwc-components/or-mwc-dialog"; -import "@openremote/or-mwc-components/or-mwc-list"; import "@openremote/or-components/or-collapsible-panel"; import { addItemOrParameterDialogStyle, baseStyle, panelStyle } from "../styles"; -import type { ListItem, OrMwcListChangedEvent } from "@openremote/or-mwc-components/or-mwc-list"; +import { createRef, type Ref, ref } from "lit/directives/ref.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { when } from "lit/directives/when.js"; import type { AdditionalProps } from "../base-element"; // language=CSS @@ -142,7 +147,7 @@ export class LayoutVerticalElement extends LayoutBaseElement void; public static get styles() { - return [baseStyle, panelStyle, style]; + return [baseStyle, panelStyle, addItemOrParameterDialogStyle, style]; } render() { @@ -322,7 +327,7 @@ export class LayoutVerticalElement extends LayoutBaseElement { + showJsonEditor(this.shadowRoot!, this.title || this.schema.title || "", this.data, (newValue) => { this.handleChange(this.path || "", newValue); }); } @@ -335,7 +340,11 @@ export class LayoutVerticalElement extends LayoutBaseElement = createRef(); + const addBtnRef: Ref = createRef(); const listItems: ListItem[] = optionalProps.map((props) => { const labelStr = computeLabel(props.label, !!props.required, false); @@ -346,155 +355,120 @@ export class LayoutVerticalElement extends LayoutBaseElement render(getDescriptionTemplate(), descRef.value!); + + const onSchemaSelected = (selectedSchema: CombinatorInfo) => { + selectedOneOf = selectedSchema; + addBtnRef.value!.disabled = !selectedOneOf; + refreshDescription(); + }; + const onParamChanged = (selected: StatePropsOfControl) => { selectedParameter = selected; - const isOneOf = !!(selectedParameter && selectedParameter.schema && selectedParameter.schema.oneOf); - (dialog.shadowRoot!.getElementById("add-btn") as OrMwcInput).disabled = isOneOf; - dialog.requestUpdate(); + selectedOneOf = undefined; + addBtnRef.value!.disabled = !!selected.schema?.oneOf; + refreshDescription(); }; - // const keyValue: [any, any] = [undefined, undefined]; - // const onKeyValueChanged = (value: any, index: 0 | 1) => { - // keyValue[index] = value; - // const valid = keyValue[0] && keyValue[1] !== undefined; - // (dialog.shadowRoot!.getElementById("add-btn") as OrMwcInput).disabled = !valid; - // }; - let keyValue: string | undefined; - const onKeyChanged = (event: KeyboardEvent) => { - const keyInput = event.currentTarget as OrMwcInput; - keyInput.setCustomValidity(undefined); - keyValue = keyInput.currentValue as string; - let valid = keyInput.valid; - - if (this.data[keyValue] !== undefined) { - valid = false; - keyInput.setCustomValidity(i18next.t("validation.keyAlreadyExists")); - } - (dialog.shadowRoot!.getElementById("add-btn") as OrMwcInput).disabled = !valid; + const onKeyChanged = (ev: Event) => { + const keyInput = ev.currentTarget as OrVaadinTextField; + keyValue = keyInput.value; + const duplicate = !!keyValue && this.data?.[keyValue] !== undefined; + keyInput.errorMessage = duplicate ? i18next.t("validation.keyAlreadyExists") : undefined; + const valid = keyInput.validate() && !duplicate; + keyInput.invalid = !valid; + addBtnRef.value!.disabled = !valid; }; - const dialogContentProvider: () => TemplateResult = () => { - // Only set when !dynamic - let schemaPicker: TemplateResult | undefined; - - if (selectedParameter && selectedParameter.schema && selectedParameter.schema.oneOf) { - const handleChange = (selectedSchema: CombinatorInfo) => { - selectedOneOf = selectedSchema; - (dialog.shadowRoot!.getElementById("add-btn") as OrMwcInput).disabled = !selectedOneOf; - (dialog.shadowRoot!.getElementById("schema-description") as HTMLParagraphElement).innerHTML = - (selectedOneOf ? selectedOneOf.description : i18next.t("schema.selectTypeMessage")) || - i18next.t("schema.noDescriptionAvailable"); - }; - schemaPicker = getSchemaPicker( - rootSchema, - selectedParameter.schema.oneOf, - selectedParameter.label, - handleChange - ); + const onAdd = () => { + const key = dynamic ? (keyValue as string) : selectedParameter!.path.split(".").pop()!; + const data = { ...this.data }; + const schema = dynamic ? dynamicValueSchema! : selectedParameter!.schema; + data[key] = Array.isArray(schema.type) + ? null + : (selectedOneOf ? selectedOneOf.defaultValueCreator() : undefined) || createDefaultValue(schema, rootSchema); + this.handleChange(this.path || "", data); + dialog?.close(); + }; + + const getDescriptionTemplate: () => TemplateResult = () => { + if (dynamic) { + return html` + + + + `; + } + + if (!selectedParameter) { + return html``; } + const oneOf = selectedParameter.schema?.oneOf as JsonSchema[] | undefined; + return html` -
-
- ${ - dynamic - ? `` - : html` -
- -
- ` - } -
- ${ - !selectedParameter - ? `` - : html` -

${selectedParameter.description}

` - } + +

${selectedParameter.description}

+ ${when( + oneOf, + () => html` +
+ ${getSchemaPicker(rootSchema, oneOf!, selectedParameter!.label, onSchemaSelected)} +
+

${ - !dynamic - ? !schemaPicker - ? `` - : html` -

- - ${schemaPicker} -
-

${i18next.t("schema.selectTypeMessage")}

` - : html` - -
- -
- ` + selectedOneOf + ? selectedOneOf.description || i18next.t("schema.noDescriptionAvailable") + : i18next.t("schema.selectTypeMessage") } -
-
-
+

+ ` + )} `; }; - const dialog = showDialog( - new OrMwcDialog() - .setContent(dialogContentProvider) - .setStyles(addItemOrParameterDialogStyle) - .setHeading( - (this.label ? computeLabel(this.label, this.required, false) + " - " : "") + i18next.t("addParameter") - ) - .setActions([ - { - actionName: "cancel", - content: "cancel", - }, - { - default: true, - actionName: "add", - action: () => { - const key = dynamic ? (keyValue as string) : selectedParameter!.path.split(".").pop()!; - const data = { ...this.data }; - const schema = dynamic ? dynamicValueSchema! : selectedParameter!.schema; - data[key] = Array.isArray(schema.type) - ? null - : (selectedOneOf ? selectedOneOf.defaultValueCreator() : undefined) || - createDefaultValue(schema, rootSchema); - this.handleChange(this.path || "", data); - }, - content: html``, - }, - ]) - .setDismissAction(null) + dialog = showDialog( + this.shadowRoot!, + html` + +

+ ${(this.label ? computeLabel(this.label, this.required, false) + " - " : "") + i18next.t("addParameter")} +

+
+ ${when( + !dynamic, + () => html` + + ${listItems.map((item) => html`${item.text}`)} + + ` + )} +
+
+ +
+ ` ); + + refreshDescription(); } } diff --git a/ui/component/or-json-forms/src/styles.ts b/ui/component/or-json-forms/src/styles.ts index 5ab07990a8..86be5d375d 100644 --- a/ui/component/or-json-forms/src/styles.ts +++ b/ui/component/or-json-forms/src/styles.ts @@ -17,7 +17,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import { DefaultColor3, DefaultColor4, DefaultColor5 } from "@openremote/core"; -import { css, html, unsafeCSS } from "lit"; +import { css, unsafeCSS } from "lit"; // language=CSS export const baseStyle = css` @@ -128,49 +128,44 @@ export const panelStyle = css` } `; -// language=HTML -export const addItemOrParameterDialogStyle = html` - +/** + * Layout for the "add item" and "add parameter" dialogs. Their content is rendered into the shadow root + * of the element that opens them, so it is styled from that element's own styles. + */ +// language=CSS +export const addItemOrParameterDialogStyle = css` + #dialog-content { + display: flex; + max-height: 50vh; + } + + #type-list { + overflow: auto; + min-width: 150px; + max-width: 300px; + flex: 0 0 40%; + border-right: 1px solid var(--or-app-color5, ${unsafeCSS(DefaultColor5)}); + } + + #parameter-title { + text-transform: capitalize; + color: var(--or-app-color3, ${unsafeCSS(DefaultColor3)}); + font-size: 18px; + font-weight: bold; + } + + #parameter-desc { + padding: 15px; + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-start; + overflow: auto; + } + + #dialog-footer { + width: 100%; + display: flex; + justify-content: space-between; + } `; diff --git a/ui/component/or-json-forms/src/util.ts b/ui/component/or-json-forms/src/util.ts index df2efd848b..22ae3ce3b2 100644 --- a/ui/component/or-json-forms/src/util.ts +++ b/ui/component/or-json-forms/src/util.ts @@ -38,16 +38,17 @@ import { Resolve, type StatePropsOfCombinator, } from "@jsonforms/core"; -import { DefaultColor5, Util } from "@openremote/core"; +import { Util } from "@openremote/core"; import { i18next } from "@openremote/or-translate"; import "@openremote/or-components/or-ace-editor"; import type { OrAceEditor, OrAceEditorChangedEvent } from "@openremote/or-components/or-ace-editor"; -import { html, type TemplateResult, unsafeCSS } from "lit"; +import { html, type TemplateResult } from "lit"; import { createRef, type Ref, ref } from "lit/directives/ref.js"; import { unknownTemplate } from "./standard-renderers"; -import { OrMwcDialog, showDialog } from "@openremote/or-mwc-components/or-mwc-dialog"; +import { type OrVaadinDialog, showDialog } from "@openremote/or-vaadin-components/or-vaadin-dialog"; import type { OrVaadinSelect, SelectItem } from "@openremote/or-vaadin-components/or-vaadin-select"; import type { OrVaadinButton } from "@openremote/or-vaadin-components/or-vaadin-button"; +import "@openremote/or-vaadin-components/or-vaadin-dialog"; import "@openremote/or-vaadin-components/or-vaadin-select"; import "@openremote/or-vaadin-components/or-vaadin-button"; @@ -382,9 +383,16 @@ export const controlWithoutLabel = (scope: string): ControlElement => ({ label: false, }); -export const showJsonEditor = (title: string, value: any, updateCallback: (newValue: string) => void): void => { +export const showJsonEditor = ( + host: Node, + title: string, + value: any, + updateCallback: (newValue: string) => void +): void => { const editorRef: Ref = createRef(); const updateBtnRef: Ref = createRef(); + let dialog: OrVaadinDialog | undefined; + const onEditorEdit = () => { // Disable update button whilst edit in progress updateBtnRef.value!.disabled = true; @@ -393,59 +401,36 @@ export const showJsonEditor = (title: string, value: any, updateCallback: (newVa const valid = ev.detail.valid; updateBtnRef.value!.disabled = !valid; }; + const onUpdate = () => { + const editor = editorRef.value!; + if (editor.validate()) { + const data = editor.getValue() ? JSON.parse(editor.getValue()!) : undefined; + updateCallback(data); + dialog?.close(); + } + }; - const dialog = showDialog( - new OrMwcDialog() - .setContent(html` + dialog = showDialog( + host, + html` + +

${title}

- `) - .setActions([ - { - actionName: "cancel", - content: "cancel", - }, - { - default: true, - actionName: "update", - action: () => { - const editor = editorRef.value!; - if (editor.validate()) { - const data = editor.getValue() ? JSON.parse(editor.getValue()!) : undefined; - updateCallback(data); - } - }, - content: html` - - - - `, - }, - ]) - .setHeading(title) - .setDismissAction(null) - .setStyles(html` - - `) +
+ + + + + + +
+
+ ` ); }; diff --git a/ui/component/or-json-forms/test/fixtures/index.ts b/ui/component/or-json-forms/test/fixtures/index.ts index b81c73b8df..4df2ba2771 100644 --- a/ui/component/or-json-forms/test/fixtures/index.ts +++ b/ui/component/or-json-forms/test/fixtures/index.ts @@ -116,7 +116,8 @@ export class JsonForms { private readonly page: Page, private dialog: Locator ) { - this.dialog = this.page.locator("or-mwc-dialog"); + // A closing dialog is only removed once its animation ends, so match on the open one to stay unambiguous + this.dialog = this.page.locator("or-vaadin-dialog[opened]"); } /** @@ -213,7 +214,7 @@ export class JsonForms { if (subType.$ref) { resolvedSchema = this.resolveSchema(schema, subType.$ref!); } - await this.dialog.locator("li").getByText(resolvedSchema.title, { exact: true }).click(); + await this.dialog.getByRole("option", { name: resolvedSchema.title, exact: true }).click(); await this.dialog.getByRole("button", { name: "Add", exact: true }).click(); } } else { @@ -231,10 +232,7 @@ export class JsonForms { if (schema.patternProperties) { await locator.getByRole("button", { name: "Add Parameter" }).click(); - await this.page - .getByRole("alertdialog", { name: " - Add" }) - .locator("label", { hasText: "Key" }) - .pressSequentially("test"); + await this.dialog.getByRole("textbox", { name: "Key" }).pressSequentially("test"); await this.dialog.getByRole("button", { name: "Add", exact: true }).click(); } else { const properties = options?.selectAllProps @@ -246,11 +244,11 @@ export class JsonForms { await locator.getByRole("button", { name: "Add Parameter" }).click(); const name = Util.camelCaseToSentenceCase(key); - await this.dialog.locator("or-mwc-list li").getByText(name, { exact: true }).click(); - const anyOfPicker = this.dialog.locator("#schema-picker or-mwc-input"); + await this.dialog.getByRole("option", { name, exact: true }).click(); + const anyOfPicker = this.dialog.locator("#schema-picker or-vaadin-select"); if (await anyOfPicker.isVisible()) { await anyOfPicker.click(); - await anyOfPicker.locator("li").first().click(); + await this.page.getByRole("listbox").getByRole("option").first().click(); } await this.dialog.getByRole("button", { name: "Add", exact: true }).click(); } From 9eb60bf4272952cb0c4ba12bb392a2a6d2905c14 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Fri, 2 Oct 2026 10:27:44 +0200 Subject: [PATCH 03/18] Render JSON input types with Vaadin Object-typed schema properties such as a websocket subscription body map to InputType.JSON, which was missing from the or-vaadin-input templates and so fell back to or-mwc-input. Register it along with JSON_OBJECT and marshal the value, since a JSON field holds text rather than its value. --- ui/AGENTS.md | 2 +- .../src/controls/control-input-element.ts | 16 ++++- .../src/or-vaadin-input.ts | 60 ++++++++++++++++++- .../src/value-input-provider.ts | 3 + .../test/or-vaadin-input.test.ts | 26 ++++++++ 5 files changed, 103 insertions(+), 4 deletions(-) diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 7f80a3a554..cd57f4d34b 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -55,7 +55,7 @@ Pick the approach by how much of the base component you need to keep. The base + ### Registering an input type in the input pipeline -To make an input type usable through the input pipeline, register it in `or-vaadin-input.ts` (`TEMPLATES` map + a `getXTemplate`, and `nativeValue` if it is a boolean that exposes `checked` instead of `value`) and update `value-input-provider.ts` (boolean/checked types use the `checked` attribute, not `value`). Where the Vaadin component represents the value differently than the attribute does, such as the date time picker taking a local ISO string instead of a timestamp, convert the value and any min/max there and convert back through `valueConverter`. +To make an input type usable through the input pipeline, register it in `or-vaadin-input.ts` (`TEMPLATES` map + a `getXTemplate`) and update `value-input-provider.ts`. Where the Vaadin component represents the value differently than the attribute does, convert at both ends: `nativeValue` and `checkValidity` read and validate what the component holds, while each consumer serializes the value and any min/max into the attributes and converts back through `valueConverter`. Booleans expose `checked` instead of `value`, JSON types carry their value as formatted text, and the date time picker takes a local ISO string instead of a timestamp. ## Storybook diff --git a/ui/component/or-json-forms/src/controls/control-input-element.ts b/ui/component/or-json-forms/src/controls/control-input-element.ts index 56838cf408..4b6fe3a0fc 100644 --- a/ui/component/or-json-forms/src/controls/control-input-element.ts +++ b/ui/component/or-json-forms/src/controls/control-input-element.ts @@ -175,11 +175,14 @@ export class ControlInputElement extends ControlBaseElement { // or-vaadin-select offers neither multi select nor search, so those stay on or-mwc-input if (OrVaadinInput.TEMPLATES.has(this.inputType) && !multiple && !searchable) { const isCheckbox = this.inputType === InputType.CHECKBOX; + const displayValue = OrVaadinInput.JSON_TYPES.includes(this.inputType) + ? OrVaadinInput.stringifyJson(value) + : (value ?? undefined); return html`([ [InputType.BIG_INT, "submit"], + [InputType.JSON, "submit"], + [InputType.JSON_OBJECT, "submit"], [InputType.NUMBER, "submit"], [InputType.TEXTAREA, "submit"], [InputType.TEXT, "submit"], @@ -172,6 +182,10 @@ export class OrVaadinInput extends OrElement { case InputType.SWITCH: { return (this._elem as HTMLInputElement | undefined)?.checked; } + case InputType.JSON: + case InputType.JSON_OBJECT: { + return OrVaadinInput.parseJson(this.type, this._elem?.value).value; + } default: { return this._elem?.value; } @@ -183,7 +197,47 @@ export class OrVaadinInput extends OrElement { * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/checkValidity|HTMLInputElement/checkValidity} */ public checkValidity(): boolean { - return this._elem?.checkValidity() ?? false; + if (!(this._elem?.checkValidity() ?? false)) { + return false; + } + // A text area accepts any text, so JSON types need their own parse check on top of the field constraints + return !OrVaadinInput.JSON_TYPES.includes(this.type) || OrVaadinInput.parseJson(this.type, this._elem?.value).valid; + } + + /** + * Serializes a value for display in a JSON input type, matching what {@link nativeValue} parses back. + * @param value - The value to serialize + */ + public static stringifyJson(value: any): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return undefined; + } + } + + /** + * Parses the text of a JSON input type. An empty field is a valid, absent value; text that does not parse, + * or that is not an object for {@link InputType.JSON_OBJECT}, is invalid and has no value. + * @param type - The input type being parsed + * @param text - The text to parse + */ + protected static parseJson(type: InputType, text?: string): { value?: any; valid: boolean } { + if (!text?.trim()) { + return { valid: true }; + } + try { + const value = JSON.parse(text); + if (type === InputType.JSON_OBJECT && (typeof value !== "object" || value === null || Array.isArray(value))) { + return { valid: false }; + } + return { value, valid: true }; + } catch { + return { valid: false }; + } } render() { @@ -228,6 +282,10 @@ export class OrVaadinInput extends OrElement { protected _onValueChange(ev: Event) { ev.stopPropagation(); if (ev.defaultPrevented) return; + // No field constraint catches malformed JSON, so the invalid state has to be set from the parse result + if (OrVaadinInput.JSON_TYPES.includes(this.type) && this._elem) { + (this._elem as HTMLInputElement & { invalid: boolean }).invalid = !this.checkValidity(); + } this.dispatchEvent(new CustomEvent("change", { bubbles: true })); } diff --git a/ui/component/or-vaadin-components/src/value-input-provider.ts b/ui/component/or-vaadin-components/src/value-input-provider.ts index a686f22ee8..65a6328370 100644 --- a/ui/component/or-vaadin-components/src/value-input-provider.ts +++ b/ui/component/or-vaadin-components/src/value-input-provider.ts @@ -417,6 +417,9 @@ export const getValueHolderInputTemplateProvider: ValueInputProviderGenerator = value = undefined; } else if (inputType === InputType.DATETIME && value !== undefined && value !== null) { value = OrVaadinDateTimePicker.getLocalizedISOString(new Date(value), step); + } else if (OrVaadinInput.JSON_TYPES.includes(inputType)) { + // The value attribute carries text, so a JSON value is shown as formatted JSON + value = OrVaadinInput.stringifyJson(value); } // The step is set before the value, so that a date time picker does not trim the value to its default precision. diff --git a/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts b/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts index e46fdda12b..204e110e5c 100644 --- a/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts +++ b/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts @@ -54,3 +54,29 @@ ct("should expose the picked date and time as a local ISO string", async ({ moun // Setting the value does not count as a change, only the picked time does. await expect.poll(() => changeCount).toBe(1); }); + +ct("should render a text area for the json type", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "json" } }); + + // The json type was absent from TEMPLATES, so every consumer fell back to the deprecated or-mwc-input. + await expect(component.getByRole("textbox")).toBeVisible(); +}); + +ct("should show a json value as text", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "json" } }); + // Values reach the Vaadin element through attributes; a property would be serialized a second time. + await component.evaluate((el) => el.setAttribute("value", '{"a":1}')); + + await expect(component.getByRole("textbox")).toHaveValue('{"a":1}'); +}); + +ct("should mark the field invalid when the json does not parse", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "json" } }); + + const input = component.getByRole("textbox"); + await input.fill("{ not json"); + await input.blur(); + + // A text area has no constraint of its own that rejects this, so the parse result has to set the state. + await expect(component.locator("or-vaadin-text-area")).toHaveAttribute("invalid"); +}); From 95901c61a892db40796f9412103124c95b8334dc Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Fri, 2 Oct 2026 10:28:52 +0200 Subject: [PATCH 04/18] Give JSON forms dialog contents a boundary and min height Render the add item and add parameter panes as cards against the tinted dialog background instead of splitting them with a border, and hold the body at the same 600px the add asset dialog uses so a short list still fills the dialog. The JSON editor matches. --- ui/component/or-json-forms/src/styles.ts | 27 ++++++++++++++++++++---- ui/component/or-json-forms/src/util.ts | 3 ++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/ui/component/or-json-forms/src/styles.ts b/ui/component/or-json-forms/src/styles.ts index 86be5d375d..fc2d9e8ebc 100644 --- a/ui/component/or-json-forms/src/styles.ts +++ b/ui/component/or-json-forms/src/styles.ts @@ -128,6 +128,9 @@ export const panelStyle = css` } `; +/** Height the dialog body keeps even when its content is short, so a short list still fills the dialog. */ +const dialogContentMinHeight = "600px"; + /** * Layout for the "add item" and "add parameter" dialogs. Their content is rendered into the shadow root * of the element that opens them, so it is styled from that element's own styles. @@ -136,15 +139,25 @@ export const panelStyle = css` export const addItemOrParameterDialogStyle = css` #dialog-content { display: flex; - max-height: 50vh; + gap: var(--lumo-space-s); + min-height: ${unsafeCSS(dialogContentMinHeight)}; + max-height: 60vh; + } + + /* The panes read as cards against the tinted dialog background, which is what separates them */ + #type-list, + #parameter-desc { + background-color: var(--lumo-base-color, #fff); + border-radius: var(--lumo-border-radius-m); } + /* The list scrolls through its own items part, so the host only has to clip the rounded corners */ #type-list { - overflow: auto; min-width: 150px; max-width: 300px; flex: 0 0 40%; - border-right: 1px solid var(--or-app-color5, ${unsafeCSS(DefaultColor5)}); + padding: var(--lumo-space-s) 0; + overflow: hidden; } #parameter-title { @@ -155,7 +168,7 @@ export const addItemOrParameterDialogStyle = css` } #parameter-desc { - padding: 15px; + padding: var(--lumo-space-m); flex: 1; display: flex; flex-direction: column; @@ -169,3 +182,9 @@ export const addItemOrParameterDialogStyle = css` justify-content: space-between; } `; + +/** + * Inline style for the JSON editor, which opens on an arbitrary host and so cannot rely on that host's styles. + * It matches the panes above: same height, and rounded corners against the tinted dialog background. + */ +export const jsonEditorStyle = `width: 100%; height: 60vh; min-height: ${dialogContentMinHeight}; border-radius: var(--lumo-border-radius-m); overflow: hidden;`; diff --git a/ui/component/or-json-forms/src/util.ts b/ui/component/or-json-forms/src/util.ts index 22ae3ce3b2..e0400c5a1f 100644 --- a/ui/component/or-json-forms/src/util.ts +++ b/ui/component/or-json-forms/src/util.ts @@ -45,6 +45,7 @@ import type { OrAceEditor, OrAceEditorChangedEvent } from "@openremote/or-compon import { html, type TemplateResult } from "lit"; import { createRef, type Ref, ref } from "lit/directives/ref.js"; import { unknownTemplate } from "./standard-renderers"; +import { jsonEditorStyle } from "./styles"; import { type OrVaadinDialog, showDialog } from "@openremote/or-vaadin-components/or-vaadin-dialog"; import type { OrVaadinSelect, SelectItem } from "@openremote/or-vaadin-components/or-vaadin-select"; import type { OrVaadinButton } from "@openremote/or-vaadin-components/or-vaadin-button"; @@ -417,7 +418,7 @@ export const showJsonEditor = (

${title}

Date: Fri, 2 Oct 2026 11:12:36 +0200 Subject: [PATCH 05/18] Unset dialog min-height for dynamic add parameter --- .../or-json-forms/src/layouts/layout-vertical-element.ts | 2 +- ui/component/or-json-forms/src/styles.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/component/or-json-forms/src/layouts/layout-vertical-element.ts b/ui/component/or-json-forms/src/layouts/layout-vertical-element.ts index 8e51799d3e..19b7e8d108 100644 --- a/ui/component/or-json-forms/src/layouts/layout-vertical-element.ts +++ b/ui/component/or-json-forms/src/layouts/layout-vertical-element.ts @@ -440,7 +440,7 @@ export class LayoutVerticalElement extends LayoutBaseElement ${(this.label ? computeLabel(this.label, this.required, false) + " - " : "") + i18next.t("addParameter")} -
+
${when( !dynamic, () => html` diff --git a/ui/component/or-json-forms/src/styles.ts b/ui/component/or-json-forms/src/styles.ts index fc2d9e8ebc..8d3c6cd879 100644 --- a/ui/component/or-json-forms/src/styles.ts +++ b/ui/component/or-json-forms/src/styles.ts @@ -144,6 +144,11 @@ export const addItemOrParameterDialogStyle = css` max-height: 60vh; } + /* A form holding nothing but a key field sizes to that field instead */ + #dialog-content.auto-height { + min-height: unset; + } + /* The panes read as cards against the tinted dialog background, which is what separates them */ #type-list, #parameter-desc { From 42b5cf6c6b8bbf5737abdce42ab6d116ac020b2b Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Fri, 2 Oct 2026 13:19:46 +0200 Subject: [PATCH 06/18] Migrate remaining JSON Forms inputs to Vaadin --- ui/component/or-json-forms/package.json | 1 - .../src/controls/control-input-element.ts | 231 +++++++++++------- .../src/layouts/layout-vertical-element.ts | 5 - .../or-json-forms/src/standard-renderers.ts | 3 +- ui/component/or-json-forms/tsconfig.json | 2 +- .../src/or-vaadin-input.ts | 8 + ui/component/or-vaadin-components/src/util.ts | 9 + .../src/value-input-provider.ts | 3 +- yarn.lock | 1 - 9 files changed, 168 insertions(+), 95 deletions(-) diff --git a/ui/component/or-json-forms/package.json b/ui/component/or-json-forms/package.json index b13380315e..ee889fd9ae 100644 --- a/ui/component/or-json-forms/package.json +++ b/ui/component/or-json-forms/package.json @@ -32,7 +32,6 @@ "@openremote/core": "workspace:*", "@openremote/or-components": "workspace:*", "@openremote/or-element": "workspace:*", - "@openremote/or-mwc-components": "workspace:*", "@openremote/or-translate": "workspace:*", "@openremote/or-vaadin-components": "workspace:*", "ajv": "^8.20.0", diff --git a/ui/component/or-json-forms/src/controls/control-input-element.ts b/ui/component/or-json-forms/src/controls/control-input-element.ts index 4b6fe3a0fc..bbba85a3db 100644 --- a/ui/component/or-json-forms/src/controls/control-input-element.ts +++ b/ui/component/or-json-forms/src/controls/control-input-element.ts @@ -16,10 +16,16 @@ * * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { OrInputChangedEvent } from "@openremote/or-mwc-components/or-mwc-input"; -import { InputType } from "@openremote/or-vaadin-components/util"; +import { type InputOption, InputType } from "@openremote/or-vaadin-components/util"; import { OrVaadinInput } from "@openremote/or-vaadin-components/or-vaadin-input"; -import { css, html } from "lit"; +import type { OrVaadinComboBox } from "@openremote/or-vaadin-components/or-vaadin-combo-box"; +import type { OrVaadinMultiSelectComboBox } from "@openremote/or-vaadin-components/or-vaadin-multi-select-combo-box"; +import "@openremote/or-vaadin-components/or-vaadin-combo-box"; +import "@openremote/or-vaadin-components/or-vaadin-multi-select-combo-box"; +import "@openremote/or-vaadin-components/or-vaadin-date-picker"; +import "@openremote/or-vaadin-components/or-vaadin-date-time-picker"; +import "@openremote/or-vaadin-components/or-vaadin-time-picker"; +import { css, html, type TemplateResult } from "lit"; import { customElement } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { ControlBaseElement } from "./control-base-element"; @@ -39,13 +45,17 @@ let defaultTz: string; // language=CSS const style = css` - or-mwc-input, or-vaadin-input { - width: 100%; + display: block; } - or-vaadin-input { - display: block; + or-vaadin-input, + or-vaadin-combo-box, + or-vaadin-multi-select-combo-box, + or-vaadin-date-picker, + or-vaadin-date-time-picker, + or-vaadin-time-picker { + width: 100%; } `; @@ -73,9 +83,7 @@ export class ControlInputElement extends ControlBaseElement { let options: [string, string][] | undefined; let multiple = false; let value: any = this.data ?? schema.default; - let searchable: boolean | undefined; - let searchProvider!: (search?: string) => [any, string][] | undefined; - let onValueChanged = (e: OrInputChangedEvent) => this.onValueChanged(e); + let searchable = false; if (Array.isArray(schema.type)) { this.inputType = InputType.JSON; @@ -123,16 +131,6 @@ export class ControlInputElement extends ControlBaseElement { }); } } - - if (multiple) { - value = Array.isArray(value) - ? value.map((v) => JSON.stringify(v)) - : value !== undefined - ? [JSON.stringify(value)] - : undefined; - } else { - value = value !== undefined ? JSON.stringify(value) : undefined; - } } else if (isStringControl(uischema, schema, context)) { minLength = schema.minLength; maxLength = schema.maxLength; @@ -154,70 +152,135 @@ export class ControlInputElement extends ControlBaseElement { this.inputType = InputType.PASSWORD; } else if (format === "timezone") { this.inputType = InputType.SELECT; - options = Intl.supportedValuesOf("timeZone").map((z) => [z, z]); + options = Intl.supportedValuesOf("timeZone").map((z) => [JSON.stringify(z), z]); if (!(defaultTz && value)) { defaultTz = Intl.DateTimeFormat().resolvedOptions().timeZone; this.handleChange(this.path, defaultTz); } + // There are hundreds of zones to pick from, so the list needs to be filterable searchable = true; - onValueChanged = (e: OrInputChangedEvent) => this.handleChange(this.path, e.detail.value); - searchProvider = (search?: string) => { - if (search) { - return options?.filter(([, name]) => name.toLowerCase().includes(search.toLowerCase())); - } - return options?.filter(([, name]) => - name.toLowerCase().includes((value ?? defaultTz).toLowerCase().split("/")[0]) - ); - }; } } - // or-vaadin-select offers neither multi select nor search, so those stay on or-mwc-input - if (OrVaadinInput.TEMPLATES.has(this.inputType) && !multiple && !searchable) { - const isCheckbox = this.inputType === InputType.CHECKBOX; - const displayValue = OrVaadinInput.JSON_TYPES.includes(this.inputType) - ? OrVaadinInput.stringifyJson(value) - : (value ?? undefined); - return html` v !== undefined).map((v) => JSON.stringify(v)); + } else { + value = value === undefined ? undefined : JSON.stringify(value); + } + } + + if (multiple || searchable) { + return this.getOptionsTemplate(options, value, multiple); + } + + if ([InputType.DATE, InputType.DATETIME, InputType.TIME].includes(this.inputType)) { + return this.getPickerTemplate(value); + } + + const isCheckbox = this.inputType === InputType.CHECKBOX; + const displayValue = OrVaadinInput.JSON_TYPES.includes(this.inputType) + ? OrVaadinInput.stringifyJson(value) + : (value ?? undefined); + return html``; + } + + /** + * Template for an enum, which a combo box renders so that long lists can be filtered and several values picked. + */ + protected getOptionsTemplate(options: [string, string][] | undefined, value: any, multiple: boolean): TemplateResult { + const items: InputOption[] | undefined = options?.map(([optionValue, optionLabel]) => ({ + value: optionValue, + label: optionLabel, + })); + + if (multiple) { + const selected: string[] = value ?? []; + return html``; + @change="${(e: Event) => this.onOptionsChanged(e)}" + >`; } - return html``; + return html``; + } + + /** + * Template for a date, time or date and time, which the pickers read and write in the same format the + * matching JSON schema format describes. + */ + protected getPickerTemplate(value: any): TemplateResult { + const label = this.label || undefined; + const errorMessage = this.errors || undefined; + const disabled = !this.enabled; + const required = !!this.required; + const onChange = (e: Event) => + this.handleChange(this.path!, (e.currentTarget as HTMLInputElement).value || undefined); + + switch (this.inputType) { + case InputType.DATE: { + return html``; + } + case InputType.TIME: { + return html``; + } + default: { + return html``; + } + } } /** @@ -252,18 +315,18 @@ export class ControlInputElement extends ControlBaseElement { } } - protected onValueChanged(e: OrInputChangedEvent) { - if (this.inputType === InputType.SELECT) { - if (Array.isArray(e.detail.value)) { - this.handleChange( - this.path!, - (e.detail.value as []).map((v: string) => JSON.parse(v)) - ); - } else { - this.handleChange(this.path!, JSON.parse(e.detail.value as string)); - } - } else { - this.handleChange(this.path!, e.detail.value); - } + /** Reads the single option a combo box holds, whose value is the JSON of the schema value. */ + protected onOptionChanged(e: Event) { + const value = (e.currentTarget as OrVaadinComboBox).value; + this.handleChange(this.path!, value ? JSON.parse(value) : undefined); + } + + /** Reads the options a multi select combo box holds, whose values are the JSON of the schema values. */ + protected onOptionsChanged(e: Event) { + const items = (e.currentTarget as OrVaadinMultiSelectComboBox).selectedItems as InputOption[]; + this.handleChange( + this.path!, + items.map((item) => JSON.parse(item.value)) + ); } } diff --git a/ui/component/or-json-forms/src/layouts/layout-vertical-element.ts b/ui/component/or-json-forms/src/layouts/layout-vertical-element.ts index 19b7e8d108..2ae89062db 100644 --- a/ui/component/or-json-forms/src/layouts/layout-vertical-element.ts +++ b/ui/component/or-json-forms/src/layouts/layout-vertical-element.ts @@ -91,11 +91,6 @@ const style = css` vertical-align: top; } - .key-container or-mwc-input, - .value-container or-mwc-input { - display: block; - } - .value-container > .item-container { margin: 0; } diff --git a/ui/component/or-json-forms/src/standard-renderers.ts b/ui/component/or-json-forms/src/standard-renderers.ts index 4408ad07e7..d482900362 100644 --- a/ui/component/or-json-forms/src/standard-renderers.ts +++ b/ui/component/or-json-forms/src/standard-renderers.ts @@ -57,7 +57,6 @@ import { uiTypeIs, } from "@jsonforms/core"; import { html, type TemplateResult } from "lit"; -import "@openremote/or-mwc-components/or-mwc-input"; import "@openremote/or-vaadin-components/or-vaadin-input"; import type { JsonFormsStateContext } from "./index"; import { @@ -247,7 +246,7 @@ export const anyOfOneOfControlRenderer = (state: JsonFormsStateContext, props: C // }; // return html` - //
${label}:
+ //
${label}:json
// `; // } else { // We have no data so show a schema picker diff --git a/ui/component/or-json-forms/tsconfig.json b/ui/component/or-json-forms/tsconfig.json index 0b27da43d9..f058b106ce 100644 --- a/ui/component/or-json-forms/tsconfig.json +++ b/ui/component/or-json-forms/tsconfig.json @@ -12,7 +12,7 @@ { "path": "../rest" }, { "path": "../model" }, { "path": "../or-translate" }, - { "path": "../or-mwc-components" }, + { "path": "../or-vaadin-components" }, { "path": "../or-icon" } ] } diff --git a/ui/component/or-vaadin-components/src/or-vaadin-input.ts b/ui/component/or-vaadin-components/src/or-vaadin-input.ts index 4dc7974f4f..f9e644f84f 100644 --- a/ui/component/or-vaadin-components/src/or-vaadin-input.ts +++ b/ui/component/or-vaadin-components/src/or-vaadin-input.ts @@ -78,6 +78,7 @@ export class OrVaadinInput extends OrElement { [InputType.BIG_INT, OrVaadinInput.getNumberFieldTemplate], [InputType.CHECKBOX, OrVaadinInput.getCheckboxTemplate], [InputType.DATETIME, OrVaadinInput.getDateTimePickerTemplate], + [InputType.EMAIL, OrVaadinInput.getEmailFieldTemplate], [InputType.JSON, OrVaadinInput.getTextAreaTemplate], [InputType.JSON_OBJECT, OrVaadinInput.getTextAreaTemplate], [InputType.NUMBER, OrVaadinInput.getNumberFieldTemplate], @@ -85,6 +86,7 @@ export class OrVaadinInput extends OrElement { [InputType.RANGE, OrVaadinInput.getSliderTemplate], [InputType.SELECT, OrVaadinInput.getSelectTemplate], [InputType.SWITCH, OrVaadinInput.getSwitchTemplate], + [InputType.TELEPHONE, OrVaadinInput.getTextFieldTemplate], [InputType.TEXT, OrVaadinInput.getTextFieldTemplate], [InputType.TEXTAREA, OrVaadinInput.getTextAreaTemplate], ]); @@ -101,9 +103,11 @@ export class OrVaadinInput extends OrElement { */ public static readonly CHANGE_EVENTS = new Map([ [InputType.BIG_INT, "submit"], + [InputType.EMAIL, "submit"], [InputType.JSON, "submit"], [InputType.JSON_OBJECT, "submit"], [InputType.NUMBER, "submit"], + [InputType.TELEPHONE, "submit"], [InputType.TEXTAREA, "submit"], [InputType.TEXT, "submit"], [InputType.PASSWORD, "submit"], @@ -334,6 +338,10 @@ export class OrVaadinInput extends OrElement { return html``; } + public static getEmailFieldTemplate(onChange?: (e: Event) => void) { + return html``; + } + public static getNumberFieldTemplate(onChange?: (e: Event) => void) { return html``; } diff --git a/ui/component/or-vaadin-components/src/util.ts b/ui/component/or-vaadin-components/src/util.ts index 3b15ad815c..93a4b749d2 100644 --- a/ui/component/or-vaadin-components/src/util.ts +++ b/ui/component/or-vaadin-components/src/util.ts @@ -87,6 +87,15 @@ export interface OrVaadinComponent { getAttributeNames(): string[]; } +/** + * An option of a select or combo box, where the label is shown and the value identifies it. + * Vaadin's own `SelectItem` leaves both optional, which an option list built in code never is. + */ +export interface InputOption { + value: string; + label: string; +} + /** * Returns whether the {@link InputType} should show a "send" button within the attribute input UI. * Some input types have internal mechanics for updating attributes, which is why they should return `false`. diff --git a/ui/component/or-vaadin-components/src/value-input-provider.ts b/ui/component/or-vaadin-components/src/value-input-provider.ts index 65a6328370..39ba23e5be 100644 --- a/ui/component/or-vaadin-components/src/value-input-provider.ts +++ b/ui/component/or-vaadin-components/src/value-input-provider.ts @@ -48,6 +48,7 @@ import { type Ref, ref, createRef } from "lit/directives/ref.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { styleMap } from "lit/directives/style-map.js"; import { + type InputOption, InputType, inputTypeSupportsHelperText, inputTypeSupportsLabel, @@ -123,7 +124,7 @@ export const getValueHolderInputTemplateProvider: ValueInputProviderGenerator = let max: any; let multiple: any; let required: boolean | undefined; - let selectOptions: { label: string; value: string }[] | undefined; + let selectOptions: InputOption[] | undefined; let valueConverter: (v: any) => any | undefined; const styles = {} as any; diff --git a/yarn.lock b/yarn.lock index 8f4dccffd4..b24e7b81fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2201,7 +2201,6 @@ __metadata: "@openremote/core": "workspace:*" "@openremote/or-components": "workspace:*" "@openremote/or-element": "workspace:*" - "@openremote/or-mwc-components": "workspace:*" "@openremote/or-translate": "workspace:*" "@openremote/or-vaadin-components": "workspace:*" "@openremote/util": "workspace:*" From 1d776e2c73cebdc054d5681e2581e91f3b5f78c8 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Fri, 2 Oct 2026 16:13:46 +0200 Subject: [PATCH 07/18] Reuse Vaadin's getAdjustedYear helper --- .../src/date-time-i18n.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/ui/component/or-vaadin-components/src/date-time-i18n.ts b/ui/component/or-vaadin-components/src/date-time-i18n.ts index 1c474bd92e..25ff35fb2a 100644 --- a/ui/component/or-vaadin-components/src/date-time-i18n.ts +++ b/ui/component/or-vaadin-components/src/date-time-i18n.ts @@ -18,6 +18,7 @@ */ import i18next from "i18next"; import { I18NEXT_TO_MOMENT_LOCALE } from "@openremote/core"; +import { getAdjustedYear } from "@vaadin/date-picker/src/vaadin-date-picker-helper.js"; import type { DatePickerDate, DatePickerI18n } from "@vaadin/date-picker"; import type { TimePickerI18n } from "@vaadin/time-picker"; import type { TimePickerTime } from "@vaadin/time-picker/src/vaadin-time-picker-helper.js"; @@ -171,24 +172,15 @@ function parseDate(fieldOrder: string[], text: string): DatePickerDate | undefin const month = parts.month === undefined ? today.getMonth() : parseInt(parts.month) - 1; let year = today.getFullYear(); if (parts.year !== undefined) { - year = parts.year.length < 3 ? getAdjustedYear(today, parseInt(parts.year), month, day) : parseInt(parts.year); + // getAdjustedYear takes at most two digits and is typed as returning a Date, though it returns a full year + year = + parts.year.length < 3 + ? (getAdjustedYear(today, parseInt(parts.year), month, day) as unknown as number) + : parseInt(parts.year); } return { day, month, year }; } -/** - * Returns the year ending in the two given digits that lies within 50 years of the reference date. - */ -function getAdjustedYear(referenceDate: Date, year: number, month: number, day: number): number { - let adjustedYear = year + Math.floor(referenceDate.getFullYear() / 100) * 100; - if (referenceDate < new Date(adjustedYear - 50, month, day)) { - adjustedYear -= 100; - } else if (referenceDate > new Date(adjustedYear + 50, month, day)) { - adjustedYear += 100; - } - return adjustedYear; -} - /** * Formats a time on a 12-hour clock in the layout of the parts of the locale, with any seconds and milliseconds after * the minutes. From 3cd79eea376a4627b0e7d1161724ea2241c54bfb Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Mon, 5 Oct 2026 08:35:53 +0200 Subject: [PATCH 08/18] Refactor date/time inputs in JSON Forms --- .../src/controls/control-input-element.ts | 101 ++++-------------- .../src/or-vaadin-input.ts | 14 +++ .../src/value-input-provider.ts | 5 +- .../test/or-vaadin-input.test.ts | 46 ++++++++ 4 files changed, 87 insertions(+), 79 deletions(-) diff --git a/ui/component/or-json-forms/src/controls/control-input-element.ts b/ui/component/or-json-forms/src/controls/control-input-element.ts index bbba85a3db..743fcd9f63 100644 --- a/ui/component/or-json-forms/src/controls/control-input-element.ts +++ b/ui/component/or-json-forms/src/controls/control-input-element.ts @@ -22,9 +22,6 @@ import type { OrVaadinComboBox } from "@openremote/or-vaadin-components/or-vaadi import type { OrVaadinMultiSelectComboBox } from "@openremote/or-vaadin-components/or-vaadin-multi-select-combo-box"; import "@openremote/or-vaadin-components/or-vaadin-combo-box"; import "@openremote/or-vaadin-components/or-vaadin-multi-select-combo-box"; -import "@openremote/or-vaadin-components/or-vaadin-date-picker"; -import "@openremote/or-vaadin-components/or-vaadin-date-time-picker"; -import "@openremote/or-vaadin-components/or-vaadin-time-picker"; import { css, html, type TemplateResult } from "lit"; import { customElement } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -43,6 +40,16 @@ import { isEnumArray } from "../standard-renderers"; let defaultTz: string; +/** Builds the options of an enum, whose values are JSON so that every enum type round-trips through them. */ +function getOptions(values: unknown[]): [string, string][] { + return values.map((value) => [JSON.stringify(value), String(value)]); +} + +/** Turns the options into the shape a Vaadin select or combo box takes. */ +function getInputOptions(options?: [string, string][]): InputOption[] | undefined { + return options?.map(([value, label]) => ({ value, label })); +} + // language=CSS const style = css` or-vaadin-input { @@ -51,10 +58,7 @@ const style = css` or-vaadin-input, or-vaadin-combo-box, - or-vaadin-multi-select-combo-box, - or-vaadin-date-picker, - or-vaadin-date-time-picker, - or-vaadin-time-picker { + or-vaadin-multi-select-combo-box { width: 100%; } `; @@ -111,25 +115,13 @@ export class ControlInputElement extends ControlBaseElement { this.inputType = InputType.SELECT; if (isEnumControl(uischema, schema, context)) { - options = schema.enum!.map((enm) => { - return [JSON.stringify(enm), String(enm)]; - }); + options = getOptions(schema.enum!); } else if (isOneOfEnumControl(uischema, schema, context)) { - options = (schema.oneOf as JsonSchema[]).map((s) => { - return [JSON.stringify(s.const), String(s.const)]; - }); + options = getOptions((schema.oneOf as JsonSchema[]).map((s) => s.const)); } else { multiple = true; - - if ((schema.items! as JsonSchema).oneOf!) { - options = (schema.items! as JsonSchema).oneOf!.map((s) => { - return [JSON.stringify(s.const), String(s.const)]; - }); - } else { - options = (schema.items! as JsonSchema).enum!.map((enm) => { - return [JSON.stringify(enm), String(enm)]; - }); - } + const items = schema.items as JsonSchema; + options = getOptions(items.oneOf ? items.oneOf.map((s) => s.const) : items.enum!); } } else if (isStringControl(uischema, schema, context)) { minLength = schema.minLength; @@ -176,10 +168,6 @@ export class ControlInputElement extends ControlBaseElement { return this.getOptionsTemplate(options, value, multiple); } - if ([InputType.DATE, InputType.DATETIME, InputType.TIME].includes(this.inputType)) { - return this.getPickerTemplate(value); - } - const isCheckbox = this.inputType === InputType.CHECKBOX; const displayValue = OrVaadinInput.JSON_TYPES.includes(this.inputType) ? OrVaadinInput.stringifyJson(value) @@ -192,7 +180,7 @@ export class ControlInputElement extends ControlBaseElement { ?checked="${isCheckbox && !!value}" ?disabled="${!this.enabled}" ?required="${!!this.required}" - .items="${options?.map(([optionValue, optionLabel]) => ({ value: optionValue, label: optionLabel }))}" + .items="${getInputOptions(options)}" minlength="${ifDefined(minLength)}" maxlength="${ifDefined(maxLength)}" pattern="${ifDefined(pattern)}" @@ -208,10 +196,7 @@ export class ControlInputElement extends ControlBaseElement { * Template for an enum, which a combo box renders so that long lists can be filtered and several values picked. */ protected getOptionsTemplate(options: [string, string][] | undefined, value: any, multiple: boolean): TemplateResult { - const items: InputOption[] | undefined = options?.map(([optionValue, optionLabel]) => ({ - value: optionValue, - label: optionLabel, - })); + const items = getInputOptions(options); if (multiple) { const selected: string[] = value ?? []; @@ -237,52 +222,6 @@ export class ControlInputElement extends ControlBaseElement { >`; } - /** - * Template for a date, time or date and time, which the pickers read and write in the same format the - * matching JSON schema format describes. - */ - protected getPickerTemplate(value: any): TemplateResult { - const label = this.label || undefined; - const errorMessage = this.errors || undefined; - const disabled = !this.enabled; - const required = !!this.required; - const onChange = (e: Event) => - this.handleChange(this.path!, (e.currentTarget as HTMLInputElement).value || undefined); - - switch (this.inputType) { - case InputType.DATE: { - return html``; - } - case InputType.TIME: { - return html``; - } - default: { - return html``; - } - } - } - /** * Vaadin inputs expose their value as a string, so it is parsed back into the type the schema expects. */ @@ -301,6 +240,12 @@ export class ControlInputElement extends ControlBaseElement { this.handleChange(this.path!, empty ? undefined : Number(value)); break; } + case InputType.DATE: + case InputType.DATETIME: + case InputType.TIME: { + this.handleChange(this.path!, empty ? undefined : value); + break; + } case InputType.JSON: case InputType.JSON_OBJECT: { // Text that does not parse has no value, so keep what is there and let the field show it is invalid diff --git a/ui/component/or-vaadin-components/src/or-vaadin-input.ts b/ui/component/or-vaadin-components/src/or-vaadin-input.ts index f9e644f84f..1fe944e8c0 100644 --- a/ui/component/or-vaadin-components/src/or-vaadin-input.ts +++ b/ui/component/or-vaadin-components/src/or-vaadin-input.ts @@ -21,6 +21,7 @@ import { OrElement } from "@openremote/or-element"; import { customElement, property, query } from "lit/decorators.js"; import { InputType } from "./util"; import "./or-vaadin-checkbox"; +import "./or-vaadin-date-picker"; import "./or-vaadin-date-time-picker"; import "./or-vaadin-email-field"; import "./or-vaadin-number-field"; @@ -29,6 +30,7 @@ import "./or-vaadin-select"; import "./or-vaadin-slider"; import "./or-vaadin-text-field"; import "./or-vaadin-text-area"; +import "./or-vaadin-time-picker"; import "./or-vaadin-toggle"; /** @@ -60,6 +62,7 @@ export class OrVaadinInput extends OrElement { * Be aware: all CustomElements defined here need to be imported during initialization; dynamic imports are not expected to work. */ public static readonly VAADIN_CLASSES: (CustomElementConstructor | undefined)[] = [ + customElements.get("or-vaadin-date-picker"), customElements.get("or-vaadin-date-time-picker"), customElements.get("or-vaadin-number-field"), customElements.get("or-vaadin-password-field"), @@ -67,6 +70,7 @@ export class OrVaadinInput extends OrElement { customElements.get("or-vaadin-slider"), customElements.get("or-vaadin-text-area"), customElements.get("or-vaadin-text-field"), + customElements.get("or-vaadin-time-picker"), ]; /** @@ -77,6 +81,7 @@ export class OrVaadinInput extends OrElement { public static readonly TEMPLATES = new Map void) => TemplateResult>([ [InputType.BIG_INT, OrVaadinInput.getNumberFieldTemplate], [InputType.CHECKBOX, OrVaadinInput.getCheckboxTemplate], + [InputType.DATE, OrVaadinInput.getDatePickerTemplate], [InputType.DATETIME, OrVaadinInput.getDateTimePickerTemplate], [InputType.EMAIL, OrVaadinInput.getEmailFieldTemplate], [InputType.JSON, OrVaadinInput.getTextAreaTemplate], @@ -89,6 +94,7 @@ export class OrVaadinInput extends OrElement { [InputType.TELEPHONE, OrVaadinInput.getTextFieldTemplate], [InputType.TEXT, OrVaadinInput.getTextFieldTemplate], [InputType.TEXTAREA, OrVaadinInput.getTextAreaTemplate], + [InputType.TIME, OrVaadinInput.getTimePickerTemplate], ]); /** @@ -334,6 +340,10 @@ export class OrVaadinInput extends OrElement { return html``; } + public static getDatePickerTemplate(onChange?: (e: Event) => void) { + return html``; + } + public static getDateTimePickerTemplate(onChange?: (e: Event) => void) { return html``; } @@ -370,6 +380,10 @@ export class OrVaadinInput extends OrElement { return html``; } + public static getTimePickerTemplate(onChange?: (e: Event) => void) { + return html``; + } + protected _getLoggingPrefix(): string { return `[${this.getAttribute("label")} ${this.type}] `; } diff --git a/ui/component/or-vaadin-components/src/value-input-provider.ts b/ui/component/or-vaadin-components/src/value-input-provider.ts index 39ba23e5be..6e7e8b8bcf 100644 --- a/ui/component/or-vaadin-components/src/value-input-provider.ts +++ b/ui/component/or-vaadin-components/src/value-input-provider.ts @@ -150,7 +150,10 @@ export const getValueHolderInputTemplateProvider: ValueInputProviderGenerator = ) : Util.getMetaValueFormat(valueHolder as Attribute, valueHolderDescriptor as AttributeDescriptor, assetType); - const supportsVaadinInput = (type: InputType) => OrVaadinInput.TEMPLATES.has(type); + // A date or time attribute holds a timestamp, which still has to be converted to and from what the pickers take + const unconvertedTypes: InputType[] = [InputType.DATE, InputType.TIME]; + const supportsVaadinInput = (type: InputType) => + OrVaadinInput.TEMPLATES.has(type) && !unconvertedTypes.includes(type); // Enforces which value types are supported making SUPPORTED_WELLKNOWN_VALUE_TYPES the single source of truth through type checking let _exhaustiveTypeCheck: never; diff --git a/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts b/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts index 204e110e5c..2dc4fd5031 100644 --- a/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts +++ b/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts @@ -55,6 +55,52 @@ ct("should expose the picked date and time as a local ISO string", async ({ moun await expect.poll(() => changeCount).toBe(1); }); +ct("should render a date picker for the date type", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "date" } }); + + await expect(component.locator("or-vaadin-date-picker")).toBeVisible(); + // A single field, where the date time picker has one for the date and one for the time. + await expect(component.getByRole("combobox")).toHaveCount(1); +}); + +ct("should expose the picked date as a local ISO string", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "date" } }); + + // Typed dates follow the date format of the browser locale, so the date is set through the local ISO value instead. + await component.evaluate((el: OrVaadinInput) => el.setAttribute("value", "2026-01-02")); + + await expect(component.getByRole("combobox")).toHaveValue("01/02/2026"); + await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toBe("2026-01-02"); +}); + +ct("should render a time picker for the time type", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "time" } }); + + await expect(component.locator("or-vaadin-time-picker")).toBeVisible(); + await expect(component.getByRole("combobox")).toHaveCount(1); +}); + +ct("should expose the picked time as a local ISO string", async ({ mount }) => { + let changeCount = 0; + const component = await mount(OrVaadinInput, { + props: { type: "time" }, + on: { + // The `change` CustomEvent carries no detail, so it can only be counted. + change: () => { + changeCount += 1; + }, + }, + }); + + const time = component.getByRole("combobox"); + await time.fill("11:45 AM"); + await time.press("Enter"); + + // The picker shows the clock of the browser locale, while its value stays a 24 hour local ISO time. + await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toBe("11:45"); + await expect.poll(() => changeCount).toBe(1); +}); + ct("should render a text area for the json type", async ({ mount }) => { const component = await mount(OrVaadinInput, { props: { type: "json" } }); From ef90dee76f645903ad15656114488f70449f1ae9 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Tue, 6 Oct 2026 11:49:09 +0200 Subject: [PATCH 09/18] Apply suggestions --- .../src/renderers/agent-link.ts | 16 ++-- .../src/controls/control-input-element.ts | 42 +++++------ ui/component/or-json-forms/src/styles.ts | 9 +-- .../src/or-vaadin-input.ts | 74 +++++++++++-------- ui/component/or-vaadin-components/src/util.ts | 9 --- .../src/value-input-provider.ts | 9 +-- .../test/or-vaadin-input.test.ts | 25 ++++++- .../test/value-input-provider.test.ts | 25 +++++++ 8 files changed, 131 insertions(+), 78 deletions(-) diff --git a/ui/component/or-attribute-input/src/renderers/agent-link.ts b/ui/component/or-attribute-input/src/renderers/agent-link.ts index c922f55c47..855cba2b1c 100644 --- a/ui/component/or-attribute-input/src/renderers/agent-link.ts +++ b/ui/component/or-attribute-input/src/renderers/agent-link.ts @@ -31,9 +31,10 @@ import { uiTypeIs, formatIs, } from "@openremote/or-json-forms"; -import type { OrVaadinSelect, SelectItem } from "@openremote/or-vaadin-components/or-vaadin-select"; +import type { SelectItem } from "@openremote/or-vaadin-components/or-vaadin-select"; +import type { OrVaadinComboBox } from "@openremote/or-vaadin-components/or-vaadin-combo-box"; import { html } from "lit"; -import "@openremote/or-vaadin-components/or-vaadin-select"; +import "@openremote/or-vaadin-components/or-vaadin-combo-box"; import { i18next } from "@openremote/or-translate"; import { until } from "lit/directives/until.js"; @@ -123,17 +124,18 @@ const agentIdRenderer = (state: JsonFormsStateContext, props: ControlProps) => { label: agent.name + " (" + agent.id + ")", })); + // A combo box rather than a select, so the list can be filtered and the agent id copied out of the field return html` - agent.id === (ev.currentTarget as OrVaadinComboBox).value))}" placeholder="${i18next.t("selectAgent")}" .items="${options}" - > + .value="${props.data ?? ""}" + > `; }); @@ -145,7 +147,7 @@ const agentIdRenderer = (state: JsonFormsStateContext, props: ControlProps) => { width: 100%; } - ${until(loadedTemplatePromise, html``)} + ${until(loadedTemplatePromise, html``)} `; return getTemplateWrapper(template, undefined); diff --git a/ui/component/or-json-forms/src/controls/control-input-element.ts b/ui/component/or-json-forms/src/controls/control-input-element.ts index 743fcd9f63..66e3f8eeae 100644 --- a/ui/component/or-json-forms/src/controls/control-input-element.ts +++ b/ui/component/or-json-forms/src/controls/control-input-element.ts @@ -16,8 +16,9 @@ * * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { type InputOption, InputType } from "@openremote/or-vaadin-components/util"; +import { InputType } from "@openremote/or-vaadin-components/util"; import { OrVaadinInput } from "@openremote/or-vaadin-components/or-vaadin-input"; +import type { SelectItem } from "@openremote/or-vaadin-components/or-vaadin-select"; import type { OrVaadinComboBox } from "@openremote/or-vaadin-components/or-vaadin-combo-box"; import type { OrVaadinMultiSelectComboBox } from "@openremote/or-vaadin-components/or-vaadin-multi-select-combo-box"; import "@openremote/or-vaadin-components/or-vaadin-combo-box"; @@ -40,16 +41,6 @@ import { isEnumArray } from "../standard-renderers"; let defaultTz: string; -/** Builds the options of an enum, whose values are JSON so that every enum type round-trips through them. */ -function getOptions(values: unknown[]): [string, string][] { - return values.map((value) => [JSON.stringify(value), String(value)]); -} - -/** Turns the options into the shape a Vaadin select or combo box takes. */ -function getInputOptions(options?: [string, string][]): InputOption[] | undefined { - return options?.map(([value, label]) => ({ value, label })); -} - // language=CSS const style = css` or-vaadin-input { @@ -115,13 +106,13 @@ export class ControlInputElement extends ControlBaseElement { this.inputType = InputType.SELECT; if (isEnumControl(uischema, schema, context)) { - options = getOptions(schema.enum!); + options = ControlInputElement.getOptions(schema.enum!); } else if (isOneOfEnumControl(uischema, schema, context)) { - options = getOptions((schema.oneOf as JsonSchema[]).map((s) => s.const)); + options = ControlInputElement.getOptions((schema.oneOf as JsonSchema[]).map((s) => s.const)); } else { multiple = true; const items = schema.items as JsonSchema; - options = getOptions(items.oneOf ? items.oneOf.map((s) => s.const) : items.enum!); + options = ControlInputElement.getOptions(items.oneOf ? items.oneOf.map((s) => s.const) : items.enum!); } } else if (isStringControl(uischema, schema, context)) { minLength = schema.minLength; @@ -180,7 +171,7 @@ export class ControlInputElement extends ControlBaseElement { ?checked="${isCheckbox && !!value}" ?disabled="${!this.enabled}" ?required="${!!this.required}" - .items="${getInputOptions(options)}" + .items="${ControlInputElement.getInputOptions(options)}" minlength="${ifDefined(minLength)}" maxlength="${ifDefined(maxLength)}" pattern="${ifDefined(pattern)}" @@ -196,13 +187,13 @@ export class ControlInputElement extends ControlBaseElement { * Template for an enum, which a combo box renders so that long lists can be filtered and several values picked. */ protected getOptionsTemplate(options: [string, string][] | undefined, value: any, multiple: boolean): TemplateResult { - const items = getInputOptions(options); + const items = ControlInputElement.getInputOptions(options); if (multiple) { const selected: string[] = value ?? []; return html` JSON.parse(item.value)) - ); + const items = (e.currentTarget as OrVaadinMultiSelectComboBox).selectedItems as SelectItem[] | undefined; + this.handleChange(this.path!, items?.map((item) => JSON.parse(item.value!)) ?? []); + } + + /** Builds the options of an enum, whose values are JSON so that every enum type round-trips through them. */ + protected static getOptions(values: unknown[]): [string, string][] { + return values.map((value) => [JSON.stringify(value), String(value)]); + } + + /** Turns the options into the shape a Vaadin select or combo box takes. */ + protected static getInputOptions(options?: [string, string][]): SelectItem[] | undefined { + return options?.map(([value, label]) => ({ value, label })); } } diff --git a/ui/component/or-json-forms/src/styles.ts b/ui/component/or-json-forms/src/styles.ts index 8d3c6cd879..8aeba5fe4a 100644 --- a/ui/component/or-json-forms/src/styles.ts +++ b/ui/component/or-json-forms/src/styles.ts @@ -128,9 +128,6 @@ export const panelStyle = css` } `; -/** Height the dialog body keeps even when its content is short, so a short list still fills the dialog. */ -const dialogContentMinHeight = "600px"; - /** * Layout for the "add item" and "add parameter" dialogs. Their content is rendered into the shadow root * of the element that opens them, so it is styled from that element's own styles. @@ -140,7 +137,8 @@ export const addItemOrParameterDialogStyle = css` #dialog-content { display: flex; gap: var(--lumo-space-s); - min-height: ${unsafeCSS(dialogContentMinHeight)}; + /* A short list still fills the dialog, so its height does not jump with the selection */ + min-height: 600px; max-height: 60vh; } @@ -192,4 +190,5 @@ export const addItemOrParameterDialogStyle = css` * Inline style for the JSON editor, which opens on an arbitrary host and so cannot rely on that host's styles. * It matches the panes above: same height, and rounded corners against the tinted dialog background. */ -export const jsonEditorStyle = `width: 100%; height: 60vh; min-height: ${dialogContentMinHeight}; border-radius: var(--lumo-border-radius-m); overflow: hidden;`; +export const jsonEditorStyle = + "width: 100%; height: 60vh; min-height: 600px; border-radius: var(--lumo-border-radius-m); overflow: hidden;"; diff --git a/ui/component/or-vaadin-components/src/or-vaadin-input.ts b/ui/component/or-vaadin-components/src/or-vaadin-input.ts index 1fe944e8c0..69356315ad 100644 --- a/ui/component/or-vaadin-components/src/or-vaadin-input.ts +++ b/ui/component/or-vaadin-components/src/or-vaadin-input.ts @@ -49,6 +49,27 @@ function registerProperties(constructors: (CustomElementConstructor | undefined) ); } +/** + * Parses the text of a JSON input type. An empty field is a valid, absent value; text that does not parse, + * or that is not an object for {@link InputType.JSON_OBJECT}, is invalid and has no value. + * @param type - The input type being parsed + * @param text - The text to parse + */ +function parseJson(type: InputType, text?: string): { value?: any; valid: boolean } { + if (!text?.trim()) { + return { valid: true }; + } + try { + const value = JSON.parse(text); + if (type === InputType.JSON_OBJECT && (typeof value !== "object" || value === null || Array.isArray(value))) { + return { valid: false }; + } + return { value, valid: true }; + } catch { + return { valid: false }; + } +} + /** * Custom element that wraps various Vaadin input components based on the specified `type`. * Provides a unified interface for interacting with different types of inputs. @@ -84,8 +105,8 @@ export class OrVaadinInput extends OrElement { [InputType.DATE, OrVaadinInput.getDatePickerTemplate], [InputType.DATETIME, OrVaadinInput.getDateTimePickerTemplate], [InputType.EMAIL, OrVaadinInput.getEmailFieldTemplate], - [InputType.JSON, OrVaadinInput.getTextAreaTemplate], - [InputType.JSON_OBJECT, OrVaadinInput.getTextAreaTemplate], + [InputType.JSON, OrVaadinInput.getJsonTemplate], + [InputType.JSON_OBJECT, OrVaadinInput.getJsonTemplate], [InputType.NUMBER, OrVaadinInput.getNumberFieldTemplate], [InputType.PASSWORD, OrVaadinInput.getPasswordFieldTemplate], [InputType.RANGE, OrVaadinInput.getSliderTemplate], @@ -194,7 +215,7 @@ export class OrVaadinInput extends OrElement { } case InputType.JSON: case InputType.JSON_OBJECT: { - return OrVaadinInput.parseJson(this.type, this._elem?.value).value; + return parseJson(this.type, this._elem?.value).value; } default: { return this._elem?.value; @@ -211,7 +232,10 @@ export class OrVaadinInput extends OrElement { return false; } // A text area accepts any text, so JSON types need their own parse check on top of the field constraints - return !OrVaadinInput.JSON_TYPES.includes(this.type) || OrVaadinInput.parseJson(this.type, this._elem?.value).valid; + if (OrVaadinInput.JSON_TYPES.includes(this.type)) { + return parseJson(this.type, this._elem?.value).valid; + } + return true; } /** @@ -229,27 +253,6 @@ export class OrVaadinInput extends OrElement { } } - /** - * Parses the text of a JSON input type. An empty field is a valid, absent value; text that does not parse, - * or that is not an object for {@link InputType.JSON_OBJECT}, is invalid and has no value. - * @param type - The input type being parsed - * @param text - The text to parse - */ - protected static parseJson(type: InputType, text?: string): { value?: any; valid: boolean } { - if (!text?.trim()) { - return { valid: true }; - } - try { - const value = JSON.parse(text); - if (type === InputType.JSON_OBJECT && (typeof value !== "object" || value === null || Array.isArray(value))) { - return { valid: false }; - } - return { value, valid: true }; - } catch { - return { valid: false }; - } - } - render() { return OrVaadinInput.TEMPLATES.get(this.type)?.(this._onValueChange) ?? html`Not supported yet.`; } @@ -292,10 +295,6 @@ export class OrVaadinInput extends OrElement { protected _onValueChange(ev: Event) { ev.stopPropagation(); if (ev.defaultPrevented) return; - // No field constraint catches malformed JSON, so the invalid state has to be set from the parse result - if (OrVaadinInput.JSON_TYPES.includes(this.type) && this._elem) { - (this._elem as HTMLInputElement & { invalid: boolean }).invalid = !this.checkValidity(); - } this.dispatchEvent(new CustomEvent("change", { bubbles: true })); } @@ -352,6 +351,23 @@ export class OrVaadinInput extends OrElement { return html``; } + /** + * Template for the JSON types, whose value a text area holds as text. No field constraint rejects malformed JSON, + * so the parse result sets the invalid state before the change is passed on. + */ + public static getJsonTemplate(onChange?: (e: Event) => void) { + // Lit calls an event listener with the element that rendered the template as `this` + function onJsonChange(this: OrVaadinInput, ev: Event) { + const textArea = this.native as (HTMLInputElement & { invalid: boolean }) | undefined; + if (textArea) { + textArea.invalid = !this.checkValidity(); + } + onChange?.call(this, ev); + } + + return html``; + } + public static getNumberFieldTemplate(onChange?: (e: Event) => void) { return html``; } diff --git a/ui/component/or-vaadin-components/src/util.ts b/ui/component/or-vaadin-components/src/util.ts index 93a4b749d2..3b15ad815c 100644 --- a/ui/component/or-vaadin-components/src/util.ts +++ b/ui/component/or-vaadin-components/src/util.ts @@ -87,15 +87,6 @@ export interface OrVaadinComponent { getAttributeNames(): string[]; } -/** - * An option of a select or combo box, where the label is shown and the value identifies it. - * Vaadin's own `SelectItem` leaves both optional, which an option list built in code never is. - */ -export interface InputOption { - value: string; - label: string; -} - /** * Returns whether the {@link InputType} should show a "send" button within the attribute input UI. * Some input types have internal mechanics for updating attributes, which is why they should return `false`. diff --git a/ui/component/or-vaadin-components/src/value-input-provider.ts b/ui/component/or-vaadin-components/src/value-input-provider.ts index 6e7e8b8bcf..511db7ed51 100644 --- a/ui/component/or-vaadin-components/src/value-input-provider.ts +++ b/ui/component/or-vaadin-components/src/value-input-provider.ts @@ -48,7 +48,6 @@ import { type Ref, ref, createRef } from "lit/directives/ref.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { styleMap } from "lit/directives/style-map.js"; import { - type InputOption, InputType, inputTypeSupportsHelperText, inputTypeSupportsLabel, @@ -124,7 +123,7 @@ export const getValueHolderInputTemplateProvider: ValueInputProviderGenerator = let max: any; let multiple: any; let required: boolean | undefined; - let selectOptions: InputOption[] | undefined; + let selectOptions: { label: string; value: string }[] | undefined; let valueConverter: (v: any) => any | undefined; const styles = {} as any; @@ -150,10 +149,10 @@ export const getValueHolderInputTemplateProvider: ValueInputProviderGenerator = ) : Util.getMetaValueFormat(valueHolder as Attribute, valueHolderDescriptor as AttributeDescriptor, assetType); - // A date or time attribute holds a timestamp, which still has to be converted to and from what the pickers take - const unconvertedTypes: InputType[] = [InputType.DATE, InputType.TIME]; + // A date or time attribute holds a timestamp, which this provider does not yet convert to what those pickers take + const typesWithoutTimestampConversion: InputType[] = [InputType.DATE, InputType.TIME]; const supportsVaadinInput = (type: InputType) => - OrVaadinInput.TEMPLATES.has(type) && !unconvertedTypes.includes(type); + OrVaadinInput.TEMPLATES.has(type) && !typesWithoutTimestampConversion.includes(type); // Enforces which value types are supported making SUPPORTED_WELLKNOWN_VALUE_TYPES the single source of truth through type checking let _exhaustiveTypeCheck: never; diff --git a/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts b/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts index 2dc4fd5031..e0ddeb1a02 100644 --- a/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts +++ b/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts @@ -105,7 +105,7 @@ ct("should render a text area for the json type", async ({ mount }) => { const component = await mount(OrVaadinInput, { props: { type: "json" } }); // The json type was absent from TEMPLATES, so every consumer fell back to the deprecated or-mwc-input. - await expect(component.getByRole("textbox")).toBeVisible(); + await expect(component.locator("or-vaadin-text-area")).toBeVisible(); }); ct("should show a json value as text", async ({ mount }) => { @@ -126,3 +126,26 @@ ct("should mark the field invalid when the json does not parse", async ({ mount // A text area has no constraint of its own that rejects this, so the parse result has to set the state. await expect(component.locator("or-vaadin-text-area")).toHaveAttribute("invalid"); }); + +ct("should take any value that parses for the json type", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "json" } }); + + const input = component.getByRole("textbox"); + await input.fill("[1,2]"); + await input.blur(); + + await expect(component.locator("or-vaadin-text-area")).not.toHaveAttribute("invalid"); + await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toEqual([1, 2]); +}); + +ct("should mark the field invalid when a json object type holds an array", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "json-object" } }); + + const input = component.getByRole("textbox"); + await input.fill("[1,2]"); + await input.blur(); + + // An array parses, so only the object requirement of the json object type rejects it. + await expect(component.locator("or-vaadin-text-area")).toHaveAttribute("invalid"); + await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toBeUndefined(); +}); diff --git a/ui/component/or-vaadin-components/test/value-input-provider.test.ts b/ui/component/or-vaadin-components/test/value-input-provider.test.ts index 880414e3ac..2ed060e7c8 100644 --- a/ui/component/or-vaadin-components/test/value-input-provider.test.ts +++ b/ui/component/or-vaadin-components/test/value-input-provider.test.ts @@ -140,6 +140,31 @@ ct("should show a number formatted as a date in minutes", async ({ mount, vaadin await expect(vaadinDateTimePicker.getTimeInput(component)).toHaveValue("10:30 AM"); }); +ct("should show a json object as formatted text and write back the edited value", async ({ mount }) => { + const values: unknown[] = []; + const component = await mount(ValueInputProviderHarness, { + props: { valueType: "JSONObject", value: { a: 1 } }, + on: { "value-change": (value: unknown) => values.push(value) }, + }); + + const textArea = component.getByRole("textbox"); + // The text area carries the value as text, so the object reaches it as formatted JSON. + await expect(textArea).toHaveValue('{\n "a": 1\n}'); + + await textArea.fill('{"b": 2}'); + await textArea.blur(); + + await expect.poll(() => values).toEqual([{ b: 2 }]); +}); + +ct("should show an empty text area for a json object that is null", async ({ mount }) => { + const component = await mount(ValueInputProviderHarness, { + props: { valueType: "JSONObject", value: null }, + }); + + await expect(component.getByRole("textbox")).toHaveValue(""); +}); + ct("should round up a minimum that falls between two picker values", async ({ mount, vaadinDateTimePicker }) => { const values: unknown[] = []; const component = await mount(ValueInputProviderHarness, { From c96742b008ca3271129951d77934378676b6e168 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Tue, 6 Oct 2026 11:57:44 +0200 Subject: [PATCH 10/18] Group tests per type in or-vaadin-input.test.ts --- .../test/or-vaadin-input.test.ts | 196 +++++++++--------- 1 file changed, 103 insertions(+), 93 deletions(-) diff --git a/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts b/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts index e0ddeb1a02..483a415b52 100644 --- a/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts +++ b/ui/component/or-vaadin-components/test/or-vaadin-input.test.ts @@ -23,129 +23,139 @@ import { OrVaadinInput } from "@openremote/or-vaadin-components/or-vaadin-input" // The date time picker shows the format of the browser locale. ct.use({ locale: "en-US" }); -ct("should render a date time picker for the datetime-local type", async ({ mount }) => { - const component = await mount(OrVaadinInput, { props: { type: "datetime-local" } }); +ct.describe("datetime-local type", () => { + ct("should render a date time picker", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "datetime-local" } }); - await expect(component.locator("or-vaadin-date-time-picker")).toBeVisible(); - // The date and the time field are separate inputs of the picker. - await expect(component.getByRole("combobox")).toHaveCount(2); -}); + await expect(component.locator("or-vaadin-date-time-picker")).toBeVisible(); + // The date and the time field are separate inputs of the picker. + await expect(component.getByRole("combobox")).toHaveCount(2); + }); -ct("should expose the picked date and time as a local ISO string", async ({ mount }) => { - let changeCount = 0; - const component = await mount(OrVaadinInput, { - props: { type: "datetime-local" }, - on: { - // The `change` CustomEvent carries no detail, so it can only be counted. - change: () => { - changeCount += 1; + ct("should expose the picked date and time as a local ISO string", async ({ mount }) => { + let changeCount = 0; + const component = await mount(OrVaadinInput, { + props: { type: "datetime-local" }, + on: { + // The `change` CustomEvent carries no detail, so it can only be counted. + change: () => { + changeCount += 1; + }, }, - }, + }); + + // Typed dates follow the date format of the browser locale, so the date is set through the local ISO value instead. + await component.evaluate((el: OrVaadinInput) => el.setAttribute("value", "2026-01-02T10:30")); + const time = component.getByRole("combobox").last(); + await expect(time).toHaveValue("10:30 AM"); + await time.fill("11:45 AM"); + await time.press("Enter"); + + await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toBe("2026-01-02T11:45"); + // Setting the value does not count as a change, only the picked time does. + await expect.poll(() => changeCount).toBe(1); }); - - // Typed dates follow the date format of the browser locale, so the date is set through the local ISO value instead. - await component.evaluate((el: OrVaadinInput) => el.setAttribute("value", "2026-01-02T10:30")); - const time = component.getByRole("combobox").last(); - await expect(time).toHaveValue("10:30 AM"); - await time.fill("11:45 AM"); - await time.press("Enter"); - - await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toBe("2026-01-02T11:45"); - // Setting the value does not count as a change, only the picked time does. - await expect.poll(() => changeCount).toBe(1); }); -ct("should render a date picker for the date type", async ({ mount }) => { - const component = await mount(OrVaadinInput, { props: { type: "date" } }); +ct.describe("date type", () => { + ct("should render a date picker", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "date" } }); - await expect(component.locator("or-vaadin-date-picker")).toBeVisible(); - // A single field, where the date time picker has one for the date and one for the time. - await expect(component.getByRole("combobox")).toHaveCount(1); -}); + await expect(component.locator("or-vaadin-date-picker")).toBeVisible(); + // A single field, where the date time picker has one for the date and one for the time. + await expect(component.getByRole("combobox")).toHaveCount(1); + }); -ct("should expose the picked date as a local ISO string", async ({ mount }) => { - const component = await mount(OrVaadinInput, { props: { type: "date" } }); + ct("should expose the picked date as a local ISO string", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "date" } }); - // Typed dates follow the date format of the browser locale, so the date is set through the local ISO value instead. - await component.evaluate((el: OrVaadinInput) => el.setAttribute("value", "2026-01-02")); + // Typed dates follow the date format of the browser locale, so the date is set through the local ISO value instead. + await component.evaluate((el: OrVaadinInput) => el.setAttribute("value", "2026-01-02")); - await expect(component.getByRole("combobox")).toHaveValue("01/02/2026"); - await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toBe("2026-01-02"); + await expect(component.getByRole("combobox")).toHaveValue("01/02/2026"); + await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toBe("2026-01-02"); + }); }); -ct("should render a time picker for the time type", async ({ mount }) => { - const component = await mount(OrVaadinInput, { props: { type: "time" } }); +ct.describe("time type", () => { + ct("should render a time picker", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "time" } }); - await expect(component.locator("or-vaadin-time-picker")).toBeVisible(); - await expect(component.getByRole("combobox")).toHaveCount(1); -}); + await expect(component.locator("or-vaadin-time-picker")).toBeVisible(); + await expect(component.getByRole("combobox")).toHaveCount(1); + }); -ct("should expose the picked time as a local ISO string", async ({ mount }) => { - let changeCount = 0; - const component = await mount(OrVaadinInput, { - props: { type: "time" }, - on: { - // The `change` CustomEvent carries no detail, so it can only be counted. - change: () => { - changeCount += 1; + ct("should expose the picked time as a local ISO string", async ({ mount }) => { + let changeCount = 0; + const component = await mount(OrVaadinInput, { + props: { type: "time" }, + on: { + // The `change` CustomEvent carries no detail, so it can only be counted. + change: () => { + changeCount += 1; + }, }, - }, - }); + }); - const time = component.getByRole("combobox"); - await time.fill("11:45 AM"); - await time.press("Enter"); + const time = component.getByRole("combobox"); + await time.fill("11:45 AM"); + await time.press("Enter"); - // The picker shows the clock of the browser locale, while its value stays a 24 hour local ISO time. - await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toBe("11:45"); - await expect.poll(() => changeCount).toBe(1); + // The picker shows the clock of the browser locale, while its value stays a 24 hour local ISO time. + await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toBe("11:45"); + await expect.poll(() => changeCount).toBe(1); + }); }); -ct("should render a text area for the json type", async ({ mount }) => { - const component = await mount(OrVaadinInput, { props: { type: "json" } }); +ct.describe("json type", () => { + ct("should render a text area", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "json" } }); - // The json type was absent from TEMPLATES, so every consumer fell back to the deprecated or-mwc-input. - await expect(component.locator("or-vaadin-text-area")).toBeVisible(); -}); + // The json type was absent from TEMPLATES, so every consumer fell back to the deprecated or-mwc-input. + await expect(component.locator("or-vaadin-text-area")).toBeVisible(); + }); -ct("should show a json value as text", async ({ mount }) => { - const component = await mount(OrVaadinInput, { props: { type: "json" } }); - // Values reach the Vaadin element through attributes; a property would be serialized a second time. - await component.evaluate((el) => el.setAttribute("value", '{"a":1}')); + ct("should show the value as text", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "json" } }); + // Values reach the Vaadin element through attributes; a property would be serialized a second time. + await component.evaluate((el) => el.setAttribute("value", '{"a":1}')); - await expect(component.getByRole("textbox")).toHaveValue('{"a":1}'); -}); + await expect(component.getByRole("textbox")).toHaveValue('{"a":1}'); + }); -ct("should mark the field invalid when the json does not parse", async ({ mount }) => { - const component = await mount(OrVaadinInput, { props: { type: "json" } }); + ct("should mark the field invalid when the text does not parse", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "json" } }); - const input = component.getByRole("textbox"); - await input.fill("{ not json"); - await input.blur(); + const input = component.getByRole("textbox"); + await input.fill("{ not json"); + await input.blur(); - // A text area has no constraint of its own that rejects this, so the parse result has to set the state. - await expect(component.locator("or-vaadin-text-area")).toHaveAttribute("invalid"); -}); + // A text area has no constraint of its own that rejects this, so the parse result has to set the state. + await expect(component.locator("or-vaadin-text-area")).toHaveAttribute("invalid"); + }); -ct("should take any value that parses for the json type", async ({ mount }) => { - const component = await mount(OrVaadinInput, { props: { type: "json" } }); + ct("should take any value that parses", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "json" } }); - const input = component.getByRole("textbox"); - await input.fill("[1,2]"); - await input.blur(); + const input = component.getByRole("textbox"); + await input.fill("[1,2]"); + await input.blur(); - await expect(component.locator("or-vaadin-text-area")).not.toHaveAttribute("invalid"); - await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toEqual([1, 2]); + await expect(component.locator("or-vaadin-text-area")).not.toHaveAttribute("invalid"); + await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toEqual([1, 2]); + }); }); -ct("should mark the field invalid when a json object type holds an array", async ({ mount }) => { - const component = await mount(OrVaadinInput, { props: { type: "json-object" } }); +ct.describe("json-object type", () => { + ct("should mark the field invalid when the value is an array", async ({ mount }) => { + const component = await mount(OrVaadinInput, { props: { type: "json-object" } }); - const input = component.getByRole("textbox"); - await input.fill("[1,2]"); - await input.blur(); + const input = component.getByRole("textbox"); + await input.fill("[1,2]"); + await input.blur(); - // An array parses, so only the object requirement of the json object type rejects it. - await expect(component.locator("or-vaadin-text-area")).toHaveAttribute("invalid"); - await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toBeUndefined(); + // An array parses, so only the object requirement of the json object type rejects it. + await expect(component.locator("or-vaadin-text-area")).toHaveAttribute("invalid"); + await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toBeUndefined(); + }); }); From aac84f86855f3f4143ff228a4fc4807710b743e5 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Tue, 6 Oct 2026 12:35:49 +0200 Subject: [PATCH 11/18] Remove type cast --- ui/component/or-vaadin-components/src/date-time-i18n.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/ui/component/or-vaadin-components/src/date-time-i18n.ts b/ui/component/or-vaadin-components/src/date-time-i18n.ts index 25ff35fb2a..b55f2195bb 100644 --- a/ui/component/or-vaadin-components/src/date-time-i18n.ts +++ b/ui/component/or-vaadin-components/src/date-time-i18n.ts @@ -172,11 +172,8 @@ function parseDate(fieldOrder: string[], text: string): DatePickerDate | undefin const month = parts.month === undefined ? today.getMonth() : parseInt(parts.month) - 1; let year = today.getFullYear(); if (parts.year !== undefined) { - // getAdjustedYear takes at most two digits and is typed as returning a Date, though it returns a full year - year = - parts.year.length < 3 - ? (getAdjustedYear(today, parseInt(parts.year), month, day) as unknown as number) - : parseInt(parts.year); + // getAdjustedYear takes at most two digits, so a longer year is already a full one + year = parts.year.length < 3 ? getAdjustedYear(today, parseInt(parts.year), month, day) : parseInt(parts.year); } return { day, month, year }; } From 9beabd90ec9e72e9ab972bdbea73e8d720e94d75 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Tue, 6 Oct 2026 14:28:02 +0200 Subject: [PATCH 12/18] Co-locate date and time narrowing with its conversion --- .../src/value-input-provider.ts | 47 +++++++++---------- .../test/value-input-provider.test.ts | 19 ++++++++ 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/ui/component/or-vaadin-components/src/value-input-provider.ts b/ui/component/or-vaadin-components/src/value-input-provider.ts index 511db7ed51..1282d9674f 100644 --- a/ui/component/or-vaadin-components/src/value-input-provider.ts +++ b/ui/component/or-vaadin-components/src/value-input-provider.ts @@ -149,10 +149,10 @@ export const getValueHolderInputTemplateProvider: ValueInputProviderGenerator = ) : Util.getMetaValueFormat(valueHolder as Attribute, valueHolderDescriptor as AttributeDescriptor, assetType); - // A date or time attribute holds a timestamp, which this provider does not yet convert to what those pickers take - const typesWithoutTimestampConversion: InputType[] = [InputType.DATE, InputType.TIME]; + // No valueConverter below turns an attribute timestamp into what the date or time picker takes + const typesWithoutValueConversion: InputType[] = [InputType.DATE, InputType.TIME]; const supportsVaadinInput = (type: InputType) => - OrVaadinInput.TEMPLATES.has(type) && !typesWithoutTimestampConversion.includes(type); + OrVaadinInput.TEMPLATES.has(type) && !typesWithoutValueConversion.includes(type); // Enforces which value types are supported making SUPPORTED_WELLKNOWN_VALUE_TYPES the single source of truth through type checking let _exhaustiveTypeCheck: never; @@ -355,31 +355,28 @@ export const getValueHolderInputTemplateProvider: ValueInputProviderGenerator = max = undefined; } - // Refine the input type based on formatting - if (format) { - if (format.timeStyle && !format.dateStyle) { - inputType = InputType.TIME; - } else if (format.dateStyle && !format.timeStyle) { - inputType = InputType.DATE; - } + // A format showing only a date or only a time refines the input type, and that narrower type is left without the + // conversion below, so it stays on the fallback input + if (format?.timeStyle && !format.dateStyle) { + inputType = InputType.TIME; + } else if (format?.dateStyle && !format.timeStyle) { + inputType = InputType.DATE; + } else { + // or-vaadin-date-time-picker works with local ISO strings, while the value is a timestamp or an ISO 8601 string + step = OrVaadinDateTimePicker.getStep(format); + // A minimum rounds up, so that the earliest value the picker allows still meets it + min = min === undefined ? undefined : OrVaadinDateTimePicker.getLocalizedISOString(new Date(min), step, true); + max = max === undefined ? undefined : OrVaadinDateTimePicker.getLocalizedISOString(new Date(max), step); + valueConverter = (v) => { + const timestamp = v ? Date.parse(v) : Number.NaN; + if (Number.isNaN(timestamp)) { + return null; + } + return valueDescriptor.jsonType === "string" ? new Date(timestamp).toISOString() : timestamp; + }; } } - // or-vaadin-date-time-picker works with local ISO strings, while the value is a timestamp or an ISO 8601 string - if (inputType === InputType.DATETIME) { - step = OrVaadinDateTimePicker.getStep(format); - // A minimum rounds up, so that the earliest value the picker allows still meets it - min = min === undefined ? undefined : OrVaadinDateTimePicker.getLocalizedISOString(new Date(min), step, true); - max = max === undefined ? undefined : OrVaadinDateTimePicker.getLocalizedISOString(new Date(max), step); - valueConverter = (v) => { - const timestamp = v ? Date.parse(v) : Number.NaN; - if (Number.isNaN(timestamp)) { - return null; - } - return valueDescriptor.jsonType === "string" ? new Date(timestamp).toISOString() : timestamp; - }; - } - if (inputType === InputType.NUMBER && format?.resolution) { step = format.resolution; } diff --git a/ui/component/or-vaadin-components/test/value-input-provider.test.ts b/ui/component/or-vaadin-components/test/value-input-provider.test.ts index 2ed060e7c8..8a161b0401 100644 --- a/ui/component/or-vaadin-components/test/value-input-provider.test.ts +++ b/ui/component/or-vaadin-components/test/value-input-provider.test.ts @@ -140,6 +140,25 @@ ct("should show a number formatted as a date in minutes", async ({ mount, vaadin await expect(vaadinDateTimePicker.getTimeInput(component)).toHaveValue("10:30 AM"); }); +ct("should keep a date only format on the fallback input", async ({ mount }) => { + const component = await mount(ValueInputProviderHarness, { + props: { valueType: "timestamp", value: localTime(10, 30), format: { dateStyle: "short" } }, + }); + + // The pickers take a local ISO string, and the provider converts the timestamp for the combined picker only. + await expect(component.locator("or-vaadin-date-picker")).toHaveCount(0); + await expect(component.locator("or-mwc-input")).toHaveCount(1); +}); + +ct("should keep a time only format on the fallback input", async ({ mount }) => { + const component = await mount(ValueInputProviderHarness, { + props: { valueType: "timestamp", value: localTime(10, 30), format: { timeStyle: "short" } }, + }); + + await expect(component.locator("or-vaadin-time-picker")).toHaveCount(0); + await expect(component.locator("or-mwc-input")).toHaveCount(1); +}); + ct("should show a json object as formatted text and write back the edited value", async ({ mount }) => { const values: unknown[] = []; const component = await mount(ValueInputProviderHarness, { From fd56bb63a8dabca94d74f6efa724dbb3a2932b4e Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Wed, 7 Oct 2026 16:21:39 +0200 Subject: [PATCH 13/18] Assert JSON Forms output and removing of items --- .../or-json-forms/src/standard-renderers.ts | 3 +- .../or-json-forms/test/fixtures/index.ts | 81 ++++++++++++++----- .../or-json-forms/test/fixtures/schemas.ts | 40 +++++++++ .../or-json-forms/test/or-json-forms.test.ts | 4 +- 4 files changed, 108 insertions(+), 20 deletions(-) diff --git a/ui/component/or-json-forms/src/standard-renderers.ts b/ui/component/or-json-forms/src/standard-renderers.ts index d482900362..aff1752f72 100644 --- a/ui/component/or-json-forms/src/standard-renderers.ts +++ b/ui/component/or-json-forms/src/standard-renderers.ts @@ -57,6 +57,7 @@ import { uiTypeIs, } from "@jsonforms/core"; import { html, type TemplateResult } from "lit"; +import { i18next } from "@openremote/or-translate"; import "@openremote/or-vaadin-components/or-vaadin-input"; import type { JsonFormsStateContext } from "./index"; import { @@ -341,7 +342,7 @@ export function getTemplateWrapper(elementTemplate: TemplateResult, deleteHandle const deleteTemplate = !deleteHandler ? `` : html` -