Compare commits

..
Author SHA1 Message Date
Bram Kragten d74fe9f012 WIP 2025-07-29 09:35:33 +02:00
111 changed files with 3975 additions and 6893 deletions
@@ -416,34 +416,6 @@ const SCHEMAS: {
},
},
},
items: {
name: "Items",
selector: {
object: {
label_field: "name",
description_field: "value",
multiple: true,
fields: {
name: {
label: "Name",
selector: { text: {} },
required: true,
},
value: {
label: "Value",
selector: {
number: {
mode: "slider",
min: 0,
max: 100,
unit_of_measurement: "%",
},
},
},
},
},
},
},
},
},
];
+6 -6
View File
@@ -158,7 +158,7 @@
"@octokit/auth-oauth-device": "8.0.1",
"@octokit/plugin-retry": "8.0.1",
"@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.1.4",
"@rsdoctor/rspack-plugin": "1.1.3",
"@rspack/cli": "1.3.12",
"@rspack/core": "1.3.12",
"@types/babel__plugin-transform-runtime": "7.9.5",
@@ -179,7 +179,7 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "3.2.4",
"@vitest/coverage-v8": "3.2.3",
"babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
@@ -188,7 +188,7 @@
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.5",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-lit": "2.1.1",
"eslint-plugin-lit-a11y": "5.0.1",
"eslint-plugin-unused-imports": "4.1.4",
@@ -210,7 +210,7 @@
"lodash.template": "4.5.0",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.6.0",
"prettier": "3.5.3",
"rspack-manifest-plugin": "5.0.3",
"serve": "14.2.4",
"sinon": "21.0.0",
@@ -218,9 +218,9 @@
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.8.3",
"typescript-eslint": "8.34.1",
"typescript-eslint": "8.34.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"vitest": "3.2.3",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20250625.0"
version = "20250430.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+4
View File
@@ -11,6 +11,7 @@ export const COLORS = [
"#9c6b4e",
"#97bbf5",
"#01ab63",
"#9498a0",
"#094bad",
"#c99000",
"#d84f3e",
@@ -20,6 +21,7 @@ export const COLORS = [
"#8043ce",
"#7599d1",
"#7a4c31",
"#74787f",
"#6989f4",
"#ffd444",
"#ff957c",
@@ -29,6 +31,7 @@ export const COLORS = [
"#c884ff",
"#badeff",
"#bf8b6d",
"#b6bac2",
"#927acc",
"#97ee3f",
"#bf3947",
@@ -41,6 +44,7 @@ export const COLORS = [
"#d9b100",
"#9d7a00",
"#698cff",
"#d9d9d9",
"#00d27e",
"#d06800",
"#009f82",
+1 -1
View File
@@ -77,7 +77,7 @@ export const formatDateNumeric = (
const month = parts.find((value) => value.type === "month")?.value;
const year = parts.find((value) => value.type === "year")?.value;
const lastPart = parts[parts.length - 1];
const lastPart = parts.at(parts.length - 1);
let lastLiteral = lastPart?.type === "literal" ? lastPart?.value : "";
if (locale.language === "bg" && locale.date_format === DateFormat.YMD) {
-68
View File
@@ -1,68 +0,0 @@
import { callService, type HassEntity } from "home-assistant-js-websocket";
import { computeStateDomain } from "./compute_state_domain";
import { isUnavailableState, UNAVAILABLE } from "../../data/entity";
import type { HomeAssistant } from "../../types";
export const computeGroupEntitiesState = (states: HassEntity[]): string => {
if (!states.length) {
return UNAVAILABLE;
}
const validState = states.filter((stateObj) => isUnavailableState(stateObj));
if (!validState) {
return UNAVAILABLE;
}
// Use the first state to determine the domain
// This assumes all states in the group have the same domain
const domain = computeStateDomain(states[0]);
if (domain === "cover") {
for (const s of ["opening", "closing", "open"]) {
if (states.some((stateObj) => stateObj.state === s)) {
return s;
}
}
return "closed";
}
if (states.some((stateObj) => stateObj.state === "on")) {
return "on";
}
return "off";
};
export const toggleGroupEntities = (
hass: HomeAssistant,
states: HassEntity[]
) => {
if (!states.length) {
return;
}
// Use the first state to determine the domain
// This assumes all states in the group have the same domain
const domain = computeStateDomain(states[0]);
const state = computeGroupEntitiesState(states);
const isOn = state === "on" || state === "open";
let service = isOn ? "turn_off" : "turn_on";
if (domain === "cover") {
if (state === "opening" || state === "closing") {
// If the cover is opening or closing, we toggle it to stop it
service = "stop_cover";
} else {
// For covers, we use the open/close service
service = isOn ? "close_cover" : "open_cover";
}
}
const entitiesIds = states.map((stateObj) => stateObj.entity_id);
callService(hass.connection, domain, service, {
entity_id: entitiesIds,
});
};
+5 -17
View File
@@ -64,27 +64,15 @@ export const domainStateColorProperties = (
const compareState = state !== undefined ? state : stateObj.state;
const active = stateActive(stateObj, state);
return domainColorProperties(
domain,
stateObj.attributes.device_class,
compareState,
active
);
};
export const domainColorProperties = (
domain: string,
deviceClass: string | undefined,
state: string,
active: boolean
) => {
const properties: string[] = [];
const stateKey = slugify(state, "_");
const stateKey = slugify(compareState, "_");
const activeKey = active ? "active" : "inactive";
if (deviceClass) {
properties.push(`--state-${domain}-${deviceClass}-${stateKey}-color`);
const dc = stateObj.attributes.device_class;
if (dc) {
properties.push(`--state-${domain}-${dc}-${stateKey}-color`);
}
properties.push(
-53
View File
@@ -1,5 +1,4 @@
import memoizeOne from "memoize-one";
import { isIPAddress } from "./is_ip_address";
const collator = memoizeOne(
(language: string | undefined) => new Intl.Collator(language)
@@ -34,19 +33,6 @@ export const stringCompare = (
return fallbackStringCompare(a, b);
};
export const ipCompare = (a: string, b: string) => {
const aIsIpV4 = isIPAddress(a);
const bIsIpV4 = isIPAddress(b);
if (aIsIpV4 && bIsIpV4) {
return ipv4Compare(a, b);
}
if (!aIsIpV4 && !bIsIpV4) {
return ipV6Compare(a, b);
}
return aIsIpV4 ? -1 : 1;
};
export const caseInsensitiveStringCompare = (
a: string,
b: string,
@@ -78,42 +64,3 @@ export const orderCompare = (order: string[]) => (a: string, b: string) => {
return idxA - idxB;
};
function ipv4Compare(a: string, b: string) {
const num1 = Number(
a
.split(".")
.map((num) => num.padStart(3, "0"))
.join("")
);
const num2 = Number(
b
.split(".")
.map((num) => num.padStart(3, "0"))
.join("")
);
return num1 - num2;
}
function ipV6Compare(a: string, b: string) {
const ipv6a = normalizeIPv6(a)
.split(":")
.map((part) => part.padStart(4, "0"))
.join("");
const ipv6b = normalizeIPv6(b)
.split(":")
.map((part) => part.padStart(4, "0"))
.join("");
return ipv6a.localeCompare(ipv6b);
}
function normalizeIPv6(ip) {
const parts = ip.split("::");
const head = parts[0].split(":");
const tail = parts[1] ? parts[1].split(":") : [];
const totalParts = 8;
const missing = totalParts - (head.length + tail.length);
const zeros = new Array(missing).fill("0");
return [...head, ...zeros, ...tail].join(":");
}
@@ -226,24 +226,22 @@ export class StateHistoryChartLine extends LitElement {
this.maxYAxis;
if (typeof minYAxis === "number") {
if (this.fitYData) {
minYAxis = ({ min }) =>
Math.min(this._roundYAxis(min, Math.floor), this.minYAxis!);
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => {
const value = min > 0 ? min * 0.95 : min * 1.05;
return this._roundYAxis(value, Math.floor);
return Math.abs(value) < 1 ? value : Math.floor(value);
};
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) =>
Math.max(this._roundYAxis(max, Math.ceil), this.maxYAxis!);
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => {
const value = max > 0 ? max * 1.05 : max * 0.95;
return this._roundYAxis(value, Math.ceil);
return Math.abs(value) < 1 ? value : Math.ceil(value);
};
}
this._chartOptions = {
@@ -731,17 +729,20 @@ export class StateHistoryChartLine extends LitElement {
}
private _formatYAxisLabel = (value: number) => {
// show the first significant digit for tiny values
const maximumFractionDigits = Math.max(
1,
// use the difference to the previous value to determine the number of significant digits #25526
-Math.floor(
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
)
);
const label = formatNumber(value, this.hass.locale, {
maximumFractionDigits,
});
const formatOptions =
value >= 1 || value <= -1
? undefined
: {
// show the first significant digit for tiny values
maximumFractionDigits: Math.max(
2,
// use the difference to the previous value to determine the number of significant digits #25526
-Math.floor(
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
)
),
};
const label = formatNumber(value, this.hass.locale, formatOptions);
const width = measureTextWidth(label, 12) + 5;
if (width > this._yWidth) {
this._yWidth = width;
@@ -766,10 +767,6 @@ export class StateHistoryChartLine extends LitElement {
}
return value;
}
private _roundYAxis(value: number, roundingFn: (value: number) => number) {
return Math.abs(value) < 1 ? value : roundingFn(value);
}
}
customElements.define("state-history-chart-line", StateHistoryChartLine);
+4 -10
View File
@@ -238,24 +238,22 @@ export class StatisticsChart extends LitElement {
this.maxYAxis;
if (typeof minYAxis === "number") {
if (this.fitYData) {
minYAxis = ({ min }) =>
Math.min(this._roundYAxis(min, Math.floor), this.minYAxis!);
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => {
const value = min > 0 ? min * 0.95 : min * 1.05;
return this._roundYAxis(value, Math.floor);
return Math.abs(value) < 1 ? value : Math.floor(value);
};
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) =>
Math.max(this._roundYAxis(max, Math.ceil), this.maxYAxis!);
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => {
const value = max > 0 ? max * 1.05 : max * 0.95;
return this._roundYAxis(value, Math.ceil);
return Math.abs(value) < 1 ? value : Math.ceil(value);
};
}
const endTime = this.endTime ?? new Date();
@@ -636,10 +634,6 @@ export class StatisticsChart extends LitElement {
return value;
}
private _roundYAxis(value: number, roundingFn: (value: number) => number) {
return Math.abs(value) < 1 ? value : roundingFn(value);
}
static styles = css`
:host {
display: block;
@@ -72,7 +72,6 @@ export interface DataTableColumnData<T = any> extends DataTableSortColumnData {
label?: TemplateResult | string;
type?:
| "numeric"
| "ip"
| "icon"
| "icon-button"
| "overflow"
@@ -1,5 +1,5 @@
import { expose } from "comlink";
import { stringCompare, ipCompare } from "../../common/string/compare";
import { stringCompare } from "../../common/string/compare";
import { stripDiacritics } from "../../common/string/strip-diacritics";
import type {
ClonedDataTableColumnData,
@@ -57,8 +57,6 @@ const sortData = (
if (column.type === "numeric") {
valA = isNaN(valA) ? undefined : Number(valA);
valB = isNaN(valB) ? undefined : Number(valB);
} else if (column.type === "ip") {
return sort * ipCompare(valA, valB);
} else if (typeof valA === "string" && typeof valB === "string") {
return sort * stringCompare(valA, valB, language);
}
@@ -1,219 +0,0 @@
import { mdiTextureBox } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { stringCompare } from "../common/string/compare";
import { areaCompare } from "../data/area_registry";
import type { FloorRegistryEntry } from "../data/floor_registry";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-floor-icon";
import "./ha-items-display-editor";
import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
import "./ha-svg-icon";
import "./ha-textfield";
export interface AreasDisplayValue {
hidden?: string[];
order?: string[];
}
const UNASSIGNED_FLOOR = "__unassigned__";
@customElement("ha-areas-floors-display-editor")
export class HaAreasFloorsDisplayEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: AreasDisplayValue;
@property() public helper?: string;
@property({ type: Boolean }) public expanded = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property({ type: Boolean, attribute: "show-navigation-button" })
public showNavigationButton = false;
protected render(): TemplateResult {
const groupedItems = this._groupedItems(this.hass.areas, this.hass.floors);
const filteredFloors = this._sortedFloors(this.hass.floors).filter(
(floor) =>
// Only include floors that have areas assigned to them
groupedItems[floor.floor_id]?.length > 0
);
const value: DisplayValue = {
order: this.value?.order ?? [],
hidden: this.value?.hidden ?? [],
};
return html`
<ha-expansion-panel
outlined
.header=${this.label}
.expanded=${this.expanded}
>
<ha-svg-icon slot="leading-icon" .path=${mdiTextureBox}></ha-svg-icon>
${filteredFloors.map(
(floor) => html`
<div class="floor">
<div class="header">
<ha-floor-icon .floor=${floor}></ha-floor-icon>
<p>${computeFloorName(floor)}</p>
</div>
<div class="areas">
<ha-items-display-editor
.hass=${this.hass}
.items=${groupedItems[floor.floor_id] || []}
.value=${value}
.floorId=${floor.floor_id}
@value-changed=${this._areaDisplayChanged}
.showNavigationButton=${this.showNavigationButton}
></ha-items-display-editor>
</div>
</div>
`
)}
</ha-expansion-panel>
`;
}
private _groupedItems = memoizeOne(
(
hassAreas: HomeAssistant["areas"],
// update items if floors change
_hassFloors: HomeAssistant["floors"]
): Record<string, DisplayItem[]> => {
const compare = areaCompare(hassAreas);
const areas = Object.values(hassAreas).sort((areaA, areaB) =>
compare(areaA.area_id, areaB.area_id)
);
const groupedItems: Record<string, DisplayItem[]> = areas.reduce(
(acc, area) => {
const { floor } = getAreaContext(area, this.hass!);
const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR;
if (!acc[floorId]) {
acc[floorId] = [];
}
acc[floorId].push({
value: area.area_id,
label: area.name,
icon: area.icon ?? undefined,
iconPath: mdiTextureBox,
description: floor?.name,
});
return acc;
},
{} as Record<string, DisplayItem[]>
);
return groupedItems;
}
);
private _sortedFloors = memoizeOne(
(hassFloors: HomeAssistant["floors"]): FloorRegistryEntry[] => {
const floors = Object.values(hassFloors).sort((floorA, floorB) => {
if (floorA.level !== floorB.level) {
return (floorA.level ?? 0) - (floorB.level ?? 0);
}
return stringCompare(floorA.name, floorB.name);
});
floors.push({
floor_id: UNASSIGNED_FLOOR,
name: this.hass.localize(
"ui.panel.lovelace.strategy.areas.unassigned_areas"
),
icon: null,
level: 999999,
aliases: [],
created_at: 0,
modified_at: 0,
});
return floors;
}
);
private async _areaDisplayChanged(ev) {
ev.stopPropagation();
const value = ev.detail.value as DisplayValue;
const currentFloorId = ev.currentTarget.floorId;
const floorIds = this._sortedFloors(this.hass.floors).map(
(floor) => floor.floor_id
);
const newHidden: string[] = [];
const newOrder: string[] = [];
for (const floorId of floorIds) {
if (currentFloorId === floorId) {
newHidden.push(...(value.hidden ?? []));
newOrder.push(...(value.order ?? []));
continue;
}
const hidden = this.value?.hidden?.filter(
(areaId) => this.hass.areas[areaId]?.floor_id === floorId
);
if (hidden) {
newHidden.push(...hidden);
}
const order = this.value?.order?.filter(
(areaId) => this.hass.areas[areaId]?.floor_id === floorId
);
if (order) {
newOrder.push(...order);
}
}
const newValue: AreasDisplayValue = {
hidden: newHidden,
order: newOrder,
};
if (newValue.hidden?.length === 0) {
delete newValue.hidden;
}
if (newValue.order?.length === 0) {
delete newValue.order;
}
fireEvent(this, "value-changed", { value: newValue });
}
static styles = css`
.floor .header p {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
flex: 1:
}
.floor .header {
margin: 16px 0 8px 0;
padding: 0 8px;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-areas-floors-display-editor": HaAreasFloorsDisplayEditor;
}
}
-61
View File
@@ -1,61 +0,0 @@
import { css, html, LitElement, type PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import parseAspectRatio from "../common/util/parse-aspect-ratio";
const DEFAULT_ASPECT_RATIO = "16:9";
@customElement("ha-aspect-ratio")
export class HaAspectRatio extends LitElement {
@property({ type: String, attribute: "aspect-ratio" })
public aspectRatio?: string;
private _ratio: {
w: number;
h: number;
} | null = null;
public willUpdate(changedProps: PropertyValues) {
if (changedProps.has("aspect_ratio") || this._ratio === null) {
this._ratio = this.aspectRatio
? parseAspectRatio(this.aspectRatio)
: null;
if (this._ratio === null || this._ratio.w <= 0 || this._ratio.h <= 0) {
this._ratio = parseAspectRatio(DEFAULT_ASPECT_RATIO);
}
}
}
protected render(): unknown {
if (!this.aspectRatio) {
return html`<slot></slot>`;
}
return html`
<div
class="ratio"
style=${styleMap({
paddingBottom: `${((100 * this._ratio!.h) / this._ratio!.w).toFixed(2)}%`,
})}
>
<slot></slot>
</div>
`;
}
static styles = css`
.ratio ::slotted(*) {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-aspect-ratio": HaAspectRatio;
}
}
+138
View File
@@ -0,0 +1,138 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "./ha-svg-icon";
@customElement("ha-automation-row")
export class HaAutomationRow extends LitElement {
@property() header?: string;
@property() secondary?: string;
protected render(): TemplateResult {
return html`
<div class="top">
<div
id="summary"
@click=${this._toggleContainer}
@keydown=${this._toggleContainer}
role="button"
>
<slot name="leading-icon"></slot>
<slot name="header">
<div class="header">
${this.header}
<slot class="secondary" name="secondary">${this.secondary}</slot>
</div>
</slot>
<slot name="icons"></slot>
</div>
</div>
`;
}
private async _toggleContainer(ev): Promise<void> {
if (ev.defaultPrevented) {
return;
}
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") {
return;
}
ev.preventDefault();
this.click();
}
static styles = css`
:host {
display: block;
}
.top {
display: flex;
align-items: center;
border-radius: var(--ha-card-border-radius, 12px);
}
.top.expanded {
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
.top.focused {
background: var(--input-fill-color);
}
:host([outlined]) {
box-shadow: none;
border-width: 1px;
border-style: solid;
border-color: var(--outline-color);
border-radius: var(--ha-card-border-radius, 12px);
}
.summary-icon {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
direction: var(--direction);
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
}
:host([left-chevron]) .summary-icon,
::slotted([slot="leading-icon"]) {
margin-left: 0;
margin-right: 8px;
margin-inline-start: 0;
margin-inline-end: 8px;
}
#summary {
flex: 1;
display: flex;
padding: var(--expansion-panel-summary-padding, 0 8px);
min-height: 48px;
align-items: center;
cursor: pointer;
overflow: hidden;
font-weight: var(--ha-font-weight-medium);
outline: none;
}
#summary.noCollapse {
cursor: default;
}
.summary-icon.expanded {
transform: rotate(180deg);
}
.header,
::slotted([slot="header"]) {
flex: 1;
overflow-wrap: anywhere;
}
.container {
padding: var(--expansion-panel-content-padding, 0 8px);
overflow: hidden;
transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1);
height: 0px;
}
.container.expanded {
height: auto;
}
.secondary {
display: block;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-row": HaAutomationRow;
}
}
-273
View File
@@ -6,7 +6,6 @@ import type {
} from "@codemirror/autocomplete";
import type { Extension, TransactionSpec } from "@codemirror/state";
import type { EditorView, KeyBinding, ViewUpdate } from "@codemirror/view";
import { mdiArrowExpand, mdiArrowCollapse } from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, ReactiveElement } from "lit";
@@ -16,7 +15,6 @@ import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-icon-button";
declare global {
interface HASSDomEvents {
@@ -61,13 +59,8 @@ export class HaCodeEditor extends ReactiveElement {
@property({ type: Boolean }) public error = false;
@property({ type: Boolean, attribute: "enable-fullscreen" })
public enableFullscreen = true;
@state() private _value = "";
@state() private _isFullscreen = false;
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
private _loadedCodeMirror?: typeof import("../resources/codemirror");
@@ -99,7 +92,6 @@ export class HaCodeEditor extends ReactiveElement {
this.requestUpdate();
}
this.addEventListener("keydown", stopPropagation);
this.addEventListener("keydown", this._handleKeyDown);
// This is unreachable as editor will not exist yet,
// but focus should not behave like this for good a11y.
// (@steverep to fix in autofocus PR)
@@ -114,10 +106,6 @@ export class HaCodeEditor extends ReactiveElement {
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("keydown", stopPropagation);
this.removeEventListener("keydown", this._handleKeyDown);
if (this._isFullscreen) {
this._toggleFullscreen();
}
this.updateComplete.then(() => {
this.codemirror!.destroy();
delete this.codemirror;
@@ -176,12 +164,6 @@ export class HaCodeEditor extends ReactiveElement {
if (changedProps.has("error")) {
this.classList.toggle("error-state", this.error);
}
if (changedProps.has("_isFullscreen")) {
this.classList.toggle("fullscreen", this._isFullscreen);
}
if (changedProps.has("enableFullscreen")) {
this._updateFullscreenButton();
}
}
private get _mode() {
@@ -256,74 +238,8 @@ export class HaCodeEditor extends ReactiveElement {
}),
parent: this.renderRoot,
});
this._updateFullscreenButton();
}
private _updateFullscreenButton() {
const existingButton = this.renderRoot.querySelector(".fullscreen-button");
if (!this.enableFullscreen) {
// Remove button if it exists and fullscreen is disabled
if (existingButton) {
existingButton.remove();
}
// Exit fullscreen if currently in fullscreen mode
if (this._isFullscreen) {
this._isFullscreen = false;
}
return;
}
// Create button if it doesn't exist
if (!existingButton) {
const button = document.createElement("ha-icon-button");
(button as any).path = this._isFullscreen
? mdiArrowCollapse
: mdiArrowExpand;
button.setAttribute(
"label",
this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen"
);
button.classList.add("fullscreen-button");
// Use bound method to ensure proper this context
button.addEventListener("click", this._handleFullscreenClick);
this.renderRoot.appendChild(button);
} else {
// Update existing button
(existingButton as any).path = this._isFullscreen
? mdiArrowCollapse
: mdiArrowExpand;
existingButton.setAttribute(
"label",
this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen"
);
}
}
private _handleFullscreenClick = (e: Event) => {
e.preventDefault();
e.stopPropagation();
this._toggleFullscreen();
};
private _toggleFullscreen() {
this._isFullscreen = !this._isFullscreen;
this._updateFullscreenButton();
}
private _handleKeyDown = (e: KeyboardEvent) => {
if (this._isFullscreen && e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
this._toggleFullscreen();
} else if (e.key === "F11" && this.enableFullscreen) {
e.preventDefault();
e.stopPropagation();
this._toggleFullscreen();
}
};
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
if (!states) {
return [];
@@ -341,126 +257,6 @@ export class HaCodeEditor extends ReactiveElement {
private _entityCompletions(
context: CompletionContext
): CompletionResult | null | Promise<CompletionResult | null> {
// Check for YAML mode and entity-related fields
if (this.mode === "yaml") {
const currentLine = context.state.doc.lineAt(context.pos);
const lineText = currentLine.text;
// Properties that commonly contain entity IDs
const entityProperties = [
"entity_id",
"entity",
"entities",
"badges",
"devices",
"lights",
"light",
"group_members",
"scene",
"zone",
"zones",
];
// Create regex pattern for all entity properties
const propertyPattern = entityProperties.join("|");
const entityFieldRegex = new RegExp(
`^\\s*(-\\s+)?(${propertyPattern}):\\s*`
);
// Check if we're in an entity field (single entity or list item)
const entityFieldMatch = lineText.match(entityFieldRegex);
const listItemMatch = lineText.match(/^\s*-\s+/);
if (entityFieldMatch) {
// Calculate the position after the entity field
const afterField = currentLine.from + entityFieldMatch[0].length;
// If cursor is after the entity field, show all entities
if (context.pos >= afterField) {
const states = this._getStates(this.hass!.states);
if (!states || !states.length) {
return null;
}
// Find what's already typed after the field
const typedText = context.state.sliceDoc(afterField, context.pos);
// Filter states based on what's typed
const filteredStates = typedText
? states.filter((entityState) =>
entityState.label
.toLowerCase()
.startsWith(typedText.toLowerCase())
)
: states;
return {
from: afterField,
options: filteredStates,
validFor: /^[a-z_]*\.?\w*$/,
};
}
} else if (listItemMatch) {
// Check if this is a list item under an entity_id field
const lineNumber = currentLine.number;
// Look at previous lines to check if we're under an entity_id field
for (let i = lineNumber - 1; i > 0 && i >= lineNumber - 10; i--) {
const prevLine = context.state.doc.line(i);
const prevText = prevLine.text;
// Stop if we hit a non-indented line (new field)
if (
prevText.trim() &&
!prevText.startsWith(" ") &&
!prevText.startsWith("\t")
) {
break;
}
// Check if we found an entity property field
const entityListFieldRegex = new RegExp(
`^\\s*(${propertyPattern}):\\s*$`
);
if (prevText.match(entityListFieldRegex)) {
// We're in a list under an entity field
const afterListMarker = currentLine.from + listItemMatch[0].length;
if (context.pos >= afterListMarker) {
const states = this._getStates(this.hass!.states);
if (!states || !states.length) {
return null;
}
// Find what's already typed after the list marker
const typedText = context.state.sliceDoc(
afterListMarker,
context.pos
);
// Filter states based on what's typed
const filteredStates = typedText
? states.filter((entityState) =>
entityState.label
.toLowerCase()
.startsWith(typedText.toLowerCase())
)
: states;
return {
from: afterListMarker,
options: filteredStates,
validFor: /^[a-z_]*\.?\w*$/,
};
}
}
}
}
}
// Original entity completion logic for non-YAML or when not in entity_id field
const entityWord = context.matchBefore(/[a-z_]{3,}\.\w*/);
if (
@@ -544,78 +340,9 @@ export class HaCodeEditor extends ReactiveElement {
};
static styles = css`
:host {
position: relative;
display: block;
}
:host(.error-state) .cm-gutters {
border-color: var(--error-state-color, red);
}
.fullscreen-button {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
color: var(--secondary-text-color);
background-color: var(--card-background-color);
border-radius: 50%;
opacity: 0.6;
transition: opacity 0.2s;
--mdc-icon-button-size: 32px;
--mdc-icon-size: 18px;
/* Ensure button is clickable on iOS */
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.fullscreen-button:hover,
.fullscreen-button:active {
opacity: 1;
}
@media (hover: none) {
.fullscreen-button {
opacity: 0.8;
}
}
:host(.fullscreen) {
position: fixed !important;
top: var(--header-height, 56px) !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
z-index: 9999 !important;
background-color: var(--primary-background-color) !important;
margin: 0 !important;
padding: 16px !important;
/* Respect iOS safe areas while accounting for header */
padding-top: max(16px, env(safe-area-inset-top)) !important;
padding-left: max(16px, env(safe-area-inset-left)) !important;
padding-right: max(16px, env(safe-area-inset-right)) !important;
padding-bottom: max(16px, env(safe-area-inset-bottom)) !important;
box-sizing: border-box !important;
display: flex !important;
flex-direction: column !important;
}
:host(.fullscreen) .cm-editor {
height: 100% !important;
max-height: 100% !important;
border-radius: 0 !important;
}
:host(.fullscreen) .fullscreen-button {
position: fixed;
top: calc(
var(--header-height, 56px) + max(8px, env(safe-area-inset-top))
);
right: max(24px, calc(env(safe-area-inset-right) + 8px));
z-index: 10000;
}
`;
}
@@ -26,7 +26,6 @@ export class HaControlButtonGroup extends LitElement {
.container {
display: flex;
flex-direction: row;
justify-content: var(--control-button-group-alignment, start);
width: 100%;
height: 100%;
}
+7 -12
View File
@@ -18,8 +18,6 @@ export class HaDomainIcon extends LitElement {
@property({ attribute: false }) public deviceClass?: string;
@property({ attribute: false }) public state?: string;
@property() public icon?: string;
@property({ attribute: "brand-fallback", type: Boolean })
@@ -38,17 +36,14 @@ export class HaDomainIcon extends LitElement {
return this._renderFallback();
}
const icon = domainIcon(
this.hass,
this.domain,
this.deviceClass,
this.state
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
const icon = domainIcon(this.hass, this.domain, this.deviceClass).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
return this._renderFallback();
});
);
return html`${until(icon)}`;
}
-16
View File
@@ -30,22 +30,6 @@ export const floorDefaultIconPath = (
return mdiHome;
};
export const floorDefaultIcon = (floor: Pick<FloorRegistryEntry, "level">) => {
switch (floor.level) {
case 0:
return "mdi:home-floor-0";
case 1:
return "mdi:home-floor-1";
case 2:
return "mdi:home-floor-2";
case 3:
return "mdi:home-floor-3";
case -1:
return "mdi:home-floor-negative-1";
}
return "mdi:home";
};
@customElement("ha-floor-icon")
export class HaFloorIcon extends LitElement {
@property({ attribute: false }) public floor!: Pick<
+5 -9
View File
@@ -24,16 +24,12 @@ export class HaFormSelect extends LitElement implements HaFormElement {
@property() public helper?: string;
@property({ attribute: false })
public localizeValue?: (key: string) => string;
@property({ type: Boolean }) public disabled = false;
private _selectSchema = memoizeOne(
(schema: HaFormSelectSchema): SelectSelector => ({
(options): SelectSelector => ({
select: {
translation_key: schema.name,
options: schema.options.map((option) => ({
options: options.map((option) => ({
value: option[0],
label: option[1],
})),
@@ -45,13 +41,13 @@ export class HaFormSelect extends LitElement implements HaFormElement {
return html`
<ha-selector-select
.hass=${this.hass}
.schema=${this.schema}
.value=${this.data}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.schema.required || false}
.selector=${this._selectSchema(this.schema)}
.localizeValue=${this.localizeValue}
.required=${this.schema.required}
.selector=${this._selectSchema(this.schema.options)}
@value-changed=${this._valueChanged}
></ha-selector-select>
`;
+1 -1
View File
@@ -44,7 +44,7 @@ class HaNavigationList extends LitElement {
>
<ha-svg-icon .path=${page.iconPath}></ha-svg-icon>
</div>
<span slot="headline">${page.name}</span>
<span>${page.name}</span>
${this.hasSecondary
? html`<span slot="supporting-text">${page.description}</span>`
: ""}
@@ -54,7 +54,7 @@ export class HaAreaSelector extends LitElement {
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.get("selector") && this.value !== undefined) {
if (changedProperties.has("selector") && this.value !== undefined) {
if (this.selector.area?.multiple && !Array.isArray(this.value)) {
this.value = [this.value];
fireEvent(this, "value-changed", { value: this.value });
@@ -56,7 +56,7 @@ export class HaDeviceSelector extends LitElement {
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.get("selector") && this.value !== undefined) {
if (changedProperties.has("selector") && this.value !== undefined) {
if (this.selector.device?.multiple && !Array.isArray(this.value)) {
this.value = [this.value];
fireEvent(this, "value-changed", { value: this.value });
@@ -43,7 +43,7 @@ export class HaEntitySelector extends LitElement {
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.get("selector") && this.value !== undefined) {
if (changedProperties.has("selector") && this.value !== undefined) {
if (this.selector.entity?.multiple && !Array.isArray(this.value)) {
this.value = [this.value];
fireEvent(this, "value-changed", { value: this.value });
@@ -54,7 +54,7 @@ export class HaFloorSelector extends LitElement {
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.get("selector") && this.value !== undefined) {
if (changedProperties.has("selector") && this.value !== undefined) {
if (this.selector.floor?.multiple && !Array.isArray(this.value)) {
this.value = [this.value];
fireEvent(this, "value-changed", { value: this.value });
+70 -91
View File
@@ -1,6 +1,6 @@
import { mdiPlayBox, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../common/dom/fire_event";
@@ -84,30 +84,20 @@ export class HaMediaSelector extends LitElement {
(stateObj &&
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
const hasAccept = this.selector.media?.accept?.length;
return html`
${hasAccept
? nothing
: html`
<ha-entity-picker
.hass=${this.hass}
.value=${this.value?.entity_id}
.label=${this.label ||
this.hass.localize(
"ui.components.selectors.media.pick_media_player"
)}
.disabled=${this.disabled}
.helper=${this.helper}
.required=${this.required}
include-domains='["media_player"]'
allow-custom-entity
@value-changed=${this._entityChanged}
></ha-entity-picker>
`}
return html`<ha-entity-picker
.hass=${this.hass}
.value=${this.value?.entity_id}
.label=${this.label ||
this.hass.localize("ui.components.selectors.media.pick_media_player")}
.disabled=${this.disabled}
.helper=${this.helper}
.required=${this.required}
include-domains='["media_player"]'
allow-custom-entity
@value-changed=${this._entityChanged}
></ha-entity-picker>
${!supportsBrowse
? html`
<ha-alert>
? html`<ha-alert>
${this.hass.localize(
"ui.components.selectors.media.browse_not_supported"
)}
@@ -117,72 +107,62 @@ export class HaMediaSelector extends LitElement {
.data=${this.value}
.schema=${MANUAL_SCHEMA}
.computeLabel=${this._computeLabelCallback}
></ha-form>
`
: html`
<ha-card
outlined
@click=${this._pickMedia}
class=${this.disabled || (!this.value?.entity_id && !hasAccept)
? "disabled"
: ""}
></ha-form>`
: html`<ha-card
outlined
@click=${this._pickMedia}
class=${this.disabled || !this.value?.entity_id ? "disabled" : ""}
>
<div
class="thumbnail ${classMap({
portrait:
!!this.value?.metadata?.media_class &&
MediaClassBrowserSettings[
this.value.metadata.children_media_class ||
this.value.metadata.media_class
].thumbnail_ratio === "portrait",
})}"
>
<div
class="thumbnail ${classMap({
portrait:
!!this.value?.metadata?.media_class &&
MediaClassBrowserSettings[
this.value.metadata.children_media_class ||
this.value.metadata.media_class
].thumbnail_ratio === "portrait",
})}"
>
${this.value?.metadata?.thumbnail
? html`
<div
class="${classMap({
"centered-image":
!!this.value.metadata.media_class &&
["app", "directory"].includes(
this.value.metadata.media_class
),
})}
${this.value?.metadata?.thumbnail
? html`
<div
class="${classMap({
"centered-image":
!!this.value.metadata.media_class &&
["app", "directory"].includes(
this.value.metadata.media_class
),
})}
image"
style=${this._thumbnailUrl
? `background-image: url(${this._thumbnailUrl});`
: ""}
></div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${!this.value?.media_content_id
? mdiPlus
: this.value?.metadata?.media_class
? MediaClassBrowserSettings[
this.value.metadata.media_class ===
"directory"
? this.value.metadata
.children_media_class ||
this.value.metadata.media_class
: this.value.metadata.media_class
].icon
: mdiPlayBox}
></ha-svg-icon>
</div>
`}
</div>
<div class="title">
${!this.value?.media_content_id
? this.hass.localize(
"ui.components.selectors.media.pick_media"
)
: this.value.metadata?.title || this.value.media_content_id}
</div>
</ha-card>
`}
`;
style=${this._thumbnailUrl
? `background-image: url(${this._thumbnailUrl});`
: ""}
></div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${!this.value?.media_content_id
? mdiPlus
: this.value?.metadata?.media_class
? MediaClassBrowserSettings[
this.value.metadata.media_class === "directory"
? this.value.metadata.children_media_class ||
this.value.metadata.media_class
: this.value.metadata.media_class
].icon
: mdiPlayBox}
></ha-svg-icon>
</div>
`}
</div>
<div class="title">
${!this.value?.media_content_id
? this.hass.localize("ui.components.selectors.media.pick_media")
: this.value.metadata?.title || this.value.media_content_id}
</div>
</ha-card>`}`;
}
private _computeLabelCallback = (
@@ -204,9 +184,8 @@ export class HaMediaSelector extends LitElement {
private _pickMedia() {
showMediaBrowserDialog(this, {
action: "pick",
entityId: this.value?.entity_id,
navigateIds: this.value?.metadata?.navigateIds,
accept: this.selector.media?.accept,
entityId: this.value!.entity_id!,
navigateIds: this.value!.metadata?.navigateIds,
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
fireEvent(this, "value-changed", {
value: {
@@ -1,27 +1,16 @@
import { mdiClose, mdiDelete, mdiDrag, mdiPencil } from "@mdi/js";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { ObjectSelector } from "../../data/selector";
import { formatSelectorValue } from "../../data/selector/format_selector_value";
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
import type { HomeAssistant } from "../../types";
import type { HaFormSchema } from "../ha-form/types";
import "../ha-input-helper-text";
import "../ha-md-list";
import "../ha-md-list-item";
import "../ha-sortable";
import "../ha-yaml-editor";
import "../ha-input-helper-text";
import type { HaYamlEditor } from "../ha-yaml-editor";
@customElement("ha-selector-object")
export class HaObjectSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: ObjectSelector;
@property() public value?: any;
@property() public label?: string;
@@ -34,136 +23,11 @@ export class HaObjectSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@property({ attribute: false }) public localizeValue?: (
key: string
) => string;
@query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor;
@query("ha-yaml-editor", true) private _yamlEditor!: HaYamlEditor;
private _valueChangedFromChild = false;
private _computeLabel = (schema: HaFormSchema): string => {
const translationKey = this.selector.object?.translation_key;
if (this.localizeValue && translationKey) {
const label = this.localizeValue(
`${translationKey}.fields.${schema.name}`
);
if (label) {
return label;
}
}
return this.selector.object?.fields?.[schema.name]?.label || schema.name;
};
private _renderItem(item: any, index: number) {
const labelField =
this.selector.object!.label_field ||
Object.keys(this.selector.object!.fields!)[0];
const labelSelector = this.selector.object!.fields![labelField].selector;
const label = labelSelector
? formatSelectorValue(this.hass, item[labelField], labelSelector)
: "";
let description = "";
const descriptionField = this.selector.object!.description_field;
if (descriptionField) {
const descriptionSelector =
this.selector.object!.fields![descriptionField].selector;
description = descriptionSelector
? formatSelectorValue(
this.hass,
item[descriptionField],
descriptionSelector
)
: "";
}
const reorderable = this.selector.object!.multiple || false;
const multiple = this.selector.object!.multiple || false;
return html`
<ha-md-list-item class="item">
${reorderable
? html`
<ha-svg-icon
class="handle"
.path=${mdiDrag}
slot="start"
></ha-svg-icon>
`
: nothing}
<div slot="headline" class="label">${label}</div>
${description
? html`<div slot="supporting-text" class="description">
${description}
</div>`
: nothing}
<ha-icon-button
slot="end"
.item=${item}
.index=${index}
.label=${this.hass.localize("ui.common.edit")}
.path=${mdiPencil}
@click=${this._editItem}
></ha-icon-button>
<ha-icon-button
slot="end"
.index=${index}
.label=${this.hass.localize("ui.common.delete")}
.path=${multiple ? mdiDelete : mdiClose}
@click=${this._deleteItem}
></ha-icon-button>
</ha-md-list-item>
`;
}
protected render() {
if (!this.selector.object) {
return nothing;
}
if (this.selector.object.fields) {
if (this.selector.object.multiple) {
const items = ensureArray(this.value ?? []);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="items-container">
<ha-sortable
handle-selector=".handle"
draggable-selector=".item"
@item-moved=${this._itemMoved}
>
<ha-md-list>
${items.map((item, index) => this._renderItem(item, index))}
</ha-md-list>
</ha-sortable>
<ha-button outlined @click=${this._addItem}>
${this.hass.localize("ui.common.add")}
</ha-button>
</div>
`;
}
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="items-container">
${this.value
? html`<ha-md-list>
${this._renderItem(this.value, 0)}
</ha-md-list>`
: html`
<ha-button outlined @click=${this._addItem}>
${this.hass.localize("ui.common.add")}
</ha-button>
`}
</div>
`;
}
return html`<ha-yaml-editor
.hass=${this.hass}
.readonly=${this.disabled}
@@ -180,103 +44,9 @@ export class HaObjectSelector extends LitElement {
: ""} `;
}
private _schema = memoizeOne((selector: ObjectSelector) => {
if (!selector.object || !selector.object.fields) {
return [];
}
return Object.entries(selector.object.fields).map(([key, field]) => ({
name: key,
selector: field.selector,
required: field.required ?? false,
}));
});
private _itemMoved(ev) {
ev.stopPropagation();
const newIndex = ev.detail.newIndex;
const oldIndex = ev.detail.oldIndex;
if (!this.selector.object!.multiple) {
return;
}
const newValue = ensureArray(this.value ?? []).concat();
const item = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, item);
fireEvent(this, "value-changed", { value: newValue });
}
private async _addItem(ev) {
ev.stopPropagation();
const newItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.add"),
schema: this._schema(this.selector),
data: {},
computeLabel: this._computeLabel,
submitText: this.hass.localize("ui.common.add"),
});
if (newItem === null) {
return;
}
if (!this.selector.object!.multiple) {
fireEvent(this, "value-changed", { value: newItem });
return;
}
const newValue = ensureArray(this.value ?? []).concat();
newValue.push(newItem);
fireEvent(this, "value-changed", { value: newValue });
}
private async _editItem(ev) {
ev.stopPropagation();
const item = ev.currentTarget.item;
const index = ev.currentTarget.index;
const updatedItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.edit"),
schema: this._schema(this.selector),
data: item,
computeLabel: this._computeLabel,
submitText: this.hass.localize("ui.common.save"),
});
if (updatedItem === null) {
return;
}
if (!this.selector.object!.multiple) {
fireEvent(this, "value-changed", { value: updatedItem });
return;
}
const newValue = ensureArray(this.value ?? []).concat();
newValue[index] = updatedItem;
fireEvent(this, "value-changed", { value: newValue });
}
private _deleteItem(ev) {
ev.stopPropagation();
const index = ev.currentTarget.index;
if (!this.selector.object!.multiple) {
fireEvent(this, "value-changed", { value: undefined });
return;
}
const newValue = ensureArray(this.value ?? []).concat();
newValue.splice(index, 1);
fireEvent(this, "value-changed", { value: newValue });
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (
changedProps.has("value") &&
!this._valueChangedFromChild &&
this._yamlEditor
) {
if (changedProps.has("value") && !this._valueChangedFromChild) {
this._yamlEditor.setValue(this.value);
}
this._valueChangedFromChild = false;
@@ -293,42 +63,6 @@ export class HaObjectSelector extends LitElement {
}
fireEvent(this, "value-changed", { value });
}
static get styles() {
return [
css`
ha-md-list {
gap: 8px;
}
ha-md-list-item {
border: 1px solid var(--divider-color);
border-radius: 8px;
--ha-md-list-item-gap: 0;
--md-list-item-top-space: 0;
--md-list-item-bottom-space: 0;
--md-list-item-leading-space: 12px;
--md-list-item-trailing-space: 4px;
--md-list-item-two-line-container-height: 48px;
--md-list-item-one-line-container-height: 48px;
}
.handle {
cursor: move;
padding: 8px;
margin-inline-start: -8px;
}
label {
margin-bottom: 8px;
display: block;
}
ha-md-list-item .label,
ha-md-list-item .description {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
`,
];
}
}
declare global {
@@ -80,16 +80,7 @@ const SELECTOR_SCHEMAS = {
] as const,
icon: [] as const,
location: [] as const,
media: [
{
name: "accept",
selector: {
text: {
multiple: true,
},
},
},
] as const,
media: [] as const,
number: [
{
name: "min",
+6 -37
View File
@@ -276,16 +276,6 @@ export class HaServiceControl extends LitElement {
private _getTargetedEntities = memoizeOne((target, value) => {
const targetSelector = target ? { target } : { target: {} };
if (
hasTemplate(value?.target) ||
hasTemplate(value?.data?.entity_id) ||
hasTemplate(value?.data?.device_id) ||
hasTemplate(value?.data?.area_id) ||
hasTemplate(value?.data?.floor_id) ||
hasTemplate(value?.data?.label_id)
) {
return null;
}
const targetEntities =
ensureArray(
value?.target?.entity_id || value?.data?.entity_id
@@ -359,11 +349,8 @@ export class HaServiceControl extends LitElement {
private _filterField(
filter: ExtHassService["fields"][number]["filter"],
targetEntities: string[] | null
targetEntities: string[]
) {
if (targetEntities === null) {
return true; // Target is a template, show all fields
}
if (!targetEntities.length) {
return false;
}
@@ -399,21 +386,8 @@ export class HaServiceControl extends LitElement {
}
private _targetSelector = memoizeOne(
(targetSelector: TargetSelector | null | undefined, value) => {
if (!value || (typeof value === "object" && !Object.keys(value).length)) {
delete this._stickySelector.target;
} else if (hasTemplate(value)) {
if (typeof value === "string") {
this._stickySelector.target = { template: null };
} else {
this._stickySelector.target = { object: null };
}
}
return (
this._stickySelector.target ??
(targetSelector ? { target: { ...targetSelector } } : { target: {} })
);
}
(targetSelector: TargetSelector | null | undefined) =>
targetSelector ? { target: { ...targetSelector } } : { target: {} }
);
protected render() {
@@ -508,8 +482,7 @@ export class HaServiceControl extends LitElement {
><ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(
serviceData.target as TargetSelector,
this._value?.target
serviceData.target as TargetSelector
)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
@@ -602,7 +575,7 @@ export class HaServiceControl extends LitElement {
private _hasFilteredFields(
dataFields: ExtHassService["fields"],
targetEntities: string[] | null
targetEntities: string[]
) {
return dataFields.some(
(dataField) =>
@@ -615,7 +588,7 @@ export class HaServiceControl extends LitElement {
hasOptional: boolean,
domain: string | undefined,
serviceName: string | undefined,
targetEntities: string[] | null
targetEntities: string[]
) => {
if (
dataField.filter &&
@@ -849,10 +822,6 @@ export class HaServiceControl extends LitElement {
private _targetChanged(ev: CustomEvent) {
ev.stopPropagation();
if (ev.detail.isValid === false) {
// Don't clear an object selector that returns invalid YAML
return;
}
const newValue = ev.detail.value;
if (this._value?.target === newValue) {
return;
@@ -164,7 +164,6 @@ class DialogMediaPlayerBrowse extends LitElement {
.navigateIds=${this._navigateIds}
.action=${this._action}
.preferredLayout=${this._preferredLayout}
.accept=${this._params.accept}
@close-dialog=${this.closeDialog}
@media-picked=${this._mediaPicked}
@media-browsed=${this._mediaBrowsed}
@@ -78,7 +78,7 @@ export interface MediaPlayerItemId {
export class HaMediaPlayerBrowse extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string;
@property({ attribute: false }) public entityId!: string;
@property() public action: MediaPlayerBrowseAction = "play";
@@ -89,8 +89,6 @@ export class HaMediaPlayerBrowse extends LitElement {
@property({ attribute: false }) public navigateIds: MediaPlayerItemId[] = [];
@property({ attribute: false }) public accept?: string[];
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
@property({ type: Boolean, reflect: true }) public narrow = false;
@@ -252,7 +250,6 @@ export class HaMediaPlayerBrowse extends LitElement {
});
} else if (
err.code === "entity_not_found" &&
this.entityId &&
isUnavailableState(this.hass.states[this.entityId]?.state)
) {
this._setError({
@@ -337,37 +334,7 @@ export class HaMediaPlayerBrowse extends LitElement {
const subtitle = this.hass.localize(
`ui.components.media-browser.class.${currentItem.media_class}`
);
let children = currentItem.children || [];
const canPlayChildren = new Set<string>();
// Filter children based on accept property if provided
if (this.accept && children.length > 0) {
let checks: ((t: string) => boolean)[] = [];
for (const type of this.accept) {
if (type.endsWith("/*")) {
const baseType = type.slice(0, -1);
checks.push((t) => t.startsWith(baseType));
} else if (type === "*") {
checks = [() => true];
break;
} else {
checks.push((t) => t === type);
}
}
children = children.filter((child) => {
const contentType = child.media_content_type.toLowerCase();
const canPlay =
child.media_content_type &&
checks.some((check) => check(contentType));
if (canPlay) {
canPlayChildren.add(child.media_content_id);
}
return !child.media_content_type || child.can_expand || canPlay;
});
}
const children = currentItem.children || [];
const mediaClass = MediaClassBrowserSettings[currentItem.media_class];
const childrenMediaClass = currentItem.children_media_class
? MediaClassBrowserSettings[currentItem.children_media_class]
@@ -400,12 +367,7 @@ export class HaMediaPlayerBrowse extends LitElement {
""
)}"
>
${this.narrow &&
currentItem?.can_play &&
(!this.accept ||
canPlayChildren.has(
currentItem.media_content_id
))
${this.narrow && currentItem?.can_play
? html`
<ha-fab
mini
@@ -786,11 +748,11 @@ export class HaMediaPlayerBrowse extends LitElement {
};
private async _fetchData(
entityId: string | undefined,
entityId: string,
mediaContentId?: string,
mediaContentType?: string
): Promise<MediaPlayerItem> {
return entityId && entityId !== BROWSER_PLAYER
return entityId !== BROWSER_PLAYER
? browseMediaPlayer(this.hass, entityId, mediaContentId, mediaContentType)
: browseLocalMediaPlayer(this.hass, mediaContentId);
}
@@ -7,11 +7,10 @@ import type { MediaPlayerItemId } from "./ha-media-player-browse";
export interface MediaPlayerBrowseDialogParams {
action: MediaPlayerBrowseAction;
entityId?: string;
entityId: string;
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => void;
navigateIds?: MediaPlayerItemId[];
minimumNavigateLevel?: number;
accept?: string[];
}
export const showMediaBrowserDialog = (
-43
View File
@@ -1,43 +0,0 @@
import type { HomeAssistant } from "../types";
export interface AITaskPreferences {
gen_data_entity_id: string | null;
}
export interface GenDataTaskResult {
conversation_id: string;
data: string;
}
export const fetchAITaskPreferences = (hass: HomeAssistant) =>
hass.callWS<AITaskPreferences>({
type: "ai_task/preferences/get",
});
export const saveAITaskPreferences = (
hass: HomeAssistant,
preferences: Partial<AITaskPreferences>
) =>
hass.callWS<AITaskPreferences>({
type: "ai_task/preferences/set",
...preferences,
});
export const generateDataAITask = async (
hass: HomeAssistant,
task: {
task_name: string;
entity_id?: string;
instructions: string;
}
): Promise<GenDataTaskResult> => {
const result = await hass.callService<GenDataTaskResult>(
"ai_task",
"generate_data",
task,
undefined,
true,
true
);
return result.response!;
};
+3 -8
View File
@@ -1114,16 +1114,12 @@ export const formatConsumptionShort = (
if (!consumption) {
return `0 ${unit}`;
}
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
const units = ["kWh", "MWh", "GWh", "TWh"];
let pickedUnit = unit;
let val = consumption;
let unitIndex = units.findIndex((u) => u === unit);
if (unitIndex >= 0) {
while (Math.abs(val) < 1 && unitIndex > 0) {
val *= 1000;
unitIndex--;
}
while (Math.abs(val) >= 1000 && unitIndex < units.length - 1) {
while (val >= 1000 && unitIndex < units.length - 1) {
val /= 1000;
unitIndex++;
}
@@ -1131,8 +1127,7 @@ export const formatConsumptionShort = (
}
return (
formatNumber(val, hass.locale, {
maximumFractionDigits:
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
maximumFractionDigits: val < 10 ? 2 : val < 100 ? 1 : 0,
}) +
" " +
pickedUnit
+2 -15
View File
@@ -37,7 +37,6 @@ import {
mdiRoomService,
mdiScriptText,
mdiSpeakerMessage,
mdiStarFourPoints,
mdiThermostat,
mdiTimerOutline,
mdiToggleSwitch,
@@ -67,7 +66,6 @@ export const DEFAULT_DOMAIN_ICON = mdiBookmark;
/** Fallback icons for each domain */
export const FALLBACK_DOMAIN_ICONS = {
ai_task: mdiStarFourPoints,
air_quality: mdiAirFilter,
alert: mdiAlert,
automation: mdiRobot,
@@ -504,25 +502,14 @@ export const serviceSectionIcon = async (
export const domainIcon = async (
hass: HomeAssistant,
domain: string,
deviceClass?: string,
state?: string
deviceClass?: string
): Promise<string | undefined> => {
const entityComponentIcons = await getComponentIcons(hass, domain);
if (entityComponentIcons) {
const translations =
(deviceClass && entityComponentIcons[deviceClass]) ||
entityComponentIcons._;
// First check for exact state match
if (state && translations.state?.[state]) {
return translations.state[state];
}
// Then check for range-based icons if we have a numeric state
if (state !== undefined && translations.range && !isNaN(Number(state))) {
return getIconFromRange(Number(state), translations.range);
}
// Fallback to default icon
return translations.default;
return translations?.default;
}
return undefined;
};
+6
View File
@@ -1,4 +1,5 @@
import { mdiContentSave, mdiMedal, mdiTrophy } from "@mdi/js";
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import type { LocalizeKeys } from "../common/translations/localize";
/**
@@ -25,6 +26,11 @@ export const QUALITY_SCALE_MAP: Record<
translationKey:
"ui.panel.config.integrations.config_entry.platinum_quality",
},
internal: {
icon: mdiHomeAssistant,
translationKey:
"ui.panel.config.integrations.config_entry.internal_integration",
},
legacy: {
icon: mdiContentSave,
translationKey:
+2 -6
View File
@@ -114,13 +114,9 @@ const getLogbookDataFromServer = (
export const subscribeLogbook = (
hass: HomeAssistant,
callbackFunction: (
message: LogbookStreamMessage,
subscriptionId: number
) => void,
callbackFunction: (message: LogbookStreamMessage) => void,
startDate: string,
endDate: string,
subscriptionId: number,
entityIds?: string[],
deviceIds?: string[]
): Promise<UnsubscribeFunc> => {
@@ -144,7 +140,7 @@ export const subscribeLogbook = (
params.device_ids = deviceIds;
}
return hass.connection.subscribeMessage<LogbookStreamMessage>(
(message) => callbackFunction(message, subscriptionId),
(message) => callbackFunction(message),
params
);
};
+1 -1
View File
@@ -13,7 +13,7 @@ export const subscribePreviewGeneric = (
hass: HomeAssistant,
domain: string,
flow_id: string,
flow_type: "config_flow" | "options_flow" | "config_subentries_flow",
flow_type: "config_flow" | "options_flow",
user_input: Record<string, any>,
callback: (preview: GenericPreview) => void
): Promise<UnsubscribeFunc> =>
+5 -17
View File
@@ -14,7 +14,6 @@ import {
literal,
is,
boolean,
refine,
} from "superstruct";
import { arrayLiteralIncludes } from "../common/array/literal-includes";
import { navigate } from "../common/navigate";
@@ -50,18 +49,13 @@ export const targetStruct = object({
label_id: optional(union([string(), array(string())])),
});
export const serviceActionStruct: Describe<ServiceActionWithTemplate> = assign(
export const serviceActionStruct: Describe<ServiceAction> = assign(
baseActionStruct,
object({
action: optional(string()),
service_template: optional(string()),
entity_id: optional(string()),
target: optional(
union([
targetStruct,
refine(string(), "has_template", (val) => hasTemplate(val)),
])
),
target: optional(targetStruct),
data: optional(object()),
response_variable: optional(string()),
metadata: optional(object()),
@@ -138,12 +132,6 @@ export interface ServiceAction extends BaseAction {
metadata?: Record<string, unknown>;
}
type ServiceActionWithTemplate = ServiceAction & {
target?: HassServiceTarget | string;
};
export type { ServiceActionWithTemplate };
export interface DeviceAction extends BaseAction {
type: string;
device_id: string;
@@ -427,7 +415,7 @@ export const migrateAutomationAction = (
return action.map(migrateAutomationAction) as Action[];
}
if (typeof action === "object" && action !== null && "service" in action) {
if ("service" in action) {
if (!("action" in action)) {
action.action = action.service;
}
@@ -435,7 +423,7 @@ export const migrateAutomationAction = (
}
// legacy scene (scene: scene_name)
if (typeof action === "object" && action !== null && "scene" in action) {
if ("scene" in action) {
action.action = "scene.turn_on";
action.target = {
entity_id: action.scene,
@@ -443,7 +431,7 @@ export const migrateAutomationAction = (
delete action.scene;
}
if (typeof action === "object" && action !== null && "sequence" in action) {
if ("sequence" in action) {
for (const sequenceAction of (action as SequenceAction).sequence) {
migrateAutomationAction(sequenceAction);
}
+2 -16
View File
@@ -303,9 +303,7 @@ export interface LocationSelectorValue {
}
export interface MediaSelector {
media: {
accept?: string[];
} | null;
media: {} | null;
}
export interface MediaSelectorValue {
@@ -336,20 +334,8 @@ export interface NumberSelector {
} | null;
}
interface ObjectSelectorField {
selector: Selector;
label?: string;
required?: boolean;
}
export interface ObjectSelector {
object?: {
label_field?: string;
description_field?: string;
translation_key?: string;
fields?: Record<string, ObjectSelectorField>;
multiple?: boolean;
} | null;
object: {} | null;
}
export interface AssistPipelineSelector {
-104
View File
@@ -1,104 +0,0 @@
import { ensureArray } from "../../common/array/ensure-array";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import type { HomeAssistant } from "../../types";
import type { Selector } from "../selector";
export const formatSelectorValue = (
hass: HomeAssistant,
value: any,
selector?: Selector
) => {
if (value == null) {
return "";
}
if (!selector) {
return ensureArray(value).join(", ");
}
if ("text" in selector) {
const { prefix, suffix } = selector.text || {};
const texts = ensureArray(value);
return texts
.map((text) => `${prefix || ""}${text}${suffix || ""}`)
.join(", ");
}
if ("number" in selector) {
const { unit_of_measurement } = selector.number || {};
const numbers = ensureArray(value);
return numbers
.map((number) => {
const num = Number(number);
if (isNaN(num)) {
return number;
}
return unit_of_measurement
? `${num}${blankBeforeUnit(unit_of_measurement, hass.locale)}${unit_of_measurement}`
: num.toString();
})
.join(", ");
}
if ("floor" in selector) {
const floors = ensureArray(value);
return floors
.map((floorId) => {
const floor = hass.floors[floorId];
if (!floor) {
return floorId;
}
return floor.name || floorId;
})
.join(", ");
}
if ("area" in selector) {
const areas = ensureArray(value);
return areas
.map((areaId) => {
const area = hass.areas[areaId];
if (!area) {
return areaId;
}
return computeAreaName(area);
})
.join(", ");
}
if ("entity" in selector) {
const entities = ensureArray(value);
return entities
.map((entityId) => {
const stateObj = hass.states[entityId];
if (!stateObj) {
return entityId;
}
const { device } = getEntityContext(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const entityName = computeEntityName(stateObj, hass);
return [deviceName, entityName].filter(Boolean).join(" ") || entityId;
})
.join(", ");
}
if ("device" in selector) {
const devices = ensureArray(value);
return devices
.map((deviceId) => {
const device = hass.devices[deviceId];
if (!device) {
return deviceId;
}
return device.name || deviceId;
})
.join(", ");
}
return ensureArray(value).join(", ");
};
-1
View File
@@ -34,7 +34,6 @@ export type SystemHealthInfo = Partial<{
dev: boolean;
hassio: boolean;
docker: boolean;
container_arch: string;
user: string;
virtualenv: boolean;
python_version: string;
@@ -82,11 +82,7 @@ export class FlowPreviewGeneric extends LitElement {
(await this._unsub)();
this._unsub = undefined;
}
if (
this.flowType !== "config_flow" &&
this.flowType !== "options_flow" &&
this.flowType !== "config_subentries_flow"
) {
if (this.flowType !== "config_flow" && this.flowType !== "options_flow") {
return;
}
this._error = undefined;
-89
View File
@@ -1,89 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../make-dialog-manager";
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
import { haStyleDialog } from "../../resources/styles";
@customElement("dialog-form")
export class DialogForm
extends LitElement
implements HassDialog<FormDialogData>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _params?: FormDialogParams;
@state() private _data: FormDialogData = {};
public async showDialog(params: FormDialogParams): Promise<void> {
this._params = params;
this._data = params.data || {};
}
public closeDialog() {
this._params = undefined;
this._data = {};
fireEvent(this, "dialog-closed", { dialog: this.localName });
return true;
}
private _submit(): void {
this._params?.submit?.(this._data);
this.closeDialog();
}
private _cancel(): void {
this._params?.cancel?.();
this.closeDialog();
}
private _valueChanged(ev: CustomEvent): void {
this._data = ev.detail.value;
}
protected render() {
if (!this._params || !this.hass) {
return nothing;
}
return html`
<ha-dialog
open
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(this.hass, this._params.title)}
@closed=${this._cancel}
>
<ha-form
dialogInitialFocus
.hass=${this.hass}
.computeLabel=${this._params.computeLabel}
.computeHelper=${this._params.computeHelper}
.data=${this._data}
.schema=${this._params.schema}
@value-changed=${this._valueChanged}
>
</ha-form>
<ha-button @click=${this._cancel} slot="secondaryAction">
${this._params.cancelText || this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._submit} slot="primaryAction">
${this._params.submitText || this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog>
`;
}
static styles = [haStyleDialog, css``];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-form": DialogForm;
}
}
-45
View File
@@ -1,45 +0,0 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { HaFormSchema } from "../../components/ha-form/types";
export type FormDialogData = Record<string, any>;
export interface FormDialogParams {
title: string;
schema: HaFormSchema[];
data?: FormDialogData;
submit?: (data?: FormDialogData) => void;
cancel?: () => void;
computeLabel?: (schema, data) => string | undefined;
computeHelper?: (schema) => string | undefined;
submitText?: string;
cancelText?: string;
}
export const showFormDialog = (
element: HTMLElement,
dialogParams: FormDialogParams
) =>
new Promise<FormDialogData | null>((resolve) => {
const origCancel = dialogParams.cancel;
const origSubmit = dialogParams.submit;
fireEvent(element, "show-dialog", {
dialogTag: "dialog-form",
dialogImport: () => import("./dialog-form"),
dialogParams: {
...dialogParams,
cancel: () => {
resolve(null);
if (origCancel) {
origCancel();
}
},
submit: (data: FormDialogData) => {
resolve(data);
if (origSubmit) {
origSubmit(data);
}
},
},
});
});
+14 -1
View File
@@ -55,7 +55,11 @@ class HassSubpage extends LitElement {
<div class="main-title"><slot name="header">${this.header}</slot></div>
<slot name="toolbar-icon"></slot>
</div>
<div class="content ha-scrollbar" @scroll=${this._saveScrollPos}>
<div
class="content ha-scrollbar"
@scroll=${this._saveScrollPos}
@scroll-to=${this._scrollTo}
>
<slot></slot>
</div>
<div id="fab">
@@ -69,6 +73,15 @@ class HassSubpage extends LitElement {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
private _scrollTo(e: CustomEvent<{ up: number }>): void {
this.renderRoot
.querySelector(".content")!
.scrollTo(
0,
e.detail.up + this.renderRoot.querySelector(".content")?.scrollTop
);
}
private _backTapped(): void {
if (this.backCallback) {
this.backCallback();
@@ -203,7 +203,7 @@ export default class HaAutomationActionRow extends LitElement {
</div>
`
: nothing}
<ha-expansion-panel left-chevron>
<ha-automation-row>
${type === "service" && "action" in this.action && this.action.action
? html`
<ha-service-icon
@@ -328,16 +328,6 @@ export default class HaAutomationActionRow extends LitElement {
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!this._uiModeAvailable}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
@@ -430,7 +420,7 @@ export default class HaAutomationActionRow extends LitElement {
</div>
`}
</div>
</ha-expansion-panel>
</ha-automation-row>
</ha-card>
`;
}
@@ -676,8 +666,8 @@ export default class HaAutomationActionRow extends LitElement {
}
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
--shadow-focus: 0 0 0 1px var(--primary-color);
border-color: var(--primary-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`,
@@ -93,6 +93,7 @@ export default class HaAutomationAction extends LitElement {
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._actionChanged}
@click=${this._actionClicked}
.hass=${this.hass}
?highlight=${this.highlightedActions?.includes(action)}
>
@@ -102,8 +103,74 @@ export default class HaAutomationAction extends LitElement {
<ha-svg-icon .path=${mdiDrag}></ha-svg-icon>
</div>
`
: nothing}
</ha-automation-action-row>
: nothing} </ha-automation-action-row
>${Object.keys(action)[0] === "choose"
? html`<div
style="padding-left: 24px; border-left: 1px solid var(--primary-color);"
>
<ha-card outlined
><ha-automation-row>
<h3
slot="header"
style=" margin: 0;
font-size: inherit;
font-weight: inherit;"
>
Option 1:
</h3>
</ha-automation-row></ha-card
>
<div
style="padding-left: 24px; border-left: 1px solid var(--primary-color); margin-top: 8px;"
>
<ha-automation-condition></ha-automation-condition>
<ha-button
outlined
style=" padding: 16px 0; padding-top: 8px;"
.disabled=${this.disabled}
.label=${"Condition"}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
<ha-card style=" padding: 0 16px;
padding-top: 8px;"
>Actions</br>
<ha-button
style=" padding: 16px 0;"
outlined
.disabled=${this.disabled}
.label=${"Action"}
>
<ha-svg-icon
.path=${mdiPlus}
slot="icon"
></ha-svg-icon> </ha-button
></ha-card>
</div>
<ha-button
outlined
style=" padding: 16px 0;"
.disabled=${this.disabled}
.label=${"Option"}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
<ha-card style=" padding: 0 16px;
padding-top: 8px;"
>Default actions</br>
<ha-button
style=" padding: 16px 0;"
outlined
.disabled=${this.disabled}
.label=${"Action"}
>
<ha-svg-icon
.path=${mdiPlus}
slot="icon"
></ha-svg-icon></ha-button
></ha-card>
</div>`
: nothing}
`
)}
<div class="buttons">
@@ -132,6 +199,15 @@ export default class HaAutomationAction extends LitElement {
`;
}
private _actionClicked(ev: MouseEvent) {
fireEvent(this, "element-selected", {
type: "action",
element: (ev.currentTarget as HaAutomationActionRow).action,
index: (ev.currentTarget as HaAutomationActionRow).index,
path: (ev.currentTarget as HaAutomationActionRow).path,
});
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
@@ -42,7 +42,7 @@ export class HaServiceAction extends LitElement implements ActionElement {
if (
this.action &&
Object.entries(this.action).some(
([key, val]) => !["data", "target"].includes(key) && hasTemplate(val)
([key, val]) => key !== "data" && hasTemplate(val)
)
) {
fireEvent(
@@ -1,9 +1,8 @@
import "@material/mwc-button";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiClose, mdiPlus, mdiStarFourPoints } from "@mdi/js";
import { dump } from "js-yaml";
import { mdiClose, mdiPlus } from "@mdi/js";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-domain-icon";
@@ -25,11 +24,6 @@ import type {
SaveDialogParams,
} from "./show-dialog-automation-save";
import { supportsMarkdownHelper } from "../../../../common/translations/markdown_support";
import {
fetchAITaskPreferences,
generateDataAITask,
} from "../../../../data/ai_task";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
@customElement("ha-dialog-automation-save")
class DialogAutomationSave extends LitElement implements HassDialog {
@@ -43,11 +37,9 @@ class DialogAutomationSave extends LitElement implements HassDialog {
@state() private _entryUpdates!: EntityRegistryUpdate;
@state() private _canSuggest = false;
private _params!: SaveDialogParams;
@state() private _newName?: string;
private _newName?: string;
private _newIcon?: string;
@@ -89,15 +81,6 @@ class DialogAutomationSave extends LitElement implements HassDialog {
return true;
}
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
if (isComponentLoaded(this.hass, "ai_task")) {
fetchAITaskPreferences(this.hass).then((prefs) => {
this._canSuggest = prefs.gen_data_entity_id !== null;
});
}
}
protected _renderOptionalChip(id: string, label: string) {
if (this._visibleOptionals.includes(id)) {
return nothing;
@@ -267,21 +250,6 @@ class DialogAutomationSave extends LitElement implements HassDialog {
.path=${mdiClose}
></ha-icon-button>
<span slot="title">${this._params.title || title}</span>
${this._canSuggest
? html`
<ha-assist-chip
id="suggest"
slot="actionItems"
@click=${this._suggest}
label=${this.hass.localize("ui.common.suggest_ai")}
>
<ha-svg-icon
slot="icon"
.path=${mdiStarFourPoints}
></ha-svg-icon>
</ha-assist-chip>
`
: nothing}
</ha-dialog-header>
${this._error
? html`<ha-alert alert-type="error"
@@ -345,20 +313,6 @@ class DialogAutomationSave extends LitElement implements HassDialog {
this.closeDialog();
}
private async _suggest() {
const result = await generateDataAITask(this.hass, {
task_name: "frontend:automation:save",
instructions: `Suggest one name for the following Home Assistant automation.
Your answer should only contain the name, without any additional text or formatting.
The name should be relevant to the automation's purpose and should not exceed 50 characters.
The name should be short, descriptive, sentence case, and written in the language ${this.hass.language}.
${dump(this._params.config)}
`,
});
this._newName = result.data.trim();
}
private async _save(): Promise<void> {
if (!this._newName) {
this._error = "Name is required";
@@ -427,10 +381,6 @@ ${dump(this._params.config)}
.destructive {
--mdc-theme-primary: var(--error-color);
}
#suggest {
margin: 8px 16px;
}
`,
];
}
@@ -128,7 +128,7 @@ export default class HaAutomationConditionRow extends LitElement {
`
: ""}
<ha-expansion-panel left-chevron>
<ha-automation-row>
<ha-svg-icon
slot="leading-icon"
class="condition-icon"
@@ -225,16 +225,6 @@ export default class HaAutomationConditionRow extends LitElement {
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${this._warnings}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
@@ -297,16 +287,8 @@ export default class HaAutomationConditionRow extends LitElement {
)}
</ha-alert>`
: ""}
<ha-automation-condition-editor
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._handleChangeEvent}
.yamlMode=${this._yamlMode}
.disabled=${this.disabled}
.hass=${this.hass}
.condition=${this.condition}
></ha-automation-condition-editor>
</div>
</ha-expansion-panel>
</ha-automation-row>
<div
class="testing ${classMap({
active: this._testing,
@@ -589,8 +571,8 @@ export default class HaAutomationConditionRow extends LitElement {
}
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
--shadow-focus: 0 0 0 1px var(--primary-color);
border-color: var(--primary-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`,
@@ -142,6 +142,7 @@ export default class HaAutomationCondition extends LitElement {
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._conditionChanged}
@click=${this._conditionClicked}
.hass=${this.hass}
?highlight=${this.highlightedConditions?.includes(cond)}
>
@@ -181,6 +182,15 @@ export default class HaAutomationCondition extends LitElement {
`;
}
private _conditionClicked(ev: MouseEvent) {
fireEvent(this, "element-selected", {
type: "condition",
element: (ev.currentTarget as HaAutomationConditionRow).condition,
index: (ev.currentTarget as HaAutomationConditionRow).index,
path: (ev.currentTarget as HaAutomationConditionRow).path,
});
}
private _addConditionDialog() {
showAddAutomationElementDialog(this, {
type: "condition",
@@ -1092,7 +1092,6 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
flex-direction: column;
padding-bottom: 0;
}
manual-automation-editor,
blueprint-automation-editor,
:not(.yaml-mode) > ha-alert {
margin: 0 auto;
@@ -1100,6 +1099,12 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
padding: 28px 20px 0;
display: block;
}
manual-automation-editor {
margin: 0 auto;
max-width: 1540px;
padding: 28px 20px 0;
display: block;
}
ha-yaml-editor {
flex-grow: 1;
--actions-border-radius: 0;
@@ -1,5 +1,11 @@
import "@material/mwc-button/mwc-button";
import { mdiHelpCircle } from "@mdi/js";
import {
mdiClose,
mdiDotsVertical,
mdiHelpCircle,
mdiIdentifier,
mdiPlaylistEdit,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -49,6 +55,51 @@ import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input";
import { showToast } from "../../../util/toast";
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
import "@shoelace-style/shoelace/dist/components/split-panel/split-panel";
import "@shoelace-style/shoelace/dist/components/drawer/drawer";
import { dynamicElement } from "../../../common/dom/dynamic-element-directive";
import { classMap } from "lit/directives/class-map";
import { getType } from "./action/ha-automation-action-row";
import { storage } from "../../../common/decorators/storage";
import { nextRender } from "../../../common/util/render-status";
import {
DIRECTION_ALL,
DIRECTION_VERTICAL,
Manager,
Pan,
Swipe,
} from "@egjs/hammerjs";
function findNestedItem(
obj: any,
path: ItemPath,
createNonExistingPath?: boolean
): any {
return path.reduce((ac, p, index, array) => {
if (ac === undefined) return undefined;
if (!ac[p] && createNonExistingPath) {
const nextP = array[index + 1];
// Create object or array depending on next path
if (nextP === undefined || typeof nextP === "number") {
ac[p] = [];
} else {
ac[p] = {};
}
}
return ac[p];
}, obj);
}
function updateNestedItem(obj: any, path: ItemPath, newValue): any {
const lastKey = path.pop()!;
const parent = findNestedItem(obj, path);
parent[lastKey] = newValue
? newValue
: Array.isArray(parent[lastKey])
? [...parent[lastKey]]
: [parent[lastKey]];
return obj;
}
const baseConfigStruct = object({
alias: optional(string()),
@@ -85,6 +136,14 @@ export class HaManualAutomationEditor extends LitElement {
@state() private _pastedConfig?: ManualAutomationConfig;
@state() private _selectedElement?: any;
@state()
@storage({ key: "automationSidebarPosition" })
private _sidebarWidth = 99999;
@state() private _yamlMode = false;
private _previousConfig?: ManualAutomationConfig;
public connectedCallback() {
@@ -114,6 +173,13 @@ export class HaManualAutomationEditor extends LitElement {
}
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("narrow") && this.narrow && this._selectedElement) {
this.renderRoot.querySelector("sl-drawer").show();
}
}
private _clearParam(param: string) {
window.history.replaceState(
null,
@@ -123,151 +189,411 @@ export class HaManualAutomationEditor extends LitElement {
}
protected render() {
return html`
${this.stateObj?.state === "off"
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.automation.editor.disabled"
)}
<mwc-button slot="action" @click=${this._enable}>
${this.hass.localize(
"ui.panel.config.automation.editor.enable"
)}
</mwc-button>
</ha-alert>
`
: nothing}
${this.config.description
? html`<ha-markdown
class="description"
breaks
.content=${this.config.description}
></ha-markdown>`
: nothing}
<div class="header">
<h2 id="triggers-heading" class="name">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.header"
)}
</h2>
<a
href=${documentationUrl(this.hass, "/docs/automation/trigger/")}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.learn_more"
)}
></ha-icon-button>
</a>
</div>
${!ensureArray(this.config.triggers)?.length
? html`<p>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.description"
)}
</p>`
: nothing}
const selectedElement = this._selectedElement?.element;
const selectedElementType = this._selectedElement?.type;
const path = this._selectedElement?.path || [];
<ha-automation-trigger
role="region"
aria-labelledby="triggers-heading"
.triggers=${this.config.triggers || []}
.highlightedTriggers=${this._pastedConfig?.triggers || []}
.path=${["triggers"]}
@value-changed=${this._triggerChanged}
.hass=${this.hass}
.disabled=${this.disabled}
></ha-automation-trigger>
const type = "";
const supported = true;
const yamlMode = this._yamlMode;
<div class="header">
<h2 id="conditions-heading" class="name">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.header"
)}
<span class="small"
>(${this.hass.localize("ui.common.optional")})</span
>
</h2>
<a
href=${documentationUrl(this.hass, "/docs/automation/condition/")}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.conditions.learn_more"
)}
></ha-icon-button>
</a>
</div>
${!ensureArray(this.config.conditions)?.length
? html`<p>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.description",
{ user: this.hass.user?.name || "Alice" }
)}
</p>`
: nothing}
<ha-automation-condition
role="region"
aria-labelledby="conditions-heading"
.conditions=${this.config.conditions || []}
.highlightedConditions=${this._pastedConfig?.conditions || []}
.path=${["conditions"]}
@value-changed=${this._conditionChanged}
.hass=${this.hass}
.disabled=${this.disabled}
></ha-automation-condition>
<div class="header">
<h2 id="actions-heading" class="name">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.header"
)}
</h2>
<div>
<a
href=${documentationUrl(this.hass, "/docs/automation/action/")}
target="_blank"
rel="noreferrer"
>
const sidePanel = this._selectedElement
? html`<ha-dialog-header>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.learn_more"
)}
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this._closeSidebar}
></ha-icon-button>
</a>
</div>
</div>
${!ensureArray(this.config.actions)?.length
? html`<p>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.description"
)}
</p>`
: nothing}
<span slot="title">${`Edit ${selectedElementType}`}</span>
<ha-button-menu slot="actionItems" fixed>
<ha-icon-button
.path=${mdiDotsVertical}
slot="trigger"
></ha-icon-button>
${selectedElementType === "trigger"
? html`<ha-md-menu-item
.clickAction=${this._showTriggerId}
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<ha-svg-icon
slot="start"
.path=${mdiIdentifier}
></ha-svg-icon>
</ha-md-menu-item>`
: nothing}
<ha-md-menu-item
@click=${this._toggleYamlMode}
.disabled=${!supported}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon
slot="start"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-md-menu-item>
</ha-button-menu>
</ha-dialog-header>
<div
class=${classMap({
"card-content": true,
disabled:
"enabled" in this._selectedElement &&
this._selectedElement.enabled === false,
})}
>
${this._yamlMode
? html`<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${selectedElement}
.readOnly=${this.disabled}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>`
: selectedElementType === "trigger"
? html`<div
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._onUiChanged}
.path=${path}
>
${dynamicElement(
`ha-automation-trigger-${selectedElement.trigger}`,
{
hass: this.hass,
trigger: selectedElement,
disabled: this.disabled,
}
)}
</div>`
: selectedElementType === "condition"
? html`<ha-automation-condition-editor
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._onUiChanged}
.path=${path}
.yamlMode=${this._yamlMode}
.disabled=${this.disabled}
.hass=${this.hass}
.condition=${selectedElement}
></ha-automation-condition-editor>`
: selectedElementType === "action"
? html`<div
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._onUiChanged}
.path=${path}
>
${dynamicElement(
`ha-automation-action-${getType(selectedElement)}`,
{
hass: this.hass,
action: selectedElement,
narrow: true,
disabled: this.disabled,
}
)}
</div>`
: nothing}
</div>`
: nothing;
<ha-automation-action
role="region"
aria-labelledby="actions-heading"
.actions=${this.config.actions || []}
.highlightedActions=${this._pastedConfig?.actions || []}
.path=${["actions"]}
@value-changed=${this._actionChanged}
.hass=${this.hass}
.narrow=${this.narrow}
.disabled=${this.disabled}
></ha-automation-action>
return html`
${this.narrow
? html`<sl-drawer
no-header
placement="bottom"
class="drawer-placement-bottom"
@sl-show=${this._drawerOpen}
@sl-hide=${this._drawerClose}
>
${sidePanel}
</sl-drawer>`
: nothing}
<sl-split-panel
primary="start"
.positionInPixels=${selectedElement && !this.narrow
? this.clientWidth - 40 - this._sidebarWidth || 99999
: 0}
style=${selectedElement && !this.narrow
? "--min: 300px; --max: calc(100% - 300px); --divider-width: 32px;"
: "--min: 100%; --max: 100%;"}
@sl-reposition=${this._splitPanelRepositioned}
>
<div slot="start" style="overflow: auto; height: 100%">
${this.stateObj?.state === "off"
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.automation.editor.disabled"
)}
<mwc-button slot="action" @click=${this._enable}>
${this.hass.localize(
"ui.panel.config.automation.editor.enable"
)}
</mwc-button>
</ha-alert>
`
: nothing}
${this.config.description
? html`<ha-markdown
class="description"
breaks
.content=${this.config.description}
></ha-markdown>`
: nothing}
<div class="header">
<h2 id="triggers-heading" class="name">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.header"
)}
</h2>
<a
href=${documentationUrl(this.hass, "/docs/automation/trigger/")}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.learn_more"
)}
></ha-icon-button>
</a>
</div>
${!ensureArray(this.config.triggers)?.length
? html`<p>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.description"
)}
</p>`
: nothing}
<ha-automation-trigger
role="region"
aria-labelledby="triggers-heading"
.triggers=${this.config.triggers || []}
.highlightedTriggers=${this._pastedConfig?.triggers || [
selectedElement,
]}
.path=${["triggers"]}
@value-changed=${this._triggerChanged}
.hass=${this.hass}
.disabled=${this.disabled}
@element-selected=${this._elementSelected}
></ha-automation-trigger>
<div class="header">
<h2 id="conditions-heading" class="name">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.header"
)}
<span class="small"
>(${this.hass.localize("ui.common.optional")})</span
>
</h2>
<a
href=${documentationUrl(this.hass, "/docs/automation/condition/")}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.conditions.learn_more"
)}
></ha-icon-button>
</a>
</div>
${!ensureArray(this.config.conditions)?.length
? html`<p>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.description",
{ user: this.hass.user?.name || "Alice" }
)}
</p>`
: nothing}
<ha-automation-condition
role="region"
aria-labelledby="conditions-heading"
.conditions=${this.config.conditions || []}
.highlightedConditions=${this._pastedConfig?.conditions || [
selectedElement,
]}
.path=${["conditions"]}
@value-changed=${this._conditionChanged}
.hass=${this.hass}
.disabled=${this.disabled}
@element-selected=${this._elementSelected}
></ha-automation-condition>
<div class="header">
<h2 id="actions-heading" class="name">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.header"
)}
</h2>
<div>
<a
href=${documentationUrl(this.hass, "/docs/automation/action/")}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.learn_more"
)}
></ha-icon-button>
</a>
</div>
</div>
${!ensureArray(this.config.actions)?.length
? html`<p>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.description"
)}
</p>`
: nothing}
<ha-automation-action
role="region"
aria-labelledby="actions-heading"
.actions=${this.config.actions || []}
.highlightedActions=${this._pastedConfig?.actions || [
selectedElement,
]}
.path=${["actions"]}
@value-changed=${this._actionChanged}
.hass=${this.hass}
.narrow=${this.narrow}
.disabled=${this.disabled}
@element-selected=${this._elementSelected}
></ha-automation-action>
</div>
${!this.narrow && selectedElement
? html`<ha-card
slot="end"
style="--ha-card-border-color: var(--primary-color); --ha-card-border-width: 2px;"
>
${sidePanel}
</ha-card>`
: nothing}
</sl-split-panel>
`;
}
private _onUiChanged(ev: CustomEvent): void {
ev.stopPropagation();
const path = ev.currentTarget?.path || [];
const newConfig = updateNestedItem(
{ ...this.config },
path,
ev.detail.value
);
console.log(newConfig);
fireEvent(this, "value-changed", { value: newConfig });
}
private async _toggleYamlMode() {
this._yamlMode = !this._yamlMode;
if (this._yamlMode) {
await this.updateComplete;
// this.renderRoot.querySelector("ha-yaml-editor").positionInPixels = 0;
}
}
private async _elementSelected(ev) {
console.log(ev);
this._selectedElement = ev.detail;
console.log("repo", this._sidebarWidth);
const target = ev.target;
await this.updateComplete;
this.renderRoot.querySelector("sl-split-panel").positionInPixels =
this.clientWidth - 40 - this._sidebarWidth;
if (this.narrow) {
this.renderRoot.querySelector("sl-drawer").show();
console.log(target);
this._targetEl = target;
}
}
private _splitPanelRepositioned(ev: CustomEvent): void {
if (!this._selectedElement) {
return;
}
console.log(ev);
console.log("reposition", ev.target.positionInPixels);
let sidebarWidth = ev.target.clientWidth - ev.target.positionInPixels;
if (this._oldClientWidth && this._oldClientWidth !== this.clientWidth) {
// If the client width has changed, we need to subtract the difference
sidebarWidth = sidebarWidth + (this._oldClientWidth - this.clientWidth);
}
this._oldClientWidth = this.clientWidth;
console.log(sidebarWidth);
console.log(this.clientWidth);
console.log(this.clientWidth - 40 - sidebarWidth);
// if (Math.abs(sidebarWidth - this._sidebarWidth) > 20) {
// this._sidebarWidth = sidebarWidth;
// }
this._sidebarWidth = sidebarWidth;
}
private _closeSidebar() {
if (this.narrow) {
this.renderRoot.querySelector("sl-drawer").hide();
}
this._selectedElement = undefined;
}
private async _drawerOpen() {
// this._oldScrollPosition = window.scrollY;
this.renderRoot.querySelector("div[slot='start']").style.paddingBottom =
"66vh";
await nextRender();
fireEvent(this, "scroll-to", {
up: this._targetEl.getBoundingClientRect().top,
});
this._setupListeners();
}
private _setupListeners() {
const mc = new Manager(this.renderRoot.querySelector("ha-dialog-header"), {
touchAction: "pan-y",
});
mc.add(
new Swipe({
direction: DIRECTION_VERTICAL,
})
);
mc.on("swipeup", (e) => {
console.log("up", e);
this.toggleAttribute("big-drawer", true);
});
mc.on("swipedown", (e) => {
console.log("down", e);
if (this.hasAttribute("big-drawer")) {
this.toggleAttribute("big-drawer", false);
} else {
this.renderRoot.querySelector("sl-drawer").hide();
}
});
this._manager = mc;
}
private _drawerClose() {
this.renderRoot.querySelector("div[slot='start']").style.paddingBottom =
"0";
}
private _triggerChanged(ev: CustomEvent): void {
ev.stopPropagation();
this.resetPastedConfig();
@@ -552,6 +878,45 @@ export class HaManualAutomationEditor extends LitElement {
font-weight: var(--ha-font-weight-normal);
line-height: 0;
}
sl-split-panel {
height: calc(100vh - var(--header-height, 64px) - 28px - 20px - 1px);
}
sl-drawer {
--sl-z-index-drawer: 9999;
--size: 66vh;
--sl-panel-background-color: var(--ha-card-background, white);
--sl-overlay-background-color: rgba(0, 0, 0, 0.32);
--sl-shadow-x-large: var(
--ha-card-box-shadow,
0px -1px 4px 1px rgba(0, 0, 0, 0.2),
0px 1px 1px 0px rgba(0, 0, 0, 0.14),
0px 1px 3px 0px rgba(0, 0, 0, 0.12)
);
--sl-panel-border-color: var(--ha-card-border-color, #e0e0e0);
}
:host([big-drawer]) sl-drawer {
--size: 90vh;
}
sl-drawer::part(panel) {
border-radius: 12px 12px 0 0;
border: 1px solid var(--ha-card-border-color, #e0e0e0);
}
sl-drawer .card-content {
padding: 12px;
}
sl-drawer ha-dialog-header {
position: sticky;
top: 0;
background: var(--card-background-color);
z-index: 999;
}
.card-content {
overflow: auto;
height: 100%;
padding-bottom: 16px;
}
`,
];
}
@@ -70,6 +70,7 @@ import "./types/ha-automation-trigger-time";
import "./types/ha-automation-trigger-time_pattern";
import "./types/ha-automation-trigger-webhook";
import "./types/ha-automation-trigger-zone";
import "../../../../components/ha-automation-row";
export interface TriggerElement extends LitElement {
trigger: Trigger;
@@ -158,7 +159,7 @@ export default class HaAutomationTriggerRow extends LitElement {
`
: nothing}
<ha-expansion-panel left-chevron>
<ha-automation-row>
<ha-svg-icon
slot="leading-icon"
class="trigger-icon"
@@ -193,16 +194,6 @@ export default class HaAutomationTriggerRow extends LitElement {
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._showTriggerId}
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<ha-svg-icon slot="start" .path=${mdiIdentifier}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
@@ -256,16 +247,6 @@ export default class HaAutomationTriggerRow extends LitElement {
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!supported}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
@@ -302,77 +283,7 @@ export default class HaAutomationTriggerRow extends LitElement {
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
<div
class=${classMap({
"card-content": true,
disabled:
"enabled" in this.trigger && this.trigger.enabled === false,
})}
>
${this._warnings
? html`<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.errors.config.editor_not_supported"
)}
>
${this._warnings.length && this._warnings[0] !== undefined
? html` <ul>
${this._warnings.map(
(warning) => html`<li>${warning}</li>`
)}
</ul>`
: ""}
${this.hass.localize(
"ui.errors.config.edit_in_yaml_supported"
)}
</ha-alert>`
: ""}
${yamlMode
? html`
${!supported
? html`
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.unsupported_platform",
{ platform: type }
)}
`
: ""}
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${this.trigger}
.readOnly=${this.disabled}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>
`
: html`
${showId && !isTriggerList(this.trigger)
? html`
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.id"
)}
.value=${this.trigger.id || ""}
.disabled=${this.disabled}
@change=${this._idChanged}
>
</ha-textfield>
`
: ""}
<div
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._onUiChanged}
>
${dynamicElement(`ha-automation-trigger-${type}`, {
hass: this.hass,
trigger: this.trigger,
disabled: this.disabled,
})}
</div>
`}
</div>
</ha-expansion-panel>
</ha-automation-row>
<div
class="triggered ${classMap({
@@ -740,8 +651,8 @@ export default class HaAutomationTriggerRow extends LitElement {
}
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
--shadow-focus: 0 0 0 1px var(--primary-color);
border-color: var(--primary-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`,
@@ -34,6 +34,8 @@ export default class HaAutomationTrigger extends LitElement {
@property({ attribute: false }) public highlightedTriggers?: Trigger[];
@property({ type: Array }) public path?: ItemPath;
@property({ type: Boolean }) public disabled = false;
@state() private _showReorder = false;
@@ -89,10 +91,12 @@ export default class HaAutomationTrigger extends LitElement {
.first=${idx === 0}
.last=${idx === this.triggers.length - 1}
.trigger=${trg}
.path=${[...(this.path ?? []), idx]}
@duplicate=${this._duplicateTrigger}
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._triggerChanged}
@click=${this._triggerClicked}
.hass=${this.hass}
.disabled=${this.disabled}
?highlight=${this.highlightedTriggers?.includes(trg)}
@@ -136,6 +140,15 @@ export default class HaAutomationTrigger extends LitElement {
});
}
private _triggerClicked(ev: MouseEvent) {
fireEvent(this, "element-selected", {
type: "trigger",
element: (ev.currentTarget as HaAutomationTriggerRow).trigger,
index: (ev.currentTarget as HaAutomationTriggerRow).index,
path: (ev.currentTarget as HaAutomationTriggerRow).path,
});
}
private _addTrigger = (value: string) => {
let triggers: Trigger[];
if (value === PASTE_VALUE) {
-160
View File
@@ -1,160 +0,0 @@
import "@material/mwc-button";
import { mdiHelpCircle, mdiStarFourPoints } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-card";
import "../../../components/ha-settings-row";
import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import {
fetchAITaskPreferences,
saveAITaskPreferences,
type AITaskPreferences,
} from "../../../data/ai_task";
import { documentationUrl } from "../../../util/documentation-url";
@customElement("ai-task-pref")
export class AITaskPref extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _prefs?: AITaskPreferences;
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
fetchAITaskPreferences(this.hass).then((prefs) => {
this._prefs = prefs;
});
}
protected render() {
if (!this._prefs) {
return nothing;
}
return html`
<ha-card outlined>
<h1 class="card-header">
<img
alt=""
src=${brandsUrl({
domain: "ai_task",
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>${this.hass.localize("ui.panel.config.ai_task.header")}
</h1>
<div class="header-actions">
<a
href=${documentationUrl(this.hass, "/integrations/ai_task/")}
target="_blank"
rel="noreferrer"
class="icon-link"
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.cloud.account.alexa.link_learn_how_it_works"
)}
.path=${mdiHelpCircle}
></ha-icon-button>
</a>
</div>
<div class="card-content">
<p>
${this.hass!.localize("ui.panel.config.ai_task.description", {
button: html`<ha-svg-icon
.path=${mdiStarFourPoints}
></ha-svg-icon>`,
})}
</p>
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
${this.hass!.localize("ui.panel.config.ai_task.gen_data_header")}
</span>
<span slot="description">
${this.hass!.localize(
"ui.panel.config.ai_task.gen_data_description"
)}
</span>
<ha-entity-picker
data-name="gen_data_entity_id"
.hass=${this.hass}
.value=${this._prefs.gen_data_entity_id}
.includeDomains=${["ai_task"]}
@value-changed=${this._handlePrefChange}
></ha-entity-picker>
</ha-settings-row>
</div>
</ha-card>
`;
}
private async _handlePrefChange(
ev: CustomEvent<{ value: string | undefined }>
) {
const input = ev.target as HaEntityPicker;
const key = input.getAttribute("data-name") as keyof AITaskPreferences;
const entityId = ev.detail.value || null;
const oldPrefs = this._prefs;
this._prefs = { ...this._prefs!, [key]: entityId };
try {
this._prefs = await saveAITaskPreferences(this.hass, {
[key]: entityId,
});
} catch (_err: any) {
this._prefs = oldPrefs;
}
}
static styles = css`
.card-header {
display: flex;
align-items: center;
}
.card-header img {
max-width: 28px;
margin-right: 16px;
}
a {
color: var(--primary-color);
}
ha-settings-row {
padding: 0;
}
.header-actions {
position: absolute;
right: 0px;
inset-inline-end: 0px;
inset-inline-start: initial;
top: 24px;
display: flex;
flex-direction: row;
}
.header-actions .icon-link {
margin-top: -16px;
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
direction: var(--direction);
color: var(--secondary-text-color);
}
ha-entity-picker {
flex: 1;
margin-left: 16px;
}
:host([narrow]) ha-entity-picker {
margin-left: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ai-task-pref": AITaskPref;
}
}
@@ -25,10 +25,8 @@ import type { ConfigUpdateValues } from "../../../data/core";
import { saveCoreConfig } from "../../../data/core";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import "./ai-task-pref";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
@customElement("ha-config-section-general")
class HaConfigSectionGeneral extends LitElement {
@@ -267,12 +265,6 @@ class HaConfigSectionGeneral extends LitElement {
</ha-progress-button>
</div>
</ha-card>
${isComponentLoaded(this.hass, "ai_task")
? html`<ai-task-pref
.hass=${this.hass}
.narrow=${this.narrow}
></ai-task-pref>`
: nothing}
</div>
</hass-subpage>
`;
@@ -385,8 +377,7 @@ class HaConfigSectionGeneral extends LitElement {
max-width: 1040px;
margin: 0 auto;
}
ha-card,
ai-task-pref {
ha-card {
max-width: 600px;
margin: 0 auto;
height: 100%;
@@ -394,9 +385,6 @@ class HaConfigSectionGeneral extends LitElement {
flex-direction: column;
display: flex;
}
ha-card {
margin-bottom: 24px;
}
.card-content {
display: flex;
justify-content: space-between;
@@ -64,7 +64,7 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
const updates = this.updateEntities;
return html`
<div class="title" role="heading" aria-level="2">
<div class="title">
${this.hass.localize("ui.panel.config.updates.title", {
count: this.total || this.updateEntities.length,
})}
@@ -1,7 +1,6 @@
import {
mdiChatQuestion,
mdiCog,
mdiDelete,
mdiDeleteForever,
mdiHospitalBox,
mdiInformation,
@@ -17,19 +16,17 @@ import {
fetchZwaveIsNodeFirmwareUpdateInProgress,
fetchZwaveNetworkStatus,
fetchZwaveNodeStatus,
fetchZwaveProvisioningEntries,
unprovisionZwaveSmartStartNode,
} from "../../../../../../data/zwave_js";
import { showConfirmationDialog } from "../../../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../../../types";
import { showZWaveJSRebuildNodeRoutesDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-rebuild-node-routes";
import { showZWaveJSNodeStatisticsDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics";
import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node";
import { showZWaveJSRemoveFailedNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node";
import { showZWaveJSUpdateFirmwareNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-update-firmware-node";
import type { DeviceAction } from "../../../ha-config-device-page";
import { showZWaveJSHardResetControllerDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-hard-reset-controller";
import { showZWaveJSAddNodeDialog } from "../../../../integrations/integration-panels/zwave_js/add-node/show-dialog-zwave_js-add-node";
import { showZWaveJSRemoveNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-node";
export const getZwaveDeviceActions = async (
el: HTMLElement,
@@ -50,43 +47,6 @@ export const getZwaveDeviceActions = async (
const entryId = configEntry.entry_id;
const provisioningEntries = await fetchZwaveProvisioningEntries(
hass,
entryId
);
const provisioningEntry = provisioningEntries.find(
(entry) => entry.device_id === device.id
);
if (provisioningEntry && !provisioningEntry.nodeId) {
return [
{
label: hass.localize("ui.panel.config.devices.delete_device"),
classes: "warning",
icon: mdiDelete,
action: async () => {
const confirm = await showConfirmationDialog(el, {
title: hass.localize(
"ui.panel.config.zwave_js.provisioned.confirm_unprovision_title"
),
text: hass.localize(
"ui.panel.config.zwave_js.provisioned.confirm_unprovision_text",
{ name: device.name_by_user || device.name }
),
confirmText: hass.localize("ui.common.remove"),
destructive: true,
});
if (confirm) {
await unprovisionZwaveSmartStartNode(
hass,
entryId,
provisioningEntry.dsk
);
}
},
},
];
}
const nodeStatus = await fetchZwaveNodeStatus(hass, device.id);
if (!nodeStatus) {
@@ -124,6 +84,16 @@ export const getZwaveDeviceActions = async (
device,
}),
},
{
label: hass.localize(
"ui.panel.config.zwave_js.device_info.remove_failed"
),
icon: mdiDeleteForever,
action: () =>
showZWaveJSRemoveFailedNodeDialog(el, {
device_id: device.id,
}),
},
{
label: hass.localize(
"ui.panel.config.zwave_js.device_info.node_statistics"
@@ -133,16 +103,6 @@ export const getZwaveDeviceActions = async (
showZWaveJSNodeStatisticsDialog(el, {
device,
}),
},
{
label: hass.localize("ui.panel.config.devices.delete_device"),
classes: "warning",
icon: mdiDelete,
action: () =>
showZWaveJSRemoveNodeDialog(el, {
deviceId: device.id,
entryId,
}),
}
);
}
@@ -15,7 +15,6 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import type { HassEntity } from "home-assistant-js-websocket";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { ASSIST_ENTITIES, SENSOR_ENTITIES } from "../../../common/const";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
@@ -186,27 +185,6 @@ export class HaConfigDevicePage extends LitElement {
)
);
private _getEntitiesSorted = (entities: HassEntity[]) =>
entities.sort((ent1, ent2) =>
stringCompare(
ent1.attributes.friendly_name || `zzz${ent1.entity_id}`,
ent2.attributes.friendly_name || `zzz${ent2.entity_id}`,
this.hass.locale.language
)
);
private _getRelated = memoizeOne((related?: RelatedResult) => ({
automation: this._getEntitiesSorted(
(related?.automation ?? []).map((entityId) => this.hass.states[entityId])
),
scene: this._getEntitiesSorted(
(related?.scene ?? []).map((entityId) => this.hass.states[entityId])
),
script: this._getEntitiesSorted(
(related?.script ?? []).map((entityId) => this.hass.states[entityId])
),
}));
private _deviceIdInList = memoizeOne((deviceId: string) => [deviceId]);
private _entityIds = memoizeOne(
@@ -455,25 +433,23 @@ export class HaConfigDevicePage extends LitElement {
${this._related?.automation?.length
? html`
<div class="items">
${this._getRelated(this._related).automation.map(
(automation) => {
const entityState = automation;
return entityState
? html`<a
href=${ifDefined(
entityState.attributes.id
? `/config/automation/edit/${encodeURIComponent(entityState.attributes.id)}`
: `/config/automation/show/${entityState.entity_id}`
)}
>
<ha-list-item hasMeta .automation=${entityState}>
${computeStateName(entityState)}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>`
: nothing;
}
)}
${this._related.automation.map((automation) => {
const entityState = this.hass.states[automation];
return entityState
? html`<a
href=${ifDefined(
entityState.attributes.id
? `/config/automation/edit/${encodeURIComponent(entityState.attributes.id)}`
: `/config/automation/show/${entityState.entity_id}`
)}
>
<ha-list-item hasMeta .automation=${entityState}>
${computeStateName(entityState)}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>`
: nothing;
})}
</div>
`
: html`
@@ -534,8 +510,8 @@ export class HaConfigDevicePage extends LitElement {
${this._related?.scene?.length
? html`
<div class="items">
${this._getRelated(this._related).scene.map((scene) => {
const entityState = scene;
${this._related.scene.map((scene) => {
const entityState = this.hass.states[scene];
return entityState && entityState.attributes.id
? html`
<a
@@ -622,10 +598,10 @@ export class HaConfigDevicePage extends LitElement {
${this._related?.script?.length
? html`
<div class="items">
${this._getRelated(this._related).script.map((script) => {
const entityState = script;
${this._related.script.map((script) => {
const entityState = this.hass.states[script];
const entry = this._entityReg.find(
(e) => e.entity_id === script.entity_id
(e) => e.entity_id === script
);
let url = `/config/script/show/${entityState.entity_id}`;
if (entry) {
@@ -1317,13 +1293,9 @@ export class HaConfigDevicePage extends LitElement {
// eslint-disable-next-line no-await-in-loop
(await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.devices.confirm_disable_config_entry_title"
"ui.panel.config.devices.confirm_disable_config_entry",
{ entry_name: config_entry.title }
),
text: this.hass.localize(
"ui.panel.config.devices.confirm_disable_config_entry_message",
{ name: config_entry.title }
),
destructive: true,
confirmText: this.hass.localize("ui.common.yes"),
dismissText: this.hass.localize("ui.common.no"),
}))
@@ -1115,10 +1115,9 @@ ${
const domain = this._searchParms.get("domain");
const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry");
const device = this._searchParms.get("device");
const label = this._searchParms.has("label");
if (!domain && !configEntry && !label && !device) {
if (!domain && !configEntry && !label) {
return;
}
@@ -1127,7 +1126,6 @@ ${
this._filters = {
"ha-filter-states": [],
"ha-filter-integrations": domain ? [domain] : [],
"ha-filter-devices": device ? [device] : [],
config_entry: configEntry ? [configEntry] : [],
sub_entry: subEntry ? [subEntry] : [],
};
@@ -1,98 +0,0 @@
import { mdiClose } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-dialog-header";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../components/ha-md-dialog";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import { ERROR_STATES, RECOVERABLE_STATES } from "../../../data/config_entries";
import type { HomeAssistant } from "../../../types";
import type { PickConfigEntryDialogParams } from "./show-pick-config-entry-dialog";
@customElement("dialog-pick-config-entry")
export class DialogPickConfigEntry extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: PickConfigEntryDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public showDialog(params: PickConfigEntryDialogParams): void {
this._params = params;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public closeDialog() {
this._dialog?.close();
return true;
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span
slot="title"
.title=${this.hass.localize(
`component.${this._params.domain}.config_subentries.${this._params.subFlowType}.initiate_flow.user`
)}
>${this.hass.localize(
`component.${this._params.domain}.config_subentries.${this._params.subFlowType}.initiate_flow.user`
)}</span
>
</ha-dialog-header>
<ha-md-list slot="content">
${this._params.configEntries.map(
(entry) =>
html`<ha-md-list-item
type="button"
@click=${this._itemPicked}
.entry=${entry}
.disabled=${!ERROR_STATES.includes(entry.state) &&
!RECOVERABLE_STATES.includes(entry.state)}
>${entry.title}</ha-md-list-item
>`
)}
</ha-md-list>
</ha-md-dialog>
`;
}
private _itemPicked(ev: Event) {
this._params?.configEntryPicked((ev.currentTarget as any).entry);
this.closeDialog();
}
static styles = css`
:host {
--dialog-content-padding: 0;
}
@media all and (min-width: 600px) {
ha-dialog {
--mdc-dialog-min-width: 400px;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-pick-config-entry": DialogPickConfigEntry;
}
}
@@ -1,320 +0,0 @@
import {
mdiCogOutline,
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiPencil,
mdiStopCircleOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { getDeviceContext } from "../../../common/entity/context/get_device_context";
import { navigate } from "../../../common/navigate";
import {
disableConfigEntry,
type ConfigEntry,
type DisableConfigEntryResult,
} from "../../../data/config_entries";
import {
removeConfigEntryFromDevice,
updateDeviceRegistryEntry,
type DeviceRegistryEntry,
} from "../../../data/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../lovelace/custom-card-helpers";
import { showDeviceRegistryDetailDialog } from "../devices/device-registry-detail/show-dialog-device-registry-detail";
import "./ha-config-sub-entry-row";
@customElement("ha-config-entry-device-row")
class HaConfigEntryDeviceRow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public entry!: ConfigEntry;
@property({ attribute: false }) public device!: DeviceRegistryEntry;
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
protected render() {
const device = this.device;
const entities = this._getEntities();
const { area } = getDeviceContext(device, this.hass);
const supportingText = [
device.model || device.sw_version || device.manufacturer,
area ? area.name : undefined,
].filter(Boolean);
return html`<ha-md-list-item @click=${this.narrow ? this._handleNavigateToDevice : undefined} class=${classMap({ disabled: Boolean(device.disabled_by) })}>
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
<div slot="headline"></div>${computeDeviceNameDisplay(device, this.hass)}</div>
<span slot="supporting-text"
>${supportingText.join(" • ")}
${supportingText.length && entities.length ? " • " : nothing}
${
entities.length
? this.narrow
? this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)
: html`<a
href=${`/config/entities/?historyBack=1&device=${device.id}`}
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)}</a
>`
: nothing
}</span
>
${
!this.narrow
? html`<ha-icon-button-next
slot="end"
@click=${this._handleNavigateToDevice}
>
</ha-icon-button-next>`
: nothing
}
</ha-icon-button>
<div class="vertical-divider" slot="end" @click=${stopPropagation}></div>
${
!this.narrow
? html`<ha-icon-button
slot="end"
@click=${this._handleConfigureDevice}
.path=${mdiPencil}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.configure"
)}
></ha-icon-button>`
: nothing
}
</ha-icon-button>
<ha-md-button-menu positioning="popover" slot="end" @click=${stopPropagation}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${
this.narrow
? html`<ha-md-menu-item @click=${this._handleConfigureDevice}>
<ha-svg-icon .path=${mdiCogOutline} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.configure"
)}
</ha-md-menu-item>`
: nothing
}
<ha-md-menu-item class=${device.disabled_by !== "user" ? "warning" : ""} @click=${this._handleDisableDevice} .disabled=${device.disabled_by !== "user" && device.disabled_by}>
<ha-svg-icon .path=${mdiStopCircleOutline} slot="start"></ha-svg-icon>
${
device.disabled_by && device.disabled_by !== "user"
? this.hass.localize(
"ui.dialogs.device-registry-detail.enabled_cause",
{
type: this.hass.localize(
`ui.dialogs.device-registry-detail.type.${
device.entry_type || "device"
}`
),
cause: this.hass.localize(
`config_entry.disabled_by.${device.disabled_by}`
),
}
)
: device.disabled_by
? this.hass.localize(
"ui.panel.config.integrations.config_entry.device.enable"
)
: this.hass.localize(
"ui.panel.config.integrations.config_entry.device.disable"
)
}
</ha-md-menu-item>
${
this.entry.supports_remove_device
? html` <ha-md-menu-item
class="warning"
@click=${this._handleDeleteDevice}
>
<ha-svg-icon .path=${mdiDelete} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.delete"
)}
</ha-md-menu-item>`
: nothing
}
</ha-md-button-menu>
</ha-md-list-item> `;
}
private _getEntities = (): EntityRegistryEntry[] =>
this.entities?.filter((entity) => entity.device_id === this.device.id);
private _handleConfigureDevice(ev: MouseEvent) {
ev.stopPropagation(); // Prevent triggering the click handler on the list item
showDeviceRegistryDetailDialog(this, {
device: this.device,
updateEntry: async (updates) => {
await updateDeviceRegistryEntry(this.hass, this.device.id, updates);
},
});
}
private async _handleDisableDevice() {
const disable = this.device.disabled_by === null;
if (disable) {
if (
!Object.values(this.hass.devices).some(
(dvc) =>
dvc.id !== this.device.id &&
dvc.config_entries.includes(this.entry.entry_id)
)
) {
const config_entry = this.entry;
if (
config_entry &&
!config_entry.disabled_by &&
(await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.devices.confirm_disable_config_entry_title"
),
text: this.hass.localize(
"ui.panel.config.devices.confirm_disable_config_entry_message",
{ name: config_entry.title }
),
destructive: true,
confirmText: this.hass.localize("ui.common.yes"),
dismissText: this.hass.localize("ui.common.no"),
}))
) {
let result: DisableConfigEntryResult;
try {
result = await disableConfigEntry(this.hass, this.entry.entry_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_error"
),
text: err.message,
});
return;
}
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_restart_confirm"
),
});
}
return;
}
}
}
if (disable) {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.device.confirm_disable_title"
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.device.confirm_disable_message",
{ name: computeDeviceNameDisplay(this.device, this.hass) }
),
destructive: true,
confirmText: this.hass.localize("ui.common.yes"),
dismissText: this.hass.localize("ui.common.no"),
});
if (!confirm) {
return;
}
}
await updateDeviceRegistryEntry(this.hass, this.device.id, {
disabled_by: disable ? "user" : null,
});
}
private async _handleDeleteDevice() {
const entry = this.entry;
const confirmed = await showConfirmationDialog(this, {
text: this.hass.localize("ui.panel.config.devices.confirm_delete"),
confirmText: this.hass.localize("ui.common.delete"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
try {
await removeConfigEntryFromDevice(
this.hass!,
this.device.id,
entry.entry_id
);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.devices.error_delete"),
text: err.message,
});
}
}
private _handleNavigateToDevice() {
navigate(`/config/devices/device/${this.device.id}`);
}
static styles = [
haStyle,
css`
:host {
border-top: 1px solid var(--divider-color);
}
ha-md-list-item {
--md-list-item-leading-space: 56px;
}
.disabled {
opacity: 0.5;
}
:host([narrow]) ha-md-list-item {
--md-list-item-leading-space: 16px;
}
.vertical-divider {
height: 100%;
width: 1px;
background: var(--divider-color);
}
a {
text-decoration: none;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-entry-device-row": HaConfigEntryDeviceRow;
}
}
@@ -1,770 +0,0 @@
import {
mdiAlertCircle,
mdiChevronDown,
mdiChevronUp,
mdiCogOutline,
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiDownload,
mdiHandExtendedOutline,
mdiPlayCircleOutline,
mdiPlus,
mdiProgressHelper,
mdiReload,
mdiReloadAlert,
mdiRenameBox,
mdiShapeOutline,
mdiStopCircleOutline,
mdiWrench,
} from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { isDevVersion } from "../../../common/config/version";
import {
deleteApplicationCredential,
fetchApplicationCredentialsConfigEntry,
} from "../../../data/application_credential";
import { getSignedPath } from "../../../data/auth";
import type {
ConfigEntry,
DisableConfigEntryResult,
SubEntry,
} from "../../../data/config_entries";
import {
deleteConfigEntry,
disableConfigEntry,
enableConfigEntry,
ERROR_STATES,
getSubEntries,
RECOVERABLE_STATES,
reloadConfigEntry,
updateConfigEntry,
} from "../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import {
domainToName,
fetchIntegrationManifest,
integrationsWithPanel,
} from "../../../data/integration";
import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entry-system-options/show-dialog-config-entry-system-options";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow";
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { fileDownload } from "../../../util/file_download";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../lovelace/custom-card-helpers";
import "./ha-config-entry-device-row";
import { renderConfigEntryError } from "./ha-config-integration-page";
import "./ha-config-sub-entry-row";
import { haStyle } from "../../../resources/styles";
@customElement("ha-config-entry-row")
class HaConfigEntryRow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public manifest?: IntegrationManifest;
@property({ attribute: false }) public diagnosticHandler?: DiagnosticInfo;
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
@property({ attribute: false }) public entry!: ConfigEntry;
@state() private _expanded = true;
@state() private _devicesExpanded = true;
@state() private _subEntries?: SubEntry[];
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("entry")) {
this._fetchSubEntries();
}
}
protected render() {
const item = this.entry;
let stateText: Parameters<typeof this.hass.localize> | undefined;
let stateTextExtra: TemplateResult | string | undefined;
let icon: string = mdiAlertCircle;
if (!item.disabled_by && item.state === "not_loaded") {
stateText = ["ui.panel.config.integrations.config_entry.not_loaded"];
} else if (item.state === "setup_in_progress") {
icon = mdiProgressHelper;
stateText = [
"ui.panel.config.integrations.config_entry.setup_in_progress",
];
} else if (ERROR_STATES.includes(item.state)) {
if (item.state === "setup_retry") {
icon = mdiReloadAlert;
}
stateText = [
`ui.panel.config.integrations.config_entry.state.${item.state}`,
];
stateTextExtra = renderConfigEntryError(this.hass, item);
}
const devices = this._getDevices();
const services = this._getServices();
const entities = this._getEntities();
const ownDevices = [...devices, ...services].filter(
(device) =>
!device.config_entries_subentries[item.entry_id].length ||
device.config_entries_subentries[item.entry_id][0] === null
);
const statusLine: (TemplateResult | string)[] = [];
if (item.disabled_by) {
statusLine.push(
this.hass.localize(
"ui.panel.config.integrations.config_entry.disable.disabled_cause",
{
cause:
this.hass.localize(
`ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}`
) || item.disabled_by,
}
)
);
if (item.state === "failed_unload") {
statusLine.push(`.
${this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_restart_confirm"
)}.`);
}
} else if (!devices.length && !services.length && entities.length) {
statusLine.push(
html`<a
href=${`/config/entities/?historyBack=1&config_entry=${item.entry_id}`}
>${entities.length} entities</a
>`
);
}
const configPanel = this._configPanel(item.domain, this.hass.panels);
const subEntries = this._subEntries || [];
return html`<ha-md-list>
<ha-md-list-item
class=${classMap({
config_entry: true,
"state-not-loaded": item!.state === "not_loaded",
"state-failed-unload": item!.state === "failed_unload",
"state-setup": item!.state === "setup_in_progress",
"state-error": ERROR_STATES.includes(item!.state),
"state-disabled": item.disabled_by !== null,
"has-subentries": this._expanded && subEntries.length > 0,
})}
>
${subEntries.length || ownDevices.length
? html`<ha-icon-button
class="expand-button"
.path=${this._expanded ? mdiChevronDown : mdiChevronUp}
slot="start"
@click=${this._toggleExpand}
></ha-icon-button>`
: nothing}
<div slot="headline">
${item.title || domainToName(this.hass.localize, item.domain)}
</div>
<div slot="supporting-text">
<div>${statusLine}</div>
${stateText
? html`
<div class="message">
<ha-svg-icon .path=${icon}></ha-svg-icon>
<div>
${this.hass.localize(...stateText)}${stateTextExtra
? html`: ${stateTextExtra}`
: nothing}
</div>
</div>
`
: nothing}
</div>
${item.disabled_by === "user"
? html`<ha-button unelevated slot="end" @click=${this._handleEnable}>
${this.hass.localize("ui.common.enable")}
</ha-button>`
: configPanel &&
(item.domain !== "matter" ||
isDevVersion(this.hass.config.version)) &&
!stateText
? html`<a
slot="end"
href=${`/${configPanel}?config_entry=${item.entry_id}`}
><ha-icon-button
.path=${mdiCogOutline}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button
></a>`
: item.supports_options
? html`
<ha-icon-button
slot="end"
@click=${this._showOptions}
.path=${mdiCogOutline}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button>
`
: nothing}
<ha-md-button-menu positioning="popover" slot="end">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${devices.length
? html`
<ha-md-menu-item
href=${devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.devices`,
{ count: devices.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${services.length
? html`<ha-md-menu-item
href=${services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon
.path=${mdiHandExtendedOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.services`,
{ count: services.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item> `
: nothing}
${entities.length
? html`
<ha-md-menu-item
href=${`/config/entities?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${!item.disabled_by &&
RECOVERABLE_STATES.includes(item.state) &&
item.supports_unload &&
item.source !== "system"
? html`
<ha-md-menu-item @click=${this._handleReload}>
<ha-svg-icon slot="start" .path=${mdiReload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reload"
)}
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item @click=${this._handleRename} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
)}
</ha-md-menu-item>
${Object.keys(item.supported_subentry_types).map(
(flowType) =>
html`<ha-md-menu-item
@click=${this._addSubEntry}
.entry=${item}
.flowType=${flowType}
graphic="icon"
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
`component.${item.domain}.config_subentries.${flowType}.initiate_flow.user`
)}</ha-md-menu-item
>`
)}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
${this.diagnosticHandler && item.state === "loaded"
? html`
<ha-md-menu-item
href=${getConfigEntryDiagnosticsDownloadUrl(item.entry_id)}
target="_blank"
@click=${this._signUrl}
>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.download_diagnostics"
)}
</ha-md-menu-item>
`
: nothing}
${!item.disabled_by &&
item.supports_reconfigure &&
item.source !== "system"
? html`
<ha-md-menu-item @click=${this._handleReconfigure}>
<ha-svg-icon slot="start" .path=${mdiWrench}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reconfigure"
)}
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item @click=${this._handleSystemOptions} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiCogOutline}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.system_options"
)}
</ha-md-menu-item>
${item.disabled_by === "user"
? html`
<ha-md-menu-item @click=${this._handleEnable}>
<ha-svg-icon
slot="start"
.path=${mdiPlayCircleOutline}
></ha-svg-icon>
${this.hass.localize("ui.common.enable")}
</ha-md-menu-item>
`
: item.source !== "system"
? html`
<ha-md-menu-item
class="warning"
@click=${this._handleDisable}
graphic="icon"
>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiStopCircleOutline}
></ha-svg-icon>
${this.hass.localize("ui.common.disable")}
</ha-md-menu-item>
`
: nothing}
${item.source !== "system"
? html`
<ha-md-menu-item class="warning" @click=${this._handleDelete}>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.delete"
)}
</ha-md-menu-item>
`
: nothing}
</ha-md-button-menu>
</ha-md-list-item>
${this._expanded
? subEntries.length
? html`${ownDevices.length
? html`<ha-md-list class="devices">
<ha-md-list-item
@click=${this._toggleOwnDevices}
type="button"
>
<ha-icon-button
class="expand-button"
.path=${this._devicesExpanded
? mdiChevronDown
: mdiChevronUp}
slot="start"
>
</ha-icon-button>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.devices_without_subentry"
)}
</ha-md-list-item>
${this._devicesExpanded
? ownDevices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
></ha-config-entry-device-row>`
)
: nothing}
</ha-md-list>`
: nothing}
${subEntries.map(
(subEntry) => html`
<ha-config-sub-entry-row
.hass=${this.hass}
.narrow=${this.narrow}
.manifest=${this.manifest}
.diagnosticHandler=${this.diagnosticHandler}
.entities=${this.entities}
.entry=${item}
.subEntry=${subEntry}
data-entry-id=${item.entry_id}
></ha-config-sub-entry-row>
`
)}`
: html`
${ownDevices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
></ha-config-entry-device-row>`
)}
`
: nothing}
</ha-md-list>`;
}
private async _fetchSubEntries() {
this._subEntries = this.entry.num_subentries
? await getSubEntries(this.hass, this.entry.entry_id)
: undefined;
}
private _configPanel = memoizeOne(
(domain: string, panels: HomeAssistant["panels"]): string | undefined =>
Object.values(panels).find(
(panel) => panel.config_panel_domain === domain
)?.url_path || integrationsWithPanel[domain]
);
private _getEntities = (): EntityRegistryEntry[] =>
this.entities.filter(
(entity) => entity.config_entry_id === this.entry.entry_id
);
private _getDevices = (): DeviceRegistryEntry[] =>
Object.values(this.hass.devices).filter(
(device) =>
device.config_entries.includes(this.entry.entry_id) &&
device.entry_type !== "service"
);
private _getServices = (): DeviceRegistryEntry[] =>
Object.values(this.hass.devices).filter(
(device) =>
device.config_entries.includes(this.entry.entry_id) &&
device.entry_type === "service"
);
private _toggleExpand() {
this._expanded = !this._expanded;
}
private _toggleOwnDevices() {
this._devicesExpanded = !this._devicesExpanded;
}
private _showOptions() {
showOptionsFlowDialog(this, this.entry, { manifest: this.manifest });
}
// Return an application credentials id for this config entry to prompt the
// user for removal. This is best effort so we don't stop overall removal
// if the integration isn't loaded or there is some other error.
private async _applicationCredentialForRemove(entryId: string) {
try {
return (await fetchApplicationCredentialsConfigEntry(this.hass, entryId))
.application_credentials_id;
} catch (_err: any) {
// We won't prompt the user to remove credentials
return null;
}
}
private async _removeApplicationCredential(applicationCredentialsId: string) {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_title"
),
text: html`${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_prompt"
)},
<br />
<br />
${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_detail"
)}
<br />
<br />
<a
href=${documentationUrl(
this.hass,
"/integrations/application_credentials/"
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.learn_more"
)}
</a>`,
destructive: true,
confirmText: this.hass.localize("ui.common.remove"),
dismissText: this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.dismiss"
),
});
if (!confirmed) {
return;
}
try {
await deleteApplicationCredential(this.hass, applicationCredentialsId);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_error_title"
),
text: err.message,
});
}
}
private async _handleReload() {
const result = await reloadConfigEntry(this.hass, this.entry.entry_id);
const locale_key = result.require_restart
? "reload_restart_confirm"
: "reload_confirm";
showAlertDialog(this, {
text: this.hass.localize(
`ui.panel.config.integrations.config_entry.${locale_key}`
),
});
}
private async _handleReconfigure() {
showConfigFlowDialog(this, {
startFlowHandler: this.entry.domain,
showAdvanced: this.hass.userData?.showAdvanced,
manifest: await fetchIntegrationManifest(this.hass, this.entry.domain),
entryId: this.entry.entry_id,
navigateToResult: true,
});
}
private async _handleRename() {
const newName = await showPromptDialog(this, {
title: this.hass.localize("ui.panel.config.integrations.rename_dialog"),
defaultValue: this.entry.title,
inputLabel: this.hass.localize(
"ui.panel.config.integrations.rename_input_label"
),
});
if (newName === null) {
return;
}
await updateConfigEntry(this.hass, this.entry.entry_id, {
title: newName,
});
}
private async _signUrl(ev) {
const anchor = ev.currentTarget;
ev.preventDefault();
const signedUrl = await getSignedPath(
this.hass,
anchor.getAttribute("href")
);
fileDownload(signedUrl.path);
}
private async _handleDisable() {
const entryId = this.entry.entry_id;
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_confirm_title",
{ title: this.entry.title }
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_confirm_text"
),
confirmText: this.hass!.localize("ui.common.disable"),
dismissText: this.hass!.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
let result: DisableConfigEntryResult;
try {
result = await disableConfigEntry(this.hass, entryId);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_error"
),
text: err.message,
});
return;
}
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_restart_confirm"
),
});
}
}
private async _handleEnable() {
const entryId = this.entry.entry_id;
let result: DisableConfigEntryResult;
try {
result = await enableConfigEntry(this.hass, entryId);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_error"
),
text: err.message,
});
return;
}
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.enable_restart_confirm"
),
});
}
}
private async _handleDelete() {
const entryId = this.entry.entry_id;
const applicationCredentialsId =
await this._applicationCredentialForRemove(entryId);
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title",
{ title: this.entry.title }
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_text"
),
confirmText: this.hass!.localize("ui.common.delete"),
dismissText: this.hass!.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
const result = await deleteConfigEntry(this.hass, entryId);
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.restart_confirm"
),
});
}
if (applicationCredentialsId) {
this._removeApplicationCredential(applicationCredentialsId);
}
}
private _handleSystemOptions() {
showConfigEntrySystemOptionsDialog(this, {
entry: this.entry,
manifest: this.manifest,
});
}
private _addSubEntry(ev) {
showSubConfigFlowDialog(this, this.entry, ev.target.flowType, {
startFlowHandler: this.entry.entry_id,
});
}
static styles = [
haStyle,
css`
.expand-button {
margin: 0 -12px;
}
ha-md-list {
border: 1px solid var(--divider-color);
border-radius: var(--ha-card-border-radius, 12px);
padding: 0;
}
:host([narrow]) {
margin-left: -12px;
margin-right: -12px;
}
ha-md-list.devices {
margin: 16px;
margin-top: 0;
}
a ha-icon-button {
color: var(
--md-list-item-trailing-icon-color,
var(--md-sys-color-on-surface-variant, #49454f)
);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-entry-row": HaConfigEntryRow;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,260 +0,0 @@
import {
mdiChevronDown,
mdiChevronUp,
mdiCogOutline,
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiHandExtendedOutline,
mdiShapeOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { deleteSubEntry } from "../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
import type { HomeAssistant } from "../../../types";
import { showConfirmationDialog } from "../../lovelace/custom-card-helpers";
import "./ha-config-entry-device-row";
@customElement("ha-config-sub-entry-row")
class HaConfigSubEntryRow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public manifest?: IntegrationManifest;
@property({ attribute: false }) public diagnosticHandler?: DiagnosticInfo;
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
@property({ attribute: false }) public entry!: ConfigEntry;
@property({ attribute: false }) public subEntry!: SubEntry;
@state() private _expanded = true;
protected render() {
const subEntry = this.subEntry;
const configEntry = this.entry;
const devices = this._getDevices();
const services = this._getServices();
const entities = this._getEntities();
return html`<ha-md-list>
<ha-md-list-item
class="sub-entry"
data-entry-id=${configEntry.entry_id}
.configEntry=${configEntry}
.subEntry=${subEntry}
>
${devices.length || services.length
? html`<ha-icon-button
class="expand-button"
.path=${this._expanded ? mdiChevronDown : mdiChevronUp}
slot="start"
@click=${this._toggleExpand}
></ha-icon-button>`
: nothing}
<span slot="headline">${subEntry.title}</span>
<span slot="supporting-text"
>${this.hass.localize(
`component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.entry_type`
)}</span
>
${configEntry.supported_subentry_types[subEntry.subentry_type]
?.supports_reconfigure
? html`
<ha-icon-button
slot="end"
@click=${this._handleReconfigureSub}
.path=${mdiCogOutline}
.label=${this.hass.localize(
`component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.initiate_flow.reconfigure`
) ||
this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button>
`
: nothing}
<ha-md-button-menu positioning="popover" slot="end">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${devices.length || services.length
? html`
<ha-md-menu-item
href=${devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
>
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.devices`,
{ count: devices.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${services.length
? html`<ha-md-menu-item
href=${services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
>
<ha-svg-icon
.path=${mdiHandExtendedOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.services`,
{ count: services.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item> `
: nothing}
${entities.length
? html`
<ha-md-menu-item
href=${`/config/entities?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item class="warning" @click=${this._handleDeleteSub}>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.delete"
)}
</ha-md-menu-item>
</ha-md-button-menu>
</ha-md-list-item>
${this._expanded
? html`
${devices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${this.entry}
.device=${device}
.entities=${this.entities}
></ha-config-entry-device-row>`
)}
${services.map(
(service) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${this.entry}
.device=${service}
.entities=${this.entities}
></ha-config-entry-device-row>`
)}
`
: nothing}
</ha-md-list>`;
}
private _toggleExpand() {
this._expanded = !this._expanded;
}
private _getEntities = (): EntityRegistryEntry[] =>
this.entities.filter(
(entity) => entity.config_subentry_id === this.subEntry.subentry_id
);
private _getDevices = (): DeviceRegistryEntry[] =>
Object.values(this.hass.devices).filter(
(device) =>
device.config_entries_subentries[this.entry.entry_id]?.includes(
this.subEntry.subentry_id
) && device.entry_type !== "service"
);
private _getServices = (): DeviceRegistryEntry[] =>
Object.values(this.hass.devices).filter(
(device) =>
device.config_entries_subentries[this.entry.entry_id]?.includes(
this.subEntry.subentry_id
) && device.entry_type === "service"
);
private async _handleReconfigureSub(): Promise<void> {
showSubConfigFlowDialog(this, this.entry, this.subEntry.subentry_type, {
startFlowHandler: this.entry.entry_id,
subEntryId: this.subEntry.subentry_id,
});
}
private async _handleDeleteSub(): Promise<void> {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title",
{ title: this.subEntry.title }
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_text"
),
confirmText: this.hass!.localize("ui.common.delete"),
dismissText: this.hass!.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
await deleteSubEntry(
this.hass,
this.entry.entry_id,
this.subEntry.subentry_id
);
}
static styles = css`
.expand-button {
margin: 0 -12px;
}
ha-md-list {
border: 1px solid var(--divider-color);
border-radius: var(--ha-card-border-radius, 12px);
padding: 0;
margin: 16px;
margin-top: 0;
}
ha-md-list-item.has-subentries {
border-bottom: 1px solid var(--divider-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-sub-entry-row": HaConfigSubEntryRow;
}
}
@@ -60,7 +60,6 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) {
title: localize("ui.panel.config.dhcp.ip_address"),
filterable: true,
sortable: true,
type: "ip",
},
};
@@ -156,7 +156,7 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
newName = name.replace(oldDeviceName, newDeviceName);
}
if (newName !== undefined && !newEntityId) {
if (newName === undefined && !newEntityId) {
return undefined;
}
@@ -80,7 +80,9 @@ export class ZWaveJsAddNodeConfigureDevice extends LitElement {
options: [
{
value: Protocols.ZWaveLongRange.toString(),
label: "Long Range", // brand name and we should not translate that
label: localize(
"ui.panel.config.zwave_js.add_node.configure_device.long_range_label"
),
description: localize(
"ui.panel.config.zwave_js.add_node.configure_device.long_range_description"
),
@@ -0,0 +1,234 @@
import "@material/mwc-button/mwc-button";
import { mdiCheckCircle, mdiCloseCircle, mdiRobotDead } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-spinner";
import { createCloseHeading } from "../../../../../components/ha-dialog";
import type { ZWaveJSRemovedNode } from "../../../../../data/zwave_js";
import { removeFailedZwaveNode } from "../../../../../data/zwave_js";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { ZWaveJSRemoveFailedNodeDialogParams } from "./show-dialog-zwave_js-remove-failed-node";
@customElement("dialog-zwave_js-remove-failed-node")
class DialogZWaveJSRemoveFailedNode extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private device_id?: string;
@state() private _status = "";
@state() private _error?: any;
@state() private _node?: ZWaveJSRemovedNode;
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
public disconnectedCallback(): void {
super.disconnectedCallback();
this._unsubscribe();
}
public async showDialog(
params: ZWaveJSRemoveFailedNodeDialogParams
): Promise<void> {
this.device_id = params.device_id;
}
public closeDialog(): void {
this._unsubscribe();
this.device_id = undefined;
this._status = "";
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public closeDialogFinished(): void {
history.back();
this.closeDialog();
}
protected render() {
if (!this.device_id) {
return nothing;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.title"
)
)}
>
${this._status === ""
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiRobotDead}
class="introduction"
></ha-svg-icon>
<div class="status">
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.introduction"
)}
</div>
</div>
<mwc-button slot="primaryAction" @click=${this._startExclusion}>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.remove_device"
)}
</mwc-button>
`
: ``}
${this._status === "started"
? html`
<div class="flex-container">
<ha-spinner></ha-spinner>
<div class="status">
<p>
<b>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.in_progress"
)}
</b>
</p>
</div>
</div>
`
: ``}
${this._status === "failed"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCloseCircle}
class="error"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.removal_failed"
)}
</p>
${this._error
? html` <p><em> ${this._error.message} </em></p> `
: ``}
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</mwc-button>
`
: ``}
${this._status === "finished"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.removal_finished",
{ id: this._node!.node_id }
)}
</p>
</div>
</div>
<mwc-button
slot="primaryAction"
@click=${this.closeDialogFinished}
>
${this.hass.localize("ui.common.close")}
</mwc-button>
`
: ``}
</ha-dialog>
`;
}
private _startExclusion(): void {
if (!this.hass) {
return;
}
this._status = "started";
this._subscribed = removeFailedZwaveNode(
this.hass,
this.device_id!,
(message: any) => this._handleMessage(message)
).catch((error) => {
this._status = "failed";
this._error = error;
return undefined;
});
}
private _handleMessage(message: any): void {
if (message.event === "exclusion started") {
this._status = "started";
}
if (message.event === "node removed") {
this._status = "finished";
this._node = message.node;
this._unsubscribe();
}
}
private async _unsubscribe(): Promise<void> {
if (this._subscribed) {
const unsubFunc = await this._subscribed;
if (unsubFunc instanceof Function) {
unsubFunc();
}
this._subscribed = undefined;
}
if (this._status !== "finished") {
this._status = "";
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
.success {
color: var(--success-color);
}
.failed {
color: var(--warning-color);
}
.flex-container {
display: flex;
align-items: center;
}
ha-svg-icon {
width: 68px;
height: 48px;
}
.flex-container ha-spinner,
.flex-container ha-svg-icon {
margin-right: 20px;
margin-inline-end: 20px;
margin-inline-start: initial;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-zwave_js-remove-failed-node": DialogZWaveJSRemoveFailedNode;
}
}
@@ -2,7 +2,6 @@ import {
mdiCheckCircle,
mdiClose,
mdiCloseCircle,
mdiRobotDead,
mdiVectorSquareRemove,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
@@ -18,14 +17,6 @@ import "../../../../../components/ha-spinner";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { ZWaveJSRemoveNodeDialogParams } from "./show-dialog-zwave_js-remove-node";
import {
fetchZwaveNodeStatus,
NodeStatus,
removeFailedZwaveNode,
} from "../../../../../data/zwave_js";
import "../../../../../components/ha-list-item";
import "../../../../../components/ha-icon-next";
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
const EXCLUSION_TIMEOUT_SECONDS = 120;
@@ -39,16 +30,10 @@ export interface ZWaveJSRemovedNode {
class DialogZWaveJSRemoveNode extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _entryId?: string;
@state() private _deviceId?: string;
private _device?: DeviceRegistryEntry;
@state() private entry_id?: string;
@state() private _step:
| "start"
| "start_exclusion"
| "start_removal"
| "exclusion"
| "remove"
| "finished"
@@ -57,7 +42,7 @@ class DialogZWaveJSRemoveNode extends LitElement {
@state() private _node?: ZWaveJSRemovedNode;
@state() private _onClose?: () => void;
@state() private _removedCallback?: () => void;
private _removeNodeTimeoutHandle?: number;
@@ -73,23 +58,15 @@ class DialogZWaveJSRemoveNode extends LitElement {
public async showDialog(
params: ZWaveJSRemoveNodeDialogParams
): Promise<void> {
this._entryId = params.entryId;
this._deviceId = params.deviceId;
this._onClose = params.onClose;
if (this._deviceId) {
const nodeStatus = await fetchZwaveNodeStatus(this.hass, this._deviceId!);
this._device = this.hass.devices[this._deviceId];
this._step =
nodeStatus.status === NodeStatus.Dead ? "start_removal" : "start";
} else if (params.skipConfirmation) {
this.entry_id = params.entry_id;
this._removedCallback = params.removedCallback;
if (params.skipConfirmation) {
this._startExclusion();
} else {
this._step = "start_exclusion";
}
}
protected render() {
if (!this._entryId) {
if (!this.entry_id) {
return nothing;
}
@@ -98,12 +75,7 @@ class DialogZWaveJSRemoveNode extends LitElement {
);
return html`
<ha-dialog
open
@closed=${this.handleDialogClosed}
.heading=${dialogTitle}
.hideActions=${this._step === "start"}
>
<ha-dialog open @closed=${this.closeDialog} .heading=${dialogTitle}>
<ha-dialog-header slot="heading">
<ha-icon-button
slot="navigationIcon"
@@ -128,47 +100,6 @@ class DialogZWaveJSRemoveNode extends LitElement {
"ui.panel.config.zwave_js.remove_node.introduction"
)}
</p>
<div class="menu-options">
<ha-list-item hasMeta @click=${this._startExclusion}>
<span
>${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.menu_exclude_device"
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
<ha-list-item hasMeta @click=${this._startRemoval}>
<span
>${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.menu_remove_device"
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</div>
`;
}
if (this._step === "start_removal") {
return html`
<ha-svg-icon .path=${mdiRobotDead}></ha-svg-icon>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.failed_node_intro",
{ name: this._device!.name_by_user || this._device!.name }
)}
</p>
`;
}
if (this._step === "start_exclusion") {
return html`
<ha-svg-icon .path=${mdiVectorSquareRemove}></ha-svg-icon>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.exclusion_intro"
)}
</p>
`;
}
@@ -212,59 +143,30 @@ class DialogZWaveJSRemoveNode extends LitElement {
`;
}
private _renderAction() {
if (this._step === "start") {
return nothing;
}
if (this._step === "start_removal") {
return html`
<ha-button slot="secondaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._startRemoval}
destructive
>
${this.hass.localize("ui.common.remove")}
</ha-button>
`;
}
if (this._step === "start_exclusion") {
return html`
<ha-button slot="secondaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._startExclusion}
destructive
>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.start_exclusion"
)}
</ha-button>
`;
}
private _renderAction(): TemplateResult {
return html`
<ha-button slot="primaryAction" @click=${this.closeDialog}>
<ha-button
slot="primaryAction"
@click=${this._step === "start"
? this._startExclusion
: this.closeDialog}
>
${this.hass.localize(
this._step === "exclusion"
? "ui.panel.config.zwave_js.remove_node.cancel_exclusion"
: "ui.common.close"
this._step === "start"
? "ui.panel.config.zwave_js.remove_node.start_exclusion"
: this._step === "exclusion"
? "ui.panel.config.zwave_js.remove_node.cancel_exclusion"
: "ui.common.close"
)}
</ha-button>
`;
}
private _startExclusion() {
private _startExclusion(): void {
this._subscribed = this.hass.connection
.subscribeMessage(this._handleMessage, {
.subscribeMessage((message) => this._handleMessage(message), {
type: "zwave_js/remove_node",
entry_id: this._entryId,
entry_id: this.entry_id,
})
.catch((err) => {
this._step = "failed";
@@ -278,20 +180,7 @@ class DialogZWaveJSRemoveNode extends LitElement {
}, EXCLUSION_TIMEOUT_SECONDS * 1000);
}
private _startRemoval() {
this._subscribed = removeFailedZwaveNode(
this.hass,
this._deviceId!,
this._handleMessage
).catch((err) => {
this._step = "failed";
this._error = err.message;
return undefined;
});
this._step = "remove";
}
private _handleMessage = (message: any) => {
private _handleMessage(message: any): void {
if (message.event === "exclusion failed") {
this._unsubscribe();
this._step = "failed";
@@ -303,14 +192,17 @@ class DialogZWaveJSRemoveNode extends LitElement {
this._step = "finished";
this._node = message.node;
this._unsubscribe();
if (this._removedCallback) {
this._removedCallback();
}
}
};
}
private _stopExclusion(): void {
try {
this.hass.callWS({
type: "zwave_js/stop_exclusion",
entry_id: this._entryId,
entry_id: this.entry_id,
});
} catch (err) {
// eslint-disable-next-line no-console
@@ -332,16 +224,10 @@ class DialogZWaveJSRemoveNode extends LitElement {
};
public closeDialog(): void {
this._entryId = undefined;
}
public handleDialogClosed(): void {
this._unsubscribe();
this._entryId = undefined;
this.entry_id = undefined;
this._step = "start";
if (this._onClose) {
this._onClose();
}
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -380,14 +266,6 @@ class DialogZWaveJSRemoveNode extends LitElement {
ha-alert {
width: 100%;
}
.menu-options {
align-self: stretch;
}
ha-list-item {
--mdc-list-side-padding: 24px;
}
`,
];
}
@@ -1,3 +1,4 @@
import "@material/mwc-button/mwc-button";
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiCheckCircle, mdiCloseCircle, mdiFileUpload } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
@@ -36,7 +37,6 @@ import {
} from "../../../../../dialogs/generic/show-dialog-box";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import "../../../../../components/ha-button";
import type { ZWaveJSUpdateFirmwareNodeDialogParams } from "./show-dialog-zwave_js-update-firmware-node";
const firmwareTargetSchema: HaFormSchema[] = [
@@ -130,7 +130,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
.schema=${firmwareTargetSchema}
@value-changed=${this._firmwareTargetChanged}
></ha-form>`}
<ha-button
<mwc-button
slot="primaryAction"
@click=${this._beginFirmwareUpdate}
.disabled=${this._firmwareFile === undefined}
@@ -138,7 +138,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
${this.hass.localize(
"ui.panel.config.zwave_js.update_firmware.begin_update"
)}
</ha-button>`;
</mwc-button>`;
const status = this._updateFinishedMessage
? this._updateFinishedMessage.success
@@ -153,23 +153,13 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
const abortFirmwareUpdateButton = this._nodeStatus.is_controller_node
? nothing
: html`
<ha-button
destructive
slot="secondaryAction"
@click=${this._abortFirmwareUpdate}
>
<mwc-button slot="primaryAction" @click=${this._abortFirmwareUpdate}>
${this.hass.localize(
"ui.panel.config.zwave_js.update_firmware.abort"
)}
</ha-button>
</mwc-button>
`;
const closeButton = html`
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
`;
return html`
<ha-dialog
open
@@ -223,7 +213,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
}
)}
</p>
${abortFirmwareUpdateButton} ${closeButton}
${abortFirmwareUpdateButton}
`
: this._updateProgressMessage && !this._updateFinishedMessage
? html`
@@ -252,7 +242,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
}
)}
</p>
${abortFirmwareUpdateButton} ${closeButton}
${abortFirmwareUpdateButton}
`
: html`
<div class="flex-container">
@@ -0,0 +1,19 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
export interface ZWaveJSRemoveFailedNodeDialogParams {
device_id: string;
}
export const loadRemoveFailedNodeDialog = () =>
import("./dialog-zwave_js-remove-failed-node");
export const showZWaveJSRemoveFailedNodeDialog = (
element: HTMLElement,
removeFailedNodeDialogParams: ZWaveJSRemoveFailedNodeDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-zwave_js-remove-failed-node",
dialogImport: loadRemoveFailedNodeDialog,
dialogParams: removeFailedNodeDialogParams,
});
};
@@ -1,10 +1,9 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
export interface ZWaveJSRemoveNodeDialogParams {
entryId: string;
deviceId?: string;
entry_id: string;
skipConfirmation?: boolean;
onClose?: () => void;
removedCallback?: () => void;
}
export const loadRemoveNodeDialog = () =>
@@ -414,7 +414,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
InclusionState.SmartStart)}
>
${this.hass.localize(
"ui.panel.config.zwave_js.common.remove_a_node"
"ui.panel.config.zwave_js.common.remove_node"
)}
</ha-button>
<ha-button
@@ -604,7 +604,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
history.back();
}
private _fetchData = async () => {
private async _fetchData() {
if (!this.configEntryId) {
return;
}
@@ -638,7 +638,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
this._dataCollectionOptIn =
dataCollectionStatus.opted_in === true ||
dataCollectionStatus.enabled === true;
};
}
private async _addNodeClicked() {
this._openInclusionDialog();
@@ -646,10 +646,10 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
private async _removeNodeClicked() {
showZWaveJSRemoveNodeDialog(this, {
entryId: this.configEntryId!,
entry_id: this.configEntryId!,
skipConfirmation:
this._network?.controller.inclusion_state === InclusionState.Excluding,
onClose: this._fetchData,
removedCallback: () => this._fetchData(),
});
}
@@ -123,21 +123,18 @@ class ZWaveJSProvisioned extends LitElement {
}
private _unprovision = async (ev) => {
const { dsk, nodeId } = ev.currentTarget.provisioningEntry;
const dsk = ev.currentTarget.provisioningEntry.dsk;
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.zwave_js.provisioned.confirm_unprovision_title"
),
text: this.hass.localize(
nodeId
? "ui.panel.config.zwave_js.provisioned.confirm_unprovision_text_included"
: "ui.panel.config.zwave_js.provisioned.confirm_unprovision_text"
"ui.panel.config.zwave_js.provisioned.confirm_unprovision_text"
),
confirmText: this.hass.localize(
"ui.panel.config.zwave_js.provisioned.unprovision"
"ui.panel.config.zwave_js.provisioned.unprovison"
),
destructive: true,
});
if (!confirm) {
@@ -1,20 +0,0 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { ConfigEntry } from "../../../data/config_entries";
export interface PickConfigEntryDialogParams {
domain: string;
subFlowType: string;
configEntries: ConfigEntry[];
configEntryPicked: (configEntry: ConfigEntry) => void;
}
export const showPickConfigEntryDialog = (
element: HTMLElement,
dialogParams?: PickConfigEntryDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-pick-config-entry",
dialogImport: () => import("./dialog-pick-config-entry"),
dialogParams: dialogParams,
});
};
@@ -41,7 +41,7 @@ class HaConfigRepairs extends LitElement {
const issues = this.repairsIssues;
return html`
<div class="title" role="heading" aria-level="2">
<div class="title">
${this.hass.localize("ui.panel.config.repairs.title", {
count: this.total || this.repairsIssues.length,
})}
@@ -355,8 +355,8 @@ export default class HaScriptFieldRow extends LitElement {
}
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
--shadow-focus: 0 0 0 1px var(--primary-color);
border-color: var(--primary-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`,
@@ -535,7 +535,7 @@ class HaPanelDevAction extends LitElement {
if (
this._serviceData &&
Object.entries(this._serviceData).some(
([key, val]) => !["data", "target"].includes(key) && hasTemplate(val)
([key, val]) => key !== "data" && hasTemplate(val)
)
) {
this._yamlMode = true;
+1 -10
View File
@@ -88,8 +88,6 @@ export class HaLogbook extends LitElement {
1000
);
private _logbookSubscriptionId = 0;
protected render() {
if (!isComponentLoaded(this.hass, "logbook")) {
return nothing;
@@ -280,20 +278,13 @@ export class HaLogbook extends LitElement {
}
try {
this._logbookSubscriptionId++;
this._unsubLogbook = subscribeLogbook(
this.hass,
(streamMessage, subscriptionId) => {
if (subscriptionId !== this._logbookSubscriptionId) {
// Ignore messages from previous subscriptions
return;
}
(streamMessage) => {
this._processOrQueueStreamMessage(streamMessage);
},
logbookPeriod.startTime.toISOString(),
logbookPeriod.endTime.toISOString(),
this._logbookSubscriptionId,
this.entityIds,
this.deviceIds
);
@@ -25,9 +25,6 @@ export const cardFeatureStyles = css`
flex-basis: 20px;
--control-button-padding: 0px;
}
ha-control-button-group[no-stretch] > ha-control-button {
max-width: 48px;
}
ha-control-button {
--control-button-focus-color: var(--feature-color);
}
@@ -1,315 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../common/array/ensure-array";
import { generateEntityFilter } from "../../../common/entity/entity_filter";
import {
computeGroupEntitiesState,
toggleGroupEntities,
} from "../../../common/entity/group_entities";
import { stateActive } from "../../../common/entity/state_active";
import { domainColorProperties } from "../../../common/entity/state_color";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-svg-icon";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import { forwardHaptic } from "../../../data/haptics";
import { computeCssVariable } from "../../../resources/css-variables";
import type { HomeAssistant } from "../../../types";
import type { AreaCardFeatureContext } from "../cards/hui-area-card";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
AreaControl,
AreaControlsCardFeatureConfig,
LovelaceCardFeatureContext,
LovelaceCardFeaturePosition,
} from "./types";
import { AREA_CONTROLS } from "./types";
interface AreaControlsButton {
offIcon?: string;
onIcon?: string;
filter: {
domain: string;
device_class?: string;
};
}
const coverButton = (deviceClass: string) => ({
filter: {
domain: "cover",
device_class: deviceClass,
},
});
export const AREA_CONTROLS_BUTTONS: Record<AreaControl, AreaControlsButton> = {
light: {
// Overrides the icons for lights
offIcon: "mdi:lightbulb-off",
onIcon: "mdi:lightbulb",
filter: {
domain: "light",
},
},
fan: {
filter: {
domain: "fan",
},
},
switch: {
filter: {
domain: "switch",
},
},
"cover-blind": coverButton("blind"),
"cover-curtain": coverButton("curtain"),
"cover-damper": coverButton("damper"),
"cover-awning": coverButton("awning"),
"cover-door": coverButton("door"),
"cover-garage": coverButton("garage"),
"cover-gate": coverButton("gate"),
"cover-shade": coverButton("shade"),
"cover-shutter": coverButton("shutter"),
"cover-window": coverButton("window"),
};
export const supportsAreaControlsCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const area = context.area_id ? hass.areas[context.area_id] : undefined;
return !!area;
};
export const getAreaControlEntities = (
controls: AreaControl[],
areaId: string,
excludeEntities: string[] | undefined,
hass: HomeAssistant
): Record<AreaControl, string[]> =>
controls.reduce(
(acc, control) => {
const controlButton = AREA_CONTROLS_BUTTONS[control];
const filter = generateEntityFilter(hass, {
area: areaId,
entity_category: "none",
...controlButton.filter,
});
acc[control] = Object.keys(hass.entities).filter(
(entityId) => filter(entityId) && !excludeEntities?.includes(entityId)
);
return acc;
},
{} as Record<AreaControl, string[]>
);
export const MAX_DEFAULT_AREA_CONTROLS = 4;
@customElement("hui-area-controls-card-feature")
class HuiAreaControlsCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: AreaCardFeatureContext;
@property({ attribute: false })
public position?: LovelaceCardFeaturePosition;
@state() private _config?: AreaControlsCardFeatureConfig;
private get _area() {
if (!this.hass || !this.context || !this.context.area_id) {
return undefined;
}
return this.hass.areas[this.context.area_id!] as
| AreaRegistryEntry
| undefined;
}
private get _controls() {
return (
this._config?.controls || (AREA_CONTROLS as unknown as AreaControl[])
);
}
static getStubConfig(): AreaControlsCardFeatureConfig {
return {
type: "area-controls",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import(
"../editor/config-elements/hui-area-controls-card-feature-editor"
);
return document.createElement("hui-area-controls-card-feature-editor");
}
public setConfig(config: AreaControlsCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
private _handleButtonTap(ev: MouseEvent) {
ev.stopPropagation();
if (!this.context?.area_id || !this.hass || !this._config) {
return;
}
const control = (ev.currentTarget as any).control as AreaControl;
const controlEntities = this._controlEntities(
this._controls,
this.context.area_id,
this.context.exclude_entities,
this.hass!.entities,
this.hass!.devices,
this.hass!.areas
);
const entitiesIds = controlEntities[control];
const entities = entitiesIds
.map((entityId) => this.hass!.states[entityId] as HassEntity | undefined)
.filter((v): v is HassEntity => Boolean(v));
forwardHaptic("light");
toggleGroupEntities(this.hass, entities);
}
private _controlEntities = memoizeOne(
(
controls: AreaControl[],
areaId: string,
excludeEntities: string[] | undefined,
// needed to update memoized function when entities, devices or areas change
_entities: HomeAssistant["entities"],
_devices: HomeAssistant["devices"],
_areas: HomeAssistant["areas"]
) => getAreaControlEntities(controls, areaId, excludeEntities, this.hass!)
);
protected render() {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._area ||
!supportsAreaControlsCardFeature(this.hass, this.context)
) {
return nothing;
}
const controlEntities = this._controlEntities(
this._controls,
this.context.area_id!,
this.context.exclude_entities,
this.hass!.entities,
this.hass!.devices,
this.hass!.areas
);
const supportedControls = this._controls.filter(
(control) => controlEntities[control].length > 0
);
const displayControls = this._config.controls
? supportedControls
: supportedControls.slice(0, MAX_DEFAULT_AREA_CONTROLS); // Limit to max if using default controls
if (!displayControls.length) {
return nothing;
}
return html`
<ha-control-button-group ?no-stretch=${this.position === "inline"}>
${displayControls.map((control) => {
const button = AREA_CONTROLS_BUTTONS[control];
const entityIds = controlEntities[control];
const entities = entityIds
.map(
(entityId) =>
this.hass!.states[entityId] as HassEntity | undefined
)
.filter((v): v is HassEntity => Boolean(v));
const groupState = computeGroupEntitiesState(entities);
const active = entities[0]
? stateActive(entities[0], groupState)
: false;
const label = this.hass!.localize(
`ui.card_features.area_controls.${control}.${active ? "off" : "on"}`
);
const icon = active ? button.onIcon : button.offIcon;
const domain = button.filter.domain;
const deviceClass = button.filter.device_class
? ensureArray(button.filter.device_class)[0]
: undefined;
const activeColor = computeCssVariable(
domainColorProperties(domain, deviceClass, groupState, true)
);
return html`
<ha-control-button
style=${styleMap({
"--active-color": activeColor,
})}
.title=${label}
aria-label=${label}
class=${active ? "active" : ""}
.control=${control}
@click=${this._handleButtonTap}
>
<ha-domain-icon
.hass=${this.hass}
.icon=${icon}
.domain=${domain}
.deviceClass=${deviceClass}
.state=${groupState}
></ha-domain-icon>
</ha-control-button>
`;
})}
</ha-control-button-group>
`;
}
static get styles() {
return [
cardFeatureStyles,
css`
ha-control-button-group {
--control-button-group-alignment: flex-end;
}
ha-control-button {
--active-color: var(--state-active-color);
--control-button-focus-color: var(--state-active-color);
}
ha-control-button.active {
--control-button-background-color: var(--active-color);
--control-button-icon-color: var(--active-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-area-controls-card-feature": HuiAreaControlsCardFeature;
}
}
@@ -7,7 +7,6 @@ import type { LovelaceCardFeature } from "../types";
import type {
LovelaceCardFeatureConfig,
LovelaceCardFeatureContext,
LovelaceCardFeaturePosition,
} from "./types";
@customElement("hui-card-feature")
@@ -20,9 +19,6 @@ export class HuiCardFeature extends LitElement {
@property({ attribute: false }) public color?: string;
@property({ attribute: false })
public position?: LovelaceCardFeaturePosition;
private _element?: LovelaceCardFeature | HuiErrorCard;
private _getFeatureElement(feature: LovelaceCardFeatureConfig) {
@@ -45,7 +41,6 @@ export class HuiCardFeature extends LitElement {
element.hass = this.hass;
element.context = this.context;
element.color = this.color;
element.position = this.position;
// Backwards compatibility from custom card features
if (this.context.entity_id) {
const stateObj = this.hass.states[this.context.entity_id];
@@ -5,7 +5,6 @@ import "./hui-card-feature";
import type {
LovelaceCardFeatureConfig,
LovelaceCardFeatureContext,
LovelaceCardFeaturePosition,
} from "./types";
@customElement("hui-card-features")
@@ -18,9 +17,6 @@ export class HuiCardFeatures extends LitElement {
@property({ attribute: false }) public color?: string;
@property({ attribute: false })
public position?: LovelaceCardFeaturePosition;
protected render() {
if (!this.features) {
return nothing;
@@ -33,7 +29,6 @@ export class HuiCardFeatures extends LitElement {
.context=${this.context}
.color=${this.color}
.feature=${feature}
.position=${this.position}
></hui-card-feature>
`
)}
+1 -28
View File
@@ -158,31 +158,6 @@ export interface UpdateActionsCardFeatureConfig {
backup?: "yes" | "no" | "ask";
}
export const AREA_CONTROLS = [
"light",
"fan",
"cover-shutter",
"cover-blind",
"cover-curtain",
"cover-shade",
"cover-awning",
"cover-garage",
"cover-gate",
"cover-door",
"cover-window",
"cover-damper",
"switch",
] as const;
export type AreaControl = (typeof AREA_CONTROLS)[number];
export interface AreaControlsCardFeatureConfig {
type: "area-controls";
controls?: AreaControl[];
}
export type LovelaceCardFeaturePosition = "bottom" | "inline";
export type LovelaceCardFeatureConfig =
| AlarmModesCardFeatureConfig
| ClimateFanModesCardFeatureConfig
@@ -212,10 +187,8 @@ export type LovelaceCardFeatureConfig =
| ToggleCardFeatureConfig
| UpdateActionsCardFeatureConfig
| VacuumCommandsCardFeatureConfig
| WaterHeaterOperationModesCardFeatureConfig
| AreaControlsCardFeatureConfig;
| WaterHeaterOperationModesCardFeatureConfig;
export interface LovelaceCardFeatureContext {
entity_id?: string;
area_id?: string;
}
@@ -392,7 +392,7 @@ export class HuiEnergyDevicesDetailGraphCard
this.hass.themes.darkMode,
false,
compare,
"--history-unknown-color"
"--state-unavailable-color"
),
},
barMaxWidth: 50,
@@ -401,7 +401,7 @@ export class HuiEnergyDevicesDetailGraphCard
this.hass.themes.darkMode,
true,
compare,
"--history-unknown-color"
"--state-unavailable-color"
),
data: untrackedConsumption,
stack: compare ? "devicesCompare" : "devices",
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -224,9 +224,7 @@ export class HuiCard extends ReactiveElement {
this._element.preview = this.preview;
// For backwards compatibility
(this._element as any).editMode = this.preview;
if (this.hasUpdated) {
fireEvent(this, "card-updated");
}
fireEvent(this, "card-updated");
} catch (e: any) {
// eslint-disable-next-line no-console
console.error(this.config?.type, e);
@@ -97,8 +97,8 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
}
if (this._config?.energy_date_selection) {
this._subscribeEnergy();
} else if (this._interval === undefined) {
this._setFetchStatisticsTimer(true);
} else {
this._setFetchStatisticsTimer();
}
}
@@ -213,7 +213,9 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
changedProps.has("_config") &&
oldConfig?.entities !== this._config.entities
) {
this._setFetchStatisticsTimer(true);
this._getStatisticsMetaData(this._entities).then(() => {
this._setFetchStatisticsTimer();
});
return;
}
@@ -228,14 +230,10 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
}
}
private async _setFetchStatisticsTimer(fetchMetadata = false) {
clearInterval(this._interval);
this._interval = 0; // block concurrent calls
if (fetchMetadata) {
await this._getStatisticsMetaData(this._entities);
}
await this._getStatistics();
private _setFetchStatisticsTimer() {
this._getStatistics();
// statistics are created every hour
clearInterval(this._interval);
if (!this._config?.energy_date_selection) {
this._interval = window.setInterval(
() => this._getStatistics(),
+3 -15
View File
@@ -9,10 +9,7 @@ import type {
ThemeMode,
TranslationDict,
} from "../../../types";
import type {
LovelaceCardFeatureConfig,
LovelaceCardFeaturePosition,
} from "../card-features/types";
import type { LovelaceCardFeatureConfig } from "../card-features/types";
import type { LegacyStateFilter } from "../common/evaluate-filter";
import type { Condition, LegacyCondition } from "../common/validate-condition";
import type { HuiImage } from "../components/hui-image";
@@ -104,20 +101,11 @@ export interface EntitiesCardConfig extends LovelaceCardConfig {
}
export interface AreaCardConfig extends LovelaceCardConfig {
area?: string;
name?: string;
color?: string;
area: string;
navigation_path?: string;
display_type?: "compact" | "icon" | "picture" | "camera";
/** @deprecated Use `display_type` instead */
show_camera?: boolean;
camera_view?: HuiImage["cameraView"];
aspect_ratio?: string;
sensor_classes?: string[];
alert_classes?: string[];
features?: LovelaceCardFeatureConfig[];
features_position?: LovelaceCardFeaturePosition;
exclude_entities?: string[];
}
export interface ButtonCardConfig extends LovelaceCardConfig {
@@ -568,7 +556,7 @@ export interface TileCardConfig extends LovelaceCardConfig {
icon_hold_action?: ActionConfig;
icon_double_tap_action?: ActionConfig;
features?: LovelaceCardFeatureConfig[];
features_position?: LovelaceCardFeaturePosition;
features_position?: "bottom" | "inline";
}
export interface HeadingCardConfig extends LovelaceCardConfig {
@@ -35,7 +35,6 @@ import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { EntityBadgeConfig } from "../badges/types";
const HIDE_DOMAIN = new Set([
"ai_task",
"automation",
"configurator",
"device_tracker",
+1 -4
View File
@@ -54,10 +54,7 @@ export class HuiImage extends LitElement {
@property({ attribute: false }) public darkModeFilter?: string;
@property({ attribute: "fit-mode", type: String }) public fitMode?:
| "cover"
| "contain"
| "fill";
@property({ attribute: false }) public fitMode?: "cover" | "contain" | "fill";
@state() private _imageVisible? = false;
@@ -1,9 +1,9 @@
import "../card-features/hui-alarm-modes-card-feature";
import "../card-features/hui-climate-fan-modes-card-feature";
import "../card-features/hui-climate-swing-modes-card-feature";
import "../card-features/hui-climate-swing-horizontal-modes-card-feature";
import "../card-features/hui-climate-hvac-modes-card-feature";
import "../card-features/hui-climate-preset-modes-card-feature";
import "../card-features/hui-climate-swing-horizontal-modes-card-feature";
import "../card-features/hui-climate-swing-modes-card-feature";
import "../card-features/hui-counter-actions-card-feature";
import "../card-features/hui-cover-open-close-card-feature";
import "../card-features/hui-cover-position-card-feature";
@@ -21,13 +21,12 @@ import "../card-features/hui-lock-open-door-card-feature";
import "../card-features/hui-media-player-volume-slider-card-feature";
import "../card-features/hui-numeric-input-card-feature";
import "../card-features/hui-select-options-card-feature";
import "../card-features/hui-target-humidity-card-feature";
import "../card-features/hui-target-temperature-card-feature";
import "../card-features/hui-target-humidity-card-feature";
import "../card-features/hui-toggle-card-feature";
import "../card-features/hui-update-actions-card-feature";
import "../card-features/hui-vacuum-commands-card-feature";
import "../card-features/hui-water-heater-operation-modes-card-feature";
import "../card-features/hui-area-controls-card-feature";
import type { LovelaceCardFeatureConfig } from "../card-features/types";
import {
@@ -37,7 +36,6 @@ import {
const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"alarm-modes",
"area-controls",
"climate-fan-modes",
"climate-swing-modes",
"climate-swing-horizontal-modes",
@@ -1,64 +1,43 @@
import { mdiGestureTap, mdiListBox, mdiTextShort } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
any,
array,
assert,
array,
assign,
boolean,
enums,
object,
optional,
string,
} from "superstruct";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import { generateEntityFilter } from "../../../../common/entity/entity_filter";
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { SelectOption } from "../../../../data/selector";
import { getSensorNumericDeviceClasses } from "../../../../data/sensor";
import type { HomeAssistant } from "../../../../types";
import type {
LovelaceCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import {
DEFAULT_ASPECT_RATIO,
DEVICE_CLASSES,
type AreaCardFeatureContext,
} from "../../cards/hui-area-card";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { AreaCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import type { EditDetailElementEvent, EditSubElementEvent } from "../types";
import { configElementStyle } from "./config-elements-style";
import { getSupportedFeaturesType } from "./hui-card-features-editor";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import type { SelectOption } from "../../../../data/selector";
import { getSensorNumericDeviceClasses } from "../../../../data/sensor";
import type { LocalizeFunc } from "../../../../common/translations/localize";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
area: optional(string()),
name: optional(string()),
color: optional(string()),
navigation_path: optional(string()),
theme: optional(string()),
show_camera: optional(boolean()),
display_type: optional(enums(["compact", "icon", "picture", "camera"])),
camera_view: optional(string()),
aspect_ratio: optional(string()),
alert_classes: optional(array(string())),
sensor_classes: optional(array(string())),
features: optional(array(any())),
features_position: optional(enums(["bottom", "inline"])),
aspect_ratio: optional(string()),
exclude_entities: optional(array(string())),
})
);
@@ -73,8 +52,6 @@ export class HuiAreaCardEditor
@state() private _numericDeviceClasses?: string[];
@state() private _featureContext: AreaCardFeatureContext = {};
private _schema = memoizeOne(
(
localize: LocalizeFunc,
@@ -84,147 +61,103 @@ export class HuiAreaCardEditor
) =>
[
{ name: "area", selector: { area: {} } },
{
name: "content",
flatten: true,
type: "expandable",
iconPath: mdiTextShort,
schema: [
{
name: "",
type: "grid",
schema: [
{ name: "name", selector: { text: {} } },
{ name: "color", selector: { ui_color: {} } },
{
name: "display_type",
required: true,
selector: {
select: {
options: ["compact", "icon", "picture", "camera"].map(
(value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.area.display_type_options.${value}`
),
})
{ name: "show_camera", required: false, selector: { boolean: {} } },
...(showCamera
? ([
{
name: "camera_view",
selector: {
select: {
options: ["auto", "live"].map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.generic.camera_view_options.${value}`
),
mode: "dropdown",
},
})),
mode: "dropdown",
},
},
...(showCamera
? ([
{
name: "camera_view",
selector: {
select: {
options: ["auto", "live"].map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.generic.camera_view_options.${value}`
),
})),
mode: "dropdown",
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
],
},
{
name: "alert_classes",
selector: {
select: {
reorder: true,
multiple: true,
custom_value: true,
options: binaryClasses,
},
},
},
{
name: "sensor_classes",
selector: {
select: {
reorder: true,
multiple: true,
custom_value: true,
options: sensorClasses,
},
},
},
],
},
] as const)
: []),
{
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
name: "",
type: "grid",
schema: [
{
name: "navigation_path",
required: false,
selector: { navigation: {} },
},
{ name: "theme", required: false, selector: { theme: {} } },
{
name: "aspect_ratio",
default: DEFAULT_ASPECT_RATIO,
selector: { text: {} },
},
],
},
] as const satisfies readonly HaFormSchema[]
{
name: "alert_classes",
selector: {
select: {
reorder: true,
multiple: true,
custom_value: true,
options: binaryClasses,
},
},
},
{
name: "sensor_classes",
selector: {
select: {
reorder: true,
multiple: true,
custom_value: true,
options: sensorClasses,
},
},
},
] as const
);
private _binaryClassesForArea = memoizeOne(
(
area: string | undefined,
excludeEntities: string[] | undefined
): string[] => {
if (!area) {
return [];
}
const binarySensorFilter = generateEntityFilter(this.hass!, {
domain: "binary_sensor",
area,
entity_category: "none",
});
const classes = Object.keys(this.hass!.entities)
.filter(
(id) => binarySensorFilter(id) && !excludeEntities?.includes(id)
)
.map((id) => this.hass!.states[id]?.attributes.device_class)
.filter((c): c is string => Boolean(c));
return [...new Set(classes)];
}
private _binaryClassesForArea = memoizeOne((area: string): string[] =>
this._classesForArea(area, "binary_sensor")
);
private _sensorClassesForArea = memoizeOne(
(
area: string | undefined,
excludeEntities: string[] | undefined,
numericDeviceClasses: string[] | undefined
): string[] => {
if (!area) {
return [];
}
const sensorFilter = generateEntityFilter(this.hass!, {
domain: "sensor",
area,
device_class: numericDeviceClasses,
entity_category: "none",
});
const classes = Object.keys(this.hass!.entities)
.filter((id) => sensorFilter(id) && !excludeEntities?.includes(id))
.map((id) => this.hass!.states[id]?.attributes.device_class)
.filter((c): c is string => Boolean(c));
return [...new Set(classes)];
}
(area: string, numericDeviceClasses?: string[]): string[] =>
this._classesForArea(area, "sensor", numericDeviceClasses)
);
private _classesForArea(
area: string,
domain: "sensor" | "binary_sensor",
numericDeviceClasses?: string[] | undefined
): string[] {
const entities = Object.values(this.hass!.entities).filter(
(e) =>
computeDomain(e.entity_id) === domain &&
!e.entity_category &&
!e.hidden &&
(e.area_id === area ||
(e.device_id && this.hass!.devices[e.device_id]?.area_id === area))
);
const classes = entities
.map((e) => this.hass!.states[e.entity_id]?.attributes.device_class || "")
.filter(
(c) =>
c &&
(domain !== "sensor" ||
!numericDeviceClasses ||
numericDeviceClasses.includes(c))
);
return [...new Set(classes)];
}
private _buildBinaryOptions = memoizeOne(
(possibleClasses: string[], currentClasses: string[]): SelectOption[] =>
this._buildOptions("binary_sensor", possibleClasses, currentClasses)
@@ -258,19 +191,7 @@ export class HuiAreaCardEditor
public setConfig(config: AreaCardConfig): void {
assert(config, cardConfigStruct);
const displayType =
config.display_type || (config.show_camera ? "camera" : "picture");
this._config = {
...config,
display_type: displayType,
};
delete this._config.show_camera;
this._featureContext = {
area_id: config.area,
exclude_entities: config.exclude_entities,
};
this._config = config;
}
protected async updated() {
@@ -281,52 +202,16 @@ export class HuiAreaCardEditor
}
}
private _featuresSchema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "features_position",
required: true,
selector: {
select: {
mode: "box",
options: ["bottom", "inline"].map((value) => ({
label: localize(
`ui.panel.lovelace.editor.card.tile.features_position_options.${value}`
),
description: localize(
`ui.panel.lovelace.editor.card.tile.features_position_options.${value}_description`
),
value,
image: {
src: `/static/images/form/tile_features_position_${value}.svg`,
src_dark: `/static/images/form/tile_features_position_${value}_dark.svg`,
flip_rtl: true,
},
})),
},
},
},
] as const satisfies readonly HaFormSchema[]
);
private _hasCompatibleFeatures = memoizeOne(
(context: LovelaceCardFeatureContext) =>
getSupportedFeaturesType(this.hass!, context).length > 0
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const possibleBinaryClasses = this._binaryClassesForArea(
this._config.area,
this._config.exclude_entities
this._config.area || ""
);
const possibleSensorClasses = this._sensorClassesForArea(
this._config.area,
this._config.exclude_entities,
this._config.area || "",
this._numericDeviceClasses
);
const binarySelectOptions = this._buildBinaryOptions(
@@ -338,195 +223,68 @@ export class HuiAreaCardEditor
this._config.sensor_classes || DEVICE_CLASSES.sensor
);
const showCamera = this._config.display_type === "camera";
const displayType =
this._config.display_type || this._config.show_camera
? "camera"
: "picture";
const schema = this._schema(
this.hass.localize,
showCamera,
this._config.show_camera || false,
binarySelectOptions,
sensorSelectOptions
);
const featuresSchema = this._featuresSchema(this.hass.localize);
const data = {
camera_view: "auto",
alert_classes: DEVICE_CLASSES.binary_sensor,
sensor_classes: DEVICE_CLASSES.sensor,
features_position: "bottom",
display_type: displayType,
...this._config,
};
const hasCompatibleFeatures = this._hasCompatibleFeatures(
this._featureContext
);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
></ha-form>
<ha-expansion-panel outlined>
<ha-svg-icon slot="leading-icon" .path=${mdiListBox}></ha-svg-icon>
<h3 slot="header">
${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.features"
)}
</h3>
<div class="content">
${hasCompatibleFeatures
? html`
<ha-form
class="features-form"
.hass=${this.hass}
.data=${data}
.schema=${featuresSchema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`
: nothing}
<hui-card-features-editor
.hass=${this.hass}
.context=${this._featureContext}
.features=${this._config!.features ?? []}
@features-changed=${this._featuresChanged}
@edit-detail-element=${this._editDetailElement}
></hui-card-features-editor>
</div>
</ha-expansion-panel>
`;
}
private _valueChanged(ev: CustomEvent): void {
const newConfig = ev.detail.value as AreaCardConfig;
const config: AreaCardConfig = {
features: this._config!.features,
...newConfig,
};
if (config.display_type !== "camera") {
const config = ev.detail.value;
if (!config.show_camera) {
delete config.camera_view;
}
fireEvent(this, "config-changed", { config });
}
private _featuresChanged(ev: CustomEvent) {
ev.stopPropagation();
if (!this._config || !this.hass) {
return;
}
const features = ev.detail.features as LovelaceCardFeatureConfig[];
const config: AreaCardConfig = {
...this._config,
features,
};
if (features.length === 0) {
delete config.features;
}
fireEvent(this, "config-changed", { config });
}
private _editDetailElement(ev: HASSDomEvent<EditDetailElementEvent>): void {
const index = ev.detail.subElementConfig.index;
const config = this._config!.features![index!];
fireEvent(this, "edit-sub-element", {
config: config,
saveConfig: (newConfig) => this._updateFeature(index!, newConfig),
context: this._featureContext,
type: "feature",
} as EditSubElementEvent<
LovelaceCardFeatureConfig,
LovelaceCardFeatureContext
>);
}
private _updateFeature(index: number, feature: LovelaceCardFeatureConfig) {
const features = this._config!.features!.concat();
features[index] = feature;
const config = { ...this._config!, features };
fireEvent(this, "config-changed", {
config: config,
});
}
private _computeHelperCallback = (
schema:
| SchemaUnion<ReturnType<typeof this._schema>>
| SchemaUnion<ReturnType<typeof this._featuresSchema>>
): string | undefined => {
switch (schema.name) {
case "alert_classes":
if (this._config?.display_type === "compact") {
return this.hass!.localize(
`ui.panel.lovelace.editor.card.area.alert_classes_helper`
);
}
return undefined;
default:
return undefined;
}
};
private _computeLabelCallback = (
schema:
| SchemaUnion<ReturnType<typeof this._schema>>
| SchemaUnion<ReturnType<typeof this._featuresSchema>>
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "theme":
return `${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.theme"
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`;
case "area":
return this.hass!.localize("ui.panel.lovelace.editor.card.area.name");
case "name":
case "camera_view":
case "content":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
case "navigation_path":
return this.hass!.localize(
"ui.panel.lovelace.editor.action-editor.navigation_path"
);
case "interactions":
case "features_position":
case "aspect_ratio":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.tile.${schema.name}`
"ui.panel.lovelace.editor.card.generic.aspect_ratio"
);
case "camera_view":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.camera_view"
);
}
return this.hass!.localize(
`ui.panel.lovelace.editor.card.area.${schema.name}`
);
};
static get styles() {
return [
configElementStyle,
css`
ha-form {
display: block;
margin-bottom: 24px;
}
.features-form {
margin-bottom: 8px;
}
`,
];
}
}
declare global {
@@ -1,187 +0,0 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import {
getAreaControlEntities,
MAX_DEFAULT_AREA_CONTROLS,
} from "../../card-features/hui-area-controls-card-feature";
import {
AREA_CONTROLS,
type AreaControl,
type AreaControlsCardFeatureConfig,
} from "../../card-features/types";
import type { AreaCardFeatureContext } from "../../cards/hui-area-card";
import type { LovelaceCardFeatureEditor } from "../../types";
type AreaControlsCardFeatureData = AreaControlsCardFeatureConfig & {
customize_controls: boolean;
};
@customElement("hui-area-controls-card-feature-editor")
export class HuiAreaControlsCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: AreaCardFeatureContext;
@state() private _config?: AreaControlsCardFeatureConfig;
public setConfig(config: AreaControlsCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(
localize: LocalizeFunc,
customizeControls: boolean,
compatibleControls: AreaControl[]
) =>
[
{
name: "customize_controls",
selector: {
boolean: {},
},
},
...(customizeControls
? ([
{
name: "controls",
selector: {
select: {
reorder: true,
multiple: true,
options: compatibleControls.map((control) => ({
value: control,
label: localize(
`ui.panel.lovelace.editor.features.types.area-controls.controls_options.${control}`
),
})),
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
] as const satisfies readonly HaFormSchema[]
);
private _supportedControls = memoizeOne(
(
areaId: string,
excludeEntities: string[] | undefined,
// needed to update memoized function when entities, devices or areas change
_entities: HomeAssistant["entities"],
_devices: HomeAssistant["devices"],
_areas: HomeAssistant["areas"]
) => {
if (!this.hass) {
return [];
}
const controlEntities = getAreaControlEntities(
AREA_CONTROLS as unknown as AreaControl[],
areaId,
excludeEntities,
this.hass!
);
return (
Object.keys(controlEntities) as (keyof typeof controlEntities)[]
).filter((control) => controlEntities[control].length > 0);
}
);
protected render() {
if (!this.hass || !this._config || !this.context?.area_id) {
return nothing;
}
const supportedControls = this._supportedControls(
this.context.area_id,
this.context.exclude_entities,
this.hass.entities,
this.hass.devices,
this.hass.areas
);
if (supportedControls.length === 0) {
return html`
<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.lovelace.editor.features.types.area-controls.no_compatible_controls"
)}
</ha-alert>
`;
}
const data: AreaControlsCardFeatureData = {
...this._config,
customize_controls: this._config.controls !== undefined,
};
const schema = this._schema(
this.hass.localize,
data.customize_controls,
supportedControls
);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
const { customize_controls, ...config } = ev.detail
.value as AreaControlsCardFeatureData;
if (customize_controls && !config.controls) {
config.controls = this._supportedControls(
this.context!.area_id!,
this.context!.exclude_entities,
this.hass!.entities,
this.hass!.devices,
this.hass!.areas
).slice(0, MAX_DEFAULT_AREA_CONTROLS); // Limit to max default controls
}
if (!customize_controls && config.controls) {
delete config.controls;
}
fireEvent(this, "config-changed", { config: config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "controls":
case "customize_controls":
return this.hass!.localize(
`ui.panel.lovelace.editor.features.types.area-controls.${schema.name}`
);
default:
return "";
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-area-controls-card-feature-editor": HuiAreaControlsCardFeatureEditor;
}
}
@@ -18,7 +18,6 @@ import {
} from "../../../../data/lovelace_custom_cards";
import type { HomeAssistant } from "../../../../types";
import { supportsAlarmModesCardFeature } from "../../card-features/hui-alarm-modes-card-feature";
import { supportsAreaControlsCardFeature } from "../../card-features/hui-area-controls-card-feature";
import { supportsClimateFanModesCardFeature } from "../../card-features/hui-climate-fan-modes-card-feature";
import { supportsClimateHvacModesCardFeature } from "../../card-features/hui-climate-hvac-modes-card-feature";
import { supportsClimatePresetModesCardFeature } from "../../card-features/hui-climate-preset-modes-card-feature";
@@ -62,7 +61,6 @@ type SupportsFeature = (
const UI_FEATURE_TYPES = [
"alarm-modes",
"area-controls",
"climate-fan-modes",
"climate-hvac-modes",
"climate-preset-modes",
@@ -97,7 +95,6 @@ type UiFeatureTypes = (typeof UI_FEATURE_TYPES)[number];
const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"alarm-modes",
"area-controls",
"climate-fan-modes",
"climate-hvac-modes",
"climate-preset-modes",
@@ -119,7 +116,6 @@ const SUPPORTS_FEATURE_TYPES: Record<
SupportsFeature | undefined
> = {
"alarm-modes": supportsAlarmModesCardFeature,
"area-controls": supportsAreaControlsCardFeature,
"climate-fan-modes": supportsClimateFanModesCardFeature,
"climate-swing-modes": supportsClimateSwingModesCardFeature,
"climate-swing-horizontal-modes":
@@ -1,5 +1,5 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { assert } from "superstruct";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
@@ -139,14 +139,7 @@ export class HuiGraphFooterEditor
}
static get styles(): CSSResultGroup {
return [
configElementStyle,
css`
.card-config ha-switch {
margin: 1px 0;
}
`,
];
return configElementStyle;
}
}
@@ -191,6 +191,12 @@ export class AreaViewStrategy extends ReactiveElement {
type: "sections",
header: {
badges_position: "bottom",
layout: "responsive",
card: {
type: "markdown",
text_only: true,
content: `## ${area.name}`,
},
},
max_columns: maxColumns,
sections: sections,

Some files were not shown because too many files have changed in this diff Show More