-
+ dialog = showDialog(
+ this.shadowRoot!,
+ html`
+
+
+ ${(this.label ? computeLabel(this.label, this.required, false) + " - " : "") + i18next.t("addItem")}
+
+
+
{
+ const selected = listItems[ev.detail.value as number];
+ if (selected) onParamChanged(selected.data as CombinatorInfo);
+ }}"
+ >
+ ${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/controls/control-input-element.ts b/ui/component/or-json-forms/src/controls/control-input-element.ts
index 8f82b111b7..564c538f43 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,16 @@
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { InputType, type OrInputChangedEvent } from "@openremote/or-mwc-components/or-mwc-input";
-import { css, html } from "lit";
+import { InputType, inputTypeIsJson, stringifyJson } from "@openremote/or-vaadin-components/util";
+import type { 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";
+import "@openremote/or-vaadin-components/or-vaadin-multi-select-combo-box";
+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";
import { baseStyle } from "../styles";
import {
@@ -36,7 +43,13 @@ let defaultTz: string;
// language=CSS
const style = css`
- or-mwc-input {
+ or-vaadin-input {
+ display: block;
+ }
+
+ or-vaadin-input,
+ or-vaadin-combo-box,
+ or-vaadin-multi-select-combo-box {
width: 100%;
}
`;
@@ -65,9 +78,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;
@@ -95,35 +106,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 = ControlInputElement.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 = ControlInputElement.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)];
- });
- }
- }
-
- 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;
+ const items = schema.items as JsonSchema;
+ options = ControlInputElement.getOptions(items.oneOf ? items.oneOf.map((s) => s.const) : items.enum!);
}
} else if (isStringControl(uischema, schema, context)) {
minLength = schema.minLength;
@@ -146,58 +135,139 @@ 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])
- );
- };
}
}
- 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);
+ }
+
+ const isCheckbox = this.inputType === InputType.CHECKBOX;
+ const displayValue = inputTypeIsJson(this.inputType) ? stringifyJson(value) : (value ?? undefined);
+ return html` `;
+ type="${this.inputType}"
+ label="${ifDefined(this.label || undefined)}"
+ value="${ifDefined(isCheckbox ? undefined : displayValue)}"
+ ?checked="${isCheckbox && !!value}"
+ ?disabled="${!this.enabled}"
+ ?required="${!!this.required}"
+ .items="${ControlInputElement.getInputOptions(options)}"
+ minlength="${ifDefined(minLength)}"
+ maxlength="${ifDefined(maxLength)}"
+ pattern="${ifDefined(pattern)}"
+ error-message="${ifDefined(this.errors || undefined)}"
+ step="${ifDefined(step)}"
+ min="${ifDefined(min)}"
+ max="${ifDefined(max)}"
+ @change="${(e: Event) => this.onVaadinValueChanged(e)}"
+ >`;
}
- 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));
+ /**
+ * 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 = ControlInputElement.getInputOptions(options);
+
+ if (multiple) {
+ const selected: string[] = value ?? [];
+ return html`
this.onOptionsChanged(e)}"
+ > `;
+ }
+
+ return html`
this.onOptionChanged(e)}"
+ > `;
+ }
+
+ /**
+ * Vaadin inputs expose their value as a string, so it is parsed back into the type the schema expects.
+ */
+ protected onVaadinValueChanged(e: Event) {
+ const input = e.currentTarget as OrVaadinInput;
+ const value = input.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;
+ }
+ 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
+ if (input.checkValidity()) {
+ this.handleChange(this.path!, value);
+ }
+ break;
+ }
+ default: {
+ this.handleChange(this.path!, value);
}
- } 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 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/layouts/layout-vertical-element.ts b/ui/component/or-json-forms/src/layouts/layout-vertical-element.ts
index b0fdfc4766..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
@@ -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
@@ -86,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;
}
@@ -142,7 +142,7 @@ export class LayoutVerticalElement extends LayoutBaseElement
void;
public static get styles() {
- return [baseStyle, panelStyle, style];
+ return [baseStyle, panelStyle, addItemOrParameterDialogStyle, style];
}
render() {
@@ -322,7 +322,7 @@ export class LayoutVerticalElement extends LayoutBaseElement {
+ showJsonEditor(this.shadowRoot!, this.title || this.schema.title || "", this.data, (newValue) => {
this.handleChange(this.path || "", newValue);
});
}
@@ -335,7 +335,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 +350,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 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 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 getDescriptionTemplate: () => TemplateResult = () => {
+ if (dynamic) {
+ return html`
+ onKeyChanged(ev)}"
+ >
+
+
+ `;
}
+ if (!selectedParameter) {
+ return html``;
+ }
+
+ const oneOf = selectedParameter.schema?.oneOf as JsonSchema[] | undefined;
+
return html`
-
+
+ `
+ )}
`;
};
- 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`
+
{
+ const selected = listItems[ev.detail.value as number];
+ if (selected) onParamChanged(selected.data as StatePropsOfControl);
+ }}"
+ >
+ ${listItems.map((item) => html`${item.text} `)}
+
+ `
+ )}
+
+
+
+
+ `
);
+
+ refreshDescription();
}
}
diff --git a/ui/component/or-json-forms/src/standard-renderers.ts b/ui/component/or-json-forms/src/standard-renderers.ts
index 4408ad07e7..aff1752f72 100644
--- a/ui/component/or-json-forms/src/standard-renderers.ts
+++ b/ui/component/or-json-forms/src/standard-renderers.ts
@@ -57,7 +57,7 @@ import {
uiTypeIs,
} from "@jsonforms/core";
import { html, type TemplateResult } from "lit";
-import "@openremote/or-mwc-components/or-mwc-input";
+import { i18next } from "@openremote/or-translate";
import "@openremote/or-vaadin-components/or-vaadin-input";
import type { JsonFormsStateContext } from "./index";
import {
@@ -247,7 +247,7 @@ export const anyOfOneOfControlRenderer = (state: JsonFormsStateContext, props: C
// };
// return html`
- // ${label}: showJson(ev)}">
+ // ${label}: showJson(ev)}"> json
// `;
// } else {
// We have no data so show a schema picker
@@ -342,7 +342,7 @@ export function getTemplateWrapper(elementTemplate: TemplateResult, deleteHandle
const deleteTemplate = !deleteHandler
? ``
: html`
-
+
`;
return html`
diff --git a/ui/component/or-json-forms/src/styles.ts b/ui/component/or-json-forms/src/styles.ts
index 5ab07990a8..8aeba5fe4a 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,67 @@ 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;
+ gap: var(--lumo-space-s);
+ /* A short list still fills the dialog, so its height does not jump with the selection */
+ min-height: 600px;
+ 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 {
+ 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 {
+ min-width: 150px;
+ max-width: 300px;
+ flex: 0 0 40%;
+ padding: var(--lumo-space-s) 0;
+ overflow: hidden;
+ }
+
+ #parameter-title {
+ text-transform: capitalize;
+ color: var(--or-app-color3, ${unsafeCSS(DefaultColor3)});
+ font-size: 18px;
+ font-weight: bold;
+ }
+
+ #parameter-desc {
+ padding: var(--lumo-space-m);
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ overflow: auto;
+ }
+
+ #dialog-footer {
+ width: 100%;
+ display: flex;
+ 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: 600px; 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 df2efd848b..e0400c5a1f 100644
--- a/ui/component/or-json-forms/src/util.ts
+++ b/ui/component/or-json-forms/src/util.ts
@@ -38,16 +38,18 @@ 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 { 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";
+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 +384,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 +402,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}
onEditorEdit()}"
@or-ace-editor-changed="${(ev: OrAceEditorChangedEvent) => onEditorChanged(ev)}"
.value="${value}"
>
- `)
- .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`
-
- `)
+
+ dialog?.close()}">
+
+
+ onUpdate()}">
+
+
+
+
+ `
);
};
diff --git a/ui/component/or-json-forms/test/fixtures/index.ts b/ui/component/or-json-forms/test/fixtures/index.ts
index b81c73b8df..b16689500a 100644
--- a/ui/component/or-json-forms/test/fixtures/index.ts
+++ b/ui/component/or-json-forms/test/fixtures/index.ts
@@ -25,6 +25,7 @@ import {
type SharedComponentTestFixtures,
} from "@openremote/test";
import type { JsonSchema7, OrJSONForms } from "@openremote/or-json-forms";
+import type { OrAceEditor } from "@openremote/or-components/or-ace-editor";
import * as Util from "@openremote/core/lib/util";
interface WalkFormOptions {
@@ -56,6 +57,14 @@ export interface JsonSchema extends JsonSchema7 {
* Describes how many items to add to an array control {@link JsonForms#walkForm}
*/
"or:test:item:count"?: number;
+ /**
+ * Describes whether {@link JsonForms#walkForm} should delete the property again once it has been filled
+ */
+ "or:test:remove"?: boolean;
+ /**
+ * The whole JSON document the form should hold once {@link JsonForms#walkForm} has been through it
+ */
+ "or:test:expected"?: unknown;
/**
* The following overwrites the {@link JsonSchema7} types with {@link JsonSchema}
*/
@@ -109,29 +118,32 @@ export interface JsonSchema extends JsonSchema7 {
}
type Path = (string | number)[];
-type Parent = "array" | "object"; // TODO: remove when or-json-forms-array-control always renders titles
+// An array control renders its items with `controlWithoutLabel`, so they carry no accessible name to match on
+type Parent = "array" | "object";
export class JsonForms {
constructor(
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]");
}
/**
- * Exhaust every `or-json-forms` option based on the provided JSONSchema. The following is done:
- * - Adds new items to arrays and parameters for objects
- * @todo builds an expected output and compares this with the JSON at the end
- * @todo moves specific fields
- * @todo removes specific fields
+ * Exhaust every `or-json-forms` option based on the provided JSONSchema. Adds items to arrays and parameters to
+ * objects, fills every primitive and deletes the properties that ask for it, leaving the form holding the document
+ * that {@link JsonForms#getData} returns and `or:test:expected` describes.
+ *
+ * Reordering items by dragging is not covered: the handle uses HTML5 drag and drop with a custom drag image, which
+ * Playwright drives too unreliably to assert on.
*
* @param locator The root element of the `or-json-forms` instance to test.
* @param schema The same JSONSchema used to generate the form.
* @param options Options to fill out in the forms without explicitly defining the values.
* @param path The path to the current node.
* @param item The item index used to locate array items.
- * @param expected not implemented - The expected JSON output built up during recursion.
+ * @param parent Whether the node sits in an array, whose items carry no accessible name.
*/
async walkForm(
locator: Locator,
@@ -139,8 +151,7 @@ export class JsonForms {
options?: WalkFormOptions,
path: Path = [],
item = 0,
- parent?: Parent, // TODO: remove when or-json-forms-array-control always renders titles
- expected?: any
+ parent?: Parent
) {
switch (schema.type) {
case "array": {
@@ -164,7 +175,9 @@ export class JsonForms {
if (!Array.isArray(schema?.items) && schema.items?.oneOf) {
let i = 0;
for (const prop of Object.values(schema.items.oneOf)) {
- await this.walkForm(locator, prop as JsonSchema, options, [...path], i, "array");
+ // A subtype is referenced, so it has to be resolved before the walk can descend into its properties
+ const subType = prop.$ref ? this.resolveSchema(schema, prop.$ref) : (prop as JsonSchema);
+ await this.walkForm(locator, subType, options, [...path], i, "array");
i++;
}
} else {
@@ -172,7 +185,6 @@ export class JsonForms {
await this.walkForm(locator, schema.items as JsonSchema, options, [...path], i, "array");
}
}
- // await locator.getByRole("button", { name: "json" }).first().click();
} else if (schema.type === "object" && !schema.patternProperties) {
let arrayControls = 0;
let verticalLayouts = 0;
@@ -188,12 +200,10 @@ export class JsonForms {
} else {
await this.walkForm(locator, prop, options, [...path]);
}
+ if (prop["or:test:remove"]) {
+ await this.removeProperty(locator, prop.title ?? key);
+ }
}
- // await locator.getByRole("button", { name: "json" }).first().click();
- }
-
- if (schema?.type === "array" || schema?.type === "object") {
- // await expect(locator.locator("or-ace-editor")).toContainText(JSON.stringify(expected, null, 2));
}
}
@@ -201,6 +211,46 @@ export class JsonForms {
return form.evaluate((el: OrJSONForms) => el.checkValidity());
}
+ /**
+ * Starts collecting the document the form reports, so that {@link JsonForms#getData} can return the latest. The form
+ * only exposes its current document through `onChange`, so this has to be in place before the walk starts.
+ */
+ public async trackData(form: Locator) {
+ await form.evaluate((el: OrJSONForms) => {
+ const store = window as unknown as { orJsonFormsData?: unknown };
+ store.orJsonFormsData = el.data;
+ el.onChange = ({ data }) => (store.orJsonFormsData = data);
+ });
+ }
+
+ /**
+ * Returns the document the form last reported, once its pending update has settled so that the final edit has been
+ * serialized into it.
+ */
+ public async getData(form: Locator) {
+ return form.evaluate(async (el: OrJSONForms) => {
+ await el.updateComplete;
+ return (window as unknown as { orJsonFormsData?: unknown }).orJsonFormsData;
+ });
+ }
+
+ /**
+ * Opens the JSON editor of the outermost control and returns the document it shows, which is how the form presents
+ * its value to the user rather than what it reports through `onChange`. Closes the dialog again through cancel, so
+ * that reading the value cannot change it.
+ */
+ public async getEditorJson(form: Locator) {
+ await form.getByRole("button", { name: "JSON", exact: true }).first().click();
+ const editor = this.dialog.locator("or-ace-editor");
+ // The editor only holds a value once Ace has rendered into it, which its content layer appearing marks
+ await expect(editor.locator(".ace_content")).toBeVisible();
+ const text = await editor.evaluate((el: OrAceEditor) => el.getValue());
+
+ await this.dialog.getByRole("button", { name: "Cancel", exact: true }).click();
+ await expect(this.dialog).toHaveCount(0);
+ return JSON.parse(text ?? "null");
+ }
+
private async walkArray(locator: Locator, schema: JsonSchema, path: Path, item: number) {
locator = locator.locator("or-json-forms-array-control").nth(item);
path.push("or-json-forms-array-control", item);
@@ -213,7 +263,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 +281,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 +293,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();
}
@@ -278,11 +325,25 @@ export class JsonForms {
await expect(locator).toBeChecked();
}
} else {
- await locator.fill(String(schema["or:test:value"] ?? fallback));
- await expect(locator).toHaveValue(String(schema["or:test:value"] ?? fallback));
+ const value = schema["or:test:value"] ?? fallback;
+ await locator.fill(String(value));
+ await expect(locator).toHaveValue(String(value));
+ // A text or number field commits on blur, so filling it alone leaves the value out of the form data
+ await locator.blur();
}
}
+ /**
+ * Deletes a property that has already been filled, through the delete button of the container it shares with its
+ * control, so that the expected document can show it absent again.
+ */
+ private async removeProperty(locator: Locator, title: string) {
+ // An exact label match, since a substring would also pick up a property whose title merely contains this one
+ const container = locator.locator(".item-container").filter({ has: this.page.getByText(title, { exact: true }) });
+ await container.getByRole("button", { name: "Delete", exact: true }).click();
+ await expect(container).toHaveCount(0);
+ }
+
private resolveSchema(schema: JsonSchema, ref: string) {
const segments = ref.split("/");
return schema.definitions![segments[segments.length - 1]];
diff --git a/ui/component/or-json-forms/test/fixtures/schemas.ts b/ui/component/or-json-forms/test/fixtures/schemas.ts
index 497cde58bb..510e017a34 100644
--- a/ui/component/or-json-forms/test/fixtures/schemas.ts
+++ b/ui/component/or-json-forms/test/fixtures/schemas.ts
@@ -24,29 +24,34 @@ export const schemas: JsonSchema[] = [
title: "String",
type: "string",
"or:test:value": "test",
+ "or:test:expected": "test",
},
{
$schema: "http://json-schema.org/draft-07/schema#",
title: "Boolean",
type: "boolean",
"or:test:value": true,
+ "or:test:expected": true,
},
{
$schema: "http://json-schema.org/draft-07/schema#",
title: "Integer",
type: "integer",
"or:test:value": 1,
+ "or:test:expected": 1,
},
{
$schema: "http://json-schema.org/draft-07/schema#",
title: "Number",
type: "number",
"or:test:value": 1,
+ "or:test:expected": 1,
},
{
$schema: "http://json-schema.org/draft-07/schema#",
title: "Object",
type: "object",
+ "or:test:expected": { value: "test" },
"or:test:props": ["value"],
properties: {
value: {
@@ -59,6 +64,7 @@ export const schemas: JsonSchema[] = [
$schema: "http://json-schema.org/draft-07/schema#",
title: "Array",
type: "array",
+ "or:test:expected": ["test"],
"or:test:item:count": 1,
items: {
title: "String",
@@ -70,6 +76,16 @@ export const schemas: JsonSchema[] = [
$schema: "http://json-schema.org/draft-07/schema#",
title: "3 Dimensional Array",
type: "array",
+ "or:test:expected": [
+ [
+ ["test", "test"],
+ ["test", "test"],
+ ],
+ [
+ ["test", "test"],
+ ["test", "test"],
+ ],
+ ],
"or:test:item:count": 2,
items: {
title: "2 Dimensional Array",
@@ -91,6 +107,11 @@ export const schemas: JsonSchema[] = [
$schema: "http://json-schema.org/draft-07/schema#",
title: "Complex",
type: "array",
+ // The boolean is filled and then deleted again, so it is absent here
+ "or:test:expected": [
+ { unresolvedBoolean: false, string: "test", array: ["test", "test"] },
+ { unresolvedBoolean: false, string: "test", array: ["test", "test"] },
+ ],
"or:test:item:count": 2,
items: {
title: "Object",
@@ -107,6 +128,7 @@ export const schemas: JsonSchema[] = [
title: "Boolean",
type: "boolean",
"or:test:value": true,
+ "or:test:remove": true,
},
// Should resolve to false
unresolvedBoolean: {
@@ -131,22 +153,40 @@ export const schemas: JsonSchema[] = [
$schema: "http://json-schema.org/draft-07/schema#",
title: "Polymorphic",
type: "array",
+ "or:test:expected": [
+ { customType: "SubType", value: "test" },
+ { customType: "CustomSubType", value: "test" },
+ ],
definitions: {
SubType: {
title: "Sub Type",
+ type: "object",
+ "or:test:props": ["value"],
properties: {
customType: {
const: "SubType",
},
+ value: {
+ title: "Value",
+ type: "string",
+ "or:test:value": "test",
+ },
},
required: ["customType"],
},
CustomSubType: {
title: "Custom Sub Type",
+ type: "object",
+ "or:test:props": ["value"],
properties: {
customType: {
const: "CustomSubType",
},
+ value: {
+ title: "Value",
+ type: "string",
+ "or:test:value": "test",
+ },
},
required: ["customType"],
},
diff --git a/ui/component/or-json-forms/test/or-json-forms.test.ts b/ui/component/or-json-forms/test/or-json-forms.test.ts
index b3dc938f08..95ecd70a4e 100644
--- a/ui/component/or-json-forms/test/or-json-forms.test.ts
+++ b/ui/component/or-json-forms/test/or-json-forms.test.ts
@@ -43,14 +43,20 @@ for (const schema of schemas) {
schema,
data: typeValueMap.get(schema.type),
renderers: StandardRenderers,
- onChange: () => null,
readonly: false,
label: schema.title,
required: false,
},
on: {},
});
+ await jsonForms.trackData(component);
await jsonForms.walkForm(component, schema);
+
+ await expect(jsonForms.getData(component)).resolves.toEqual(schema["or:test:expected"]);
+ // Only an array or an object carries the header that opens the JSON editor
+ if (schema.type === "array" || schema.type === "object") {
+ await expect(jsonForms.getEditorJson(component)).resolves.toEqual(schema["or:test:expected"]);
+ }
await expect(jsonForms.getValidity(component)).resolves.toBeTruthy();
});
}
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/date-time-i18n.ts b/ui/component/or-vaadin-components/src/date-time-i18n.ts
index 1c474bd92e..b55f2195bb 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,12 @@ 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, 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 };
}
-/**
- * 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.
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..3d1570ab65 100644
--- a/ui/component/or-vaadin-components/src/or-vaadin-input.ts
+++ b/ui/component/or-vaadin-components/src/or-vaadin-input.ts
@@ -19,8 +19,9 @@
import { css, html, type LitElement, type PropertyValues, type TemplateResult } from "lit";
import { OrElement } from "@openremote/or-element";
import { customElement, property, query } from "lit/decorators.js";
-import { InputType } from "./util";
+import { InputType, inputTypeIsJson, parseJson } 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,14 +81,20 @@ 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.getJsonTemplate],
+ [InputType.JSON_OBJECT, OrVaadinInput.getJsonTemplate],
[InputType.NUMBER, OrVaadinInput.getNumberFieldTemplate],
[InputType.PASSWORD, OrVaadinInput.getPasswordFieldTemplate],
[InputType.RANGE, OrVaadinInput.getSliderTemplate],
[InputType.SELECT, OrVaadinInput.getSelectTemplate],
[InputType.SWITCH, OrVaadinInput.getSwitchTemplate],
+ [InputType.TELEPHONE, OrVaadinInput.getTextFieldTemplate],
[InputType.TEXT, OrVaadinInput.getTextFieldTemplate],
[InputType.TEXTAREA, OrVaadinInput.getTextAreaTemplate],
+ [InputType.TIME, OrVaadinInput.getTimePickerTemplate],
]);
/**
@@ -93,7 +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"],
@@ -143,15 +157,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);
}
@@ -171,6 +186,10 @@ export class OrVaadinInput extends OrElement {
case InputType.SWITCH: {
return (this._elem as HTMLInputElement | undefined)?.checked;
}
+ case InputType.JSON:
+ case InputType.JSON_OBJECT: {
+ return parseJson(this.type, this._elem?.value).value;
+ }
default: {
return this._elem?.value;
}
@@ -182,7 +201,14 @@ 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
+ if (inputTypeIsJson(this.type)) {
+ return parseJson(this.type, this._elem?.value).valid;
+ }
+ return true;
}
render() {
@@ -230,6 +256,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
@@ -260,10 +297,35 @@ 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` `;
}
+ public static getEmailFieldTemplate(onChange?: (e: Event) => void) {
+ 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` `;
}
@@ -292,6 +354,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/util.ts b/ui/component/or-vaadin-components/src/util.ts
index 3b15ad815c..4d5ac9a2d7 100644
--- a/ui/component/or-vaadin-components/src/util.ts
+++ b/ui/component/or-vaadin-components/src/util.ts
@@ -87,6 +87,14 @@ export interface OrVaadinComponent {
getAttributeNames(): string[];
}
+/**
+ * Returns whether the {@link InputType} holds its value as JSON text, so it needs to be parsed on the way out
+ * and serialized on the way in.
+ */
+export function inputTypeIsJson(inputType: InputType) {
+ return inputType === InputType.JSON || inputType === InputType.JSON_OBJECT;
+}
+
/**
* 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`.
@@ -102,8 +110,7 @@ export function inputTypeSupportsSendButton(inputType: InputType): boolean {
inputType === InputType.DATE ||
inputType === InputType.DATETIME ||
inputType === InputType.EMAIL ||
- inputType === InputType.JSON ||
- inputType === InputType.JSON_OBJECT ||
+ inputTypeIsJson(inputType) ||
inputType === InputType.MONTH ||
inputType === InputType.TEXTAREA ||
inputType === InputType.TIME ||
@@ -123,3 +130,39 @@ export function inputTypeSupportsLabel(inputType: InputType) {
inputType === InputType.BUTTON_MOMENTARY
);
}
+
+/**
+ * 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 inputType - The input type being parsed
+ * @param text - The text to parse
+ */
+export function parseJson(inputType: InputType, text?: string): { value?: any; valid: boolean } {
+ if (!text?.trim()) {
+ return { valid: true };
+ }
+ try {
+ const value = JSON.parse(text);
+ if (inputType === InputType.JSON_OBJECT && (typeof value !== "object" || value === null || Array.isArray(value))) {
+ return { valid: false };
+ }
+ return { value, valid: true };
+ } catch {
+ return { valid: false };
+ }
+}
+
+/**
+ * Serializes a value for display in a JSON input type, matching what {@link parseJson} reads back.
+ * @param value - The value to serialize
+ */
+export function stringifyJson(value: any): string | undefined {
+ if (value === undefined || value === null) {
+ return undefined;
+ }
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return undefined;
+ }
+}
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..27ce057bf1 100644
--- a/ui/component/or-vaadin-components/src/value-input-provider.ts
+++ b/ui/component/or-vaadin-components/src/value-input-provider.ts
@@ -49,9 +49,11 @@ import { ifDefined } from "lit/directives/if-defined.js";
import { styleMap } from "lit/directives/style-map.js";
import {
InputType,
+ inputTypeIsJson,
inputTypeSupportsHelperText,
inputTypeSupportsLabel,
inputTypeSupportsSendButton,
+ stringifyJson,
type SupportedWellknownValueTypes,
} from "./util";
import { OrVaadinInput } from "./or-vaadin-input";
@@ -149,7 +151,10 @@ export const getValueHolderInputTemplateProvider: ValueInputProviderGenerator =
)
: Util.getMetaValueFormat(valueHolder as Attribute, valueHolderDescriptor as AttributeDescriptor, assetType);
- const supportsVaadinInput = (type: InputType) => OrVaadinInput.TEMPLATES.has(type);
+ // 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) && !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;
@@ -352,31 +357,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;
}
@@ -417,6 +419,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 (inputTypeIsJson(inputType)) {
+ // The value attribute carries text, so a JSON value is shown as formatted JSON
+ value = 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..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,34 +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;
+ },
+ },
+ });
+
+ // 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 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.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);
+ });
+
+ 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.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);
+ });
+
+ 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.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();
+ });
+
+ 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}');
+ });
+
+ 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();
- // 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");
+ // 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", 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.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);
+ await expect(component.locator("or-vaadin-text-area")).not.toHaveAttribute("invalid");
+ await expect.poll(() => component.evaluate((el: OrVaadinInput) => el.nativeValue)).toEqual([1, 2]);
+ });
+});
+
+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();
+
+ // 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..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,50 @@ 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, {
+ 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, {
diff --git a/ui/test/package.json b/ui/test/package.json
index 84b99e337b..3e06c1079d 100644
--- a/ui/test/package.json
+++ b/ui/test/package.json
@@ -34,6 +34,7 @@
"@rspack/core": "~2.1.10",
"@rspack/dev-server": "~2.1.0",
"@sand4rt/experimental-ct-web": "1.59.1",
+ "ace-builds": "^1.44.0",
"css-loader": "^7.1.2",
"html-webpack-plugin": "^5.6.6",
"i18next": "^21.5.3",
diff --git a/ui/test/plugin/index.ts b/ui/test/plugin/index.ts
index 40c8ab0c58..61c027cee2 100644
--- a/ui/test/plugin/index.ts
+++ b/ui/test/plugin/index.ts
@@ -171,9 +171,11 @@ async function buildBundle(ctConfig: FullConfig, configDir: string): Promise