Compare commits

..

5 Commits

Author SHA1 Message Date
Wendelin fa43ca949d Add trigger position reference warning 2026-05-22 12:25:50 +02:00
Wendelin ac50ba4edc Add new edit trigger 2026-05-21 17:09:52 +02:00
Wendelin 58d4edaa63 Respect via device in device picker, device list (#52131)
* Respect via device in device picker

* Add context to device data table too
2026-05-21 16:01:43 +02:00
Wendelin 176841e647 Automation triggers - auto IDs (#52129)
* Add auto trigger ids

* improve

* Fix paste with no trigger id

* Fix trigger-id-chip and add jsdocs

* review
2026-05-21 16:52:03 +03:00
renovate[bot] 0759e82b47 Update dependency date-fns to v4.2.1 (#52135)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 16:44:50 +03:00
29 changed files with 1288 additions and 1560 deletions
+1 -1
View File
@@ -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",
+99 -135
View File
@@ -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 {
-394
View File
@@ -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;
}
}
-257
View File
@@ -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;
}
}
-188
View File
@@ -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;
}
}
-60
View File
@@ -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 };
}
}
-94
View File
@@ -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
View File
@@ -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 {
+8 -2
View File
@@ -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");
+36
View File
@@ -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,
+70 -3
View File
@@ -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("_"),
};
+44
View File
@@ -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>
`;
}
+10 -2
View File
@@ -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": {
+5 -5
View File
@@ -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"