mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-13 20:36:35 +00:00
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:
parent
f87e20cae9
commit
b608bd949b
@ -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: "%",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
104
src/data/selector/format_selector_value.ts
Normal file
104
src/data/selector/format_selector_value.ts
Normal 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(", ");
|
||||
};
|
89
src/dialogs/form/dialog-form.ts
Normal file
89
src/dialogs/form/dialog-form.ts
Normal 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;
|
||||
}
|
||||
}
|
45
src/dialogs/form/show-form-dialog.ts
Normal file
45
src/dialogs/form/show-form-dialog.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user