diff --git a/src/components/ha-selector/ha-selector-area.ts b/src/components/ha-selector/ha-selector-area.ts index d28b60fbc6..def6fa11a5 100644 --- a/src/components/ha-selector/ha-selector-area.ts +++ b/src/components/ha-selector/ha-selector-area.ts @@ -5,6 +5,7 @@ import memoizeOne from "memoize-one"; import { ensureArray } from "../../common/array/ensure-array"; import type { DeviceRegistryEntry } from "../../data/device_registry"; import { getDeviceIntegrationLookup } from "../../data/device_registry"; +import { fireEvent } from "../../common/dom/fire_event"; import { EntitySources, fetchEntitySourcesWithCache, @@ -49,6 +50,18 @@ export class HaAreaSelector extends LitElement { ); } + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("selector") && this.value !== undefined) { + if (this.selector.area?.multiple && !Array.isArray(this.value)) { + this.value = [this.value]; + fireEvent(this, "value-changed", { value: this.value }); + } else if (!this.selector.area?.multiple && Array.isArray(this.value)) { + this.value = this.value[0]; + fireEvent(this, "value-changed", { value: this.value }); + } + } + } + protected updated(changedProperties: PropertyValues): void { if ( changedProperties.has("selector") && diff --git a/src/components/ha-selector/ha-selector-device.ts b/src/components/ha-selector/ha-selector-device.ts index 0af1b288a1..ab79b2c1d1 100644 --- a/src/components/ha-selector/ha-selector-device.ts +++ b/src/components/ha-selector/ha-selector-device.ts @@ -1,8 +1,9 @@ import { HassEntity } from "home-assistant-js-websocket"; -import { html, LitElement, nothing } from "lit"; +import { html, LitElement, PropertyValues, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { ensureArray } from "../../common/array/ensure-array"; +import { fireEvent } from "../../common/dom/fire_event"; import type { DeviceRegistryEntry } from "../../data/device_registry"; import { getDeviceIntegrationLookup } from "../../data/device_registry"; import { @@ -51,7 +52,19 @@ export class HaDeviceSelector extends LitElement { ); } - protected updated(changedProperties): void { + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("selector") && this.value !== undefined) { + if (this.selector.device?.multiple && !Array.isArray(this.value)) { + this.value = [this.value]; + fireEvent(this, "value-changed", { value: this.value }); + } else if (!this.selector.device?.multiple && Array.isArray(this.value)) { + this.value = this.value[0]; + fireEvent(this, "value-changed", { value: this.value }); + } + } + } + + protected updated(changedProperties: PropertyValues): void { super.updated(changedProperties); if ( changedProperties.has("selector") && diff --git a/src/components/ha-selector/ha-selector-entity.ts b/src/components/ha-selector/ha-selector-entity.ts index c6d1a2c3c1..5e44ab2175 100644 --- a/src/components/ha-selector/ha-selector-entity.ts +++ b/src/components/ha-selector/ha-selector-entity.ts @@ -2,6 +2,7 @@ import { HassEntity } from "home-assistant-js-websocket"; import { html, LitElement, PropertyValues, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { ensureArray } from "../../common/array/ensure-array"; +import { fireEvent } from "../../common/dom/fire_event"; import { EntitySources, fetchEntitySourcesWithCache, @@ -37,6 +38,18 @@ export class HaEntitySelector extends LitElement { ); } + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("selector") && this.value !== undefined) { + if (this.selector.entity?.multiple && !Array.isArray(this.value)) { + this.value = [this.value]; + fireEvent(this, "value-changed", { value: this.value }); + } else if (!this.selector.entity?.multiple && Array.isArray(this.value)) { + this.value = this.value[0]; + fireEvent(this, "value-changed", { value: this.value }); + } + } + } + protected render() { if (this._hasIntegration(this.selector) && !this._entitySources) { return nothing; diff --git a/src/components/ha-selector/ha-selector-selector.ts b/src/components/ha-selector/ha-selector-selector.ts new file mode 100644 index 0000000000..eb3aab9d38 --- /dev/null +++ b/src/components/ha-selector/ha-selector-selector.ts @@ -0,0 +1,297 @@ +import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../common/dom/fire_event"; +import { LocalizeFunc, LocalizeKeys } from "../../common/translations/localize"; +import type { HomeAssistant } from "../../types"; +import "../ha-alert"; +import "../ha-form/ha-form"; + +const SELECTOR_DEFAULTS = { + number: { + min: 1, + max: 100, + }, +}; + +const SELECTOR_SCHEMAS = { + action: [] as const, + area: [ + { + name: "multiple", + selector: { boolean: {} }, + }, + ] as const, + attribute: [ + { + name: "entity_id", + selector: { entity: {} }, + }, + ] as const, + boolean: [] as const, + color_temp: [ + { + name: "unit", + selector: { select: { options: ["kelvin", "mired"] } }, + }, + { + name: "min", + selector: { number: { mode: "box" } }, + }, + { + name: "max", + selector: { number: { mode: "box" } }, + }, + ] as const, + condition: [] as const, + date: [] as const, + datetime: [] as const, + device: [ + { + name: "multiple", + selector: { boolean: {} }, + }, + ] as const, + duration: [ + { + name: "enable_day", + selector: { boolean: {} }, + }, + ] as const, + entity: [ + { + name: "multiple", + selector: { boolean: {} }, + }, + ] as const, + icon: [] as const, + location: [] as const, + media: [] as const, + number: [ + { + name: "min", + selector: { number: { mode: "box" } }, + }, + { + name: "max", + selector: { number: { mode: "box" } }, + }, + { + name: "step", + selector: { number: { mode: "box" } }, + }, + ] as const, + object: [] as const, + color_rgb: [] as const, + select: [ + { + name: "options", + selector: { object: {} }, + }, + { + name: "multiple", + selector: { boolean: {} }, + }, + ] as const, + state: [ + { + name: "entity_id", + selector: { entity: {} }, + }, + ] as const, + target: [] as const, + template: [] as const, + text: [ + { + name: "multiple", + selector: { boolean: {} }, + }, + { + name: "multiline", + selector: { boolean: {} }, + }, + { name: "prefix", selector: { text: {} } }, + { name: "suffix", selector: { text: {} } }, + ] as const, + theme: [] as const, + time: [] as const, +}; + +@customElement("ha-selector-selector") +export class HaSelectorSelector extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public value?: any; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean, reflect: true }) public disabled = false; + + @property({ type: Boolean, reflect: true }) public required = true; + + private _yamlMode = false; + + protected shouldUpdate(changedProps: PropertyValues) { + if (changedProps.size === 1 && changedProps.has("hass")) { + return false; + } + return true; + } + + private _schema = memoizeOne( + (choice: string, localize: LocalizeFunc) => + [ + { + name: "type", + selector: { + select: { + mode: "dropdown", + required: true, + options: Object.keys(SELECTOR_SCHEMAS) + .concat("manual") + .map((key) => ({ + label: + localize( + `ui.components.selectors.selector.types.${key}` as LocalizeKeys + ) || key, + value: key, + })), + }, + }, + }, + ...(choice === "manual" + ? ([ + { + name: "manual", + selector: { object: {} }, + }, + ] as const) + : []), + ...(SELECTOR_SCHEMAS[choice] + ? SELECTOR_SCHEMAS[choice].length > 1 + ? [ + { + name: "", + type: "expandable", + title: localize("ui.components.selectors.selector.options"), + schema: SELECTOR_SCHEMAS[choice], + }, + ] + : SELECTOR_SCHEMAS[choice] + : []), + ] as const + ); + + protected render() { + let data; + let type; + if (this._yamlMode) { + type = "manual"; + data = { type, manual: this.value }; + } else { + type = Object.keys(this.value)[0]; + const value0 = Object.values(this.value)[0]; + data = { + type, + ...(typeof value0 === "object" ? value0 : []), + }; + } + + const schema = this._schema(type, this.hass.localize); + + return html` +
+

${this.label ? this.label : ""}

+
`; + } + + private _valueChanged(ev: CustomEvent) { + ev.stopPropagation(); + const value = ev.detail.value; + + const type = value.type; + if (!type || typeof value !== "object" || Object.keys(value).length === 0) { + // not sure how this happens, but reject it + return; + } + + const oldType = Object.keys(this.value)[0]; + if (type === "manual" && !this._yamlMode) { + this._yamlMode = true; + this.requestUpdate(); + return; + } + if (type === "manual" && value.manual === undefined) { + return; + } + if (type !== "manual") { + this._yamlMode = false; + } + delete value.type; + + let newValue; + if (type === "manual") { + newValue = value.manual; + } else if (type === oldType) { + newValue = { + [type]: { ...(value.manual ? value.manual[oldType] : value) }, + }; + } else { + newValue = { [type]: { ...SELECTOR_DEFAULTS[type] } }; + } + + fireEvent(this, "value-changed", { value: newValue }); + } + + private _computeLabelCallback = (schema: any): string => + this.hass.localize( + `ui.components.selectors.selector.${schema.name}` as LocalizeKeys + ) || schema.name; + + static get styles(): CSSResultGroup { + return css` + :host { + --expansion-panel-summary-padding: 0 16px; + } + ha-alert { + display: block; + margin-bottom: 16px; + } + ha-card { + margin: 0 0 16px 0; + } + ha-card.disabled { + pointer-events: none; + color: var(--disabled-text-color); + } + .card-content { + padding: 0px 16px 16px 16px; + } + .title { + font-size: 16px; + padding-top: 16px; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 16px; + padding-left: 16px; + padding-right: 4px; + white-space: nowrap; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-selector": HaSelectorSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 74d129d901..5f68124f89 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -35,6 +35,7 @@ const LOAD_ELEMENTS = { number: () => import("./ha-selector-number"), object: () => import("./ha-selector-object"), select: () => import("./ha-selector-select"), + selector: () => import("./ha-selector-selector"), state: () => import("./ha-selector-state"), backup_location: () => import("./ha-selector-backup-location"), stt: () => import("./ha-selector-stt"), diff --git a/src/data/selector.ts b/src/data/selector.ts index 4e716d10c8..cddf581020 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -42,6 +42,7 @@ export type Selector = | ObjectSelector | AssistPipelineSelector | SelectSelector + | SelectorSelector | StateSelector | StatisticSelector | StringSelector @@ -323,6 +324,11 @@ export interface SelectSelector { } | null; } +export interface SelectorSelector { + // eslint-disable-next-line @typescript-eslint/ban-types + selector: {} | null; +} + export interface StateSelector { state: { extra_options?: { label: string; value: any }[]; diff --git a/src/panels/config/script/ha-script-field-row.ts b/src/panels/config/script/ha-script-field-row.ts index 119bae74ac..8e57629789 100644 --- a/src/panels/config/script/ha-script-field-row.ts +++ b/src/panels/config/script/ha-script-field-row.ts @@ -58,7 +58,7 @@ export default class HaScriptFieldRow extends LitElement { }, { name: "selector", - selector: { object: {} }, + selector: { selector: {} }, }, { name: "default", @@ -269,6 +269,14 @@ export default class HaScriptFieldRow extends LitElement { this._errorKey = undefined; this._uiError = undefined; + // If we render the default with an incompatible selector, it risks throwing an exception and not rendering. + // Clear the default when changing the selector type. + if ( + Object.keys(this.field.selector)[0] !== Object.keys(value.selector)[0] + ) { + delete value.default; + } + fireEvent(this, "value-changed", { value }); } diff --git a/src/translations/en.json b/src/translations/en.json index a0d485a375..bbc3d3bec3 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -361,6 +361,36 @@ "upload_failed": "Upload failed", "unknown_file": "Unknown file" }, + "selector": { + "options": "Selector Options", + "types": { + "action": "Action", + "area": "Area", + "attribute": "Attribute", + "boolean": "Boolean", + "color_temp": "Color temperature", + "condition": "Condition", + "date": "Date", + "datetime": "Date and Time", + "device": "Device", + "duration": "Duration", + "entity": "Entity", + "icon": "Icon", + "location": "Location", + "media": "Media", + "number": "Number", + "object": "Object", + "color_rgb": "RGB Color", + "select": "Select", + "state": "State", + "target": "Target", + "template": "Template", + "text": "Text", + "theme": "Theme", + "time": "Time", + "manual": "Manual Entry" + } + }, "text": { "show_password": "Show password", "hide_password": "Hide password"