mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-24 18:17:10 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa43ca949d | |||
| ac50ba4edc | |||
| 58d4edaa63 | |||
| 176841e647 | |||
| 0759e82b47 |
+1
-1
@@ -86,7 +86,7 @@
|
||||
"core-js": "3.49.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.2.0",
|
||||
"date-fns": "4.2.1",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"dialog-polyfill": "0.5.6",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiPlus, mdiShape } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
|
||||
import { isValidEntityId } from "../../common/entity/valid_entity_id";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
@@ -20,20 +20,17 @@ import {
|
||||
} from "../../panels/config/helpers/const";
|
||||
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-button";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import "../ha-icon";
|
||||
import "../ha-picker-field";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
import "../ha-picker-popover";
|
||||
import "../ha-picker-search-list";
|
||||
import type {
|
||||
HaPickerSearchList,
|
||||
PickerSearchFn,
|
||||
} from "../ha-picker-search-list";
|
||||
import "../ha-svg-icon";
|
||||
import "./state-badge";
|
||||
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
|
||||
@customElement("ha-entity-picker")
|
||||
export class HaEntityPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -125,17 +122,15 @@ export class HaEntityPicker extends LitElement {
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
|
||||
@query(".trigger") private _trigger?: HTMLElement;
|
||||
@property({ attribute: "add-button", type: Boolean })
|
||||
public addButton = false;
|
||||
|
||||
@query("ha-picker-search-list") private _searchList?: HaPickerSearchList;
|
||||
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
||||
|
||||
@state() private _pickerOpen = false;
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@state() private _pendingEntityId?: string;
|
||||
|
||||
// Commit fires on @closed (after the hide animation) to avoid flicker.
|
||||
private _pendingValue?: string;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (
|
||||
this._pendingEntityId &&
|
||||
@@ -150,7 +145,7 @@ export class HaEntityPicker extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
// Preload title translations so they're ready when the dropdown opens.
|
||||
// Load title translations so it is available when the combo-box opens
|
||||
this.hass.loadBackendTranslation("title");
|
||||
}
|
||||
|
||||
@@ -280,48 +275,40 @@ export class HaEntityPicker extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private _getCreateActions = memoizeOne(
|
||||
private _getAdditionalItems = () =>
|
||||
this._getCreateItems(this.hass.localize, this.createDomains);
|
||||
|
||||
private _getCreateItems = memoizeOne(
|
||||
(
|
||||
localize: this["hass"]["localize"],
|
||||
createDomains: this["createDomains"]
|
||||
): EntityComboBoxItem[] => {
|
||||
) => {
|
||||
if (!createDomains?.length) {
|
||||
return [];
|
||||
}
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
return createDomains.map((domain) => ({
|
||||
id: `__create-helper__${domain}`,
|
||||
primary: localize("ui.components.entity.entity-picker.create_helper", {
|
||||
domain: isHelperDomain(domain)
|
||||
? localize(
|
||||
`ui.panel.config.helpers.types.${domain as HelperDomain}`
|
||||
) || domain
|
||||
: domainToName(localize, domain),
|
||||
}),
|
||||
secondary: localize("ui.components.entity.entity-picker.new_entity"),
|
||||
icon_path: mdiPlus,
|
||||
onSelect: ({ close }) => {
|
||||
close();
|
||||
this._openCreateHelper(domain);
|
||||
},
|
||||
}));
|
||||
return createDomains.map((domain) => {
|
||||
const primary = localize(
|
||||
"ui.components.entity.entity-picker.create_helper",
|
||||
{
|
||||
domain: isHelperDomain(domain)
|
||||
? localize(
|
||||
`ui.panel.config.helpers.types.${domain as HelperDomain}`
|
||||
) || domain
|
||||
: domainToName(localize, domain),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: CREATE_ID + domain,
|
||||
primary: primary,
|
||||
secondary: localize("ui.components.entity.entity-picker.new_entity"),
|
||||
icon_path: mdiPlus,
|
||||
} satisfies EntityComboBoxItem;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private _openCreateHelper(domain: string) {
|
||||
showHelperDetailDialog(this, {
|
||||
domain,
|
||||
dialogClosedCallback: (item) => {
|
||||
if (!item.entityId) return;
|
||||
if (this.hass.states[item.entityId]) {
|
||||
this._setValue(item.entityId);
|
||||
} else {
|
||||
this._pendingEntityId = item.entityId;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne(getEntities);
|
||||
|
||||
private _getItems = () => {
|
||||
@@ -354,67 +341,53 @@ export class HaEntityPicker extends LitElement {
|
||||
const placeholder =
|
||||
this.placeholder ??
|
||||
this.hass.localize("ui.components.entity.entity-picker.placeholder");
|
||||
const items = this._getItems();
|
||||
const actions = this._getCreateActions(
|
||||
this.hass.localize,
|
||||
this.createDomains
|
||||
);
|
||||
const hideClearIcon = this.hideClearIcon || this._shouldHideClearIcon();
|
||||
|
||||
return html`
|
||||
<div class="picker">
|
||||
<div class="trigger" @click=${this._openPicker}>
|
||||
<slot name="trigger">
|
||||
<ha-picker-field
|
||||
type="button"
|
||||
compact
|
||||
.label=${this.label}
|
||||
.placeholder=${placeholder}
|
||||
.value=${this.value}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.required=${this.required}
|
||||
.disabled=${this.disabled}
|
||||
.helper=${this.helper}
|
||||
.hideClearIcon=${hideClearIcon}
|
||||
?autofocus=${this.autofocus}
|
||||
@clear=${this._clear}
|
||||
></ha-picker-field>
|
||||
</slot>
|
||||
</div>
|
||||
<ha-picker-popover
|
||||
.open=${this._pickerOpen}
|
||||
.anchor=${this._trigger ?? null}
|
||||
.label=${this.label ?? ""}
|
||||
@closed=${this._handlePickerClosed}
|
||||
>
|
||||
<ha-picker-search-list
|
||||
autofocus
|
||||
.items=${items}
|
||||
.value=${this.value}
|
||||
.searchKeys=${entityComboBoxKeys}
|
||||
.searchFn=${this._searchFn}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.actions=${actions}
|
||||
.searchPlaceholder=${this.searchLabel ??
|
||||
this.hass.localize("ui.common.search")}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
@item-selected=${this._handleItemSelected}
|
||||
></ha-picker-search-list>
|
||||
</ha-picker-popover>
|
||||
</div>
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.autofocus=${this.autofocus}
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
.required=${this.required}
|
||||
.label=${this.label}
|
||||
.placeholder=${placeholder}
|
||||
.helper=${this.helper}
|
||||
.value=${this.addButton ? undefined : this.value}
|
||||
.searchLabel=${this.searchLabel}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.getItems=${this._getItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
.hideClearIcon=${this.hideClearIcon || this._shouldHideClearIcon()}
|
||||
.searchFn=${this._searchFn}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.searchKeys=${entityComboBoxKeys}
|
||||
use-top-label
|
||||
.addButtonLabel=${this.addButton
|
||||
? (this.addButtonLabel ??
|
||||
this.hass.localize("ui.components.entity.entity-picker.add"))
|
||||
: undefined}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.components.entity.entity-picker.unknown"
|
||||
)}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _searchFn: PickerSearchFn<EntityComboBoxItem> = (
|
||||
private _searchFn: PickerComboBoxSearchFn<EntityComboBoxItem> = (
|
||||
search,
|
||||
filteredItems
|
||||
) => {
|
||||
// If there is exact match for entity id, put it first
|
||||
const index = filteredItems.findIndex(
|
||||
(item) => item.stateObj?.entity_id === search
|
||||
);
|
||||
if (index === -1) {
|
||||
return filteredItems;
|
||||
}
|
||||
|
||||
const [exactMatch] = filteredItems.splice(index, 1);
|
||||
filteredItems.unshift(exactMatch);
|
||||
return filteredItems;
|
||||
@@ -422,43 +395,46 @@ export class HaEntityPicker extends LitElement {
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
this._openPicker();
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private _openPicker = () => {
|
||||
if (this.disabled) return;
|
||||
this._pickerOpen = true;
|
||||
};
|
||||
|
||||
private _handlePickerClosed = () => {
|
||||
if (this._pendingValue !== undefined) {
|
||||
const pending = this._pendingValue;
|
||||
this._pendingValue = undefined;
|
||||
this._setValue(pending);
|
||||
}
|
||||
this._pickerOpen = false;
|
||||
this._searchList?.reset();
|
||||
};
|
||||
|
||||
private _handleItemSelected = (
|
||||
ev: HASSDomEvent<{ id: string; index: number; newTab?: boolean }>
|
||||
) => {
|
||||
private _valueChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.id;
|
||||
if (!isValidEntityId(value) && !this._findExtraOption(value)) {
|
||||
this._pickerOpen = false;
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (!value) {
|
||||
this._setValue(undefined);
|
||||
return;
|
||||
}
|
||||
this._pendingValue = value;
|
||||
this._pickerOpen = false;
|
||||
};
|
||||
|
||||
private _clear() {
|
||||
this._setValue(undefined);
|
||||
if (value.startsWith(CREATE_ID)) {
|
||||
const domain = value.substring(CREATE_ID.length);
|
||||
|
||||
showHelperDetailDialog(this, {
|
||||
domain,
|
||||
dialogClosedCallback: (item) => {
|
||||
if (item.entityId) {
|
||||
if (this.hass.states[item.entityId]) {
|
||||
this._setValue(item.entityId);
|
||||
} else {
|
||||
this._pendingEntityId = item.entityId;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidEntityId(value) && !this._findExtraOption(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setValue(value);
|
||||
}
|
||||
|
||||
private _setValue(value: string | undefined) {
|
||||
this.value = value;
|
||||
|
||||
fireEvent(this, "value-changed", { value });
|
||||
fireEvent(this, "change");
|
||||
}
|
||||
@@ -467,18 +443,6 @@ export class HaEntityPicker extends LitElement {
|
||||
this.hass.localize("ui.components.entity.entity-picker.no_match", {
|
||||
term: html`<b>‘${search}’</b>`,
|
||||
});
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.picker {
|
||||
position: relative;
|
||||
}
|
||||
ha-picker-field {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiMagnify, mdiMinusBoxOutline } from "@mdi/js";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
type CSSResultGroup,
|
||||
type TemplateResult,
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
} from "../common/dom/fire_event";
|
||||
import { loadVirtualizer } from "../resources/virtualizer";
|
||||
import "./ha-combo-box-item";
|
||||
import {
|
||||
DEFAULT_ROW_RENDERER_CONTENT,
|
||||
type PickerComboBoxItem,
|
||||
} from "./ha-picker-combo-box";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
const EMPTY_ROW_ID = "___empty___";
|
||||
|
||||
export interface PickerActionContext {
|
||||
host: HTMLElement;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/** Items with `onSelect` are action rows: the callback fires instead of `item-selected`. */
|
||||
export interface PickerListItem extends PickerComboBoxItem {
|
||||
onSelect?: (ctx: PickerActionContext) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type PickerListEntry = PickerListItem | string;
|
||||
|
||||
interface PickerListRowElement extends HTMLDivElement {
|
||||
index: number;
|
||||
item: PickerListItem;
|
||||
}
|
||||
|
||||
const DEFAULT_ROW: RenderItemFunction<PickerListItem> = (item) =>
|
||||
html`<ha-combo-box-item type="button" compact>
|
||||
${DEFAULT_ROW_RENDERER_CONTENT(item)}
|
||||
</ha-combo-box-item>`;
|
||||
|
||||
/**
|
||||
* Headless virtualized list for picker UIs. Receives pre-filtered `items`,
|
||||
* renders rows via `rowRenderer`. String entries are section titles.
|
||||
*/
|
||||
@customElement("ha-picker-list")
|
||||
export class HaPickerList extends LitElement {
|
||||
@property({ attribute: false }) public items: PickerListEntry[] = [];
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public rowRenderer?: RenderItemFunction<PickerListItem>;
|
||||
|
||||
@property({ attribute: "empty-label" }) public emptyLabel?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public notFoundLabel?:
|
||||
| string
|
||||
| TemplateResult
|
||||
| ((search: string) => string | TemplateResult);
|
||||
|
||||
/** Current search string. Picks between empty/notFound placeholders; filtering is the consumer's job. */
|
||||
@property({ attribute: "current-search" }) public currentSearch = "";
|
||||
|
||||
@state() private _highlightedIndex = -1;
|
||||
|
||||
@state() private _valuePinned = true;
|
||||
|
||||
@query("lit-virtualizer") private _virtualizer?: LitVirtualizer;
|
||||
|
||||
private _unsubscribeKeys?: () => void;
|
||||
|
||||
public willUpdate() {
|
||||
if (!this.hasUpdated) {
|
||||
loadVirtualizer();
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this._registerKeys();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeKeys?.();
|
||||
}
|
||||
|
||||
public selectNext = (ev?: KeyboardEvent) => this._next(ev);
|
||||
|
||||
public selectPrev = (ev?: KeyboardEvent) => this._prev(ev);
|
||||
|
||||
public selectFirst = (ev?: KeyboardEvent) => this._first(ev);
|
||||
|
||||
public selectLast = (ev?: KeyboardEvent) => this._last(ev);
|
||||
|
||||
public commitHighlighted = (newTab = false) =>
|
||||
this._commitAt(this._highlightedIndex, newTab);
|
||||
|
||||
protected render() {
|
||||
const items = this.items.length
|
||||
? this.items
|
||||
: [{ id: EMPTY_ROW_ID, primary: "" } as PickerListItem];
|
||||
return html`
|
||||
<lit-virtualizer
|
||||
.keyFunction=${this._keyFunction}
|
||||
tabindex="0"
|
||||
scroller
|
||||
.items=${items}
|
||||
.renderItem=${this._renderEntry}
|
||||
.layout=${this.value && this._valuePinned
|
||||
? {
|
||||
pin: {
|
||||
index: this._initialPinIndex(),
|
||||
block: "center",
|
||||
},
|
||||
}
|
||||
: undefined}
|
||||
@unpinned=${this._handleUnpinned}
|
||||
@focus=${this._focusList}
|
||||
@blur=${this._resetHighlight}
|
||||
></lit-virtualizer>
|
||||
`;
|
||||
}
|
||||
|
||||
private _keyFunction = (item: PickerListEntry) =>
|
||||
typeof item === "string" ? item : item.id;
|
||||
|
||||
private _renderEntry: RenderItemFunction<PickerListEntry> = (item, index) => {
|
||||
if (typeof item === "string") {
|
||||
return html`<div class="title">${item}</div>`;
|
||||
}
|
||||
if (item.id === EMPTY_ROW_ID) {
|
||||
return this._renderEmptyRow();
|
||||
}
|
||||
const renderer = this.rowRenderer ?? DEFAULT_ROW;
|
||||
return html`<div
|
||||
id=${`list-item-${index}`}
|
||||
class="row ${this.value === item.id ? "current-value" : ""}"
|
||||
.item=${item}
|
||||
.index=${index}
|
||||
@click=${this._handleClick}
|
||||
>
|
||||
${renderer(item as PickerListItem, index)}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
private _renderEmptyRow() {
|
||||
const search = this.currentSearch;
|
||||
const message = search
|
||||
? typeof this.notFoundLabel === "function"
|
||||
? this.notFoundLabel(search)
|
||||
: (this.notFoundLabel ?? "No matching items found")
|
||||
: (this.emptyLabel ?? "No items available");
|
||||
return html`
|
||||
<div class="row empty">
|
||||
<ha-combo-box-item type="text" compact>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${search ? mdiMagnify : mdiMinusBoxOutline}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">${message}</span>
|
||||
</ha-combo-box-item>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleClick = (
|
||||
ev: MouseEvent & HASSDomCurrentTargetEvent<PickerListRowElement>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const row = ev.currentTarget;
|
||||
if (row.item.disabled) return;
|
||||
this._dispatchSelection(row.item, row.index, ev.ctrlKey || ev.metaKey);
|
||||
};
|
||||
|
||||
private _dispatchSelection(
|
||||
item: PickerListItem,
|
||||
index: number,
|
||||
newTab: boolean
|
||||
) {
|
||||
if (item.onSelect) {
|
||||
void item.onSelect({
|
||||
host: this,
|
||||
close: () => fireEvent(this, "picker-close-request"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "item-selected", { id: item.id, index, newTab });
|
||||
}
|
||||
|
||||
private _handleUnpinned = () => {
|
||||
this._valuePinned = false;
|
||||
};
|
||||
|
||||
private _registerKeys() {
|
||||
this._unsubscribeKeys = tinykeys(this, {
|
||||
ArrowDown: this._next,
|
||||
ArrowUp: this._prev,
|
||||
Home: this._first,
|
||||
End: this._last,
|
||||
Enter: this._commitHighlight,
|
||||
"$mod+Enter": this._commitHighlightNewTab,
|
||||
});
|
||||
}
|
||||
|
||||
private _focusList = () => {
|
||||
if (this._highlightedIndex === -1) this._initializeHighlight();
|
||||
};
|
||||
|
||||
private _resetHighlight = () => {
|
||||
this._virtualizer?.querySelector(".selected")?.classList.remove("selected");
|
||||
this._highlightedIndex = -1;
|
||||
};
|
||||
|
||||
private _initializeHighlight() {
|
||||
if (!this._virtualizer) return;
|
||||
const items = this._virtualizer.items as PickerListEntry[];
|
||||
if (this.value) {
|
||||
const i = items.findIndex(
|
||||
(item) => typeof item !== "string" && item.id === this.value
|
||||
);
|
||||
if (i !== -1) {
|
||||
this._highlightedIndex = i;
|
||||
this._scrollToHighlight();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._first();
|
||||
}
|
||||
|
||||
private _initialPinIndex(): number {
|
||||
if (!this.value) return 0;
|
||||
const i = this.items.findIndex(
|
||||
(item) => typeof item !== "string" && item.id === this.value
|
||||
);
|
||||
return i === -1 ? 0 : i;
|
||||
}
|
||||
|
||||
private _isPickable(item: PickerListEntry | undefined): boolean {
|
||||
return !!item && typeof item !== "string" && item.id !== EMPTY_ROW_ID;
|
||||
}
|
||||
|
||||
private _step(direction: 1 | -1) {
|
||||
if (!this._virtualizer) return;
|
||||
const items = this._virtualizer.items as PickerListEntry[];
|
||||
if (!items.length) return;
|
||||
let i = this._highlightedIndex + direction;
|
||||
const guard = items.length;
|
||||
let n = 0;
|
||||
while (n++ < guard && i >= 0 && i < items.length) {
|
||||
if (this._isPickable(items[i])) {
|
||||
this._highlightedIndex = i;
|
||||
this._scrollToHighlight();
|
||||
return;
|
||||
}
|
||||
i += direction;
|
||||
}
|
||||
}
|
||||
|
||||
private _next = (ev?: KeyboardEvent) => {
|
||||
ev?.preventDefault();
|
||||
if (this._highlightedIndex === -1) {
|
||||
this._initializeHighlight();
|
||||
return;
|
||||
}
|
||||
this._step(1);
|
||||
};
|
||||
|
||||
private _prev = (ev?: KeyboardEvent) => {
|
||||
ev?.preventDefault();
|
||||
if (this._highlightedIndex === -1) {
|
||||
this._initializeHighlight();
|
||||
return;
|
||||
}
|
||||
this._step(-1);
|
||||
};
|
||||
|
||||
private _first = (ev?: KeyboardEvent) => {
|
||||
ev?.preventDefault();
|
||||
this._jumpTo(0, 1);
|
||||
};
|
||||
|
||||
private _last = (ev?: KeyboardEvent) => {
|
||||
ev?.preventDefault();
|
||||
if (!this._virtualizer) return;
|
||||
this._jumpTo(this._virtualizer.items.length - 1, -1);
|
||||
};
|
||||
|
||||
private _jumpTo(start: number, direction: 1 | -1) {
|
||||
if (!this._virtualizer) return;
|
||||
const items = this._virtualizer.items as PickerListEntry[];
|
||||
for (let i = start; i >= 0 && i < items.length; i += direction) {
|
||||
if (this._isPickable(items[i])) {
|
||||
this._highlightedIndex = i;
|
||||
this._scrollToHighlight();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _commitHighlight = (ev: KeyboardEvent) => {
|
||||
this._commitAt(this._highlightedIndex, ev.ctrlKey || ev.metaKey);
|
||||
};
|
||||
|
||||
private _commitHighlightNewTab = () => {
|
||||
this._commitAt(this._highlightedIndex, true);
|
||||
};
|
||||
|
||||
private _commitAt(index: number, newTab: boolean) {
|
||||
if (index === -1 || !this._virtualizer) return;
|
||||
const item = this._virtualizer.items[index] as PickerListEntry;
|
||||
if (typeof item === "string" || item.disabled) return;
|
||||
this._dispatchSelection(item, index, newTab);
|
||||
}
|
||||
|
||||
private _scrollToHighlight() {
|
||||
this._virtualizer?.querySelector(".selected")?.classList.remove("selected");
|
||||
this._virtualizer
|
||||
?.element(this._highlightedIndex)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
requestAnimationFrame(() => {
|
||||
this._virtualizer
|
||||
?.querySelector(`#list-item-${this._highlightedIndex}`)
|
||||
?.classList.add("selected");
|
||||
});
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
lit-virtualizer {
|
||||
flex: 1;
|
||||
outline: none;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
min-height: 36px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row.empty {
|
||||
cursor: default;
|
||||
}
|
||||
.row ha-combo-box-item {
|
||||
width: 100%;
|
||||
}
|
||||
.row.current-value {
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
}
|
||||
.row.selected {
|
||||
background-color: var(--ha-color-fill-neutral-quiet-hover);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.row.selected {
|
||||
background-color: var(--ha-color-fill-neutral-normal-hover);
|
||||
}
|
||||
}
|
||||
.title {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
padding: var(--ha-space-1) var(--ha-space-4);
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
color: var(--secondary-text-color);
|
||||
min-height: var(--ha-space-6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-list": HaPickerList;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"item-selected": { id: string; index: number; newTab?: boolean };
|
||||
"picker-close-request": undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
nothing,
|
||||
type CSSResultGroup,
|
||||
type PropertyValues,
|
||||
} from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import "./ha-bottom-sheet";
|
||||
|
||||
/**
|
||||
* Responsive popover for picker UIs: anchored `wa-popover` on desktop,
|
||||
* `ha-bottom-sheet` on narrow viewports. Anchor drives the width.
|
||||
*/
|
||||
@customElement("ha-picker-popover")
|
||||
export class HaPickerPopover extends LitElement {
|
||||
@property({ type: Boolean, reflect: true }) public open = false;
|
||||
|
||||
@property({ attribute: false }) public anchor?: HTMLElement | null;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public placement:
|
||||
| "bottom"
|
||||
| "top"
|
||||
| "left"
|
||||
| "right"
|
||||
| "top-start"
|
||||
| "top-end"
|
||||
| "right-start"
|
||||
| "right-end"
|
||||
| "bottom-start"
|
||||
| "bottom-end"
|
||||
| "left-start"
|
||||
| "left-end" = "bottom-start";
|
||||
|
||||
@state() private _bodyWidth = 0;
|
||||
|
||||
@state() private _narrow = false;
|
||||
|
||||
@state() private _openedNarrow = false;
|
||||
|
||||
// Kept true across the hide animation so wa-popover can finish its
|
||||
// transition; cleared on wa-after-hide.
|
||||
@state() private _mounted = false;
|
||||
|
||||
// Flipped one rAF after mount so wa-popover sees a false→true edge
|
||||
// and runs the show flow.
|
||||
@state() private _showing = false;
|
||||
|
||||
// Defers slot projection until after the show animation so a
|
||||
// virtualized list inside isn't measured at the scaled size.
|
||||
@state() private _contentReady = false;
|
||||
|
||||
private _openFrame?: number;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("open")) {
|
||||
if (this.open) {
|
||||
this._measureAnchor();
|
||||
this._openedNarrow = this._narrow;
|
||||
this._mounted = true;
|
||||
} else {
|
||||
this._showing = false;
|
||||
}
|
||||
}
|
||||
if (changedProperties.has("anchor") && this.open) {
|
||||
this._measureAnchor();
|
||||
}
|
||||
}
|
||||
|
||||
private _measureAnchor() {
|
||||
if (this.anchor) {
|
||||
this._bodyWidth = this.anchor.offsetWidth;
|
||||
}
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
if (this.open && this._mounted && !this._showing) {
|
||||
this._scheduleShow();
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleShow() {
|
||||
if (this._openFrame !== undefined) return;
|
||||
this._openFrame = requestAnimationFrame(() => {
|
||||
this._openFrame = undefined;
|
||||
if (this.open && this._mounted) {
|
||||
this._showing = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _cancelShow() {
|
||||
if (this._openFrame === undefined) return;
|
||||
cancelAnimationFrame(this._openFrame);
|
||||
this._openFrame = undefined;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._handleResize();
|
||||
window.addEventListener("resize", this._handleResize);
|
||||
this.addEventListener("picker-close-request", this._handleCloseRequest);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this._handleResize);
|
||||
this.removeEventListener("picker-close-request", this._handleCloseRequest);
|
||||
this._cancelShow();
|
||||
}
|
||||
|
||||
private _handleCloseRequest = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
this._showing = false;
|
||||
};
|
||||
|
||||
private _handleResize = () => {
|
||||
this._narrow =
|
||||
window.matchMedia("(max-width: 870px)").matches ||
|
||||
window.matchMedia("(max-height: 500px)").matches;
|
||||
|
||||
if (!this._openedNarrow && this.open) {
|
||||
this._measureAnchor();
|
||||
}
|
||||
};
|
||||
|
||||
private _handleShown = () => {
|
||||
this._contentReady = true;
|
||||
fireEvent(this, "opened");
|
||||
// Native [autofocus] fires before the popover is visible; refocus
|
||||
// a slotted search component after projection.
|
||||
requestAnimationFrame(() => {
|
||||
const focusable = this.querySelector(
|
||||
"ha-picker-search-list, ha-picker-search"
|
||||
) as (HTMLElement & { focus?: () => void }) | null;
|
||||
focusable?.focus?.();
|
||||
});
|
||||
};
|
||||
|
||||
private _handleHidden = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
this._mounted = false;
|
||||
this._showing = false;
|
||||
this._contentReady = false;
|
||||
fireEvent(this, "closed");
|
||||
};
|
||||
|
||||
protected render() {
|
||||
if (!this._mounted) return nothing;
|
||||
|
||||
if (this._openedNarrow) {
|
||||
return html`
|
||||
<ha-bottom-sheet
|
||||
flexcontent
|
||||
.open=${this._showing}
|
||||
@wa-after-show=${this._handleShown}
|
||||
@closed=${this._handleHidden}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label=${this.label ?? ""}
|
||||
>
|
||||
<div class="content">
|
||||
${this._contentReady ? html`<slot></slot>` : nothing}
|
||||
</div>
|
||||
</ha-bottom-sheet>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<wa-popover
|
||||
.open=${this._showing}
|
||||
style=${styleMap({ "--body-width": `${this._bodyWidth}px` })}
|
||||
without-arrow
|
||||
distance="-4"
|
||||
.placement=${this.placement}
|
||||
.anchor=${this.anchor ?? null}
|
||||
auto-size="vertical"
|
||||
auto-size-padding="16"
|
||||
@wa-after-show=${this._handleShown}
|
||||
@wa-after-hide=${this._handleHidden}
|
||||
trap-focus
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label=${this.label ?? ""}
|
||||
>
|
||||
<div class="content">
|
||||
${this._contentReady ? html`<slot></slot>` : nothing}
|
||||
</div>
|
||||
</wa-popover>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
/* Disable wa-popover's built-in 25rem cap. */
|
||||
--max-width: none;
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
background: none;
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
width: var(--ha-picker-popover-width, max(var(--body-width), 250px));
|
||||
max-width: var(
|
||||
--ha-picker-popover-max-width,
|
||||
var(--ha-picker-popover-width, max(var(--body-width), 250px))
|
||||
);
|
||||
max-height: 500px;
|
||||
height: 70vh;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-height: 1000px) {
|
||||
wa-popover::part(body) {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
ha-bottom-sheet {
|
||||
--ha-bottom-sheet-height: 90vh;
|
||||
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
|
||||
--ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
|
||||
--ha-bottom-sheet-max-width: 600px;
|
||||
--ha-bottom-sheet-padding: 0;
|
||||
--ha-bottom-sheet-surface-background: var(--card-background-color);
|
||||
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
|
||||
--ha-bottom-sheet-content-padding: 0 var(--safe-area-inset-right)
|
||||
var(--safe-area-inset-bottom) var(--safe-area-inset-left);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-popover": HaPickerPopover;
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import Fuse from "fuse.js";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
type CSSResultGroup,
|
||||
type TemplateResult,
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import {
|
||||
multiTermSortedSearch,
|
||||
type FuseWeightedKey,
|
||||
} from "../resources/fuseMultiTerm";
|
||||
import { DEFAULT_SEARCH_KEYS } from "./ha-picker-combo-box";
|
||||
import type { HaPickerList, PickerListItem } from "./ha-picker-list";
|
||||
import "./ha-picker-list";
|
||||
import "./ha-picker-search";
|
||||
import type { HaPickerSearch } from "./ha-picker-search";
|
||||
|
||||
export type PickerSearchFn<T extends PickerListItem = PickerListItem> = (
|
||||
search: string,
|
||||
filtered: T[],
|
||||
all: T[]
|
||||
) => T[];
|
||||
|
||||
/**
|
||||
* Search input + virtualized list with built-in Fuse.js filtering.
|
||||
* For custom filtering pipelines, compose `ha-picker-search` and
|
||||
* `ha-picker-list` directly instead.
|
||||
*/
|
||||
@customElement("ha-picker-search-list")
|
||||
export class HaPickerSearchList<
|
||||
T extends PickerListItem = PickerListItem,
|
||||
> extends LitElement {
|
||||
@property({ attribute: false }) public items: T[] = [];
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property({ attribute: false }) public searchKeys?: FuseWeightedKey[];
|
||||
|
||||
@property({ attribute: false }) public searchFn?: PickerSearchFn<T>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public rowRenderer?: RenderItemFunction<T>;
|
||||
|
||||
@property({ attribute: false }) public actions?: PickerListItem[];
|
||||
|
||||
@property({ attribute: "search-placeholder" })
|
||||
public searchPlaceholder?: string;
|
||||
|
||||
@property({ attribute: "empty-label" }) public emptyLabel?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public notFoundLabel?:
|
||||
| string
|
||||
| TemplateResult
|
||||
| ((search: string) => string | TemplateResult);
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@state() private _search = "";
|
||||
|
||||
@query("ha-picker-search") private _searchEl?: HaPickerSearch;
|
||||
|
||||
@query("ha-picker-list") private _listEl?: HaPickerList;
|
||||
|
||||
public focus() {
|
||||
this._searchEl?.focus();
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this._search = "";
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const displayItems = this._computeDisplayItems(
|
||||
this.items,
|
||||
this._search,
|
||||
this.searchKeys,
|
||||
this.searchFn,
|
||||
this.actions
|
||||
);
|
||||
return html`
|
||||
<ha-picker-search
|
||||
?autofocus=${this.autofocus}
|
||||
.value=${this._search}
|
||||
.placeholder=${this.searchPlaceholder ?? ""}
|
||||
@search-changed=${this._handleSearchChanged}
|
||||
@keydown=${this._handleSearchKeydown}
|
||||
></ha-picker-search>
|
||||
<ha-picker-list
|
||||
.items=${displayItems}
|
||||
.value=${this.value}
|
||||
.rowRenderer=${this.rowRenderer as RenderItemFunction<PickerListItem>}
|
||||
.currentSearch=${this._search}
|
||||
.notFoundLabel=${this.notFoundLabel}
|
||||
.emptyLabel=${this.emptyLabel}
|
||||
></ha-picker-list>
|
||||
`;
|
||||
}
|
||||
|
||||
// Forward nav keys from search input to list (focus stays in search).
|
||||
private _handleSearchKeydown = (ev: KeyboardEvent) => {
|
||||
const list = this._listEl;
|
||||
if (!list) return;
|
||||
switch (ev.key) {
|
||||
case "ArrowDown":
|
||||
ev.preventDefault();
|
||||
list.selectNext(ev);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
ev.preventDefault();
|
||||
list.selectPrev(ev);
|
||||
break;
|
||||
case "Home":
|
||||
ev.preventDefault();
|
||||
list.selectFirst(ev);
|
||||
break;
|
||||
case "End":
|
||||
ev.preventDefault();
|
||||
list.selectLast(ev);
|
||||
break;
|
||||
case "Enter":
|
||||
ev.preventDefault();
|
||||
list.commitHighlighted(ev.ctrlKey || ev.metaKey);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
private _fuseIndex = memoizeOne(
|
||||
(items: T[], searchKeys?: FuseWeightedKey[]) =>
|
||||
Fuse.createIndex(searchKeys ?? DEFAULT_SEARCH_KEYS, items)
|
||||
);
|
||||
|
||||
private _computeDisplayItems = memoizeOne(
|
||||
(
|
||||
items: T[],
|
||||
search: string,
|
||||
searchKeys: FuseWeightedKey[] | undefined,
|
||||
searchFn: PickerSearchFn<T> | undefined,
|
||||
actions: PickerListItem[] | undefined
|
||||
): PickerListItem[] => {
|
||||
let filtered = items;
|
||||
if (search) {
|
||||
const keys = searchKeys ?? DEFAULT_SEARCH_KEYS;
|
||||
const index = this._fuseIndex(items, keys);
|
||||
filtered = multiTermSortedSearch<T>(
|
||||
items,
|
||||
search,
|
||||
keys,
|
||||
(item) => item.id,
|
||||
index
|
||||
);
|
||||
if (searchFn) {
|
||||
filtered = searchFn(search, filtered, items);
|
||||
}
|
||||
}
|
||||
if (actions?.length) {
|
||||
return [...filtered, ...actions];
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
);
|
||||
|
||||
private _handleSearchChanged = (ev: HASSDomEvent<{ value: string }>) => {
|
||||
this._search = ev.detail.value;
|
||||
};
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-search-list": HaPickerSearchList;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { css, html, LitElement, type CSSResultGroup } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
/** Search input for picker UIs; emits `search-changed`. */
|
||||
@customElement("ha-picker-search")
|
||||
export class HaPickerSearch extends LitElement {
|
||||
@property() public value = "";
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@query("ha-input-search") private _input?: HaInputSearch;
|
||||
|
||||
public focus() {
|
||||
// ha-input doesn't expose focus(); reach the wa-input it wraps.
|
||||
this._input?.shadowRoot?.querySelector<HTMLElement>("wa-input")?.focus();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this.value}
|
||||
.placeholder=${this.placeholder ?? ""}
|
||||
?autofocus=${this.autofocus}
|
||||
@input=${this._handleInput}
|
||||
></ha-input-search>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleInput = (ev: Event) => {
|
||||
const value = (ev.target as HaInputSearch).value ?? "";
|
||||
this.value = value;
|
||||
fireEvent(this, "search-changed", { value });
|
||||
};
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
ha-input-search {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-search": HaPickerSearch;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"search-changed": { value: string };
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { isTouch } from "../util/is_touch";
|
||||
import "./chips/ha-chip-set";
|
||||
import "./chips/ha-filter-chip";
|
||||
|
||||
export interface PickerSection {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type PickerSectionDef = PickerSection | "separator";
|
||||
|
||||
/** Section filter chip bar; emits `section-changed`. Toggling the active chip clears the filter. */
|
||||
@customElement("ha-picker-section-chips")
|
||||
export class HaPickerSectionChips extends LitElement {
|
||||
@property({ attribute: false }) public sections?: PickerSectionDef[];
|
||||
|
||||
@property() public selected?: string;
|
||||
|
||||
protected render() {
|
||||
if (!this.sections?.length) return nothing;
|
||||
return html`
|
||||
<ha-chip-set>
|
||||
${this.sections.map((section) =>
|
||||
section === "separator"
|
||||
? html`<div class="separator"></div>`
|
||||
: html`<ha-filter-chip
|
||||
@mousedown=${isTouch ? undefined : this._preventBlur}
|
||||
@click=${this._handleClick}
|
||||
data-section-id=${section.id}
|
||||
.selected=${this.selected === section.id}
|
||||
.label=${section.label}
|
||||
></ha-filter-chip>`
|
||||
)}
|
||||
</ha-chip-set>
|
||||
`;
|
||||
}
|
||||
|
||||
private _preventBlur = (ev: Event) => {
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
private _handleClick = (ev: Event) => {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.sectionId;
|
||||
if (!id) return;
|
||||
const next = this.selected === id ? undefined : id;
|
||||
this.selected = next;
|
||||
fireEvent(this, "section-changed", { section: next });
|
||||
};
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
ha-chip-set {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--ha-space-2);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
/* Room for the chip's focus ring (clipped by overflow-y: hidden). */
|
||||
padding: var(--ha-space-1) 0;
|
||||
margin: calc(-1 * var(--ha-space-1)) 0;
|
||||
}
|
||||
ha-chip-set::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
ha-chip-set ha-filter-chip {
|
||||
flex-shrink: 0;
|
||||
--md-filter-chip-selected-container-color: var(
|
||||
--ha-color-fill-primary-normal-hover
|
||||
);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.separator {
|
||||
height: var(--ha-space-8);
|
||||
width: 0;
|
||||
border: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-section-chips": HaPickerSectionChips;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"section-changed": { section: string | undefined };
|
||||
}
|
||||
}
|
||||
+132
-145
@@ -1,5 +1,6 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiPlaylistPlus, mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import { mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import Fuse from "fuse.js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
@@ -52,22 +53,19 @@ import {
|
||||
multiTermSortedSearch,
|
||||
type FuseWeightedKey,
|
||||
} from "../resources/fuseMultiTerm";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import { brandsUrl } from "../util/brands-url";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||
import "./ha-button";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import "./ha-picker-list";
|
||||
import type { PickerListEntry, PickerListItem } from "./ha-picker-list";
|
||||
import "./ha-picker-popover";
|
||||
import "./ha-picker-search";
|
||||
import "./ha-picker-section-chips";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tree-indicator";
|
||||
import "./target-picker/ha-target-picker-item-group";
|
||||
import "./target-picker/ha-target-picker-value-chip";
|
||||
|
||||
const SEPARATOR = "________";
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
const isTargetType = (value: string): value is TargetType =>
|
||||
value === "entity" ||
|
||||
value === "device" ||
|
||||
@@ -124,15 +122,11 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
|
||||
@state() private _pickerOpen = false;
|
||||
|
||||
@state() private _search = "";
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
private _labelRegistry!: LabelRegistryEntry[];
|
||||
|
||||
@query(".add-target-wrapper") private _addTargetWrapper?: HTMLElement;
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
private _newTarget?: TargetItem;
|
||||
|
||||
@@ -418,92 +412,56 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
},
|
||||
];
|
||||
|
||||
const items = this._buildListEntries(
|
||||
this._search,
|
||||
this._selectedSection,
|
||||
this.createDomains
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="add-target-wrapper">
|
||||
<ha-button
|
||||
class="add-target-button"
|
||||
size="small"
|
||||
appearance="filled"
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._openPicker}
|
||||
.autofocus=${this.autofocus}
|
||||
.helper=${this.helper}
|
||||
.sections=${sections}
|
||||
.notFoundLabel=${this._noTargetFoundLabel}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
"ui.components.target-picker.no_targets"
|
||||
)}
|
||||
.sectionTitleFunction=${this._sectionTitleFunction}
|
||||
.selectedSection=${this._selectedSection}
|
||||
.popoverAnchor=${this._replaceTargetAnchor}
|
||||
.rowRenderer=${this._renderRow}
|
||||
.getItems=${this._getItems}
|
||||
@value-changed=${this._targetPicked}
|
||||
@picker-closed=${this._handlePickerClosed}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.components.target-picker.add_target"
|
||||
)}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize("ui.components.target-picker.add_target")}
|
||||
</ha-button>
|
||||
<ha-picker-popover
|
||||
.open=${this._pickerOpen}
|
||||
.anchor=${this._replaceTargetAnchor ?? this._addTargetWrapper}
|
||||
.label=${this.hass.localize("ui.components.target-picker.add_target")}
|
||||
@closed=${this._handlePickerClosed}
|
||||
>
|
||||
<div class="picker-body">
|
||||
<ha-picker-search
|
||||
autofocus
|
||||
.value=${this._search}
|
||||
.placeholder=${this.hass.localize("ui.common.search")}
|
||||
@search-changed=${this._handleSearchChanged}
|
||||
></ha-picker-search>
|
||||
<ha-picker-section-chips
|
||||
.sections=${sections}
|
||||
.selected=${this._selectedSection}
|
||||
@section-changed=${this._handleSectionChanged}
|
||||
></ha-picker-section-chips>
|
||||
<ha-picker-list
|
||||
.items=${items}
|
||||
.rowRenderer=${this._renderRow}
|
||||
.currentSearch=${this._search}
|
||||
.notFoundLabel=${this._noTargetFoundLabel}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
"ui.components.target-picker.no_targets"
|
||||
)}
|
||||
@item-selected=${this._handleItemSelected}
|
||||
></ha-picker-list>
|
||||
</div>
|
||||
</ha-picker-popover>
|
||||
</ha-generic-picker>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _openPicker = () => {
|
||||
if (this.disabled) return;
|
||||
this._pickerOpen = true;
|
||||
};
|
||||
|
||||
private _handleSearchChanged = (ev: HASSDomEvent<{ value: string }>) => {
|
||||
this._search = ev.detail.value;
|
||||
};
|
||||
|
||||
private _handleSectionChanged = (
|
||||
ev: HASSDomEvent<{ section: string | undefined }>
|
||||
) => {
|
||||
this._selectedSection = ev.detail.section as
|
||||
| TargetTypeFloorless
|
||||
| undefined;
|
||||
};
|
||||
|
||||
private _handleItemSelected = (
|
||||
ev: HASSDomEvent<{ id: string; index: number; newTab?: boolean }>
|
||||
) => {
|
||||
private _targetPicked(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.id;
|
||||
const value = ev.detail.value;
|
||||
if (value.startsWith(CREATE_ID)) {
|
||||
this._createNewDomainElement(value.substring(CREATE_ID.length));
|
||||
return;
|
||||
}
|
||||
|
||||
const [rawType, id] = value.split(SEPARATOR);
|
||||
|
||||
if (!id || !isTargetType(rawType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._pickerOpen = false;
|
||||
this._pendingPick = { type: rawType, id };
|
||||
};
|
||||
if (this._replaceTarget) {
|
||||
this._replaceTargetItem(this._replaceTarget, { type: rawType, id });
|
||||
return;
|
||||
}
|
||||
|
||||
// Commit fires on @closed (after the hide animation) to avoid flicker.
|
||||
private _pendingPick?: TargetItem;
|
||||
this._addTarget(id, rawType);
|
||||
}
|
||||
|
||||
private _replaceTargetItem(currentTarget: TargetItem, newTarget: TargetItem) {
|
||||
const value = this._replaceTargetInValue(
|
||||
@@ -776,27 +734,18 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return;
|
||||
}
|
||||
this._replaceTarget = { type, id: ev.detail.id };
|
||||
this._pickerOpen = true;
|
||||
this._picker?.open(undefined, {
|
||||
selectedValue: `${type}${SEPARATOR}${ev.detail.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
private _handlePickerClosed = () => {
|
||||
if (this._pendingPick) {
|
||||
const pick = this._pendingPick;
|
||||
this._pendingPick = undefined;
|
||||
if (this._replaceTarget) {
|
||||
this._replaceTargetItem(this._replaceTarget, pick);
|
||||
} else {
|
||||
this._addTarget(pick.id, pick.type);
|
||||
}
|
||||
}
|
||||
this._pickerOpen = false;
|
||||
this._search = "";
|
||||
private _handlePickerClosed() {
|
||||
if (this._replaceTarget) {
|
||||
this._selectedSection = undefined;
|
||||
}
|
||||
this._replaceTarget = undefined;
|
||||
this._replaceTargetAnchor = undefined;
|
||||
};
|
||||
}
|
||||
|
||||
private _addItems(
|
||||
value: this["value"],
|
||||
@@ -833,12 +782,55 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _buildListEntries(
|
||||
searchString: string,
|
||||
section: TargetTypeFloorless | undefined,
|
||||
createDomains: this["createDomains"]
|
||||
): PickerListEntry[] {
|
||||
const items = this._getItemsMemoized(
|
||||
private _sectionTitleFunction = ({
|
||||
firstIndex,
|
||||
lastIndex,
|
||||
firstItem,
|
||||
secondItem,
|
||||
itemsCount,
|
||||
}: {
|
||||
firstIndex: number;
|
||||
lastIndex: number;
|
||||
firstItem: PickerComboBoxItem | string;
|
||||
secondItem: PickerComboBoxItem | string;
|
||||
itemsCount: number;
|
||||
}) => {
|
||||
if (
|
||||
firstItem === undefined ||
|
||||
secondItem === undefined ||
|
||||
typeof firstItem === "string" ||
|
||||
(typeof secondItem === "string" && secondItem !== "padding") ||
|
||||
(firstIndex === 0 && lastIndex === itemsCount - 1)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const type = getTargetComboBoxItemType(firstItem as PickerComboBoxItem);
|
||||
const translationType:
|
||||
| "areas"
|
||||
| "entities"
|
||||
| "devices"
|
||||
| "labels"
|
||||
| undefined =
|
||||
type === "area" || type === "floor"
|
||||
? "areas"
|
||||
: type === "entity"
|
||||
? "entities"
|
||||
: type && type !== "empty"
|
||||
? `${type}s`
|
||||
: undefined;
|
||||
|
||||
return translationType
|
||||
? this.hass.localize(
|
||||
`ui.components.target-picker.type.${translationType}`
|
||||
)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
private _getItems = (searchString: string, section: string) => {
|
||||
this._selectedSection = section as TargetTypeFloorless | undefined;
|
||||
|
||||
return this._getItemsMemoized(
|
||||
this.hass.localize,
|
||||
this.entityFilter,
|
||||
this.deviceFilter,
|
||||
@@ -848,37 +840,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._replaceTarget,
|
||||
searchString,
|
||||
this._configEntryLookup,
|
||||
section
|
||||
) as PickerListEntry[];
|
||||
|
||||
const actions = this._buildActionEntries(createDomains);
|
||||
return actions.length ? [...items, ...actions] : items;
|
||||
}
|
||||
|
||||
private _buildActionEntries = memoizeOne(
|
||||
(createDomains: this["createDomains"]): PickerListItem[] => {
|
||||
if (!createDomains?.length) return [];
|
||||
return createDomains.map((domain) => ({
|
||||
id: `__create-helper__${SEPARATOR}${domain}`,
|
||||
primary: this.hass.localize(
|
||||
"ui.components.entity.entity-picker.create_helper",
|
||||
{
|
||||
domain: isHelperDomain(domain)
|
||||
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
|
||||
: domainToName(this.hass.localize, domain),
|
||||
}
|
||||
),
|
||||
secondary: this.hass.localize(
|
||||
"ui.components.entity.entity-picker.new_entity"
|
||||
),
|
||||
icon_path: mdiPlus,
|
||||
onSelect: ({ close }) => {
|
||||
close();
|
||||
this._createNewDomainElement(domain);
|
||||
},
|
||||
}));
|
||||
}
|
||||
);
|
||||
this._selectedSection
|
||||
);
|
||||
};
|
||||
|
||||
private _getItemsMemoized = memoizeOne(
|
||||
(
|
||||
@@ -1119,6 +1083,36 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);
|
||||
|
||||
private _getCreateItems = memoizeOne(
|
||||
(createDomains: this["createDomains"]) => {
|
||||
if (!createDomains?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return createDomains.map((domain) => {
|
||||
const primary = this.hass.localize(
|
||||
"ui.components.entity.entity-picker.create_helper",
|
||||
{
|
||||
domain: isHelperDomain(domain)
|
||||
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
|
||||
: domainToName(this.hass.localize, domain),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: CREATE_ID + domain,
|
||||
primary: primary,
|
||||
secondary: this.hass.localize(
|
||||
"ui.components.entity.entity-picker.new_entity"
|
||||
),
|
||||
icon_path: mdiPlus,
|
||||
} satisfies EntityComboBoxItem;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
@@ -1262,21 +1256,14 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.add-target-wrapper {
|
||||
display: block;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.picker-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding-top: var(--ha-space-4);
|
||||
ha-generic-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.items {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createContext } from "@lit/context";
|
||||
import type {
|
||||
Connection,
|
||||
HassEntityAttributeBase,
|
||||
@@ -490,12 +491,12 @@ export const migrateAutomationTrigger = (
|
||||
|
||||
export const flattenTriggers = (
|
||||
triggers: undefined | Trigger | Trigger[]
|
||||
): Trigger[] => {
|
||||
): Exclude<Trigger, TriggerList>[] => {
|
||||
if (!triggers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const flatTriggers: Trigger[] = [];
|
||||
const flatTriggers: Exclude<Trigger, TriggerList>[] = [];
|
||||
|
||||
ensureArray(triggers).forEach((t) => {
|
||||
if ("triggers" in t) {
|
||||
@@ -614,6 +615,7 @@ export interface BaseSidebarConfig {
|
||||
|
||||
export interface TriggerSidebarConfig extends BaseSidebarConfig {
|
||||
save: (value: Trigger) => void;
|
||||
editId: () => void;
|
||||
rename: () => void;
|
||||
disable: () => void;
|
||||
duplicate: () => void;
|
||||
@@ -697,3 +699,7 @@ export interface ShowAutomationEditorParams {
|
||||
data?: Partial<AutomationConfig>;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
export const automationConfigContext = createContext<
|
||||
AutomationConfig | undefined
|
||||
>("automationConfig");
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
LegacyTrigger,
|
||||
Trigger,
|
||||
} from "./automation";
|
||||
import { flattenTriggers } from "./automation";
|
||||
import { getConditionDomain, getConditionObjectId } from "./condition";
|
||||
import type {
|
||||
DeviceCondition,
|
||||
@@ -107,6 +108,41 @@ const formatNumericLimitValue = (
|
||||
: value;
|
||||
};
|
||||
|
||||
export interface TriggerInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
triggerType: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const getTriggerInfos = (
|
||||
triggers: Trigger[] | undefined,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
): TriggerInfo[] => {
|
||||
if (!triggers) {
|
||||
return [];
|
||||
}
|
||||
const map = new Map<string, TriggerInfo>();
|
||||
for (const t of flattenTriggers(triggers)) {
|
||||
if (isTriggerList(t) || !t.id) {
|
||||
continue;
|
||||
}
|
||||
const existing = map.get(t.id);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
map.set(t.id, {
|
||||
id: t.id,
|
||||
label: describeTrigger(t, hass, entityRegistry),
|
||||
triggerType: t.trigger,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
export const describeTrigger = (
|
||||
trigger: Trigger,
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -2,16 +2,23 @@ import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
|
||||
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
|
||||
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { ConfigEntry } from "../config_entries";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../entity/entity";
|
||||
import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
EntityRegistryEntry,
|
||||
} from "../entity/entity_registry";
|
||||
import { domainToName } from "../integration";
|
||||
import {
|
||||
getDeviceEntityDisplayLookup,
|
||||
type DeviceEntityDisplayLookup,
|
||||
type DeviceRegistryEntry,
|
||||
} from "./device_registry";
|
||||
|
||||
export interface DevicePickerItem extends PickerComboBoxItem {
|
||||
@@ -19,6 +26,46 @@ export interface DevicePickerItem extends PickerComboBoxItem {
|
||||
domain_name?: string;
|
||||
}
|
||||
|
||||
export interface DeviceAreaLabel {
|
||||
areaName?: string;
|
||||
viaDeviceName?: string;
|
||||
viaDeviceAreaName?: string;
|
||||
}
|
||||
|
||||
export const computeDeviceAreaLabel = (
|
||||
device: DeviceRegistryEntry,
|
||||
areas: HomeAssistant["areas"],
|
||||
devices: HomeAssistant["devices"],
|
||||
states: HomeAssistant["states"],
|
||||
localize: LocalizeFunc,
|
||||
language: HomeAssistant["language"],
|
||||
translationMetadata: HomeAssistant["translationMetadata"],
|
||||
viaDeviceEntities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[]
|
||||
): DeviceAreaLabel => {
|
||||
const area = getDeviceArea(device, areas);
|
||||
|
||||
const viaDevice = device.via_device_id
|
||||
? devices[device.via_device_id]
|
||||
: undefined;
|
||||
const viaDeviceName = viaDevice
|
||||
? computeDeviceNameDisplay(viaDevice, localize, states, viaDeviceEntities)
|
||||
: undefined;
|
||||
const viaDeviceArea = viaDevice ? getDeviceArea(viaDevice, areas) : undefined;
|
||||
const viaDeviceAreaName = viaDeviceArea
|
||||
? computeAreaName(viaDeviceArea)
|
||||
: undefined;
|
||||
|
||||
const isRTL = computeRTL(language, translationMetadata.translations);
|
||||
|
||||
const areaName = area
|
||||
? computeAreaName(area)
|
||||
: viaDeviceAreaName
|
||||
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
|
||||
: viaDeviceName || undefined;
|
||||
|
||||
return { areaName, viaDeviceName, viaDeviceAreaName };
|
||||
};
|
||||
|
||||
export const deviceComboBoxKeys: FuseWeightedKey[] = [
|
||||
{
|
||||
name: "search_labels.deviceName",
|
||||
@@ -36,6 +83,14 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
|
||||
name: "search_labels.domain",
|
||||
weight: 4,
|
||||
},
|
||||
{
|
||||
name: "search_labels.viaDeviceName",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "search_labels.viaDeviceArea",
|
||||
weight: 3,
|
||||
},
|
||||
];
|
||||
|
||||
export const getDevices = (
|
||||
@@ -149,9 +204,19 @@ export const getDevices = (
|
||||
deviceEntityLookup[device.id]
|
||||
);
|
||||
|
||||
const area = getDeviceArea(device, hass.areas);
|
||||
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const { areaName, viaDeviceName, viaDeviceAreaName } =
|
||||
computeDeviceAreaLabel(
|
||||
device,
|
||||
hass.areas,
|
||||
hass.devices,
|
||||
hass.states,
|
||||
hass.localize,
|
||||
hass.language,
|
||||
hass.translationMetadata,
|
||||
device.via_device_id
|
||||
? deviceEntityLookup[device.via_device_id]
|
||||
: undefined
|
||||
);
|
||||
|
||||
const configEntry = device.primary_config_entry
|
||||
? configEntryLookup?.[device.primary_config_entry]
|
||||
@@ -174,6 +239,8 @@ export const getDevices = (
|
||||
areaName: areaName || null,
|
||||
domain: domain || null,
|
||||
domainName: domainName || null,
|
||||
viaDeviceName: viaDeviceName || null,
|
||||
viaDeviceArea: viaDeviceAreaName || null,
|
||||
},
|
||||
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
Trigger,
|
||||
TriggerList,
|
||||
} from "./automation";
|
||||
import { flattenTriggers } from "./automation";
|
||||
import type { Selector, TargetSelector } from "./selector";
|
||||
|
||||
export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
@@ -56,6 +57,49 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
export const isTriggerList = (trigger: Trigger): trigger is TriggerList =>
|
||||
"triggers" in trigger;
|
||||
|
||||
export const getTriggerIds = (triggers: Trigger[]): string[] =>
|
||||
flattenTriggers(triggers)
|
||||
.map((trigger) => trigger.id)
|
||||
.filter((id): id is string => !!id);
|
||||
|
||||
export const getNextNumericTriggerId = (triggers: Trigger[]): string => {
|
||||
let max = 0;
|
||||
for (const id of getTriggerIds(triggers)) {
|
||||
const num = Number(id);
|
||||
if (Number.isInteger(num) && num > max) {
|
||||
max = num;
|
||||
}
|
||||
}
|
||||
return String(max + 1);
|
||||
};
|
||||
|
||||
const computeUniqueId = (id: string, existing: Set<string>): string => {
|
||||
if (!existing.has(id)) {
|
||||
return id;
|
||||
}
|
||||
|
||||
// Split into a base and a trailing integer suffix so we can bump the
|
||||
// suffix on collision (e.g. "foo2" -> "foo3"); if there's no trailing
|
||||
// digit we start at 2 ("foo" -> "foo2").
|
||||
const match = id.match(/^(.*?)(\d+)$/);
|
||||
let base: string;
|
||||
let num: number;
|
||||
if (match) {
|
||||
base = match[1];
|
||||
num = Number(match[2]) + 1;
|
||||
} else {
|
||||
base = id;
|
||||
num = 2;
|
||||
}
|
||||
while (existing.has(`${base}${num}`)) {
|
||||
num++;
|
||||
}
|
||||
return `${base}${num}`;
|
||||
};
|
||||
|
||||
export const getUniqueTriggerId = (id: string, triggers: Trigger[]): string =>
|
||||
computeUniqueId(id, new Set(getTriggerIds(triggers)));
|
||||
|
||||
export interface TriggerDescription {
|
||||
target?: TargetSelector["target"];
|
||||
fields: Record<
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAlert,
|
||||
mdiAppleKeyboardCommand,
|
||||
mdiArrowDown,
|
||||
mdiArrowUp,
|
||||
@@ -25,7 +26,7 @@ import type {
|
||||
} from "home-assistant-js-websocket";
|
||||
import { dump } from "js-yaml";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -44,6 +45,7 @@ import type { HaAutomationRow } from "../../../../components/automation/ha-autom
|
||||
import "../../../../components/automation/ha-automation-row-event-chip";
|
||||
import "../../../../components/automation/ha-automation-row-live-test";
|
||||
import type { LiveTestState } from "../../../../components/automation/ha-automation-row-live-test";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-condition-icon";
|
||||
import "../../../../components/ha-dropdown";
|
||||
@@ -51,18 +53,26 @@ import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
import "../../../../components/ha-dropdown-item";
|
||||
import "../../../../components/ha-expansion-panel";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import "../../../../components/ha-trigger-icon";
|
||||
import type {
|
||||
AutomationClipboard,
|
||||
AutomationConfig,
|
||||
Condition,
|
||||
ConditionSidebarConfig,
|
||||
PlatformCondition,
|
||||
TriggerCondition,
|
||||
} from "../../../../data/automation";
|
||||
import {
|
||||
automationConfigContext,
|
||||
isCondition,
|
||||
subscribeCondition,
|
||||
testCondition,
|
||||
} from "../../../../data/automation";
|
||||
import { describeCondition } from "../../../../data/automation_i18n";
|
||||
import {
|
||||
describeCondition,
|
||||
getTriggerInfos,
|
||||
} from "../../../../data/automation_i18n";
|
||||
import type { ConditionDescriptions } from "../../../../data/condition";
|
||||
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
|
||||
import {
|
||||
@@ -82,6 +92,7 @@ import type { HomeAssistant } from "../../../../types";
|
||||
import { isMac } from "../../../../util/is_mac";
|
||||
import { showEditorToast } from "../editor-toast";
|
||||
import "../ha-automation-editor-warning";
|
||||
import "../ha-trigger-id-chip";
|
||||
import { overflowStyles, rowStyles } from "../styles";
|
||||
import "../target/ha-automation-row-targets";
|
||||
import "./ha-automation-condition-editor";
|
||||
@@ -155,6 +166,10 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
|
||||
@state() private _liveTestResult: LiveTestState = "unknown";
|
||||
|
||||
@state()
|
||||
@consume({ context: automationConfigContext, subscribe: true })
|
||||
private _automationConfig?: AutomationConfig;
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityReg: EntityRegistryEntry[] = [];
|
||||
@@ -213,9 +228,13 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
.condition=${this.condition.condition}
|
||||
></ha-condition-icon>
|
||||
<h3 slot="header">
|
||||
${capitalizeFirstLetter(
|
||||
describeCondition(this.condition, this.hass, this._entityReg)
|
||||
)}
|
||||
${this.condition.condition === "trigger"
|
||||
? this._renderTriggerConditionDescription(
|
||||
this.condition as TriggerCondition
|
||||
)
|
||||
: capitalizeFirstLetter(
|
||||
describeCondition(this.condition, this.hass, this._entityReg)
|
||||
)}
|
||||
${target !== undefined || (descriptionHasTarget && !this._isNew)
|
||||
? this._renderTargets(
|
||||
target,
|
||||
@@ -529,9 +548,11 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
>${this._renderRow()}
|
||||
<ha-automation-row-live-test
|
||||
slot="icons"
|
||||
.state=${this._liveTestResult}
|
||||
.state=${this.condition.condition !== "trigger"
|
||||
? this._liveTestResult
|
||||
: "unknown"}
|
||||
.label=${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult}`
|
||||
`ui.panel.config.automation.editor.conditions.live_test_state.${this.condition.condition !== "trigger" ? this._liveTestResult : "unknown"}`
|
||||
)}
|
||||
></ha-automation-row-live-test
|
||||
></ha-automation-row>`
|
||||
@@ -564,6 +585,116 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _getTriggerInfos = memoizeOne(getTriggerInfos);
|
||||
|
||||
private _renderTriggerConditionDescription(condition: TriggerCondition) {
|
||||
const ids = ensureArray(condition.id ?? []).filter((id) => id !== "");
|
||||
const prefix = capitalizeFirstLetter(
|
||||
this.hass
|
||||
.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.description.full",
|
||||
{ id: "" }
|
||||
)
|
||||
.trim()
|
||||
);
|
||||
if (!ids.length) {
|
||||
return html`${prefix}
|
||||
<div class="trigger warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.description.no_trigger"
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const triggerInfos = this._getTriggerInfos(
|
||||
ensureArray(this._automationConfig?.triggers || []),
|
||||
this.hass,
|
||||
this._entityReg
|
||||
);
|
||||
const infoById = new Map(triggerInfos.map((info) => [info.id, info]));
|
||||
return html`${prefix}
|
||||
${ids.map((id) => {
|
||||
const info = infoById.get(id);
|
||||
const isTriggerPositionReference = typeof id === "number";
|
||||
|
||||
if (!info || isTriggerPositionReference) {
|
||||
return html`<div class="trigger">
|
||||
<ha-trigger-id-chip
|
||||
id=${`trigger-${id}`}
|
||||
warning
|
||||
.triggerId=${`${
|
||||
typeof id === "number"
|
||||
? `${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.position_reference"
|
||||
)}: `
|
||||
: ""
|
||||
}${id}`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiAlert}></ha-svg-icon>
|
||||
</ha-trigger-id-chip>
|
||||
${ids.length < 4 && !isTriggerPositionReference
|
||||
? html`<span
|
||||
>${this.hass.localize("state.default.unavailable")}</span
|
||||
>`
|
||||
: nothing}
|
||||
|
||||
<ha-tooltip .for=${`trigger-${id}`}>
|
||||
${ids.length >= 4 && !isTriggerPositionReference
|
||||
? html`<div>
|
||||
${this.hass.localize("state.default.unavailable")}
|
||||
</div>`
|
||||
: nothing}
|
||||
${isTriggerPositionReference
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.position_reference_warning"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.unavailable_info",
|
||||
{ id: html`<b>${id}</b>` }
|
||||
)}
|
||||
</ha-tooltip>
|
||||
</div>`;
|
||||
}
|
||||
const triggerIcon = html`<ha-trigger-icon
|
||||
.slot=${ids.length < 4 ? "start" : ""}
|
||||
.hass=${this.hass}
|
||||
.trigger=${info.triggerType}
|
||||
></ha-trigger-icon>`;
|
||||
|
||||
const isDuplicateId = info.count > 1;
|
||||
|
||||
return html`
|
||||
<div class="trigger">
|
||||
${ids.length < 4 ? triggerIcon : nothing}
|
||||
<ha-trigger-id-chip
|
||||
id=${`trigger-${id}`}
|
||||
.triggerId=${id}
|
||||
.warning=${isDuplicateId}
|
||||
>
|
||||
${isDuplicateId
|
||||
? html`<ha-svg-icon slot="start" .path=${mdiAlert}></ha-svg-icon>`
|
||||
: nothing}
|
||||
</ha-trigger-id-chip>
|
||||
${ids.length < 4
|
||||
? html`<span>${info.label}</span>`
|
||||
: html`<ha-tooltip .for=${`trigger-${id}`}></ha-tooltip>`}
|
||||
${isDuplicateId || ids.length >= 4
|
||||
? html`<ha-tooltip .for=${`trigger-${id}`}>
|
||||
${ids.length >= 4
|
||||
? html`<div>${triggerIcon}${info.label}</div>`
|
||||
: nothing}
|
||||
${isDuplicateId
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.duplicate_id_warning"
|
||||
)
|
||||
: nothing}
|
||||
</ha-tooltip>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
private _renderTargets = memoizeOne(
|
||||
(
|
||||
target?: HassServiceTarget,
|
||||
@@ -1107,7 +1238,26 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [rowStyles, overflowStyles];
|
||||
return [
|
||||
rowStyles,
|
||||
overflowStyles,
|
||||
css`
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
background-color: var(--ha-color-fill-neutral-normal-resting);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
padding-inline: var(--ha-space-2);
|
||||
color: var(--ha-color-on-neutral-normal);
|
||||
height: 32px;
|
||||
}
|
||||
.trigger.warning {
|
||||
background-color: var(--ha-color-fill-warning-normal-resting);
|
||||
color: var(--ha-color-on-warning-normal);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { html, LitElement } from "lit";
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiAlert } from "@mdi/js";
|
||||
import { css, html, LitElement, 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 "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import "../../../../../components/ha-select";
|
||||
import "../../../../../components/item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "../../../../../components/item/ha-list-item-option";
|
||||
import "../../../../../components/list/ha-list-selectable";
|
||||
import type { HaListSelectable } from "../../../../../components/list/ha-list-selectable";
|
||||
import type { HaListSelectedDetail } from "../../../../../components/list/types";
|
||||
import {
|
||||
flattenTriggers,
|
||||
automationConfigContext,
|
||||
type AutomationConfig,
|
||||
type Trigger,
|
||||
type TriggerCondition,
|
||||
} from "../../../../../data/automation";
|
||||
import {
|
||||
getTriggerInfos,
|
||||
type TriggerInfo,
|
||||
} from "../../../../../data/automation_i18n";
|
||||
import { fullEntitiesContext } from "../../../../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
const getTriggersIds = (triggers: Trigger[]): string[] => {
|
||||
const triggerIds = flattenTriggers(triggers)
|
||||
.map((t) => ("id" in t ? t.id : undefined))
|
||||
.filter(Boolean) as string[];
|
||||
return Array.from(new Set(triggerIds));
|
||||
};
|
||||
import "../../ha-trigger-id-chip";
|
||||
|
||||
@customElement("ha-automation-condition-trigger")
|
||||
export class HaTriggerCondition extends LitElement {
|
||||
@@ -30,9 +35,25 @@ export class HaTriggerCondition extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state() private _triggerIds: string[] = [];
|
||||
@state()
|
||||
@consume({ context: automationConfigContext, subscribe: true })
|
||||
private _automationConfig?: AutomationConfig;
|
||||
|
||||
private _unsub?: UnsubscribeFunc;
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
private _entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
private _triggerInfos = memoizeOne(
|
||||
(
|
||||
triggers: AutomationConfig["triggers"] | undefined,
|
||||
entityReg: EntityRegistryEntry[]
|
||||
): TriggerInfo[] =>
|
||||
getTriggerInfos(
|
||||
triggers ? ensureArray(triggers) : undefined,
|
||||
this.hass,
|
||||
entityReg
|
||||
)
|
||||
);
|
||||
|
||||
public static get defaultConfig(): TriggerCondition {
|
||||
return {
|
||||
@@ -41,89 +62,161 @@ export class HaTriggerCondition extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(triggerIds: string[]) =>
|
||||
[
|
||||
{
|
||||
name: "id",
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
options: triggerIds,
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
] as const
|
||||
);
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const details = { callback: (config) => this._automationUpdated(config) };
|
||||
fireEvent(this, "subscribe-automation-config", details);
|
||||
this._unsub = (details as any).unsub;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._unsub) {
|
||||
this._unsub();
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._triggerIds.length) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
|
||||
);
|
||||
}
|
||||
const selectedIds: (string | number)[] = ensureArray(
|
||||
this.condition.id || []
|
||||
);
|
||||
|
||||
const schema = this._schema(this._triggerIds);
|
||||
const triggerInfos = this._triggerInfos(
|
||||
this._automationConfig?.triggers,
|
||||
this._entityReg
|
||||
);
|
||||
|
||||
if (!triggerInfos.length && !selectedIds.length) {
|
||||
return html`
|
||||
<ha-alert alert-type="info">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
|
||||
)}
|
||||
</ha-alert>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.schema=${schema}
|
||||
.data=${this.condition}
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
<ha-list-selectable @ha-list-selected=${this._valueChanged} multi>
|
||||
${this._renderOptions(selectedIds, triggerInfos)}
|
||||
</ha-list-selectable>
|
||||
`;
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.trigger.${schema.name}`
|
||||
private _renderOptions(
|
||||
selectedIds: (string | number)[],
|
||||
triggerInfos: TriggerInfo[]
|
||||
) {
|
||||
const unknownTriggerIds = selectedIds.filter(
|
||||
(id) => !triggerInfos.some((info) => info.id === id)
|
||||
);
|
||||
|
||||
private _automationUpdated(config?: AutomationConfig) {
|
||||
this._triggerIds = config?.triggers
|
||||
? getTriggersIds(ensureArray(config.triggers))
|
||||
: [];
|
||||
const alertIcon = html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiAlert}
|
||||
></ha-svg-icon>`;
|
||||
|
||||
return html`
|
||||
${unknownTriggerIds.map(
|
||||
(id) => html`
|
||||
<ha-list-item-option
|
||||
.value=${id}
|
||||
.selected=${true}
|
||||
appearance="checkbox"
|
||||
>
|
||||
<div class="option" slot="headline">
|
||||
<ha-trigger-id-chip
|
||||
id=${`trigger-${id}`}
|
||||
warning
|
||||
.triggerId=${`${
|
||||
typeof id === "number"
|
||||
? `${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.position_reference"
|
||||
)}: `
|
||||
: ""
|
||||
}${id}`}
|
||||
>
|
||||
${alertIcon}
|
||||
</ha-trigger-id-chip>
|
||||
${typeof id === "string"
|
||||
? this.hass.localize("state.default.unavailable")
|
||||
: nothing}
|
||||
<ha-tooltip .for=${`trigger-${id}`}>
|
||||
${typeof id === "string"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.unavailable_info",
|
||||
{ id: html`<b>${id}</b>` }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.position_reference_warning"
|
||||
)}
|
||||
</ha-tooltip>
|
||||
</div>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}
|
||||
${triggerInfos.map(
|
||||
(info) => html`
|
||||
<ha-list-item-option
|
||||
.value=${info.id}
|
||||
.selected=${selectedIds.includes(info.id)}
|
||||
appearance="checkbox"
|
||||
>
|
||||
<div class="option" slot="headline">
|
||||
<ha-trigger-id-chip
|
||||
id=${`trigger-${info.id}`}
|
||||
.warning=${info.count > 1}
|
||||
.triggerId=${info.id}
|
||||
>
|
||||
${info.count > 1 ? alertIcon : nothing}
|
||||
</ha-trigger-id-chip>
|
||||
${info.label}${info.count > 1
|
||||
? html`<ha-tooltip .for=${`trigger-${info.id}`}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.duplicated_info"
|
||||
)}</ha-tooltip
|
||||
>`
|
||||
: nothing}
|
||||
</div>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
private _valueChanged(ev: CustomEvent<HaListSelectedDetail>): void {
|
||||
ev.stopPropagation();
|
||||
const newValue = ev.detail.value;
|
||||
if (
|
||||
!ev.detail.diff ||
|
||||
(!ev.detail.diff?.added.size && !ev.detail.diff?.removed.size)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof newValue.id === "string") {
|
||||
if (!this._triggerIds.some((id) => id === newValue.id)) {
|
||||
newValue.id = "";
|
||||
}
|
||||
} else if (Array.isArray(newValue.id)) {
|
||||
newValue.id = newValue.id.filter((_id) =>
|
||||
this._triggerIds.some((id) => id === _id)
|
||||
);
|
||||
if (!newValue.id.length) {
|
||||
newValue.id = "";
|
||||
const ids = ensureArray(this.condition.id || []);
|
||||
|
||||
const valueSet = ev.detail.diff.added.size
|
||||
? ev.detail.diff.added
|
||||
: ev.detail.diff.removed;
|
||||
|
||||
const index = valueSet.values().next().value;
|
||||
|
||||
if (index === undefined) {
|
||||
return;
|
||||
}
|
||||
const triggerId = (
|
||||
(ev.currentTarget as HaListSelectable).items[index] as HaListItemOption
|
||||
).value;
|
||||
if (triggerId === undefined || triggerId === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.detail.diff.added.size) {
|
||||
ids.push(triggerId);
|
||||
} else {
|
||||
const removeIndex = ids.indexOf(triggerId);
|
||||
if (removeIndex > -1) {
|
||||
ids.splice(removeIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
fireEvent(this, "value-changed", { value: newValue });
|
||||
fireEvent(this, "value-changed", { value: { ...this.condition, id: ids } });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-1);
|
||||
color: var(--ha-color-on-neutral-normal);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { provide } from "@lit/context";
|
||||
import {
|
||||
mdiAppleKeyboardCommand,
|
||||
mdiCog,
|
||||
@@ -20,10 +21,9 @@ import {
|
||||
mdiTransitConnection,
|
||||
mdiUndo,
|
||||
} from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { UndoRedoController } from "../../../common/controllers/undo-redo-controller";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
@@ -31,6 +31,7 @@ import { goBack, navigate } from "../../../common/navigate";
|
||||
import { promiseTimeout } from "../../../common/util/promise-timeout";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
@@ -45,6 +46,7 @@ import type {
|
||||
Trigger,
|
||||
} from "../../../data/automation";
|
||||
import {
|
||||
automationConfigContext,
|
||||
deleteAutomation,
|
||||
fetchAutomationFileConfig,
|
||||
getAutomationEditorInitData,
|
||||
@@ -72,13 +74,12 @@ import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { Entries, ValueChangedEvent } from "../../../types";
|
||||
import { isMac } from "../../../util/is_mac";
|
||||
import { showEditorToast } from "./editor-toast";
|
||||
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
|
||||
import { showAutomationModeDialog } from "./automation-mode-dialog/show-dialog-automation-mode";
|
||||
import { showAutomationSaveDialog } from "./automation-save-dialog/show-dialog-automation-save";
|
||||
import { showAutomationSaveTimeoutDialog } from "./automation-save-timeout-dialog/show-dialog-automation-save-timeout";
|
||||
import { ADD_AUTOMATION_ELEMENT_QUERY_PARAM } from "./show-add-automation-element-dialog";
|
||||
import "./blueprint-automation-editor";
|
||||
import { showEditorToast } from "./editor-toast";
|
||||
import type { EditorDomainHooks } from "./ha-automation-script-editor-mixin";
|
||||
import {
|
||||
AutomationScriptEditorMixin,
|
||||
@@ -86,7 +87,7 @@ import {
|
||||
} from "./ha-automation-script-editor-mixin";
|
||||
import "./manual-automation-editor";
|
||||
import type { HaManualAutomationEditor } from "./manual-automation-editor";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import { ADD_AUTOMATION_ELEMENT_QUERY_PARAM } from "./show-add-automation-element-dialog";
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -94,10 +95,6 @@ declare global {
|
||||
}
|
||||
// for fire event
|
||||
interface HASSDomEvents {
|
||||
"subscribe-automation-config": {
|
||||
callback: (config: AutomationConfig) => void;
|
||||
unsub?: UnsubscribeFunc;
|
||||
};
|
||||
"ui-mode-not-available": Error;
|
||||
"move-down": undefined;
|
||||
"move-up": undefined;
|
||||
@@ -125,12 +122,9 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
|
||||
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
|
||||
|
||||
private _configSubscriptions: Record<
|
||||
string,
|
||||
(config?: AutomationConfig) => void
|
||||
> = {};
|
||||
|
||||
private _configSubscriptionsId = 1;
|
||||
@provide({ context: automationConfigContext })
|
||||
@state()
|
||||
protected config?: AutomationConfig;
|
||||
|
||||
private _newAutomationId?: string;
|
||||
|
||||
@@ -404,10 +398,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
</ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
<div
|
||||
class=${this.mode === "yaml" ? "yaml-mode" : ""}
|
||||
@subscribe-automation-config=${this._subscribeAutomationConfig}
|
||||
>
|
||||
<div class=${this.mode === "yaml" ? "yaml-mode" : ""}>
|
||||
${this.mode === "gui"
|
||||
? html`
|
||||
<div>
|
||||
@@ -638,12 +629,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
) {
|
||||
this._setEntityId();
|
||||
}
|
||||
|
||||
if (changedProps.has("config")) {
|
||||
Object.values(this._configSubscriptions).forEach((sub) =>
|
||||
sub(this.config)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _setEntityId() {
|
||||
@@ -1021,15 +1006,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
}
|
||||
}
|
||||
|
||||
private _subscribeAutomationConfig(ev) {
|
||||
const id = this._configSubscriptionsId++;
|
||||
this._configSubscriptions[id] = ev.detail.callback;
|
||||
ev.detail.unsub = () => {
|
||||
delete this._configSubscriptions[id];
|
||||
};
|
||||
ev.detail.callback(this.config);
|
||||
}
|
||||
|
||||
protected supportedShortcuts(): SupportedShortcuts {
|
||||
return {
|
||||
s: () => this._handleSaveAutomation(),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mdiPound } from "@mdi/js";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-svg-icon";
|
||||
|
||||
/**
|
||||
* Home Assistant trigger ID chip component
|
||||
*
|
||||
* @element ha-trigger-id-chip
|
||||
* @extends {LitElement}
|
||||
*
|
||||
* @summary
|
||||
* A small chip that displays an automation trigger ID prefixed with a hash icon.
|
||||
*
|
||||
* @slot start - Optional content rendered before the hash icon (usually an icon).
|
||||
*
|
||||
* @attr {string} trigger-id - The trigger ID to display.
|
||||
* @attr {boolean} warning - Renders the chip with warning colors.
|
||||
*/
|
||||
@customElement("ha-trigger-id-chip")
|
||||
export class HaTriggerIdChip extends LitElement {
|
||||
@property({ attribute: "trigger-id" }) public triggerId!: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public warning = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<slot name="start"></slot>
|
||||
<ha-svg-icon .path=${mdiPound}></ha-svg-icon>
|
||||
<span>${this.triggerId}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
background-color: var(--card-background-color);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border: var(--ha-border-width-sm) solid
|
||||
var(--ha-color-border-neutral-normal);
|
||||
--mdc-icon-size: 16px;
|
||||
display: inline-flex;
|
||||
gap: var(--ha-space-1);
|
||||
align-items: center;
|
||||
color: var(--ha-color-on-neutral-normal);
|
||||
padding: 0 var(--ha-space-1);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
:host([warning]) {
|
||||
border-color: var(--ha-color-border-warning-normal);
|
||||
color: var(--ha-color-on-warning-normal);
|
||||
background-color: var(--ha-color-fill-warning-quiet-resting);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-trigger-id-chip": HaTriggerIdChip;
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,11 @@ import {
|
||||
normalizeAutomationConfig,
|
||||
} from "../../../data/automation";
|
||||
import { getActionType, type Action } from "../../../data/script";
|
||||
import { showEditorToast } from "./editor-toast";
|
||||
import "./action/ha-automation-action";
|
||||
import type HaAutomationAction from "./action/ha-automation-action";
|
||||
import "./condition/ha-automation-condition";
|
||||
import type HaAutomationCondition from "./condition/ha-automation-condition";
|
||||
import { showEditorToast } from "./editor-toast";
|
||||
import { ManualEditorMixin } from "./ha-manual-editor-mixin";
|
||||
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
|
||||
import { manualEditorStyles, saveFabStyles } from "./styles";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAlert,
|
||||
mdiAppleKeyboardCommand,
|
||||
mdiCommentEditOutline,
|
||||
mdiContentCopy,
|
||||
@@ -14,27 +16,35 @@ import {
|
||||
mdiStopCircleOutline,
|
||||
} from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { handleStructError } from "../../../../common/structs/handle-errors";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
import "../../../../components/ha-dropdown-item";
|
||||
import type {
|
||||
LegacyTrigger,
|
||||
Trigger,
|
||||
TriggerList,
|
||||
TriggerSidebarConfig,
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import {
|
||||
automationConfigContext,
|
||||
type AutomationConfig,
|
||||
type LegacyTrigger,
|
||||
type Trigger,
|
||||
type TriggerList,
|
||||
type TriggerSidebarConfig,
|
||||
} from "../../../../data/automation";
|
||||
import {
|
||||
getTriggerDomain,
|
||||
getTriggerIds,
|
||||
getTriggerObjectId,
|
||||
isTriggerList,
|
||||
} from "../../../../data/trigger";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { isMac } from "../../../../util/is_mac";
|
||||
import "../ha-automation-comment";
|
||||
import "../ha-trigger-id-chip";
|
||||
import { overflowStyles, sidebarEditorStyles } from "../styles";
|
||||
import "../trigger/ha-automation-trigger-editor";
|
||||
import type HaAutomationTriggerEditor from "../trigger/ha-automation-trigger-editor";
|
||||
@@ -57,7 +67,9 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
@property({ type: Number, attribute: "sidebar-key" })
|
||||
public sidebarKey?: number;
|
||||
|
||||
@state() private _requestShowId = false;
|
||||
@state()
|
||||
@consume({ context: automationConfigContext, subscribe: true })
|
||||
private _automationConfig?: AutomationConfig;
|
||||
|
||||
@state() private _warnings?: string[];
|
||||
|
||||
@@ -66,7 +78,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("config")) {
|
||||
this._requestShowId = false;
|
||||
this._warnings = undefined;
|
||||
if (this.config) {
|
||||
this.yamlMode = this.config.yamlMode;
|
||||
@@ -101,6 +112,11 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
) ||
|
||||
this.hass.localize(`component.${domain}.triggers.${triggerName}.name`);
|
||||
|
||||
const duplicatedId = this._isDuplicateId(
|
||||
"id" in this.config.config ? this.config.config.id : undefined,
|
||||
this._automationConfig?.triggers
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-automation-sidebar-card
|
||||
.hass=${this.hass}
|
||||
@@ -111,11 +127,35 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
@wa-select=${this._handleDropdownSelect}
|
||||
>
|
||||
<span slot="title">${title}</span>
|
||||
<span slot="subtitle"
|
||||
>${subtitle}${rowDisabled
|
||||
? ` (${this.hass.localize("ui.panel.config.automation.editor.actions.disabled")})`
|
||||
: ""}</span
|
||||
>
|
||||
<div slot="subtitle" class="subtitle">
|
||||
${subtitle}
|
||||
${"id" in this.config.config
|
||||
? html`<ha-trigger-id-chip
|
||||
id="trigger-id-chip"
|
||||
.warning=${duplicatedId}
|
||||
.triggerId=${(
|
||||
this.config.config as Exclude<Trigger, TriggerList>
|
||||
).id}
|
||||
>
|
||||
${duplicatedId
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiAlert}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
</ha-trigger-id-chip>
|
||||
${duplicatedId
|
||||
? html`<ha-tooltip for="trigger-id-chip">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.duplicate_id_warning"
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
: nothing} `
|
||||
: nothing}
|
||||
${rowDisabled
|
||||
? `(${this.hass.localize("ui.panel.config.automation.editor.actions.disabled")})`
|
||||
: nothing}
|
||||
</div>
|
||||
<ha-dropdown-item
|
||||
slot="menu-items"
|
||||
value="rename"
|
||||
@@ -147,18 +187,16 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
</div>
|
||||
</ha-dropdown-item>`
|
||||
: nothing}
|
||||
${!this.yamlMode &&
|
||||
!("id" in this.config.config) &&
|
||||
!this._requestShowId
|
||||
? html`<ha-dropdown-item
|
||||
${type !== "list"
|
||||
? html` <ha-dropdown-item
|
||||
slot="menu-items"
|
||||
value="show_id"
|
||||
value="edit_id"
|
||||
.disabled=${this.disabled || type === "list"}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiIdentifier}></ha-svg-icon>
|
||||
<div class="overflow-label">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.edit_id"
|
||||
`ui.panel.config.automation.editor.triggers.${"id" in this.config.config ? "edit" : "add"}_id`
|
||||
)}
|
||||
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
|
||||
</div>
|
||||
@@ -335,7 +373,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
@value-changed=${this._valueChangedSidebar}
|
||||
@yaml-changed=${this._yamlChangedSidebar}
|
||||
.uiSupported=${this.config.uiSupported}
|
||||
.showId=${this._requestShowId}
|
||||
.yamlMode=${this.yamlMode}
|
||||
.disabled=${this.disabled}
|
||||
@ui-mode-not-available=${this._handleUiModeNotAvailable}
|
||||
@@ -361,6 +398,16 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _isDuplicateId = memoizeOne(
|
||||
(id: string | undefined, triggers: Trigger | Trigger[] | undefined) => {
|
||||
if (!id || !triggers) {
|
||||
return false;
|
||||
}
|
||||
const triggerIds = getTriggerIds(ensureArray(triggers));
|
||||
return triggerIds.filter((triggerId) => triggerId === id).length > 1;
|
||||
}
|
||||
);
|
||||
|
||||
private _valueChangedSidebar(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
|
||||
@@ -386,10 +433,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
fireEvent(this, "toggle-yaml-mode");
|
||||
};
|
||||
|
||||
private _showTriggerId = () => {
|
||||
this._requestShowId = true;
|
||||
};
|
||||
|
||||
private _handleDropdownSelect(ev: HaDropdownSelectEvent) {
|
||||
const action = ev.detail?.item?.value;
|
||||
|
||||
@@ -404,8 +447,8 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
case "edit_comment":
|
||||
this.config.editComment();
|
||||
break;
|
||||
case "show_id":
|
||||
this._showTriggerId();
|
||||
case "edit_id":
|
||||
this.config.editId();
|
||||
break;
|
||||
case "duplicate":
|
||||
this.config.duplicate();
|
||||
@@ -431,7 +474,16 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [sidebarEditorStyles, overflowStyles];
|
||||
static styles = [
|
||||
sidebarEditorStyles,
|
||||
overflowStyles,
|
||||
css`
|
||||
.subtitle {
|
||||
display: flex;
|
||||
gap: var(--ha-space-1);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
import {
|
||||
automationConfigContext,
|
||||
type AutomationConfig,
|
||||
type Trigger,
|
||||
} from "../../../../data/automation";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
import {
|
||||
getNextNumericTriggerId,
|
||||
getTriggerIds,
|
||||
} from "../../../../data/trigger";
|
||||
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { EditTriggerIdDialogParams } from "./show-edit-trigger-id";
|
||||
|
||||
@customElement("ha-automation-edit-trigger-id-dialog")
|
||||
class HaAutomationEditTriggerIdDialog extends DialogMixin<EditTriggerIdDialogParams>(
|
||||
LitElement
|
||||
) {
|
||||
@state() private _newId = "";
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
protected _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state() private _duplicateWarning = false;
|
||||
|
||||
@consume({ context: automationConfigContext, subscribe: true })
|
||||
private _automationConfig?: AutomationConfig;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._setInitialId();
|
||||
}
|
||||
|
||||
private _setInitialId() {
|
||||
if (this.params?.id) {
|
||||
this._newId = this.params.id;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._automationConfig?.triggers) {
|
||||
this._newId = getNextNumericTriggerId(
|
||||
ensureArray(this._automationConfig.triggers)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const title = this._i18n.localize(
|
||||
`ui.panel.config.automation.editor.triggers.${
|
||||
this.params.id ? "edit_id" : "add_id"
|
||||
}`
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dialog open header-title=${title}>
|
||||
<ha-input
|
||||
autofocus
|
||||
.label=${this._i18n.localize(
|
||||
"ui.panel.config.automation.editor.triggers.id"
|
||||
)}
|
||||
.value=${this._newId}
|
||||
@input=${this._idChanged}
|
||||
@keydown=${this._handleKeyDown}
|
||||
></ha-input>
|
||||
<ha-alert .alertType=${this._duplicateWarning ? "warning" : "info"}>
|
||||
${this._i18n.localize(
|
||||
`ui.panel.config.automation.editor.triggers.${this._duplicateWarning ? "duplicate_id_warning" : "id_description"}`
|
||||
)}
|
||||
</ha-alert>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this._i18n.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._save}>
|
||||
${this._i18n.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _getTriggerIds = memoizeOne((triggers: Trigger | Trigger[]) =>
|
||||
getTriggerIds(ensureArray(triggers))
|
||||
);
|
||||
|
||||
private _idChanged(ev: InputEvent) {
|
||||
const target = ev.target as HaInput;
|
||||
this._newId = target.value ?? "";
|
||||
|
||||
if (this._automationConfig?.triggers) {
|
||||
const existingTriggerIds = this._getTriggerIds(
|
||||
this._automationConfig.triggers
|
||||
);
|
||||
this._duplicateWarning = existingTriggerIds.includes(this._newId);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
this._save();
|
||||
}
|
||||
}
|
||||
|
||||
private _save(): void {
|
||||
const trimmed = this._newId.trim();
|
||||
this.params!.onUpdate(trimmed || undefined);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-input {
|
||||
width: 100%;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-6);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-automation-edit-trigger-id-dialog": HaAutomationEditTriggerIdDialog;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { dynamicElement } from "../../../../common/dom/dynamic-element-directive
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import "../../../../components/input/ha-input";
|
||||
import type { Trigger } from "../../../../data/automation";
|
||||
import { migrateAutomationTrigger } from "../../../../data/automation";
|
||||
import type { TriggerDescription } from "../../../../data/trigger";
|
||||
@@ -31,8 +30,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
|
||||
@property({ type: Boolean, attribute: "sidebar" }) public inSidebar = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "show-id" }) public showId = false;
|
||||
|
||||
@property({ attribute: false }) public description?: TriggerDescription;
|
||||
|
||||
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
|
||||
@@ -42,8 +39,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
|
||||
const yamlMode = this.yamlMode || !this.uiSupported;
|
||||
|
||||
const showId = "id" in this.trigger || this.showId;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
@@ -77,18 +72,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
></ha-yaml-editor>
|
||||
`
|
||||
: html`
|
||||
${showId && !isTriggerList(this.trigger)
|
||||
? html`
|
||||
<ha-input
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.id"
|
||||
)}
|
||||
.value=${this.trigger.id || ""}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._idChanged}
|
||||
></ha-input>
|
||||
`
|
||||
: nothing}
|
||||
<div @value-changed=${this._onUiChanged}>
|
||||
${this.description
|
||||
? html`<ha-automation-trigger-platform
|
||||
@@ -108,24 +91,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _idChanged(ev: CustomEvent) {
|
||||
if (isTriggerList(this.trigger)) return;
|
||||
const newId = (ev.target as any).value;
|
||||
|
||||
if (newId === (this.trigger.id ?? "")) {
|
||||
return;
|
||||
}
|
||||
const value = { ...this.trigger };
|
||||
if (!newId) {
|
||||
delete value.id;
|
||||
} else {
|
||||
value.id = newId;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
private _onYamlChange(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
if (!ev.detail.isValid) {
|
||||
@@ -160,9 +125,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
ha-input {
|
||||
margin-bottom: var(--ha-space-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAlert,
|
||||
mdiAppleKeyboardCommand,
|
||||
mdiArrowDown,
|
||||
mdiArrowUp,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
mdiContentPaste,
|
||||
mdiDelete,
|
||||
mdiDotsVertical,
|
||||
mdiIdentifier,
|
||||
mdiPlayCircleOutline,
|
||||
mdiPlaylistEdit,
|
||||
mdiPlusCircleMultipleOutline,
|
||||
@@ -28,6 +30,7 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { transform } from "../../../../common/decorators/transform";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
@@ -48,15 +51,21 @@ import "../../../../components/ha-dropdown-item";
|
||||
import "../../../../components/ha-expansion-panel";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import { TRIGGER_ICONS } from "../../../../components/ha-trigger-icon";
|
||||
import type {
|
||||
AutomationClipboard,
|
||||
AutomationConfig,
|
||||
PlatformTrigger,
|
||||
Trigger,
|
||||
TriggerList,
|
||||
TriggerSidebarConfig,
|
||||
} from "../../../../data/automation";
|
||||
import { isTrigger, subscribeTrigger } from "../../../../data/automation";
|
||||
import {
|
||||
automationConfigContext,
|
||||
isTrigger,
|
||||
subscribeTrigger,
|
||||
} from "../../../../data/automation";
|
||||
import { describeTrigger } from "../../../../data/automation_i18n";
|
||||
import { validateConfig } from "../../../../data/config";
|
||||
import { fullEntitiesContext } from "../../../../data/context";
|
||||
@@ -73,10 +82,12 @@ import type { HomeAssistant } from "../../../../types";
|
||||
import { isMac } from "../../../../util/is_mac";
|
||||
import { showEditorToast } from "../editor-toast";
|
||||
import "../ha-automation-editor-warning";
|
||||
import "../ha-trigger-id-chip";
|
||||
import { overflowStyles, rowStyles } from "../styles";
|
||||
import "../target/ha-automation-row-targets";
|
||||
import "./ha-automation-trigger-editor";
|
||||
import type HaAutomationTriggerEditor from "./ha-automation-trigger-editor";
|
||||
import { showEditTriggerIdDialog } from "./show-edit-trigger-id";
|
||||
import "./types/ha-automation-trigger-calendar";
|
||||
import "./types/ha-automation-trigger-conversation";
|
||||
import "./types/ha-automation-trigger-device";
|
||||
@@ -178,6 +189,30 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
@state()
|
||||
@consume({ context: automationConfigContext, subscribe: true })
|
||||
@transform<AutomationConfig, boolean>({
|
||||
transformer: function (this: HaAutomationTriggerRow, value) {
|
||||
if (
|
||||
!this.trigger ||
|
||||
isTriggerList(this.trigger) ||
|
||||
!(this.trigger as Exclude<Trigger, TriggerList>).id
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const triggerId = (this.trigger as Exclude<Trigger, TriggerList>).id;
|
||||
// count how often this trigger id is used in the automation, if more than once, show warning
|
||||
return (
|
||||
ensureArray(value?.triggers || []).filter(
|
||||
(trigger) =>
|
||||
(trigger as Exclude<Trigger, TriggerList>).id === triggerId
|
||||
).length > 1
|
||||
);
|
||||
},
|
||||
watch: ["trigger"],
|
||||
})
|
||||
private _duplicateTriggerId = false;
|
||||
|
||||
get selected() {
|
||||
return this._selected;
|
||||
}
|
||||
@@ -244,6 +279,28 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
.trigger=${(this.trigger as Exclude<Trigger, TriggerList>).trigger}
|
||||
></ha-trigger-icon>`}
|
||||
<h3 slot="header">
|
||||
${type !== "list" && (this.trigger as Exclude<Trigger, TriggerList>).id
|
||||
? html`<ha-trigger-id-chip
|
||||
id="trigger-id-chip"
|
||||
.warning=${this._duplicateTriggerId}
|
||||
slot="leading-icon"
|
||||
.triggerId=${(this.trigger as Exclude<Trigger, TriggerList>).id}
|
||||
>
|
||||
${this._duplicateTriggerId
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiAlert}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
</ha-trigger-id-chip>
|
||||
${this._duplicateTriggerId
|
||||
? html`<ha-tooltip for="trigger-id-chip">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.duplicate_id_warning"
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
: nothing}`
|
||||
: nothing}
|
||||
${describeTrigger(this.trigger, this.hass, this._entityReg)}
|
||||
${target !== undefined || (descriptionHasTarget && !this._isNew)
|
||||
? this._renderTargets(
|
||||
@@ -321,6 +378,17 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
)}
|
||||
</ha-dropdown-item>`
|
||||
: nothing}
|
||||
${type !== "list"
|
||||
? html`<ha-dropdown-item value="edit_id" .disabled=${this.disabled}>
|
||||
<ha-svg-icon slot="icon" .path=${mdiIdentifier}></ha-svg-icon>
|
||||
<div class="overflow-label">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.${"id" in this.trigger ? "edit" : "add"}_id`
|
||||
)}
|
||||
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
|
||||
</div>
|
||||
</ha-dropdown-item>`
|
||||
: nothing}
|
||||
<wa-divider></wa-divider>
|
||||
|
||||
<ha-dropdown-item value="duplicate" .disabled=${this.disabled}>
|
||||
@@ -694,6 +762,7 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
this.focus();
|
||||
}
|
||||
},
|
||||
editId: this._editTriggerId,
|
||||
rename: () => {
|
||||
this._renameTrigger();
|
||||
},
|
||||
@@ -805,6 +874,34 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _editTriggerId = () => {
|
||||
if (isTriggerList(this.trigger)) {
|
||||
return;
|
||||
}
|
||||
const trigger = this.trigger as Exclude<Trigger, TriggerList>;
|
||||
showEditTriggerIdDialog(this, {
|
||||
id: trigger.id,
|
||||
onUpdate: (newId) => {
|
||||
if (newId === (trigger.id ?? undefined)) {
|
||||
return;
|
||||
}
|
||||
const value: Trigger = { ...trigger };
|
||||
if (newId) {
|
||||
value.id = newId;
|
||||
} else {
|
||||
delete value.id;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value,
|
||||
});
|
||||
|
||||
if (this._selected && this.optionsInSidebar) {
|
||||
this.openSidebar(value); // refresh sidebar
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private _renameTrigger = async (): Promise<void> => {
|
||||
if (isTriggerList(this.trigger)) return;
|
||||
const alias = await showPromptDialog(this, {
|
||||
@@ -989,6 +1086,9 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
case "edit_comment":
|
||||
this._editCommentTrigger();
|
||||
break;
|
||||
case "edit_id":
|
||||
this._editTriggerId();
|
||||
break;
|
||||
case "duplicate":
|
||||
this._duplicateTrigger();
|
||||
break;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import "../../../../components/ha-button";
|
||||
@@ -21,15 +22,20 @@ import {
|
||||
} from "../../../../data/automation";
|
||||
import { subscribeLabFeature } from "../../../../data/labs";
|
||||
import type { TriggerDescriptions } from "../../../../data/trigger";
|
||||
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
|
||||
import {
|
||||
getNextNumericTriggerId,
|
||||
getUniqueTriggerId,
|
||||
isTriggerList,
|
||||
subscribeTriggers,
|
||||
} from "../../../../data/trigger";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
|
||||
import { AutomationSortableListMixin } from "../ha-automation-sortable-list-mixin";
|
||||
import {
|
||||
getAddAutomationElementTargetFromQuery,
|
||||
PASTE_VALUE,
|
||||
showAddAutomationElementDialog,
|
||||
} from "../show-add-automation-element-dialog";
|
||||
import { AutomationSortableListMixin } from "../ha-automation-sortable-list-mixin";
|
||||
import { automationRowsStyles } from "../styles";
|
||||
import "./ha-automation-trigger-row";
|
||||
import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
|
||||
@@ -67,6 +73,53 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
|
||||
this.highlightedTriggers = items;
|
||||
}
|
||||
|
||||
protected override pasteItem(ev: CustomEvent) {
|
||||
if (this.root && ev.detail.item) {
|
||||
const pasted = deepClone(ev.detail.item) as Trigger;
|
||||
if (!isTriggerList(pasted)) {
|
||||
pasted.id = pasted.id
|
||||
? getUniqueTriggerId(pasted.id, this.triggers)
|
||||
: getNextNumericTriggerId(this.triggers);
|
||||
}
|
||||
ev.detail.item = pasted;
|
||||
}
|
||||
super.pasteItem(ev);
|
||||
}
|
||||
|
||||
protected override insertAfter(ev: CustomEvent) {
|
||||
// Only dedupe when a single trigger is being inserted.
|
||||
const incoming = ensureArray(ev.detail.value) as Trigger[];
|
||||
if (this.root && incoming.length === 1) {
|
||||
const trigger = deepClone(incoming[0]);
|
||||
if (!isTriggerList(trigger)) {
|
||||
trigger.id = trigger.id
|
||||
? getUniqueTriggerId(trigger.id, this.triggers)
|
||||
: getNextNumericTriggerId(this.triggers);
|
||||
}
|
||||
ev.detail.value = trigger;
|
||||
}
|
||||
super.insertAfter(ev);
|
||||
}
|
||||
|
||||
protected override duplicateItem(ev: CustomEvent) {
|
||||
if (this.root) {
|
||||
const index = (ev.target as any).index;
|
||||
const duplicated = deepClone(this.triggers[index]);
|
||||
if (!isTriggerList(duplicated)) {
|
||||
duplicated.id = duplicated.id
|
||||
? getUniqueTriggerId(duplicated.id, this.triggers)
|
||||
: getNextNumericTriggerId(this.triggers);
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
// @ts-expect-error Requires library bump to ES2023
|
||||
value: this.triggers.toSpliced(index + 1, 0, duplicated),
|
||||
});
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
super.duplicateItem(ev);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribe();
|
||||
@@ -213,23 +266,36 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
|
||||
private _addTrigger = (value: string, target?: HassServiceTarget) => {
|
||||
let triggers: Trigger[];
|
||||
if (value === PASTE_VALUE) {
|
||||
triggers = this.triggers.concat(deepClone(this._clipboard!.trigger!));
|
||||
} else if (isDynamic(value)) {
|
||||
triggers = this.triggers.concat({
|
||||
trigger: getValueFromDynamic(value),
|
||||
target,
|
||||
});
|
||||
const pasted = deepClone(this._clipboard!.trigger!);
|
||||
if (this.root && !isTriggerList(pasted)) {
|
||||
pasted.id = pasted.id
|
||||
? getUniqueTriggerId(pasted.id, this.triggers)
|
||||
: getNextNumericTriggerId(this.triggers);
|
||||
}
|
||||
triggers = this.triggers.concat(pasted);
|
||||
} else {
|
||||
const trigger = value as Exclude<Trigger, TriggerList>["trigger"];
|
||||
const elClass = customElements.get(
|
||||
`ha-automation-trigger-${trigger}`
|
||||
) as CustomElementConstructor & {
|
||||
defaultConfig: Trigger;
|
||||
};
|
||||
triggers = this.triggers.concat({
|
||||
...elClass.defaultConfig,
|
||||
...(target?.entity_id ? { entity_id: target.entity_id } : {}),
|
||||
});
|
||||
let newTrigger: Trigger;
|
||||
if (isDynamic(value)) {
|
||||
newTrigger = {
|
||||
trigger: getValueFromDynamic(value),
|
||||
target,
|
||||
};
|
||||
} else {
|
||||
const trigger = value as Exclude<Trigger, TriggerList>["trigger"];
|
||||
const elClass = customElements.get(
|
||||
`ha-automation-trigger-${trigger}`
|
||||
) as CustomElementConstructor & {
|
||||
defaultConfig: Trigger;
|
||||
};
|
||||
newTrigger = {
|
||||
...elClass.defaultConfig,
|
||||
...(target?.entity_id ? { entity_id: target.entity_id } : {}),
|
||||
};
|
||||
}
|
||||
if (this.root && !isTriggerList(newTrigger)) {
|
||||
newTrigger.id = getNextNumericTriggerId(this.triggers);
|
||||
}
|
||||
triggers = this.triggers.concat(newTrigger);
|
||||
}
|
||||
this.focusLastItemOnChange = true;
|
||||
fireEvent(this, "value-changed", { value: triggers });
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { LitElement } from "lit";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
|
||||
export const loadEditTriggerIdDialog = () =>
|
||||
import("./ha-automation-edit-trigger-id-dialog");
|
||||
|
||||
export interface EditTriggerIdDialogParams {
|
||||
id?: string;
|
||||
onUpdate: (newId: string | undefined) => void;
|
||||
}
|
||||
|
||||
export const showEditTriggerIdDialog = (
|
||||
element: LitElement,
|
||||
dialogParams: EditTriggerIdDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
parentElement: element,
|
||||
dialogTag: "ha-automation-edit-trigger-id-dialog",
|
||||
dialogImport: loadEditTriggerIdDialog,
|
||||
dialogParams,
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiCog, mdiContentCopy } from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -13,9 +13,10 @@ import "../../../../../components/ha-dropdown-item";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../../../components/input/ha-input";
|
||||
import type {
|
||||
AutomationConfig,
|
||||
WebhookTrigger,
|
||||
import {
|
||||
automationConfigContext,
|
||||
type AutomationConfig,
|
||||
type WebhookTrigger,
|
||||
} from "../../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { showEditorToast } from "../../editor-toast";
|
||||
@@ -33,9 +34,9 @@ export class HaWebhookTrigger extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state() private _config?: AutomationConfig;
|
||||
|
||||
private _unsub?: UnsubscribeFunc;
|
||||
@consume({ context: automationConfigContext, subscribe: true })
|
||||
@state()
|
||||
private _config?: AutomationConfig;
|
||||
|
||||
public static get defaultConfig(): WebhookTrigger {
|
||||
return {
|
||||
@@ -46,24 +47,6 @@ export class HaWebhookTrigger extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const details = {
|
||||
callback: (config) => {
|
||||
this._config = config;
|
||||
},
|
||||
};
|
||||
fireEvent(this, "subscribe-automation-config", details);
|
||||
this._unsub = (details as any).unsub;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._unsub) {
|
||||
this._unsub();
|
||||
}
|
||||
}
|
||||
|
||||
private _generateWebhookId(): string {
|
||||
// The webhook_id should be treated like a password. Generate a default
|
||||
// value that would be hard for someone to guess. This generates a
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { computeFloorName } from "../../../common/entity/compute_floor_name";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
|
||||
import {
|
||||
PROTOCOL_INTEGRATIONS,
|
||||
protocolIntegrationPicked,
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
deserializeFilters,
|
||||
serializeFilters,
|
||||
} from "../../../data/data_table_filters";
|
||||
import { computeDeviceAreaLabel } from "../../../data/device/device_picker";
|
||||
import type {
|
||||
DeviceEntityLookup,
|
||||
DeviceRegistryEntry,
|
||||
@@ -458,17 +460,29 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
.map((lbl) => labelReg!.find((label) => label.label_id === lbl))
|
||||
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
|
||||
|
||||
let floorName;
|
||||
if (
|
||||
device.area_id &&
|
||||
areas[device.area_id]?.floor_id &&
|
||||
this.hass.floors
|
||||
) {
|
||||
const floorId = areas[device.area_id].floor_id;
|
||||
if (this.hass.floors[floorId!]) {
|
||||
floorName = computeFloorName(this.hass.floors[floorId!]);
|
||||
}
|
||||
}
|
||||
const { areaName } = computeDeviceAreaLabel(
|
||||
device,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.states,
|
||||
this.hass.localize,
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata,
|
||||
device.via_device_id
|
||||
? deviceEntityLookup[device.via_device_id]
|
||||
: undefined
|
||||
);
|
||||
|
||||
const floorArea =
|
||||
getDeviceArea(device, areas) ??
|
||||
(device.via_device_id && this.hass.devices[device.via_device_id]
|
||||
? getDeviceArea(this.hass.devices[device.via_device_id], areas)
|
||||
: undefined);
|
||||
const floorId = floorArea?.floor_id;
|
||||
const floorName =
|
||||
floorId && this.hass.floors?.[floorId]
|
||||
? computeFloorName(this.hass.floors[floorId])
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...device,
|
||||
@@ -484,10 +498,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
manufacturer:
|
||||
device.manufacturer ||
|
||||
`<${localize("ui.panel.config.devices.data_table.unknown")}>`,
|
||||
area:
|
||||
device.area_id && areas[device.area_id]
|
||||
? areas[device.area_id].name
|
||||
: undefined,
|
||||
area: areaName,
|
||||
floor: floorName,
|
||||
integration: deviceEntries.length
|
||||
? deviceEntries
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
mdiClose,
|
||||
mdiDragHorizontalVariant,
|
||||
mdiPencil,
|
||||
mdiPlaylistPlus,
|
||||
} from "@mdi/js";
|
||||
import { mdiClose, mdiDragHorizontalVariant, mdiPencil } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
@@ -12,10 +7,8 @@ import { entityUseDeviceName } from "../../../common/entity/compute_entity_name"
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { EntityConfig } from "../entity-rows/types";
|
||||
@@ -190,12 +183,8 @@ export class HuiEntityEditor extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.entityFilter=${this.entityFilter}
|
||||
@value-changed=${this._addEntity}
|
||||
>
|
||||
<ha-button slot="trigger" size="small" appearance="filled">
|
||||
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize("ui.components.entity.entity-picker.add")}
|
||||
</ha-button>
|
||||
</ha-entity-picker>
|
||||
add-button
|
||||
></ha-entity-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import {
|
||||
mdiClose,
|
||||
mdiDragHorizontalVariant,
|
||||
mdiPencil,
|
||||
mdiPlaylistPlus,
|
||||
} from "@mdi/js";
|
||||
import { mdiClose, mdiDragHorizontalVariant, mdiPencil } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
@@ -120,12 +114,8 @@ export class HuiEntitiesCardRowEditor extends LitElement {
|
||||
class="add-entity"
|
||||
.hass=${this.hass}
|
||||
@value-changed=${this._addEntity}
|
||||
>
|
||||
<ha-button slot="trigger" size="small" appearance="filled">
|
||||
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass!.localize("ui.components.entity.entity-picker.add")}
|
||||
</ha-button>
|
||||
</ha-entity-picker>
|
||||
add-button
|
||||
></ha-entity-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -5163,9 +5163,14 @@
|
||||
"item": "No triggers found for {term}"
|
||||
},
|
||||
"id": "Trigger ID",
|
||||
"position_reference": "Trigger position reference",
|
||||
"optional": "Optional",
|
||||
"edit_id": "Edit ID",
|
||||
"add_id": "Add trigger ID",
|
||||
"edit_id": "Edit trigger ID",
|
||||
"id_description": "Use trigger IDs in a Triggered by condition to run different actions depending on which trigger started the automation.",
|
||||
"duplicate": "[%key:ui::common::duplicate%]",
|
||||
"duplicate_id_warning": "This trigger ID is used multiple times in this automation. Trigger IDs should be unique.",
|
||||
"position_reference_warning": "This trigger ID is numeric because of that this is a position reference. Position references are not unique and can change when reordering triggers. If you want to use a unique trigger ID, please change it to a string that is not numeric.",
|
||||
"re_order": "Re-order",
|
||||
"rename": "Rename",
|
||||
"cut": "Cut",
|
||||
@@ -5591,10 +5596,13 @@
|
||||
"trigger": {
|
||||
"label": "Triggered by",
|
||||
"no_triggers": "There are no triggers with ID's set in this automation. Edit a trigger and give it a Trigger ID name.",
|
||||
"duplicated_info": "This ID is used by multiple triggers. Trigger IDs should be unique.",
|
||||
"unavailable_info": "No trigger has the ID {id}. Set this ID on a trigger to use it.",
|
||||
"id": "Trigger",
|
||||
"description": {
|
||||
"picker": "Tests if the automation has been triggered by a specific trigger.",
|
||||
"full": "If triggered by {id}"
|
||||
"full": "If triggered by {id}",
|
||||
"no_trigger": "No trigger selected"
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
|
||||
@@ -6666,10 +6666,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"date-fns@npm:4.2.0":
|
||||
version: 4.2.0
|
||||
resolution: "date-fns@npm:4.2.0"
|
||||
checksum: 10/6f50c3da98286a93807ad7c1b790ba753d6ea17d028cf0ffe39aa208693d940100a9069ec444f9436dc010de6a94242b8cb459e86e92e8d1948c553f4d141f71
|
||||
"date-fns@npm:4.2.1":
|
||||
version: 4.2.1
|
||||
resolution: "date-fns@npm:4.2.1"
|
||||
checksum: 10/c2731946a2d022935b3641062cd64bcdd62a65066115fee3490ba360a17fbf42134918493c43acb2c5c995b9ea4a2c7f505f0d9c02e73351cd2899b13e081729
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8635,7 +8635,7 @@ __metadata:
|
||||
core-js: "npm:3.49.0"
|
||||
cropperjs: "npm:1.6.2"
|
||||
culori: "npm:4.0.2"
|
||||
date-fns: "npm:4.2.0"
|
||||
date-fns: "npm:4.2.1"
|
||||
deep-clone-simple: "npm:1.1.1"
|
||||
deep-freeze: "npm:0.0.1"
|
||||
del: "npm:8.0.1"
|
||||
|
||||
Reference in New Issue
Block a user