Compare commits

..

1 Commits

Author SHA1 Message Date
Wendelin
5706be3fb7 Add click actions to automation row targets 2026-05-07 16:52:23 +02:00
42 changed files with 1550 additions and 1764 deletions

View File

@@ -28,26 +28,26 @@
"dependencies": {
"@babel/runtime": "7.29.2",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.2",
"@codemirror/autocomplete": "6.20.1",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.6",
"@codemirror/lint": "6.9.5",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.42.0",
"@codemirror/view": "6.41.1",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.2",
"@formatjs/intl-displaynames": "7.3.5",
"@formatjs/intl-durationformat": "0.10.8",
"@formatjs/intl-getcanonicallocales": "3.2.6",
"@formatjs/intl-listformat": "8.3.5",
"@formatjs/intl-locale": "5.3.5",
"@formatjs/intl-numberformat": "9.3.5",
"@formatjs/intl-pluralrules": "6.3.5",
"@formatjs/intl-relativetimeformat": "12.3.5",
"@formatjs/intl-datetimeformat": "7.4.1",
"@formatjs/intl-displaynames": "7.3.4",
"@formatjs/intl-durationformat": "0.10.7",
"@formatjs/intl-getcanonicallocales": "3.2.5",
"@formatjs/intl-listformat": "8.3.4",
"@formatjs/intl-locale": "5.3.4",
"@formatjs/intl-numberformat": "9.3.4",
"@formatjs/intl-pluralrules": "6.3.4",
"@formatjs/intl-relativetimeformat": "12.3.4",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -80,7 +80,7 @@
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.1.3",
"barcode-detector": "3.1.2",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
@@ -99,7 +99,7 @@
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.4",
"intl-messageformat": "11.2.3",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -121,19 +121,19 @@
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
"workbox-expiration": "7.4.1",
"workbox-precaching": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1",
"workbox-cacheable-response": "7.4.0",
"workbox-core": "7.4.0",
"workbox-expiration": "7.4.0",
"workbox-precaching": "7.4.0",
"workbox-routing": "7.4.0",
"workbox-strategies": "7.4.0",
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.5",
"@babel/preset-env": "7.29.3",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.60.0",
@@ -142,7 +142,7 @@
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.9",
"@rspack/core": "2.0.2",
"@rspack/core": "2.0.1",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -186,7 +186,7 @@
"husky": "9.1.7",
"jsdom": "29.1.1",
"jszip": "3.10.1",
"lint-staged": "17.0.2",
"lint-staged": "16.4.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -195,17 +195,17 @@
"prettier": "3.8.3",
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.14",
"sinon": "21.1.2",
"tar": "7.5.13",
"terser-webpack-plugin": "5.5.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.2",
"typescript-eslint": "8.59.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.5",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
},
"resolutions": {
"lit": "3.3.2",

View File

@@ -1,54 +0,0 @@
/**
* Walks up the composed tree (jumping shadow roots → their hosts), returning
* the ancestor chain top-down. Used to compare two nodes that may live in
* different shadow trees — `Node.compareDocumentPosition` only works within a
* single root and returns `DOCUMENT_POSITION_DISCONNECTED` otherwise.
*/
const composedAncestorPath = (node: Node): Node[] => {
const path: Node[] = [];
let cur: Node | null = node;
while (cur) {
path.push(cur);
const parent = cur.parentNode;
if (parent instanceof ShadowRoot) {
cur = parent.host;
} else if (parent) {
cur = parent;
} else {
const root = cur.getRootNode();
cur = root instanceof ShadowRoot ? root.host : null;
}
}
return path.reverse();
};
/**
* Document-order comparator that works across shadow boundaries. Suitable as
* the `Array.prototype.sort` callback for collections of nodes that may live
* in different shadow trees.
*/
export const compareNodeOrder = (a: Node, b: Node): number => {
if (a === b) {
return 0;
}
const pa = composedAncestorPath(a);
const pb = composedAncestorPath(b);
let i = 0;
while (i < pa.length && i < pb.length && pa[i] === pb[i]) {
i++;
}
if (i === 0) {
return 0;
}
if (i === pa.length) {
return -1;
}
if (i === pb.length) {
return 1;
}
// pa[i] and pb[i] are siblings under the LCA, guaranteed same root.
// eslint-disable-next-line no-bitwise
return pa[i].compareDocumentPosition(pb[i]) & Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1;
};

View File

@@ -117,6 +117,9 @@ export const generateEntityFilter = (
}
}
if (entityCategories) {
if (!entity) {
return false;
}
const category = entity?.entity_category || "none";
if (!entityCategories.has(category)) {
return false;

View File

@@ -1110,7 +1110,7 @@ export class HaChartBase extends LitElement {
private _updateSankeyRoam() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s != null && s.type === "sankey"
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({

View File

@@ -228,10 +228,6 @@ export class HaSwitch extends Switch {
outline: var(--wa-focus-ring);
outline-offset: var(--wa-focus-ring-offset);
}
:host(:empty) slot.label {
display: none;
}
`,
];
}

View File

@@ -1,8 +1,6 @@
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../list/types";
import { HaRowItem } from "./ha-row-item";
/**
@@ -41,12 +39,6 @@ export class HaListItemBase extends HaRowItem {
if (!this.hasAttribute("role")) {
this.setAttribute("role", this.defaultRole);
}
fireEvent(this, "ha-list-item-register", { item: this });
}
public disconnectedCallback(): void {
super.disconnectedCallback();
fireEvent(this, "ha-list-item-unregister", { item: this });
}
/**

View File

@@ -62,20 +62,26 @@ export class HaRowItem extends LitElement {
}
protected _renderInner(): TemplateResult {
const hasStart = this._slotController.test("start");
const hasEnd = this._slotController.test("end");
const hasContent = this._slotController.test("content");
return html`
<div part="start" class="start">
<slot name="start"></slot>
</div>
${hasStart
? html`<div part="start" class="start">
<slot name="start"></slot>
</div>`
: nothing}
<div part="content" class="content">
${hasContent
? html`<slot name="content"></slot>`
: this._renderDefaultContent()}
</div>
<div part="end" class="end">
<slot name="end"></slot>
</div>
${hasEnd
? html`<div part="end" class="end">
<slot name="end"></slot>
</div>`
: nothing}
`;
}
@@ -142,10 +148,6 @@ export class HaRowItem extends LitElement {
align-items: center;
flex: 0 0 auto;
}
:host(:not(:has([slot="start"]))) .start,
:host(:not(:has([slot="end"]))) .end {
display: none;
}
.headline {
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -1,12 +1,10 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { tinykeys } from "tinykeys";
import { compareNodeOrder } from "../../common/dom/compare-node-order";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { HaListItemBase } from "../item/ha-list-item-base";
import { fireEvent } from "../../common/dom/fire_event";
import { HaListItemBase } from "../item/ha-list-item-base";
import "./types";
import type { HaListItemRegistrationDetail } from "./types";
/**
* @element ha-list-base
@@ -14,11 +12,9 @@ import type { HaListItemRegistrationDetail } from "./types";
*
* @summary
* Base list container with roving-tabindex keyboard navigation (ArrowUp/Down,
* Home/End, optional Enter/Space activation, optional wrap-focus). Tracks
* `HaListItemBase` descendants via the `ha-list-item-register` /
* `ha-list-item-unregister` events they fire on connect/disconnect — works
* across any nesting depth and shadow boundaries. Subclasses override
* `hostRole` and/or `render()` to specialize.
* Home/End, optional Enter/Space activation, optional wrap-focus). Discovers
* slotted `HaListItemBase` descendants. Subclasses override `hostRole` and/or
* `render()` to specialize.
*
* @slot - List items (`<ha-list-item-*>`).
*
@@ -72,14 +68,6 @@ export class HaListBase extends LitElement {
Space: this._onActivate,
});
this.addEventListener("focusin", this._onFocusIn);
this.addEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.addEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
public disconnectedCallback() {
@@ -87,14 +75,11 @@ export class HaListBase extends LitElement {
this._unbindKeys?.();
this._unbindKeys = undefined;
this.removeEventListener("focusin", this._onFocusIn);
this.removeEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.removeEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
public firstUpdated(changed: PropertyValues) {
super.firstUpdated(changed);
this.updateListItems();
}
public focus(options?: FocusOptions) {
@@ -130,14 +115,18 @@ export class HaListBase extends LitElement {
this._applyActive(focusItem);
}
/**
* Hook called whenever the items array has changed. Subclasses can override
* to layer in extra bookkeeping (e.g. selection state sync).
*/
public updateListItems() {
const next = this._discoverListItems();
const changed =
next.length !== this.items.length ||
next.some((it, i) => it !== this.items[i]);
if (!changed) {
return;
}
this.items = next;
this._recomputeFocusableIndexes();
if (
this._activeItemIndex >= this.items.length ||
this._activeItemIndex >= next.length ||
!this._hasFocusableItem ||
this._activeItemIndex < 0
) {
@@ -146,32 +135,6 @@ export class HaListBase extends LitElement {
this._applyActive(false);
}
private _onItemRegister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (this.items.includes(item)) {
return;
}
const next = [...this.items, item];
next.sort(compareNodeOrder);
this.items = next;
this.updateListItems();
};
private _onItemUnregister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (!this.items.includes(item)) {
return;
}
this.items = this.items.filter((it) => it !== item);
this.updateListItems();
};
private _recomputeFocusableIndexes() {
let first = -1;
let last = -1;
@@ -188,12 +151,27 @@ export class HaListBase extends LitElement {
this._hasFocusableItem = first !== -1;
}
public handleSlotChange = () => {
this.updateListItems();
};
protected render(): TemplateResult {
return html`<div part="base" class="base">
<slot></slot>
<slot @slotchange=${this.handleSlotChange}></slot>
</div>`;
}
private _discoverListItems(): HaListItemBase[] {
const slot =
this.renderRoot?.querySelector<HTMLSlotElement>("slot:not([name])");
if (!slot) {
return [];
}
return slot
.assignedElements({ flatten: true })
.filter((el): el is HaListItemBase => el instanceof HaListItemBase);
}
private _isFocusable(index: number): boolean {
const item = this.items[index];
return !!item && item.interactive && !item.disabled;

View File

@@ -31,7 +31,7 @@ export class HaListNav extends HaListBase {
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
>
<div part="base" class="base" role="list">
<slot></slot>
<slot @slotchange=${this.handleSlotChange}></slot>
</div>
</nav>`;
}

View File

@@ -11,15 +11,9 @@ export interface HaListActivatedDetail {
item: HaListItemBase;
}
export interface HaListItemRegistrationDetail {
item: HaListItemBase;
}
declare global {
interface HASSDomEvents {
"ha-list-selected": HaListSelectedDetail;
"ha-list-activated": HaListActivatedDetail;
"ha-list-item-register": HaListItemRegistrationDetail;
"ha-list-item-unregister": HaListItemRegistrationDetail;
}
}

View File

@@ -1,8 +1,23 @@
import { html, LitElement, nothing } from "lit";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
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 type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import type { TargetSelector } from "../../../data/selector";
import {
filterSelectorDevices,
filterSelectorEntities,
} from "../../../data/selector";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
import "../../ha-dialog";
import "../../ha-dialog-header";
import "../../ha-icon-button";
@@ -10,6 +25,7 @@ import "../../ha-icon-next";
import "../../ha-md-list";
import "../../ha-md-list-item";
import "../../ha-svg-icon";
import "../../list/ha-list-base";
import "../ha-target-picker-item-row";
import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
@@ -21,6 +37,12 @@ class DialogTargetDetails extends LitElement implements HassDialog {
@state() private _opened = false;
@state() private _entitySources?: EntitySources;
@state() private _entitySourcesLoaded = false;
private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup);
public showDialog(params: TargetDetailsDialogParams): void {
this._params = params;
this._opened = true;
@@ -34,6 +56,72 @@ class DialogTargetDetails extends LitElement implements HassDialog {
private _dialogClosed() {
fireEvent(this, "dialog-closed", { dialog: this.localName });
this._params = undefined;
this._entitySources = undefined;
this._entitySourcesLoaded = false;
}
private _hasIntegration(selector: TargetSelector) {
return (
(selector.target?.entity &&
ensureArray(selector.target.entity).some((e) => e.integration)) ||
(selector.target?.device &&
ensureArray(selector.target.device).some((d) => d.integration))
);
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (!changedProperties.has("_params")) {
return;
}
if (
this._params?.selector &&
this._hasIntegration(this._params.selector) &&
!this._entitySourcesLoaded
) {
this._loadEntitySources();
}
}
private async _loadEntitySources(): Promise<void> {
try {
this._entitySources = await fetchEntitySourcesWithCache(this.hass);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to load entity sources for target details", err);
} finally {
this._entitySourcesLoaded = true;
}
}
private _filterEntities = (entity: HassEntity): boolean => {
const target = this._selectorTarget();
if (!target?.entity) {
return true;
}
return ensureArray(target.entity).some((e) =>
filterSelectorEntities(e, entity, this._entitySources)
);
};
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
const target = this._selectorTarget();
if (!target?.device) {
return true;
}
const deviceIntegrations = this._entitySources
? this._deviceIntegrationLookup(
this._entitySources,
Object.values(this.hass.entities)
)
: undefined;
return ensureArray(target.device).some((d) =>
filterSelectorDevices(d, device, deviceIntegrations)
);
};
private _selectorTarget() {
return this._params?.selector?.target || null;
}
protected render() {
@@ -41,6 +129,29 @@ class DialogTargetDetails extends LitElement implements HassDialog {
return nothing;
}
let deviceFilter: HaDevicePickerDeviceFilterFunc | undefined;
let entityFilter: HaEntityPickerEntityFilterFunc | undefined;
let includeDomains: string[] | undefined;
let includeDeviceClasses: string[] | undefined;
let primaryEntitiesOnly: boolean | undefined;
if (this._params.selector) {
deviceFilter = this._filterDevices;
entityFilter = this._filterEntities;
primaryEntitiesOnly = this._params.selector.target?.primary_entities_only;
} else {
deviceFilter = this._params.deviceFilter;
entityFilter = this._params.entityFilter;
includeDomains = this._params.includeDomains;
includeDeviceClasses = this._params.includeDeviceClasses;
primaryEntitiesOnly = this._params.primaryEntitiesOnly;
}
const waitingForSources =
this._params.selector &&
this._hasIntegration(this._params.selector) &&
!this._entitySourcesLoaded;
return html`
<ha-dialog
.hass=${this.hass}
@@ -48,26 +159,57 @@ class DialogTargetDetails extends LitElement implements HassDialog {
header-title=${this.hass.localize(
"ui.components.target-picker.target_details"
)}
header-subtitle=${`${this.hass.localize(
`ui.components.target-picker.type.${this._params.type}`
)}:
${this._params.title}`}
@closed=${this._dialogClosed}
>
<ha-target-picker-item-row
.hass=${this.hass}
.type=${this._params.type}
.itemId=${this._params.itemId}
.deviceFilter=${this._params.deviceFilter}
.entityFilter=${this._params.entityFilter}
.includeDomains=${this._params.includeDomains}
.includeDeviceClasses=${this._params.includeDeviceClasses}
.primaryEntitiesOnly=${this._params.primaryEntitiesOnly}
expand
></ha-target-picker-item-row>
<div class="type-wrapper">
<div class="type-label">
${this.hass.localize(
`ui.components.target-picker.type.${this._params.type}`
)}
</div>
<ha-list-base
.ariaLabel=${`${this.hass.localize(`ui.components.target-picker.type.${this._params.type}`)}: ${this._params.title}`}
wrap-focus
>
${waitingForSources
? nothing
: html`
<ha-target-picker-item-row
.hass=${this.hass}
.type=${this._params.type}
.itemId=${this._params.itemId}
.deviceFilter=${deviceFilter}
.entityFilter=${entityFilter}
.includeDomains=${includeDomains}
.includeDeviceClasses=${includeDeviceClasses}
.primaryEntitiesOnly=${primaryEntitiesOnly}
expand
></ha-target-picker-item-row>
`}
</ha-list-base>
</div>
</ha-dialog>
`;
}
static styles = css`
.type-wrapper {
display: flex;
flex-direction: column;
border-radius: var(--ha-border-radius-xl);
border: var(--ha-border-width-sm) solid
var(--ha-color-border-neutral-normal);
overflow: hidden;
}
.type-label {
background-color: var(--ha-color-surface-low);
padding: var(--ha-space-1) var(--ha-space-3);
font-weight: var(--ha-font-weight-bold);
display: flex;
align-items: center;
height: 20px;
}
`;
}
declare global {

View File

@@ -1,14 +1,14 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
import type { TargetSelector } from "../../../data/selector";
import type { TargetType } from "../../../data/target";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
export type NewBackupType = "automatic" | "manual";
export interface TargetDetailsDialogParams {
title: string;
type: TargetType;
itemId: string;
selector?: TargetSelector;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
includeDomains?: string[];

View File

@@ -5,7 +5,7 @@ import type { TargetType, TargetTypeFloorless } from "../../data/target";
import type { HomeAssistant } from "../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import "../ha-expansion-panel";
import "../ha-md-list";
import "../list/ha-list-base";
import "./ha-target-picker-item-row";
@customElement("ha-target-picker-item-group")
@@ -66,23 +66,25 @@ export class HaTargetPickerItemGroup extends LitElement {
}
)}
</div>
${Object.entries(this.items).map(([type, items]) =>
items
? items.map(
(item) =>
html`<ha-target-picker-item-row
.hass=${this.hass}
.type=${type as TargetTypeFloorless}
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
></ha-target-picker-item-row>`
)
: nothing
)}
<ha-list-base>
${Object.entries(this.items).map(([type, items]) =>
items
? items.map(
(item) =>
html`<ha-target-picker-item-row
.hass=${this.hass}
.type=${type as TargetTypeFloorless}
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
></ha-target-picker-item-row>`
)
: nothing
)}
</ha-list-base>
</ha-expansion-panel>`;
}
@@ -96,7 +98,7 @@ export class HaTargetPickerItemGroup extends LitElement {
--expansion-panel-content-padding: 0;
}
ha-expansion-panel::part(summary) {
background-color: var(--ha-color-fill-neutral-quiet-resting);
background-color: var(--ha-color-surface-low);
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
@@ -104,9 +106,6 @@ export class HaTargetPickerItemGroup extends LitElement {
justify-content: space-between;
min-height: unset;
}
ha-md-list {
padding: 0;
}
`;
}

View File

@@ -1,14 +1,24 @@
import { consume } from "@lit/context";
import {
mdiChevronLeft,
mdiChevronRight,
mdiClose,
mdiDevices,
mdiHome,
mdiLabel,
mdiMinusBox,
mdiTextureBox,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import {
css,
html,
LitElement,
nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
@@ -38,18 +48,17 @@ import {
type ExtractFromTargetResultReferenced,
type TargetType,
} from "../../data/target";
import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-dialog";
import { buttonLinkStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon-button";
import "../ha-md-list";
import type { HaMdList } from "../ha-md-list";
import "../ha-md-list-item";
import type { HaMdListItem } from "../ha-md-list-item";
import "../ha-state-icon";
import "../ha-svg-icon";
import "../item/ha-list-item-base";
import "../item/ha-list-item-button";
import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details";
@customElement("ha-target-picker-item-row")
@@ -65,6 +74,9 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ type: Boolean, attribute: "sub-entry", reflect: true })
public subEntry = false;
@property({ attribute: false })
public subLevel = 0;
@property({ type: Boolean, attribute: "hide-context" })
public hideContext = false;
@@ -106,12 +118,6 @@ export class HaTargetPickerItemRow extends LitElement {
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
@query("ha-md-list-item") public item?: HaMdListItem;
@query("ha-md-list") public list?: HaMdList;
@query("ha-target-picker-item-row") public itemRow?: HaTargetPickerItemRow;
protected willUpdate(changedProps: PropertyValues<this>) {
if (!this.subEntry && changedProps.has("itemId")) {
this._updateItemData();
@@ -137,101 +143,128 @@ export class HaTargetPickerItemRow extends LitElement {
const replaceable = !this.subEntry && !this.expand;
return html`
<ha-md-list-item
type=${replaceable ? "button" : "text"}
class=${classMap({
error: notFound,
replaceable,
})}
@click=${replaceable ? this._replaceItem : undefined}
>
<div class="icon" slot="start">
${this.subEntry
? html`
<div class="horizontal-line-wrapper">
<div class="horizontal-line"></div>
</div>
`
: nothing}
${iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)}
>
</ha-state-icon>
`
: nothing}
</div>
const content = html`
<div class="icon" slot="start">
${iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)}
>
</ha-state-icon>
`
: nothing}
</div>
<div slot="headline">${name}</div>
${notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context}</span
>`
: nothing}
${this._domainName && this.subEntry
? html`<span slot="supporting-text" class="domain"
>${this._domainName}</span
>`
: nothing}
${!this.subEntry && entries && showEntities
? html`
<div slot="end" class="summary">
${showEntities &&
!this.expand &&
entries?.referenced_entities.length
? html`<button class="main link" @click=${this._openDetails}>
<div slot="headline">${name}</div>
${notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context}</span
>`
: nothing}
${stateObject && this.subEntry
? html`<span slot="supporting-text" class="state"
>${this.hass.formatEntityState(stateObject)}</span
>`
: nothing}
${!this.subEntry && entries && showEntities
? html`
<div slot="end" class="summary">
${showEntities &&
!this.expand &&
entries?.referenced_entities.length
? html`<button class="main link" @click=${this._openDetails}>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</button>`
: showEntities
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</button>`
: showEntities
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</span>`
: nothing}
</div>
`
: nothing}
${!this.expand && !this.subEntry
</span>`
: nothing}
</div>
`
: nothing}
${!this.expand && !this.subEntry
? html`
<ha-icon-button
.path=${mdiClose}
slot="end"
@click=${this._removeItem}
></ha-icon-button>
`
: this.subEntry && this.type === "entity"
? html`
<ha-icon-button
.path=${mdiClose}
<ha-svg-icon
.path=${computeRTL(this.hass)
? mdiChevronLeft
: mdiChevronRight}
slot="end"
@click=${this._removeItem}
></ha-icon-button>
></ha-svg-icon>
`
: nothing}
</ha-md-list-item>
`;
let item: TemplateResult;
if (replaceable || (this.subEntry && this.type === "entity")) {
item = html`
<ha-list-item-button
class=${classMap({
error: notFound,
replaceable,
})}
@click=${replaceable
? this._replaceItem
: this.subEntry && this.type === "entity"
? this._openMoreInfo
: undefined}
>
${content}
</ha-list-item-button>
`;
} else {
item = html`
<ha-list-item-base
class=${classMap({
error: notFound,
})}
>
${content}
</ha-list-item-base>
`;
}
return html`
${item}
${this.expand && entries && entries.referenced_entities
? this._renderEntries()
: nothing}
@@ -241,6 +274,10 @@ export class HaTargetPickerItemRow extends LitElement {
private _renderEntries() {
const entries = this.parentEntries || this._entries;
if (!entries || entries.referenced_entities.length === 0) {
return this._renderEmptyEntries();
}
let nextType: TargetType =
this.type === "floor"
? "area"
@@ -350,54 +387,64 @@ export class HaTargetPickerItemRow extends LitElement {
) || ([] as string[]),
}));
const nextSubLevel = this.subLevel + 1;
return html`
<div class="entries-tree">
<div class="line-wrapper">
<div class="line"></div>
</div>
<ha-md-list class="entries">
${rows1.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rows1Entries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${deviceRows.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowsEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${entityRows.map(
(itemId) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
type="entity"
.itemId=${itemId}
.hideContext=${this.hideContext || this.type !== "label"}
></ha-target-picker-item-row>
`
)}
</ha-md-list>
</div>
${rows1.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.subLevel=${nextSubLevel}
style=${`--sub-entry-indent: calc(${nextSubLevel} * var(--ha-space-10));`}
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rows1Entries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${deviceRows.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.subLevel=${nextSubLevel}
style=${`--sub-entry-indent: calc(${nextSubLevel} * var(--ha-space-10));`}
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowsEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${entityRows.map(
(itemId) => html`
<ha-target-picker-item-row
sub-entry
.subLevel=${nextSubLevel}
style=${`--sub-entry-indent: calc(${nextSubLevel} * var(--ha-space-10));`}
.hass=${this.hass}
type="entity"
.itemId=${itemId}
.hideContext=${this.hideContext || this.type !== "label"}
></ha-target-picker-item-row>
`
)}
`;
}
private _renderEmptyEntries() {
return html`<ha-list-item-base>
<ha-svg-icon .path=${mdiMinusBox} slot="start" class="icon"></ha-svg-icon>
<span slot="headline"
>${this.hass.localize("ui.components.target-picker.no_targets")}</span
>
</ha-list-item-base>`;
}
private async _updateItemData() {
if (this.type === "entity") {
this._entries = undefined;
@@ -640,6 +687,12 @@ export class HaTargetPickerItemRow extends LitElement {
});
}
private _openMoreInfo = () => {
showMoreInfoDialog(this, {
entityId: this.itemId,
});
};
static styles = [
buttonLinkStyle,
css`
@@ -651,12 +704,6 @@ export class HaTargetPickerItemRow extends LitElement {
--md-list-item-two-line-container-height: 56px;
}
:host([expand]:not([sub-entry])) ha-md-list-item {
border: 2px solid var(--ha-color-border-neutral-loud);
background-color: var(--ha-color-fill-neutral-quiet-resting);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
.error {
background: var(--ha-color-fill-warning-quiet-resting);
}
@@ -680,6 +727,7 @@ export class HaTargetPickerItemRow extends LitElement {
.icon {
width: 24px;
display: flex;
color: var(--ha-color-on-neutral-normal);
}
img {
@@ -697,53 +745,21 @@ export class HaTargetPickerItemRow extends LitElement {
line-height: var(--ha-line-height-condensed);
}
:host([sub-entry]) .summary {
margin-right: var(--ha-space-12);
margin-inline-start: var(--ha-space-12);
}
.summary .main {
font-weight: var(--ha-font-weight-medium);
}
:host([expand]) .summary .main {
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-normal);
}
.summary .secondary {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.entries-tree {
display: flex;
position: relative;
}
.entries-tree .line-wrapper {
padding: var(--ha-space-5);
}
.entries-tree .line-wrapper .line {
border-left: 2px dashed var(--divider-color);
height: calc(100% - 28px);
position: absolute;
top: 0;
}
:host([sub-entry]) .entries-tree .line-wrapper .line {
height: calc(100% - 12px);
top: -18px;
}
.entries {
padding: 0;
--md-item-overflow: visible;
}
.horizontal-line-wrapper {
position: relative;
}
.horizontal-line-wrapper .horizontal-line {
position: absolute;
top: 11px;
margin-inline-start: -28px;
width: 29px;
border-top: 2px dashed var(--divider-color);
}
button.link {
text-decoration: none;
color: var(--primary-color);
@@ -754,12 +770,19 @@ export class HaTargetPickerItemRow extends LitElement {
text-decoration: underline;
}
.domain {
.state {
width: fit-content;
border-radius: var(--ha-border-radius-md);
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1);
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-s);
color: var(--ha-color-text-secondary);
}
ha-list-item-button::part(end) {
gap: var(--ha-space-2);
}
:host([sub-entry]) ha-list-item-button::part(base),
:host([sub-entry]) ha-list-item-base::part(base) {
padding-inline-start: var(--sub-entry-indent);
}
`,
];

View File

@@ -18,7 +18,6 @@ import { toggleAttribute } from "../../common/dom/toggle_attribute";
import { fullEntitiesContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LogbookEntry } from "../../data/logbook";
import { localizeTriggerDescription } from "../../data/logbook";
import type {
ChooseAction,
IfAction,
@@ -333,10 +332,7 @@ class ActionRenderer {
: "other",
alias: triggerStep.changed_variables.trigger?.alias,
triggeredPath: triggerStep.path === "trigger" ? "manual" : "trigger",
trigger: localizeTriggerDescription(
this.hass.localize,
this.trace.trigger
),
trigger: this.trace.trigger,
time: formatDateTimeWithSeconds(
new Date(triggerStep.timestamp),
this.hass.locale,

View File

@@ -195,49 +195,6 @@ export const localizeTriggerSource = (
return source;
};
// Mapping from a phrase key to the bare-phrase translation key (without the
// "triggered by" prefix), used by localizeTriggerDescription below.
const triggerDescriptionKeys: Record<
TriggerPhraseKeys,
| "numeric_state_of"
| "state_of"
| "event"
| "time"
| "time_pattern"
| "homeassistant_stopping"
| "homeassistant_starting"
> = {
triggered_by_numeric_state_of: "numeric_state_of",
triggered_by_state_of: "state_of",
triggered_by_event: "event",
triggered_by_time_pattern: "time_pattern",
triggered_by_time: "time",
triggered_by_homeassistant_stopping: "homeassistant_stopping",
triggered_by_homeassistant_starting: "homeassistant_starting",
};
// Like localizeTriggerSource, but returns just the bare localized trigger
// description (without the "triggered by" prefix). Used where the surrounding
// template already supplies its own "triggered by" wording.
export const localizeTriggerDescription = (
localize: LocalizeFunc,
source: string
) => {
for (const triggerPhraseKey of Object.keys(
triggerPhrases
) as TriggerPhraseKeys[]) {
const phrase = triggerPhrases[triggerPhraseKey];
if (source.startsWith(phrase)) {
const bareKey = triggerDescriptionKeys[triggerPhraseKey];
return source.replace(
phrase,
`${localize(`ui.components.logbook.${bareKey}`)}`
);
}
}
return source;
};
export const localizeStateMessage = (
hass: HomeAssistant,
localize: LocalizeFunc,

View File

@@ -3,13 +3,10 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-icon-button";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
import {
@@ -43,33 +40,14 @@ interface FlowUpdateEvent {
stepPromise?: Promise<DataEntryFlowStep>;
}
interface FlowStepFooterStateChangedEvent {
loading?: boolean;
hasPendingUpdates?: boolean;
}
interface FormStepElement extends HTMLElement {
submit(): Promise<void>;
}
interface AbortStepElement extends HTMLElement {
close(): void;
}
interface CreateEntryStepElement extends HTMLElement {
finish(): Promise<void>;
}
declare global {
// for fire event
interface HASSDomEvents {
"flow-update": FlowUpdateEvent;
"flow-step-footer-state-changed": FlowStepFooterStateChangedEvent;
}
// for add event listener
interface HTMLElementEventMap {
"flow-update": HASSDomEvent<FlowUpdateEvent>;
"flow-step-footer-state-changed": HASSDomEvent<FlowStepFooterStateChangedEvent>;
}
}
@@ -95,16 +73,6 @@ class DataEntryFlowDialog extends LitElement {
@state() private _handler?: string;
@state() private _formStepLoading = false;
@state() private _createEntryHasPendingUpdates = false;
private _formStepRef = createRef<FormStepElement>();
private _abortStepRef = createRef<AbortStepElement>();
private _createEntryStepRef = createRef<CreateEntryStepElement>();
private _unsubDataEntryFlowProgress?: UnsubscribeFunc;
public async showDialog(params: DataEntryFlowDialogParams): Promise<void> {
@@ -398,14 +366,11 @@ class DataEntryFlowDialog extends LitElement {
${this._step.type === "form"
? html`
<step-flow-form
${ref(this._formStepRef)}
autofocus
narrow
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
></step-flow-form>
`
: this._step.type === "external"
@@ -419,7 +384,6 @@ class DataEntryFlowDialog extends LitElement {
: this._step.type === "abort"
? html`
<step-flow-abort
${ref(this._abortStepRef)}
.params=${this._params}
.step=${this._step}
.hass=${this.hass}
@@ -447,14 +411,11 @@ class DataEntryFlowDialog extends LitElement {
`
: html`
<step-flow-create-entry
${ref(this._createEntryStepRef)}
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.navigateToResult=${this._params
.navigateToResult ?? false}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
.devices=${this._devices(
this._params.flowConfig.showDevices,
Object.values(this.hass.devices),
@@ -465,95 +426,10 @@ class DataEntryFlowDialog extends LitElement {
`}
`}
</div>
${this._renderFooter()}
</ha-dialog>
`;
}
private _renderFooter() {
if (!this._step || this._loading) {
return nothing;
}
switch (this._step.type) {
case "form":
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
.loading=${this._formStepLoading}
@click=${this._submitFormStep}
>
${this._params!.flowConfig.renderShowFormStepSubmitButton(
this.hass,
this._step
)}
</ha-button>
</ha-dialog-footer>
`;
case "abort":
return this._step.reason === "missing_credentials"
? nothing
: html`
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._closeAbortStep}
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.close"
)}
</ha-button>
</ha-dialog-footer>
`;
case "external":
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
href=${this._step.url}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
</ha-dialog-footer>
`;
case "create_entry": {
const devices = this._devices(
this._params!.flowConfig.showDevices,
Object.values(this.hass.devices),
this._step.result?.entry_id,
this._params!.carryOverDevices
);
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
@click=${this._finishCreateEntryStep}
>
${this.hass.localize(
`ui.panel.config.integrations.config_flow.${
!devices.length ||
this._createEntryHasPendingUpdates ||
devices.some((device) => device.area_id)
? "finish"
: "finish_skip"
}`
)}
</ha-button>
</ha-dialog-footer>
`;
}
default:
return nothing;
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.addEventListener("flow-update", (ev) => {
@@ -603,8 +479,6 @@ class DataEntryFlowDialog extends LitElement {
}
this._step = undefined;
this._formStepLoading = false;
this._createEntryHasPendingUpdates = false;
await this.updateComplete;
this._step = _step;
if (
@@ -688,36 +562,20 @@ class DataEntryFlowDialog extends LitElement {
}
await this.updateComplete;
this._formStepRef.value?.focus();
};
private _handleFooterStateChanged = (
ev: HASSDomEvent<FlowStepFooterStateChangedEvent>
) => {
if (ev.detail.loading !== undefined) {
this._formStepLoading = ev.detail.loading;
}
if (ev.detail.hasPendingUpdates !== undefined) {
this._createEntryHasPendingUpdates = ev.detail.hasPendingUpdates;
}
};
private _submitFormStep = () => {
this._formStepRef.value?.submit();
};
private _closeAbortStep = () => {
this._abortStepRef.value?.close();
};
private _finishCreateEntryStep = () => {
this._createEntryStepRef.value?.finish();
(
this.renderRoot.querySelector(
"step-flow-form[autofocus]"
) as HTMLElement | null
)?.focus();
};
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.dialog-title {
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -8,6 +8,7 @@ import type { HomeAssistant } from "../../types";
import { showConfigFlowDialog } from "./show-dialog-config-flow";
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
import "../../components/ha-button";
@customElement("step-flow-abort")
class StepFlowAbort extends LitElement {
@@ -36,6 +37,13 @@ class StepFlowAbort extends LitElement {
<div class="content">
${this.params.flowConfig.renderAbortDescription(this.hass, this.step)}
</div>
<div class="buttons">
<ha-button appearance="plain" @click=${this._flowDone}
>${this.hass.localize(
"ui.panel.config.integrations.config_flow.close"
)}</ha-button
>
</div>
`;
}
@@ -60,10 +68,6 @@ class StepFlowAbort extends LitElement {
fireEvent(this, "flow-update", { step: undefined });
}
public close(): void {
this._flowDone();
}
static get styles(): CSSResultGroup {
return configFlowContentStyles;
}

View File

@@ -10,6 +10,7 @@ import {
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import "../../components/ha-area-picker";
import "../../components/ha-button";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
import { assistSatelliteSupportsSetupFlow } from "../../data/assist_satellite";
@@ -30,10 +31,6 @@ import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voi
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
interface DeviceTarget {
device: string;
}
@customElement("step-flow-create-entry")
class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public flowConfig!: FlowConfig;
@@ -195,18 +192,20 @@ class StepFlowCreateEntry extends LitElement {
</div>
`}
</div>
<div class="buttons">
<ha-button @click=${this._flowDone}
>${localize(
`ui.panel.config.integrations.config_flow.${
!this.devices.length || Object.keys(this._deviceUpdate).length
? "finish"
: "finish_skip"
}`
)}</ha-button
>
</div>
`;
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_deviceUpdate")) {
fireEvent(this, "flow-step-footer-state-changed", {
hasPendingUpdates: Object.keys(this._deviceUpdate).length > 0,
});
}
}
private async _loadDomains() {
const entries = await getConfigEntries(this.hass);
this._domains = Object.fromEntries(
@@ -225,20 +224,18 @@ class StepFlowCreateEntry extends LitElement {
return updateDeviceRegistryEntry(this.hass, deviceId, {
name_by_user: update.name,
area_id: update.area,
}).catch((err: unknown) => {
const message =
err instanceof Error ? err.message : "Unknown error";
}).catch((err: any) => {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_device",
{ error: message }
{ error: err.message }
),
});
});
}
);
await Promise.allSettled(deviceUpdates);
const entityUpdates: Promise<unknown>[] = [];
const entityUpdates: Promise<any>[] = [];
const entityIds: string[] = [];
renamedDevices.forEach((deviceId) => {
const entities = this._deviceEntities(
@@ -284,15 +281,8 @@ class StepFlowCreateEntry extends LitElement {
}
}
public finish(): Promise<void> {
return this._flowDone();
}
private async _areaPicked(ev: ValueChangedEvent<string>) {
const picker = ev.currentTarget as DeviceTarget | null;
if (!picker) {
return;
}
const picker = ev.currentTarget as any;
const device = picker.device;
const area = ev.detail.value;
@@ -304,11 +294,8 @@ class StepFlowCreateEntry extends LitElement {
}
private _deviceNameChanged(ev: InputEvent): void {
const picker = ev.currentTarget as (HaInput & DeviceTarget) | null;
if (!picker) {
return;
}
const device = picker.device;
const picker = ev.currentTarget as HaInput;
const device = (picker as any).device;
const name = picker.value;
if (!(device in this._deviceUpdate)) {
@@ -324,13 +311,22 @@ class StepFlowCreateEntry extends LitElement {
css`
.devices {
display: flex;
gap: var(--ha-space-2);
margin: -4px;
max-height: 600px;
overflow-y: auto;
flex-direction: column;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.devices {
/* header - margin content - footer */
max-height: calc(100vh - 52px - 20px - 52px);
}
}
.device {
border: 1px solid var(--divider-color);
padding: 6px;
border-radius: var(--ha-border-radius-sm);
margin: 4px;
display: inline-block;
}
.device-info {
@@ -356,6 +352,11 @@ class StepFlowCreateEntry extends LitElement {
ha-input {
margin: var(--ha-space-2) 0;
}
.buttons > *:last-child {
margin-left: auto;
margin-inline-start: auto;
margin-inline-end: initial;
}
.error {
color: var(--error-color);
}

View File

@@ -1,10 +1,11 @@
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { DataEntryFlowStepExternal } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
import "../../components/ha-button";
@customElement("step-flow-external")
class StepFlowExternal extends LitElement {
@@ -15,9 +16,18 @@ class StepFlowExternal extends LitElement {
@property({ attribute: false }) public step!: DataEntryFlowStepExternal;
protected render(): TemplateResult {
const localize = this.hass.localize;
return html`
<div class="content">
${this.flowConfig.renderExternalStepDescription(this.hass, this.step)}
<div class="open-button">
<ha-button href=${this.step.url} target="_blank" rel="noreferrer">
${localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
</div>
</div>
`;
}
@@ -28,7 +38,18 @@ class StepFlowExternal extends LitElement {
}
static get styles(): CSSResultGroup {
return [configFlowContentStyles];
return [
configFlowContentStyles,
css`
.open-button {
text-align: center;
padding: 24px 0;
}
.open-button a {
text-decoration: none;
}
`,
];
}
}

View File

@@ -1,11 +1,11 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import "../../components/ha-button";
import "../../components/ha-alert";
import { computeInitialHaFormData } from "../../components/ha-form/compute-initial-ha-form-data";
import "../../components/ha-form/ha-form";
@@ -19,7 +19,7 @@ import { autocompleteLoginFields } from "../../data/auth";
import type { DataEntryFlowStepForm } from "../../data/data_entry_flow";
import { previewModule } from "../../data/preview";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
@@ -47,8 +47,6 @@ class StepFlowForm extends LitElement {
private _errors?: Record<string, string>;
private _formRef = createRef<HTMLElementTagNameMap["ha-form"]>();
static shadowRootOptions: ShadowRootInit = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
@@ -90,27 +88,24 @@ class StepFlowForm extends LitElement {
${this.flowConfig.renderShowFormStepDescription(this.hass, this.step)}
${this._errorMsg
? html`<ha-alert alert-type="error">${this._errorMsg}</ha-alert>`
: nothing}
${step.data_schema.length
? html`<ha-form
${ref(this._formRef)}
?autofocus=${this.autoFocus}
.hass=${this.hass}
.narrow=${this.narrow}
.data=${stepData}
.disabled=${this._loading}
@value-changed=${this._stepDataChanged}
.schema=${autocompleteLoginFields(
this.handleReadOnlyFields(step.data_schema)
)}
.error=${this._errors}
.computeLabel=${this._labelCallback}
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler }}
></ha-form>`
: nothing}
: ""}
<ha-form
?autofocus=${this.autoFocus}
.hass=${this.hass}
.narrow=${this.narrow}
.data=${stepData}
.disabled=${this._loading}
@value-changed=${this._stepDataChanged}
.schema=${autocompleteLoginFields(
this.handleReadOnlyFields(step.data_schema)
)}
.error=${this._errors}
.computeLabel=${this._labelCallback}
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler }}
></ha-form>
</div>
${step.preview
? html`<div class="preview" @set-flow-errors=${this._setError}>
@@ -130,6 +125,14 @@ class StepFlowForm extends LitElement {
})}
</div>`
: nothing}
<div class="buttons">
<ha-button @click=${this._submitStep} .loading=${this._loading}>
${this.flowConfig.renderShowFormStepSubmitButton(
this.hass,
this.step
)}
</ha-button>
</div>
`;
}
@@ -142,17 +145,8 @@ class StepFlowForm extends LitElement {
this.addEventListener("keydown", this._handleKeyDown);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_loading")) {
fireEvent(this, "flow-step-footer-state-changed", {
loading: this._loading,
});
}
}
public override focus(_options?: FocusOptions): void {
this._formRef.value?.focus();
this.renderRoot.querySelector("ha-form")?.focus();
}
protected willUpdate(changedProps: PropertyValues): void {
@@ -235,15 +229,13 @@ class StepFlowForm extends LitElement {
const flowId = this.step.flow_id;
const toSendData: Record<string, unknown> = {};
const toSendData = {};
Object.keys(stepData).forEach((key) => {
const value = stepData[key];
const isEmpty = [undefined, ""].includes(value);
const field = this.step.data_schema?.find((f) => f.name === key);
const selector = (field as HaFormSelector)?.selector ?? {};
const read_only = (
Object.values(selector)[0] as { read_only?: boolean } | null | undefined
)?.read_only;
const read_only = (Object.values(selector)[0] as any)?.read_only;
if (!isEmpty && !read_only) {
toSendData[key] = value;
}
@@ -285,13 +277,7 @@ class StepFlowForm extends LitElement {
}
}
public submit(): Promise<void> {
return this._submitStep();
}
private _stepDataChanged(
ev: ValueChangedEvent<Record<string, unknown>>
): void {
private _stepDataChanged(ev: CustomEvent): void {
this._stepData = ev.detail.value;
}
@@ -335,9 +321,13 @@ class StepFlowForm extends LitElement {
ha-alert,
ha-form {
margin-top: var(--ha-space-6);
margin-top: 24px;
display: block;
}
.buttons {
padding: 16px;
}
`,
];
}

View File

@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-spinner";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
@@ -28,7 +28,7 @@ class StepFlowLoading extends LitElement {
return html`
<div class="content">
<ha-spinner size="large"></ha-spinner>
${description ? html`<div>${description}</div>` : nothing}
${description ? html`<div>${description}</div>` : ""}
</div>
`;
}
@@ -40,7 +40,7 @@ class StepFlowLoading extends LitElement {
text-align: center;
}
ha-spinner {
margin-bottom: var(--ha-space-4);
margin-bottom: 16px;
}
`;
}

View File

@@ -78,7 +78,7 @@ class StepFlowMenu extends LitElement {
);
return html`
${description ? html`<div class="content">${description}</div>` : nothing}
${description ? html`<div class="content">${description}</div>` : ""}
<div class="options">
${options.map(
(option) => html`
@@ -119,17 +119,17 @@ class StepFlowMenu extends LitElement {
configFlowContentStyles,
css`
.options {
margin-top: var(--ha-space-5);
margin-bottom: var(--ha-space-4);
margin-top: 20px;
margin-bottom: 16px;
}
.content {
padding-bottom: var(--ha-space-4);
padding-bottom: 16px;
}
.content + .options {
margin-top: var(--ha-space-2);
margin-top: 8px;
}
ha-list-item {
--mdc-list-side-padding: var(--ha-space-6);
--mdc-list-side-padding: 24px;
}
`,
];

View File

@@ -53,7 +53,7 @@ class StepFlowProgress extends LitElement {
text-align: center;
}
ha-spinner {
margin-bottom: var(--ha-space-4);
margin-bottom: 16px;
}
`,
];

View File

@@ -2,9 +2,12 @@ import { css } from "lit";
export const configFlowContentStyles = css`
h2 {
margin: var(--ha-space-6) var(--ha-space-10) 0 0;
margin: 24px 38px 0 0;
margin-inline-start: 0px;
margin-inline-end: var(--ha-space-10);
margin-inline-end: 38px;
padding: 0 24px;
padding-inline-start: 24px;
padding-inline-end: 24px;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
font-family: var(
@@ -25,8 +28,19 @@ export const configFlowContentStyles = css`
.content,
.preview {
margin-top: var(--ha-space-5);
margin-top: 20px;
padding: 0 24px;
}
.buttons {
position: relative;
padding: 16px;
margin: 8px 0 0;
color: var(--primary-color);
display: flex;
justify-content: flex-end;
}
ha-markdown {
overflow-wrap: break-word;
}

View File

@@ -76,6 +76,7 @@ import type {
} from "../../../../data/script";
import { getActionType, isAction } from "../../../../data/script";
import { describeAction } from "../../../../data/script_i18n";
import type { TargetSelector } from "../../../../data/selector";
import { callExecuteScript } from "../../../../data/service";
import {
showAlertDialog,
@@ -288,6 +289,12 @@ export default class HaAutomationActionRow extends LitElement {
? { device_id: (this.action as DeviceAction).device_id }
: undefined;
const serviceTargetSpec =
type === "service" && action
? this.hass.services?.[computeDomain(action)]?.[computeObjectId(action)]
?.target
: undefined;
return html`
${type === "service" && "action" in this.action && this.action.action
? html`
@@ -317,7 +324,11 @@ export default class HaAutomationActionRow extends LitElement {
)
)}
${target !== undefined || (actionHasTarget && !this._isNew)
? this._renderTargets(target, actionHasTarget && !this._isNew)
? this._renderTargets(
target,
actionHasTarget && !this._isNew,
serviceTargetSpec
)
: nothing}
${type !== "condition" &&
(this.action as NonConditionAction).continue_on_error === true
@@ -331,7 +342,7 @@ export default class HaAutomationActionRow extends LitElement {
></ha-svg-icon>
<ha-tooltip for="svg-icon">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.continue_on_error_description"
"ui.panel.config.automation.editor.actions.continue_on_error"
)}
</ha-tooltip>`
: nothing}
@@ -681,11 +692,16 @@ export default class HaAutomationActionRow extends LitElement {
}
private _renderTargets = memoizeOne(
(target?: HassServiceTarget, targetRequired = false) =>
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"]
) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
></ha-automation-row-targets>`
);
@@ -1148,7 +1164,7 @@ export default class HaAutomationActionRow extends LitElement {
overflowStyles,
css`
ha-svg-icon.arrow-right {
--icon-primary-color: var(--ha-color-fill-neutral-loud-resting);
--icon-primary-color: var(--ha-color-fill-neutral-normal-resting);
}
ha-svg-icon#svg-icon {
--icon-primary-color: var(--ha-color-fill-neutral-loud-active);

View File

@@ -60,6 +60,7 @@ import {
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceCondition } from "../../../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import {
showAlertDialog,
showPromptDialog,
@@ -180,6 +181,9 @@ export default class HaAutomationConditionRow extends LitElement {
? { device_id: [(this.condition as DeviceCondition).device_id] }
: undefined;
const conditionTargetSpec =
this.conditionDescriptions[this.condition.condition]?.target;
return html`
<ha-condition-icon
slot="leading-icon"
@@ -191,7 +195,11 @@ export default class HaAutomationConditionRow extends LitElement {
describeCondition(this.condition, this.hass, this._entityReg)
)}
${target !== undefined || (descriptionHasTarget && !this._isNew)
? this._renderTargets(target, descriptionHasTarget && !this._isNew)
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
conditionTargetSpec
)
: nothing}
</h3>
<ha-automation-row-event-chip
@@ -505,11 +513,16 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _renderTargets = memoizeOne(
(target?: HassServiceTarget, targetRequired = false) =>
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"]
) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
></ha-automation-row-targets>`
);

View File

@@ -14,7 +14,8 @@ export const getTargetIcon = (
targetType: string,
targetId: string | undefined,
configEntryLookup: Record<string, ConfigEntry>,
getLabel?: (id: string) => LabelRegistryEntry | undefined
getLabel?: (id: string) => LabelRegistryEntry | undefined,
slot?: string
): TemplateResult | typeof nothing => {
if (!targetId) {
return nothing;
@@ -22,6 +23,7 @@ export const getTargetIcon = (
if (targetType === "floor" && hass.floors[targetId]) {
return html`<ha-floor-icon
.slot=${slot}
.floor=${hass.floors[targetId]}
></ha-floor-icon>`;
}
@@ -29,9 +31,12 @@ export const getTargetIcon = (
if (targetType === "area") {
const area = hass.areas[targetId];
if (area?.icon) {
return html`<ha-icon .icon=${area.icon}></ha-icon>`;
return html`<ha-icon .slot=${slot} .icon=${area.icon}></ha-icon>`;
}
return html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`;
return html`<ha-svg-icon
.slot=${slot}
.path=${mdiTextureBox}
></ha-svg-icon>`;
}
if (targetType === "device" && hass.devices[targetId]) {
@@ -45,6 +50,7 @@ export const getTargetIcon = (
return html`<ha-domain-icon
.domain=${domain}
brand-fallback
.slot=${slot}
></ha-domain-icon>`;
}
}
@@ -53,15 +59,16 @@ export const getTargetIcon = (
return html`<ha-state-icon
.hass=${hass}
.stateObj=${hass.states[targetId]}
.slot=${slot}
></ha-state-icon>`;
}
if (targetType === "label" && getLabel) {
const label = getLabel(targetId);
if (label?.icon) {
return html`<ha-icon .icon=${label.icon}></ha-icon>`;
return html`<ha-icon .slot=${slot} .icon=${label.icon}></ha-icon>`;
}
return html`<ha-svg-icon .path=${mdiLabel}></ha-svg-icon>`;
return html`<ha-svg-icon .slot=${slot} .path=${mdiLabel}></ha-svg-icon>`;
}
return nothing;

View File

@@ -1,9 +1,11 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume, type ContextType } from "@lit/context";
import {
mdiAlert,
mdiAlertOctagon,
mdiCodeBraces,
mdiFormatListBulleted,
mdiMenuDown,
mdiShape,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
@@ -12,8 +14,13 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ensureArray } from "../../../../common/array/ensure-array";
import { transform } from "../../../../common/decorators/transform";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { isTemplate } from "../../../../common/string/has-template";
import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-svg-icon";
import { showTargetDetailsDialog } from "../../../../components/target-picker/dialog/show-dialog-target-details";
import type { ConfigEntry } from "../../../../data/config_entries";
import {
configEntriesContext,
@@ -23,6 +30,9 @@ import {
statesContext,
} from "../../../../data/context";
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
import type { TargetSelector } from "../../../../data/selector";
import type { TargetType } from "../../../../data/target";
import { showMoreInfoDialog } from "../../../../dialogs/more-info/show-ha-more-info-dialog";
import type { HomeAssistant } from "../../../../types";
import { getTargetIcon } from "./get_target_icon";
import { getTargetText } from "./get_target_text";
@@ -38,6 +48,9 @@ export class HaAutomationRowTargets extends LitElement {
@property({ attribute: false })
public targetRequired = false;
@property({ attribute: false })
public selector?: TargetSelector;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -110,17 +123,67 @@ export class HaAutomationRowTargets extends LitElement {
);
}
return html`<span class="target">
<ha-svg-icon .path=${mdiFormatListBulleted}></ha-svg-icon>
<div class="label">
${this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.targets",
{
count: totalLength,
}
)}
</div>
</span>`;
const rows = Object.entries(this.target!)
.reduce<["floor" | "area" | "device" | "entity" | "label", string][]>(
(acc, [targetType, targetId]) => {
const type = targetType.replace("_id", "") as
| "floor"
| "area"
| "device"
| "entity"
| "label";
return [
...acc,
...ensureArray(targetId).map((id): [typeof type, string] => [
type,
id,
]),
];
},
[]
)
.sort(([typeA], [typeB]) => {
const order = ["entity", "device", "area", "floor", "label"];
return order.indexOf(typeA) - order.indexOf(typeB);
});
let lastTargetType: string | null = null;
return html`
<ha-dropdown
@wa-select=${this._handleTargetSelect}
@click=${stopPropagation}
>
<span slot="trigger" class="target interactive">
<ha-svg-icon .path=${mdiFormatListBulleted}></ha-svg-icon>
<div class="label">
${this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.targets",
{
count: totalLength,
}
)}
</div>
<ha-svg-icon .path=${mdiMenuDown}></ha-svg-icon>
</span>
${rows.map(([targetType, targetId]) => {
const content = html`${lastTargetType !== null &&
lastTargetType !== targetType
? html`<wa-divider></wa-divider>`
: nothing}
${!lastTargetType || lastTargetType !== targetType
? html`<h3>
${this._i18n.localize(
`ui.panel.config.automation.editor.target_summary.types.${targetType}`
)}
</h3>`
: nothing}
${this._renderTarget(targetType, targetId, true)}`;
lastTargetType = targetType;
return content;
})}
</ha-dropdown>
`;
}
private _getLabel = (id: string) =>
@@ -152,9 +215,22 @@ export class HaAutomationRowTargets extends LitElement {
icon: TemplateResult | typeof nothing,
label: string,
warning = false,
error = false
error = false,
targetId?: string,
targetType?: string
) {
return html`<div class=${classMap({ target: true, warning, error })}>
return html`<div
class=${classMap({
target: true,
warning,
error,
interactive: targetId && targetType,
})}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@click=${this._handleTargetClick}
>
${icon}
<div class="label">${label}</div>
</div>`;
@@ -162,48 +238,131 @@ export class HaAutomationRowTargets extends LitElement {
private _renderTarget(
targetType: "floor" | "area" | "device" | "entity" | "label",
targetId: string
targetId: string,
dropdownOption = false
) {
let icon: string | undefined;
let label: string;
let warning = false;
let badgeTargetId: string | undefined = targetId;
let badgeTargetType: string | undefined = targetType;
if (targetType === "entity" && ["all", "none"].includes(targetId)) {
return this._renderTargetBadge(
html`<ha-svg-icon .path=${mdiShape}></ha-svg-icon>`,
this._i18n.localize(
`ui.panel.config.automation.editor.target_summary.${targetId as "all" | "none"}_entities`
)
icon = mdiShape;
label = this._i18n.localize(
`ui.panel.config.automation.editor.target_summary.${targetId as "all" | "none"}_entities`
);
badgeTargetId = undefined;
badgeTargetType = undefined;
} else if (isTemplate(targetId)) {
// Check if the target is a template
icon = mdiCodeBraces;
label = this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.template"
);
badgeTargetId = undefined;
badgeTargetType = undefined;
} else {
const exists = this._checkTargetExists(targetType, targetId);
if (!exists) {
icon = mdiAlert;
label = getTargetText(this.hass, targetType, targetId, this._getLabel);
warning = true;
badgeTargetId = undefined;
badgeTargetType = undefined;
} else {
label = getTargetText(this.hass, targetType, targetId, this._getLabel);
}
}
// Check if the target is a template
if (isTemplate(targetId)) {
return this._renderTargetBadge(
html`<ha-svg-icon .path=${mdiCodeBraces}></ha-svg-icon>`,
this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.template"
)
);
}
const iconTemplate = icon
? html`<ha-svg-icon
.slot=${dropdownOption ? "icon" : ""}
.icon=${icon}
></ha-svg-icon>`
: getTargetIcon(
this.hass,
targetType,
targetId,
this._configEntryLookup || {},
this._getLabel,
dropdownOption ? "icon" : ""
);
const exists = this._checkTargetExists(targetType, targetId);
if (!exists) {
return this._renderTargetBadge(
html`<ha-svg-icon .path=${mdiAlert}></ha-svg-icon>`,
getTargetText(this.hass, targetType, targetId, this._getLabel),
true
);
if (dropdownOption) {
return html`<ha-dropdown-item
.value=${{
targetId: badgeTargetId,
targetType: badgeTargetType,
label,
}}
class=${classMap({
warning,
})}
>${iconTemplate} ${label}</ha-dropdown-item
>`;
}
return this._renderTargetBadge(
getTargetIcon(
this.hass,
targetType,
targetId,
this._configEntryLookup || {},
this._getLabel
),
getTargetText(this.hass, targetType, targetId, this._getLabel)
iconTemplate,
label,
warning,
false,
badgeTargetId,
badgeTargetType
);
}
private _handleTargetClick(ev: Event) {
const target = ev.currentTarget as HTMLDivElement & {
targetId: string;
targetType: TargetType;
label: string;
};
this._showTargetInfo(target.targetId, target.targetType, target.label, ev);
}
private _handleTargetSelect(
ev: HaDropdownSelectEvent<{
targetId?: string;
targetType?: TargetType;
label: string;
}>
) {
const value = ev.detail.item.value;
if (!value.targetId || !value.targetType) {
return;
}
this._showTargetInfo(value.targetId, value.targetType, value.label);
}
private _showTargetInfo(
targetId: string,
targetType: TargetType,
label: string,
ev?: Event
) {
if (!targetId || !targetType) {
return;
}
ev?.stopPropagation();
if (targetType === "entity") {
showMoreInfoDialog(this, { entityId: targetId });
return;
}
showTargetDetailsDialog(this, {
title: label,
type: targetType,
itemId: targetId,
selector: this.selector,
});
}
static styles = css`
:host {
display: contents;
@@ -255,6 +414,25 @@ export class HaAutomationRowTargets extends LitElement {
height: 32px;
align-items: center;
}
.target.interactive {
cursor: pointer;
}
.target.interactive:hover {
background: var(--ha-color-fill-neutral-normal-hover);
}
ha-dropdown-item {
padding: 0 var(--ha-space-2);
}
ha-dropdown-item.warning {
background-color: var(--ha-color-fill-warning-quiet-resting);
color: var(--ha-color-on-warning-normal);
}
ha-dropdown-item.warning:hover {
background-color: var(--ha-color-fill-warning-quiet-hover);
color: var(--ha-color-on-warning-normal);
}
`;
}

View File

@@ -59,6 +59,7 @@ import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceTrigger } from "../../../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
import {
@@ -214,6 +215,12 @@ export default class HaAutomationTriggerRow extends LitElement {
? { device_id: (this.trigger as DeviceTrigger).device_id }
: undefined;
const triggerTargetSpec =
type === "platform"
? this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger]
?.target
: undefined;
return html`
${type === "list"
? html`<ha-svg-icon
@@ -229,7 +236,11 @@ export default class HaAutomationTriggerRow extends LitElement {
<h3 slot="header">
${describeTrigger(this.trigger, this.hass, this._entityReg)}
${target !== undefined || (descriptionHasTarget && !this._isNew)
? this._renderTargets(target, descriptionHasTarget && !this._isNew)
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
triggerTargetSpec
)
: nothing}
</h3>
<ha-automation-row-event-chip
@@ -507,11 +518,16 @@ export default class HaAutomationTriggerRow extends LitElement {
}
private _renderTargets = memoizeOne(
(target?: HassServiceTarget, targetRequired = false) =>
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"]
) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
></ha-automation-row-targets>`
);

View File

@@ -224,7 +224,7 @@ class HaConfigSystemNavigation extends LitElement {
return;
}
}
this._externalAccess = this.hass.config.external_url != null;
this._externalAccess = this.hass.config.external_url !== null;
}
private async _fetchLabFeatures() {

View File

@@ -291,20 +291,40 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this.configEntriesInProgress
);
const discoveryFlows = this._discoveryFlows(
configEntriesInProgress,
this.hass.locale.language
const discoveryFlows = configEntriesInProgress
.filter((flow) => !ATTENTION_SOURCES.includes(flow.context.source))
.sort((a, b) =>
caseInsensitiveStringCompare(
a.localized_title || "zzz",
b.localized_title || "zzz",
this.hass.locale.language
)
);
const attentionFlows = configEntriesInProgress.filter((flow) =>
ATTENTION_SOURCES.includes(flow.context.source)
);
const attentionFlows = this._attentionFlows(configEntriesInProgress);
const attentionEntries = this._attentionEntries(configEntries);
const normalEntries = this._normalEntries(
configEntries,
this.hass.locale.language
const attentionEntries = configEntries.filter((entry) =>
ERROR_STATES.includes(entry.state)
);
const normalEntries = configEntries
.filter(
(entry) =>
entry.source !== "ignore" && !ERROR_STATES.includes(entry.state)
)
.sort((a, b) => {
if (Boolean(a.disabled_by) !== Boolean(b.disabled_by)) {
return a.disabled_by ? 1 : -1;
}
return caseInsensitiveStringCompare(
a.title,
b.title,
this.hass.locale.language
);
});
const normalData = this._buildNormalEntryData(
normalEntries,
this.hass.devices,
@@ -324,14 +344,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this._filter,
this.hass.areas
);
const filteredDiscoveryData = this._filterDiscoveryTree(
discoveryFlows,
this._filter
);
const filteredAttentionFlows = this._filterAttentionFlowTree(
attentionFlows,
this._filter
);
const filteredAttentionData = this._filterAttentionTree(
attentionData,
this._filter,
@@ -680,7 +692,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</ha-alert>
</div>`
: nothing}
${filteredDiscoveryData.length
${discoveryFlows.length
? html`
<div class="section">
<h3 class="section-header">
@@ -689,7 +701,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
)}
</h3>
<ha-md-list class="discovered">
${filteredDiscoveryData.map(
${discoveryFlows.map(
(flow) =>
html`<ha-md-list-item class="discovered">
${flow.localized_title}
@@ -708,7 +720,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</div>
`
: nothing}
${filteredAttentionFlows.length || filteredAttentionData.length
${attentionFlows.length || filteredAttentionData.length
? html`
<div class="section">
<h3 class="section-header">
@@ -716,9 +728,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
`ui.panel.config.integrations.integration_page.attention_entries`
)}
</h3>
${filteredAttentionFlows.length
${attentionFlows.length
? html`<ha-md-list class="attention">
${filteredAttentionFlows.map((flow) => {
${attentionFlows.map((flow) => {
const attention = ATTENTION_SOURCES.includes(
flow.context.source
);
@@ -982,49 +994,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
private _buildAttentionEntryData = memoizeOne(this._buildEntryData);
private _normalEntries = memoizeOne(
(
data: ConfigEntry[],
language: HomeAssistant["locale"]["language"]
): ConfigEntry[] =>
data
.filter(
(entry) =>
entry.source !== "ignore" && !ERROR_STATES.includes(entry.state)
)
.sort((a, b) => {
if (Boolean(a.disabled_by) !== Boolean(b.disabled_by)) {
return a.disabled_by ? 1 : -1;
}
return caseInsensitiveStringCompare(a.title, b.title, language);
})
);
private _attentionEntries = memoizeOne((data: ConfigEntry[]): ConfigEntry[] =>
data.filter((entry) => ERROR_STATES.includes(entry.state))
);
private _discoveryFlows = memoizeOne(
(
data: DataEntryFlowProgressExtended[],
language: HomeAssistant["locale"]["language"]
): DataEntryFlowProgressExtended[] =>
data
.filter((flow) => !ATTENTION_SOURCES.includes(flow.context.source))
.sort((a, b) =>
caseInsensitiveStringCompare(
a.localized_title || "zzz",
b.localized_title || "zzz",
language
)
)
);
private _attentionFlows = memoizeOne(
(data: DataEntryFlowProgressExtended[]): DataEntryFlowProgressExtended[] =>
data.filter((flow) => ATTENTION_SOURCES.includes(flow.context.source))
);
private _filterTree = memoizeOne(
(
data: ConfigEntryData[],
@@ -1120,35 +1089,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this._filterTree(data, filter, areas)
);
private _filterFlowTree = (
data: DataEntryFlowProgressExtended[],
filter: string
): DataEntryFlowProgressExtended[] => {
if (!filter) {
return data;
}
const TITLE_KEYS = ["localized_title"];
return multiTermSearch(
data.filter((item) => item.localized_title),
filter,
TITLE_KEYS,
undefined,
{ keys: TITLE_KEYS }
);
};
private _filterDiscoveryTree = memoizeOne(
(data: DataEntryFlowProgressExtended[], filter: string) =>
this._filterFlowTree(data, filter)
);
private _filterAttentionFlowTree = memoizeOne(
(data: DataEntryFlowProgressExtended[], filter: string) =>
this._filterFlowTree(data, filter)
);
private _filterAttentionTree = memoizeOne(
(data: ConfigEntryData[], filter: string, areas: HomeAssistant["areas"]) =>
this._filterTree(data, filter, areas)

View File

@@ -178,7 +178,7 @@ class DialogPersonDetail extends LitElement implements HassDialog {
<ha-switch
slot="end"
@change=${this._allowLoginChanged}
?disabled=${this._user &&
.disabled=${this._user &&
(this._user.id === this.hass.user?.id ||
this._user.system_generated ||
this._user.is_owner)}

View File

@@ -60,21 +60,17 @@ export function getSuggestedMax(
noRounding: boolean
): Date {
// Maximum period depends on whether plotting a line chart or discrete bars.
// - For line charts use noRounding true as we must always plot all the way
// to end of a given period, otherwise we cut off the last period of data.
// - For bar charts with 5minute intervals, leave the full time range
// to ensure we don't cut off any bars
// - For bar charts of hourly intervals, round to half-period to avoid excess
// padding but not cut off the final bar if placed mid interval.
// - For bar charts with whole numbers of days we need to round down to the
// start of the final bars period to avoid unnecessary padding of the chart.
// - For line charts we must be plotting all the way to end of a given period,
// otherwise we cut off the last period of data.
// - For bar charts we need to round down to the start of the final bars period
// to avoid unnecessary padding of the chart.
let suggestedMax = new Date(end);
if (noRounding || period === "5minute") {
return suggestedMax;
}
suggestedMax.setMinutes(0, 0, 0);
if (period === "hour") {
suggestedMax.setMinutes(30, 0, 0);
return suggestedMax;
}
// Sometimes around DST we get a time of 0:59 instead of 23:59 as expected.
@@ -82,7 +78,7 @@ export function getSuggestedMax(
if (suggestedMax.getHours() === 0) {
suggestedMax = subHours(suggestedMax, 1);
}
suggestedMax.setHours(0, 0, 0, 0);
suggestedMax.setHours(0);
if (period === "day" || period === "week") {
return suggestedMax;
}

View File

@@ -315,18 +315,14 @@ export class HuiPowerSourcesGraphCard
typeof item === "object" && "value" in item!
? item.value![0]
: item![0];
let sum = 0;
usageData[i] = [x, 0];
this._chartData.forEach((dataset) => {
const y =
typeof dataset.data![i] === "object" && "value" in dataset.data![i]!
? dataset.data![i].value![1]
: dataset.data![i]![1];
sum += y as number;
usageData[i]![1] += y as number;
});
// Consumption can't be negative; sources unaccounted for in the
// configuration (e.g. solar exporting to grid without a configured
// solar source) would otherwise drag the usage line below zero.
usageData[i] = [x, Math.max(0, sum)];
});
this._chartData.push({
...commonSeriesOptions,

View File

@@ -9,7 +9,6 @@ import { checkConditionsMet } from "../common/validate-condition";
import { createHeadingBadgeElement } from "../create-element/create-heading-badge-element";
import type { LovelaceHeadingBadge } from "../types";
import type { LovelaceHeadingBadgeConfig } from "./types";
import { getConfigEntityId } from "../common/get-config-entity-id";
declare global {
interface HASSDomEvents {
@@ -96,13 +95,6 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("config")) {
this._conditionContext = {
...this._conditionContext,
entity_id: this.config ? getConfigEntityId(this.config) : undefined,
};
}
if (!this._element) {
this.load();
}

View File

@@ -676,13 +676,6 @@
"triggered_by_time_pattern": "triggered by time pattern",
"triggered_by_homeassistant_stopping": "triggered by Home Assistant stopping",
"triggered_by_homeassistant_starting": "triggered by Home Assistant starting",
"numeric_state_of": "numeric state of",
"state_of": "state of",
"event": "event",
"time": "time",
"time_pattern": "time pattern",
"homeassistant_stopping": "Home Assistant stopping",
"homeassistant_starting": "Home Assistant starting",
"show_trace": "[%key:ui::panel::config::automation::editor::show_trace%]",
"retrieval_error": "Could not load activity",
"not_loaded": "[%key:ui::dialogs::helper_settings::platform_not_loaded%]",
@@ -5108,7 +5101,14 @@
"invalid": "Invalid target",
"all_entities": "All entities",
"none_entities": "No entities",
"template": "Template"
"template": "Template",
"types": {
"entity": "[%key:ui::components::target-picker::type::entities%]",
"device": "[%key:ui::components::target-picker::type::devices%]",
"area": "[%key:ui::components::target-picker::type::areas%]",
"floor": "Floors",
"label": "[%key:ui::components::target-picker::type::labels%]"
}
},
"generic": "Generic",
"triggers": {
@@ -5593,7 +5593,6 @@
"unsupported_action": "No visual editor support for this action",
"type_select": "Action type",
"continue_on_error": "Continue on error",
"continue_on_error_description": "If this action fails, the next action will still run.",
"action": "Action",
"copied_to_clipboard": "Action copied to clipboard",
"cut_to_clipboard": "Action cut to clipboard",

View File

@@ -55,11 +55,6 @@ const mockHass: HomeAssistant = {
state: "off",
attributes: { device_class: "light" },
},
"binary_sensor.unregistered_battery": {
entity_id: "binary_sensor.unregistered_battery",
state: "off",
attributes: { device_class: "battery" },
},
} as any,
entities: {
"light.living_room": {
@@ -307,20 +302,6 @@ describe("generateEntityFilter", () => {
expect(filter("light.living_room")).toBe(true);
expect(filter("sensor.humidity")).toBe(false);
});
it("should treat entities without a registry entry as having no category", () => {
const noneFilter = generateEntityFilter(mockHass, {
entity_category: "none",
});
const diagnosticFilter = generateEntityFilter(mockHass, {
entity_category: "diagnostic",
});
expect(noneFilter("binary_sensor.unregistered_battery")).toBe(true);
expect(diagnosticFilter("binary_sensor.unregistered_battery")).toBe(
false
);
});
});
describe("label filtering", () => {

View File

@@ -1,47 +0,0 @@
import { describe, it, expect } from "vitest";
import {
localizeTriggerDescription,
localizeTriggerSource,
} from "../../src/data/logbook";
const fakeLocalize = ((key: string) => `<${key}>`) as any;
describe("localizeTriggerSource", () => {
it("replaces a known phrase with the prefixed translation", () => {
expect(localizeTriggerSource(fakeLocalize, "Home Assistant starting")).toBe(
"<ui.components.logbook.triggered_by_homeassistant_starting>"
);
});
it("preserves trailing context after the matched phrase", () => {
expect(
localizeTriggerSource(fakeLocalize, "state of binary_sensor.foo")
).toBe("<ui.components.logbook.triggered_by_state_of> binary_sensor.foo");
});
it("returns the source unchanged when no phrase matches", () => {
expect(localizeTriggerSource(fakeLocalize, "something else")).toBe(
"something else"
);
});
});
describe("localizeTriggerDescription", () => {
it("returns just the bare-phrase translation, without 'triggered by'", () => {
expect(
localizeTriggerDescription(fakeLocalize, "Home Assistant starting")
).toBe("<ui.components.logbook.homeassistant_starting>");
});
it("preserves trailing context after the matched phrase", () => {
expect(
localizeTriggerDescription(fakeLocalize, "state of binary_sensor.foo")
).toBe("<ui.components.logbook.state_of> binary_sensor.foo");
});
it("returns the source unchanged when no phrase matches", () => {
expect(localizeTriggerDescription(fakeLocalize, "something else")).toBe(
"something else"
);
});
});

View File

@@ -31,10 +31,10 @@ describe("getSuggestedMax", () => {
assert.equal(result.getTime(), end.getTime());
});
it("rounds down to middle of hour for hour period", () => {
it("rounds down to start of hour for hour period", () => {
const end = new Date("2024-03-15T14:37:22.000");
const result = getSuggestedMax("hour", end, false);
assert.equal(result.getMinutes(), 30);
assert.equal(result.getMinutes(), 0);
assert.equal(result.getSeconds(), 0);
assert.equal(result.getMilliseconds(), 0);
assert.equal(result.getHours(), 14);

1415
yarn.lock

File diff suppressed because it is too large Load Diff