diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index 0f50f6421f..4420b483ed 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -416,6 +416,34 @@ const SCHEMAS: { }, }, }, + items: { + name: "Items", + selector: { + object: { + label_field: "name", + description_field: "value", + multiple: true, + fields: { + name: { + label: "Name", + selector: { text: {} }, + required: true, + }, + value: { + label: "Value", + selector: { + number: { + mode: "slider", + min: 0, + max: 100, + unit_of_measurement: "%", + }, + }, + }, + }, + }, + }, + }, }, }, ]; diff --git a/src/components/ha-selector/ha-selector-object.ts b/src/components/ha-selector/ha-selector-object.ts index f2b9040b20..bca45148b3 100644 --- a/src/components/ha-selector/ha-selector-object.ts +++ b/src/components/ha-selector/ha-selector-object.ts @@ -1,16 +1,27 @@ -import type { PropertyValues } from "lit"; -import { html, LitElement } from "lit"; +import { mdiClose, mdiDelete, mdiDrag, mdiPencil } from "@mdi/js"; +import { css, html, LitElement, nothing, type PropertyValues } from "lit"; import { customElement, property, query } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { ensureArray } from "../../common/array/ensure-array"; import { fireEvent } from "../../common/dom/fire_event"; +import type { ObjectSelector } from "../../data/selector"; +import { formatSelectorValue } from "../../data/selector/format_selector_value"; +import { showFormDialog } from "../../dialogs/form/show-form-dialog"; import type { HomeAssistant } from "../../types"; -import "../ha-yaml-editor"; +import type { HaFormSchema } from "../ha-form/types"; import "../ha-input-helper-text"; +import "../ha-md-list"; +import "../ha-md-list-item"; +import "../ha-sortable"; +import "../ha-yaml-editor"; import type { HaYamlEditor } from "../ha-yaml-editor"; @customElement("ha-selector-object") export class HaObjectSelector extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; + @property({ attribute: false }) public selector!: ObjectSelector; + @property() public value?: any; @property() public label?: string; @@ -23,11 +34,136 @@ export class HaObjectSelector extends LitElement { @property({ type: Boolean }) public required = true; - @query("ha-yaml-editor", true) private _yamlEditor!: HaYamlEditor; + @property({ attribute: false }) public localizeValue?: ( + key: string + ) => string; + + @query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor; private _valueChangedFromChild = false; + private _computeLabel = (schema: HaFormSchema): string => { + const translationKey = this.selector.object?.translation_key; + + if (this.localizeValue && translationKey) { + const label = this.localizeValue( + `${translationKey}.fields.${schema.name}` + ); + if (label) { + return label; + } + } + return this.selector.object?.fields?.[schema.name]?.label || schema.name; + }; + + private _renderItem(item: any, index: number) { + const labelField = + this.selector.object!.label_field || + Object.keys(this.selector.object!.fields!)[0]; + + const labelSelector = this.selector.object!.fields![labelField].selector; + + const label = labelSelector + ? formatSelectorValue(this.hass, item[labelField], labelSelector) + : ""; + + let description = ""; + + const descriptionField = this.selector.object!.description_field; + if (descriptionField) { + const descriptionSelector = + this.selector.object!.fields![descriptionField].selector; + + description = descriptionSelector + ? formatSelectorValue( + this.hass, + item[descriptionField], + descriptionSelector + ) + : ""; + } + + const reorderable = this.selector.object!.multiple || false; + const multiple = this.selector.object!.multiple || false; + return html` + + ${reorderable + ? html` + + ` + : nothing} +
${label}
+ ${description + ? html`
+ ${description} +
` + : nothing} + + +
+ `; + } + protected render() { + if (!this.selector.object) { + return nothing; + } + + if (this.selector.object.fields) { + if (this.selector.object.multiple) { + const items = ensureArray(this.value ?? []); + return html` + ${this.label ? html`` : nothing} +
+ + + ${items.map((item, index) => this._renderItem(item, index))} + + + + ${this.hass.localize("ui.common.add")} + +
+ `; + } + + return html` + ${this.label ? html`` : nothing} +
+ ${this.value + ? html` + ${this._renderItem(this.value, 0)} + ` + : html` + + ${this.hass.localize("ui.common.add")} + + `} +
+ `; + } + return html` { + if (!selector.object || !selector.object.fields) { + return []; + } + return Object.entries(selector.object.fields).map(([key, field]) => ({ + name: key, + selector: field.selector, + required: field.required ?? false, + })); + }); + + private _itemMoved(ev) { + ev.stopPropagation(); + const newIndex = ev.detail.newIndex; + const oldIndex = ev.detail.oldIndex; + if (!this.selector.object!.multiple) { + return; + } + const newValue = ensureArray(this.value ?? []).concat(); + const item = newValue.splice(oldIndex, 1)[0]; + newValue.splice(newIndex, 0, item); + fireEvent(this, "value-changed", { value: newValue }); + } + + private async _addItem(ev) { + ev.stopPropagation(); + + const newItem = await showFormDialog(this, { + title: this.hass.localize("ui.common.add"), + schema: this._schema(this.selector), + data: {}, + computeLabel: this._computeLabel, + submitText: this.hass.localize("ui.common.add"), + }); + + if (newItem === null) { + return; + } + + if (!this.selector.object!.multiple) { + fireEvent(this, "value-changed", { value: newItem }); + return; + } + + const newValue = ensureArray(this.value ?? []).concat(); + newValue.push(newItem); + fireEvent(this, "value-changed", { value: newValue }); + } + + private async _editItem(ev) { + ev.stopPropagation(); + const item = ev.currentTarget.item; + const index = ev.currentTarget.index; + + const updatedItem = await showFormDialog(this, { + title: this.hass.localize("ui.common.edit"), + schema: this._schema(this.selector), + data: item, + computeLabel: this._computeLabel, + submitText: this.hass.localize("ui.common.save"), + }); + + if (updatedItem === null) { + return; + } + + if (!this.selector.object!.multiple) { + fireEvent(this, "value-changed", { value: updatedItem }); + return; + } + + const newValue = ensureArray(this.value ?? []).concat(); + newValue[index] = updatedItem; + fireEvent(this, "value-changed", { value: newValue }); + } + + private _deleteItem(ev) { + ev.stopPropagation(); + const index = ev.currentTarget.index; + + if (!this.selector.object!.multiple) { + fireEvent(this, "value-changed", { value: undefined }); + return; + } + + const newValue = ensureArray(this.value ?? []).concat(); + newValue.splice(index, 1); + fireEvent(this, "value-changed", { value: newValue }); + } + protected updated(changedProps: PropertyValues) { super.updated(changedProps); - if (changedProps.has("value") && !this._valueChangedFromChild) { + if ( + changedProps.has("value") && + !this._valueChangedFromChild && + this._yamlEditor + ) { this._yamlEditor.setValue(this.value); } this._valueChangedFromChild = false; @@ -63,6 +293,42 @@ export class HaObjectSelector extends LitElement { } fireEvent(this, "value-changed", { value }); } + + static get styles() { + return [ + css` + ha-md-list { + gap: 8px; + } + ha-md-list-item { + border: 1px solid var(--divider-color); + border-radius: 8px; + --ha-md-list-item-gap: 0; + --md-list-item-top-space: 0; + --md-list-item-bottom-space: 0; + --md-list-item-leading-space: 12px; + --md-list-item-trailing-space: 4px; + --md-list-item-two-line-container-height: 48px; + --md-list-item-one-line-container-height: 48px; + } + .handle { + cursor: move; + padding: 8px; + margin-inline-start: -8px; + } + label { + margin-bottom: 8px; + display: block; + } + ha-md-list-item .label, + ha-md-list-item .description { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + `, + ]; + } } declare global { diff --git a/src/data/selector.ts b/src/data/selector.ts index 60d2b133fe..c76a458a05 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -334,8 +334,20 @@ export interface NumberSelector { } | null; } +interface ObjectSelectorField { + selector: Selector; + label?: string; + required?: boolean; +} + export interface ObjectSelector { - object: {} | null; + object?: { + label_field?: string; + description_field?: string; + translation_key?: string; + fields?: Record; + multiple?: boolean; + } | null; } export interface AssistPipelineSelector { diff --git a/src/data/selector/format_selector_value.ts b/src/data/selector/format_selector_value.ts new file mode 100644 index 0000000000..47950879d3 --- /dev/null +++ b/src/data/selector/format_selector_value.ts @@ -0,0 +1,104 @@ +import { ensureArray } from "../../common/array/ensure-array"; +import { computeAreaName } from "../../common/entity/compute_area_name"; +import { computeDeviceName } from "../../common/entity/compute_device_name"; +import { computeEntityName } from "../../common/entity/compute_entity_name"; +import { getEntityContext } from "../../common/entity/context/get_entity_context"; +import { blankBeforeUnit } from "../../common/translations/blank_before_unit"; +import type { HomeAssistant } from "../../types"; +import type { Selector } from "../selector"; + +export const formatSelectorValue = ( + hass: HomeAssistant, + value: any, + selector?: Selector +) => { + if (value == null) { + return ""; + } + + if (!selector) { + return ensureArray(value).join(", "); + } + + if ("text" in selector) { + const { prefix, suffix } = selector.text || {}; + + const texts = ensureArray(value); + return texts + .map((text) => `${prefix || ""}${text}${suffix || ""}`) + .join(", "); + } + + if ("number" in selector) { + const { unit_of_measurement } = selector.number || {}; + const numbers = ensureArray(value); + return numbers + .map((number) => { + const num = Number(number); + if (isNaN(num)) { + return number; + } + return unit_of_measurement + ? `${num}${blankBeforeUnit(unit_of_measurement, hass.locale)}${unit_of_measurement}` + : num.toString(); + }) + .join(", "); + } + + if ("floor" in selector) { + const floors = ensureArray(value); + return floors + .map((floorId) => { + const floor = hass.floors[floorId]; + if (!floor) { + return floorId; + } + return floor.name || floorId; + }) + .join(", "); + } + + if ("area" in selector) { + const areas = ensureArray(value); + return areas + .map((areaId) => { + const area = hass.areas[areaId]; + if (!area) { + return areaId; + } + return computeAreaName(area); + }) + .join(", "); + } + + if ("entity" in selector) { + const entities = ensureArray(value); + return entities + .map((entityId) => { + const stateObj = hass.states[entityId]; + if (!stateObj) { + return entityId; + } + const { device } = getEntityContext(stateObj, hass); + const deviceName = device ? computeDeviceName(device) : undefined; + const entityName = computeEntityName(stateObj, hass); + return [deviceName, entityName].filter(Boolean).join(" ") || entityId; + }) + .join(", "); + } + + if ("device" in selector) { + const devices = ensureArray(value); + return devices + .map((deviceId) => { + const device = hass.devices[deviceId]; + if (!device) { + return deviceId; + } + return device.name || deviceId; + }) + .join(", "); + } + + return ensureArray(value).join(", "); +}; diff --git a/src/dialogs/form/dialog-form.ts b/src/dialogs/form/dialog-form.ts new file mode 100644 index 0000000000..af755a2224 --- /dev/null +++ b/src/dialogs/form/dialog-form.ts @@ -0,0 +1,89 @@ +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-button"; +import { createCloseHeading } from "../../components/ha-dialog"; +import "../../components/ha-form/ha-form"; +import type { HomeAssistant } from "../../types"; +import type { HassDialog } from "../make-dialog-manager"; +import type { FormDialogData, FormDialogParams } from "./show-form-dialog"; +import { haStyleDialog } from "../../resources/styles"; + +@customElement("dialog-form") +export class DialogForm + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _params?: FormDialogParams; + + @state() private _data: FormDialogData = {}; + + public async showDialog(params: FormDialogParams): Promise { + this._params = params; + this._data = params.data || {}; + } + + public closeDialog() { + this._params = undefined; + this._data = {}; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + return true; + } + + private _submit(): void { + this._params?.submit?.(this._data); + this.closeDialog(); + } + + private _cancel(): void { + this._params?.cancel?.(); + this.closeDialog(); + } + + private _valueChanged(ev: CustomEvent): void { + this._data = ev.detail.value; + } + + protected render() { + if (!this._params || !this.hass) { + return nothing; + } + + return html` + + + + + ${this._params.cancelText || this.hass.localize("ui.common.cancel")} + + + ${this._params.submitText || this.hass.localize("ui.common.save")} + + + `; + } + + static styles = [haStyleDialog, css``]; +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-form": DialogForm; + } +} diff --git a/src/dialogs/form/show-form-dialog.ts b/src/dialogs/form/show-form-dialog.ts new file mode 100644 index 0000000000..3cd49fd6ce --- /dev/null +++ b/src/dialogs/form/show-form-dialog.ts @@ -0,0 +1,45 @@ +import { fireEvent } from "../../common/dom/fire_event"; +import type { HaFormSchema } from "../../components/ha-form/types"; + +export type FormDialogData = Record; + +export interface FormDialogParams { + title: string; + schema: HaFormSchema[]; + data?: FormDialogData; + submit?: (data?: FormDialogData) => void; + cancel?: () => void; + computeLabel?: (schema, data) => string | undefined; + computeHelper?: (schema) => string | undefined; + submitText?: string; + cancelText?: string; +} + +export const showFormDialog = ( + element: HTMLElement, + dialogParams: FormDialogParams +) => + new Promise((resolve) => { + const origCancel = dialogParams.cancel; + const origSubmit = dialogParams.submit; + + fireEvent(element, "show-dialog", { + dialogTag: "dialog-form", + dialogImport: () => import("./dialog-form"), + dialogParams: { + ...dialogParams, + cancel: () => { + resolve(null); + if (origCancel) { + origCancel(); + } + }, + submit: (data: FormDialogData) => { + resolve(data); + if (origSubmit) { + origSubmit(data); + } + }, + }, + }); + });