Add fields and multiple support to object selector (#25843)

* Add schema and multiple for object selector

* Add selector to gallery

* Fix description

* Improve formatting

* Add ellipsis

* Update fields instead of schema

* Update gallery

* Update format

* Fix format value

* Fix dialog size
This commit is contained in:
Paul Bottein 2025-06-20 15:48:59 +02:00 committed by GitHub
parent f87e20cae9
commit b608bd949b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 550 additions and 6 deletions

View File

@ -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: "%",
},
},
},
},
},
},
},
},
},
];

View File

@ -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`
<ha-md-list-item class="item">
${reorderable
? html`
<ha-svg-icon
class="handle"
.path=${mdiDrag}
slot="start"
></ha-svg-icon>
`
: nothing}
<div slot="headline" class="label">${label}</div>
${description
? html`<div slot="supporting-text" class="description">
${description}
</div>`
: nothing}
<ha-icon-button
slot="end"
.item=${item}
.index=${index}
.label=${this.hass.localize("ui.common.edit")}
.path=${mdiPencil}
@click=${this._editItem}
></ha-icon-button>
<ha-icon-button
slot="end"
.index=${index}
.label=${this.hass.localize("ui.common.delete")}
.path=${multiple ? mdiDelete : mdiClose}
@click=${this._deleteItem}
></ha-icon-button>
</ha-md-list-item>
`;
}
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`<label>${this.label}</label>` : nothing}
<div class="items-container">
<ha-sortable
handle-selector=".handle"
draggable-selector=".item"
@item-moved=${this._itemMoved}
>
<ha-md-list>
${items.map((item, index) => this._renderItem(item, index))}
</ha-md-list>
</ha-sortable>
<ha-button outlined @click=${this._addItem}>
${this.hass.localize("ui.common.add")}
</ha-button>
</div>
`;
}
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="items-container">
${this.value
? html`<ha-md-list>
${this._renderItem(this.value, 0)}
</ha-md-list>`
: html`
<ha-button outlined @click=${this._addItem}>
${this.hass.localize("ui.common.add")}
</ha-button>
`}
</div>
`;
}
return html`<ha-yaml-editor
.hass=${this.hass}
.readonly=${this.disabled}
@ -44,9 +180,103 @@ export class HaObjectSelector extends LitElement {
: ""} `;
}
private _schema = memoizeOne((selector: ObjectSelector) => {
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 {

View File

@ -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<string, ObjectSelectorField>;
multiple?: boolean;
} | null;
}
export interface AssistPipelineSelector {

View File

@ -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(", ");
};

View File

@ -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<FormDialogData>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _params?: FormDialogParams;
@state() private _data: FormDialogData = {};
public async showDialog(params: FormDialogParams): Promise<void> {
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`
<ha-dialog
open
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(this.hass, this._params.title)}
@closed=${this._cancel}
>
<ha-form
dialogInitialFocus
.hass=${this.hass}
.computeLabel=${this._params.computeLabel}
.computeHelper=${this._params.computeHelper}
.data=${this._data}
.schema=${this._params.schema}
@value-changed=${this._valueChanged}
>
</ha-form>
<ha-button @click=${this._cancel} slot="secondaryAction">
${this._params.cancelText || this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._submit} slot="primaryAction">
${this._params.submitText || this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog>
`;
}
static styles = [haStyleDialog, css``];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-form": DialogForm;
}
}

View File

@ -0,0 +1,45 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { HaFormSchema } from "../../components/ha-form/types";
export type FormDialogData = Record<string, any>;
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<FormDialogData | null>((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);
}
},
},
});
});