Compare commits

..
Author SHA1 Message Date
Bram KragtenandClaude Opus 5 2f4e5279a5 Localize built-in YAML field descriptions
The built-in trigger/condition/action schemas carried English descriptions
inline, which surfaced untranslated in the hover tooltip and completion info.

Store translation keys in `description` instead — the hover element and the
completion source already localize it and fall back to the raw string, which
keeps backend-supplied descriptions (services, integration triggers and
conditions) working unchanged. Adds the strings under
`ui.panel.config.automation.editor.yaml_schema`, plus a test asserting every
built-in description key resolves in en.json.

Also drops the remaining `any` casts in serviceActionSchema in favour of a
declared ServiceField shape.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-13 15:56:12 +02:00
Bram KragtenandClaude Opus 5 848496b09e Review fixes: live schema context, memoization, localized editor chrome
- ha-code-editor: adapt the YAML schema wiring to the context-based API
  (states/registries/i18n) instead of the removed `hass` property.
- haYamlCompletionSource now takes a context getter instead of a captured
  snapshot, so a schema or registry that changes after the editor was created
  is picked up. Previously the completion source kept the schema and registry
  it was built with for the editor's lifetime.
- Memoize the trigger/condition/action YAML schema on the type discriminator
  rather than the whole config object, which changes identity on every
  keystroke and made both lint compartments reconfigure per character.
- Memoize the registry-derived completion lists; they were rebuilt for every
  completion request.
- Localize the editor chrome: "Required" badge, example/default labels, lint
  diagnostics, and Jinja value hints.
- buildTargetSchema: forward a target's entity/device filters as selector
  `filter`, instead of nesting the whole target definition under `entity`.
- Replace the `__allowUnknownFields` string marker with a symbol, drop `any`
  casts in the hover source and selector helpers, fix stale comments.
- Add unit tests for the schema converters and the lint source.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-13 15:42:42 +02:00
Bram Kragten 2c2eef7942 add floor_id and label_id to YAML hover tooltip selector type mapping 2026-08-13 15:27:15 +02:00
Bram Kragten 4e3cc4c705 Add to developer tools action 2026-08-13 15:27:15 +02:00
Bram Kragten 8f96d2c6e4 add built-in triggers and conditions, add floor and label 2026-08-13 15:26:55 +02:00
Bram Kragten 2b9347fd72 Add support for numeric threshold and behavior selectors 2026-08-13 15:25:27 +02:00
Bram Kragten 12f54835f0 Add field-aware YAML completions, hover tooltips, and linting to automation editor
- New files: yaml_field_schema.ts (types), yaml_ha_completions.ts (completion/hover/lint
  sources), ha_completion_items.ts (entity/device/area completion builders),
  ha-code-editor-yaml-hover.ts (key hover tooltip element),
  yaml_schema_helpers.ts (action/trigger/condition schema converters)
- ha-code-editor: wire schema completions, hover tooltips, and schema linter;
  replace setDiagnostics-based syntax error reporting with a linter() so syntax
  and schema diagnostics no longer overwrite each other; add forceLinting export
- ha-yaml-editor: suppress generic entity autocomplete when schema is present
- Action/trigger/condition editors: pass yamlFieldSchema to ha-yaml-editor;
  memoize on (services, localize) not full hass to avoid constant re-renders
- codemirror.ts: export forceLinting; mount-aware BlockMapping traversal in lint
  source to handle jinja({ base: yaml() }) tree structure (Template → Text with
  NodeProp.mounted subtree)
2026-08-13 15:25:11 +02:00
132 changed files with 5510 additions and 6083 deletions
-35
View File
@@ -51,39 +51,4 @@ export const demoDevices: DeviceRegistryEntry[] = [
primary_config_entry: "mock-sonos",
entry_type: null,
},
{
...baseDevice,
id: "power-strip",
name: "Power strip",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
},
// Child devices (logical parts of the power strip). They carry the parent's
// inherited hardware fields, mirroring how resolveChildDevices fills them in
// from the WebSocket, and reference the parent via parent_device_id.
{
...baseDevice,
id: "power-strip-outlet-1",
name: "Outlet 1",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
{
...baseDevice,
id: "power-strip-outlet-2",
name: "Outlet 2",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
];
@@ -152,84 +152,6 @@ const DEVICES: DeviceRegistryEntry[] = [
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "livingroom",
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_power_strip",
identifiers: [["demo", "strip1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Power strip",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
// Child devices of the power strip. They have no area of their own and
// inherit the parent's area ("Livingroom"); the picker renders them indented
// under the parent with a tree connector.
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_1",
identifiers: [["demo", "outlet1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 1",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_2",
identifiers: [["demo", "outlet2"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 2",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
];
const AREAS: DemoArea[] = [
+1 -79
View File
@@ -132,12 +132,12 @@ const ENTITIES = [
fan_modes: ["on_low", "on_high", "auto_low", "auto_high", "off"],
preset_modes: ["home", "eco", "away"],
swing_modes: ["auto", "1", "2", "3", "off"],
switch_horizontal_modes: ["auto", "4", "5", "6", "off"],
current_temperature: 23,
target_temp_high: 24,
target_temp_low: 21,
fan_mode: "auto_low",
preset_mode: "home",
swing_horizontal_modes: ["auto", "4", "5", "6", "off"],
swing_mode: "auto",
swing_horizontal_mode: "off",
supported_features:
@@ -340,84 +340,6 @@ const CONFIGS = [
features: [{ type: "fan-oscillate" }],
},
},
{
heading: "Inline features: one feature",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [{ type: "climate-hvac-modes", style: "dropdown" }],
},
},
{
heading: "Inline features: two features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: three features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: four features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
{
heading: "Bottom features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "bottom",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
] satisfies DemoCardConfig<TileCardConfig>[];
@customElement("demo-lovelace-tile-card")
+2 -2
View File
@@ -152,7 +152,7 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.9",
"@rspack/core": "2.1.8",
"@rspack/dev-server": "2.2.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -210,7 +210,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"typescript-eslint": "8.66.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
@@ -1,213 +0,0 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import { css, type LitElement } from "lit";
import type { Ref } from "lit/directives/ref";
import { parseAnimationDuration } from "../util/parse-animation-duration";
type FilterPanelHost = ReactiveControllerHost &
LitElement & { expanded: boolean };
const EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
/**
* Layout the controller relies on: the filter is a flex column made of its
* header (`ha-expansion-panel`) and a `.content` wrapper. Collapsed, it is as
* tall as its header; expanded, it fills what is left of the pane and hands
* that space down to the list through the wrapper.
*/
export const filterPanelStyles = css`
:host {
display: flex;
flex-direction: column;
box-sizing: border-box;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
flex: none;
--ha-card-border-radius: var(--ha-border-radius-square);
}
ha-expansion-panel::part(summary) {
-webkit-user-select: none;
user-select: none;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
`;
const panels = new Set<FilterPanelController>();
let pending: Set<FilterPanelController> | undefined;
const flush = () => {
const batch = [...pending!].filter((panel) => panel.host.isConnected);
pending = undefined;
batch.forEach((panel) => panel.prepare());
batch.forEach((panel) => panel.measure());
batch.forEach((panel) => panel.play());
};
// Filters that change in the same frame (one closing while another opens) are
// animated as one batch: every filter is measured before any of them has
// changed the DOM, and the closing ones are parked at their final height so
// the opening one reads its own final height from the real layout.
const enqueue = (panel: FilterPanelController) => {
if (!pending) {
pending = new Set();
panels.forEach((other) => other.snapshot());
requestAnimationFrame(flush);
}
pending.add(panel);
};
const clearInlineStyles = (element?: HTMLElement) => {
element?.style.removeProperty("height");
element?.style.removeProperty("flex");
element?.style.removeProperty("overflow");
};
/**
* Animates a filter of the filter pane between its collapsed and expanded
* heights whenever `expanded` changes, and tells the host when to render its
* content: from the moment it expands until its collapse animation has ended.
*
* During the animation the content keeps its final size and the host clips
* it, so the list is revealed rather than resized.
*/
export class FilterPanelController implements ReactiveController {
public showContent = false;
public host: FilterPanelHost;
private _content: Ref<HTMLElement>;
private _expanded?: boolean;
private _first = 0;
private _last = 0;
private _contentHeight = 0;
private _animation?: Animation;
constructor(host: FilterPanelHost, content: Ref<HTMLElement>) {
this.host = host;
this._content = content;
host.addController(this);
}
public hostConnected() {
panels.add(this);
}
public hostDisconnected() {
panels.delete(this);
this._animation?.cancel();
this._animation = undefined;
clearInlineStyles(this.host);
clearInlineStyles(this._content.value);
}
public hostUpdate() {
const expanded = this.host.expanded;
if (this._expanded === undefined) {
this._expanded = expanded;
this.showContent = expanded;
return;
}
if (expanded === this._expanded) {
return;
}
this._expanded = expanded;
if (!this.host.isConnected) {
this.showContent = expanded;
return;
}
if (expanded) {
this.showContent = true;
}
enqueue(this);
}
public snapshot() {
this._first = this.host.getBoundingClientRect().height;
}
public prepare() {
this._animation?.cancel();
this._animation = undefined;
const host = this.host;
const content = this._content.value;
clearInlineStyles(host);
clearInlineStyles(content);
if (host.expanded) {
return;
}
this._last =
host.getBoundingClientRect().height -
(content?.getBoundingClientRect().height ?? 0);
this._contentHeight = this._first - this._last;
host.style.flex = "none";
host.style.height = `${this._last}px`;
}
public measure() {
if (!this.host.expanded) {
return;
}
this._last = this.host.getBoundingClientRect().height;
this._contentHeight =
this._content.value?.getBoundingClientRect().height ?? 0;
}
public play() {
const host = this.host;
if (this._first === this._last) {
this._finish();
return;
}
const content = this._content.value;
host.style.flex = "none";
host.style.overflow = "hidden";
if (content) {
content.style.flex = "none";
content.style.height = `${this._contentHeight}px`;
}
const animation = host.animate(
[{ height: `${this._first}px` }, { height: `${this._last}px` }],
{
duration:
parseAnimationDuration(
getComputedStyle(host).getPropertyValue(
"--ha-animation-duration-normal"
)
) || 250,
easing: EASING,
fill: "forwards",
}
);
animation.onfinish = () => this._finish();
this._animation = animation;
}
private _finish() {
if (!this.host.expanded) {
this.showContent = false;
this.host.requestUpdate();
}
clearInlineStyles(this.host);
clearInlineStyles(this._content.value);
this._animation?.cancel();
this._animation = undefined;
}
}
-19
View File
@@ -39,25 +39,6 @@ const formatTimeWithSecondsMem = memoizeOne(
})
);
// 9:15:24.123 PM || 21:15:24,123
export const formatTimeWithMilliseconds = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => formatTimeWithMillisecondsMem(locale, config.time_zone).format(dateObj);
const formatTimeWithMillisecondsMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
hour: useAmPm(locale) ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
hourCycle: useAmPm(locale) ? "h12" : "h23",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// Tuesday 7:00 PM || Tuesday 19:00
export const formatTimeWeekday = (
dateObj: Date,
-64
View File
@@ -1,64 +0,0 @@
import {
isMoreInfoView,
type MoreInfoView,
} from "../../dialogs/more-info/more-info-view";
import type { SearchParamsSource } from "./query-params";
const ENTITY_ID_PARAM = "more-info-entity-id";
const VIEW_PARAM = "more-info-view";
export interface MoreInfoUrlData {
entityId?: string;
view?: MoreInfoView;
hash: URLSearchParams;
}
export interface CreateMoreInfoUrlData {
entityId: string;
view: MoreInfoView;
hash?: URLSearchParams;
}
export const decodeMoreInfoUrl = (
search: SearchParamsSource,
hash = ""
): MoreInfoUrlData => {
const params =
typeof search === "string"
? new URLSearchParams(search)
: search instanceof URLSearchParams
? search
: new URLSearchParams(search);
const entityId = params.get(ENTITY_ID_PARAM) || undefined;
const view = params.get(VIEW_PARAM) || undefined;
return {
entityId,
view: isMoreInfoView(view) ? view : undefined,
hash: new URLSearchParams(
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
),
};
};
export const createMoreInfoUrl = (
base: string,
data: CreateMoreInfoUrlData
): string => {
const url = new URL(base, window.location.origin);
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
url.searchParams.set(VIEW_PARAM, data.view);
if (!__DEMO__ && data.hash !== undefined) {
url.hash = data.hash.toString();
}
return `${url.pathname}${url.search}${url.hash}`;
};
export const removeMoreInfoUrl = (base: string): string => {
const url = new URL(base, window.location.origin);
url.searchParams.delete(ENTITY_ID_PARAM);
url.searchParams.delete(VIEW_PARAM);
return `${url.pathname}${url.search}${url.hash}`;
};
+15 -17
View File
@@ -117,7 +117,7 @@ export class HaDataTable extends LitElement {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Object }) public columns: DataTableColumnContainer = {};
@@ -1158,11 +1158,6 @@ export class HaDataTable extends LitElement {
/* default mdc styles, colors changed, without checkbox styles */
:host {
height: 100%;
--_cell-padding-inline: 16px;
}
:host([narrow]) {
--_cell-padding-inline: 8px;
}
.mdc-data-table__content {
font-family: var(--ha-font-family-body);
@@ -1243,7 +1238,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__cell,
.mdc-data-table__header-cell {
padding-inline: var(--_cell-padding-inline);
padding-right: 16px;
padding-left: 16px;
min-width: 150px;
align-self: center;
overflow: hidden;
@@ -1263,8 +1259,14 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--checkbox,
.mdc-data-table__cell--checkbox {
padding-inline-start: var(--_cell-padding-inline);
padding-inline-end: 0;
/* @noflip */
padding-left: 16px;
/* @noflip */
padding-right: 0;
/* @noflip */
padding-inline-start: 16px;
/* @noflip */
padding-inline-end: initial;
width: 60px;
min-width: 60px;
}
@@ -1377,7 +1379,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--overflow-menu:first-child,
.mdc-data-table__header-cell--icon-button:first-child,
.mdc-data-table__cell--icon-button:first-child {
padding-inline-start: var(--_cell-padding-inline);
padding-left: 16px;
padding-inline-start: 16px;
padding-inline-end: initial;
}
@@ -1385,7 +1388,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--overflow-menu:last-child,
.mdc-data-table__header-cell--icon-button:last-child,
.mdc-data-table__cell--icon-button:last-child {
padding-inline-end: var(--_cell-padding-inline);
padding-right: 16px;
padding-inline-end: 16px;
padding-inline-start: initial;
}
.mdc-data-table__cell--overflow-menu,
@@ -1512,17 +1516,11 @@ export class HaDataTable extends LitElement {
.center {
text-align: center;
}
.primary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.secondary {
color: var(--secondary-text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.scroller {
height: calc(100% - 57px);
-1
View File
@@ -8,7 +8,6 @@ export const datePickerStyles = css`
}
calendar-date::part(button),
calendar-range::part(button) {
color: var(--primary-text-color);
border: none;
background-color: unset;
border-radius: var(--ha-border-radius-circle);
+38 -103
View File
@@ -3,13 +3,11 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { computeRTL } from "../../common/util/compute_rtl";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
deviceComboBoxKeys,
@@ -28,9 +26,7 @@ import "../ha-alert";
import "../ha-button";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
import "../ha-svg-icon";
import "../ha-tree-indicator";
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
export type HaDevicePickerDeviceFilterFunc = (
@@ -132,7 +128,6 @@ export class HaDevicePicker extends LitElement {
entityFilter,
excludeDevices,
value,
nested: true,
})
);
@@ -221,34 +216,6 @@ export class HaDevicePicker extends LitElement {
this.value
);
// The fuzzy search ranks matches by relevance, which would pull a child device
// above its parent (the parent often only matches through the lower-weighted
// child names). Restore the nested order from the full item list and recompute
// which child is last, so the tree connectors stay correct while searching.
private _searchFn: PickerComboBoxSearchFn<DevicePickerItem> = (
_search,
filteredItems,
allItems
) => {
const matchedIds = new Set(filteredItems.map((item) => item.id));
const ordered = allItems.filter((item) => matchedIds.has(item.id));
// Keep any items the search added that are not part of the nested list
// (for example the "no items available" placeholder or additional items).
const orderedIds = new Set(ordered.map((item) => item.id));
const extras = filteredItems.filter((item) => !orderedIds.has(item.id));
return [
...ordered.map((item, index) => {
if (!item.is_child) {
return item;
}
const nextItem = ordered[index + 1];
return { ...item, last: !nextItem || !nextItem.is_child };
}),
...extras,
];
};
private _valueRenderer = memoizeOne(
(
configEntriesLookup: Record<string, ConfigEntry>,
@@ -312,76 +279,46 @@ export class HaDevicePicker extends LitElement {
}
);
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => {
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`
<ha-combo-box-item
type="button"
style=${
item.is_child
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""
}
>
${
item.is_child
? html`<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${item.last}
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
<ha-combo-box-item type="button">
${
item.domain
? html`
<img
slot="start"
></ha-tree-indicator>`
: nothing
}
${
item.domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing
}
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing
}
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${
item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing
}
</ha-combo-box-item>
`;
};
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${
item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing
}
</ha-combo-box-item>
`;
protected render() {
const placeholder =
@@ -438,8 +375,6 @@ export class HaDevicePicker extends LitElement {
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.searchFn=${this._searchFn}
no-sort
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
+172
View File
@@ -0,0 +1,172 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../common/translations/localize";
import type { YamlFieldSchema } from "../resources/yaml_field_schema";
/**
* Tooltip element rendered inside a CodeMirror hoverTooltip for YAML field
* keys in the automation / script / card YAML editors.
*
* Shows:
* - Field name (monospace)
* - "required" badge when applicable
* - Description paragraph
* - Selector type hint
* - Example value
* - Default value
*/
@customElement("ha-code-editor-yaml-hover")
export class HaCodeEditorYamlHover extends LitElement {
@property({ attribute: false }) public fieldName = "";
@property({ attribute: false }) public fieldSchema!: YamlFieldSchema;
/**
* Optional localize callback forwarded from the editor so translated
* descriptions can be rendered. When absent, strings are shown verbatim.
*/
@property({ attribute: false }) public localize?: LocalizeFunc;
render() {
const schema = this.fieldSchema;
if (!schema) return nothing;
// Built-in field descriptions are translation keys; descriptions coming
// from the backend are already translated and fall through unchanged.
const description = schema.description
? this.localize?.(schema.description as LocalizeKeys) ||
schema.description
: undefined;
const selectorType = schema.selector
? Object.keys(schema.selector)[0]
: undefined;
return html`
<div class="header">
<code class="key">${this.fieldName}</code>
${
schema.required
? html`<span class="badge required"
>${this._label("required", "required")}</span
>`
: nothing
}
${
selectorType
? html`<span class="badge type">${selectorType}</span>`
: nothing
}
</div>
${description ? html`<div class="desc">${description}</div>` : nothing}
${
schema.example != null
? html`<div class="meta">
<span class="meta-label"
>${this._label("example", "Example:")}</span
>
<code>${String(schema.example)}</code>
</div>`
: nothing
}
${
schema.default != null
? html`<div class="meta">
<span class="meta-label"
>${this._label("default", "Default:")}</span
>
<code>${String(schema.default)}</code>
</div>`
: nothing
}
`;
}
private _label(
key: "required" | "example" | "default",
fallback: string
): string {
return (
this.localize?.(`ui.components.yaml-editor.schema.${key}`) || fallback
);
}
static styles = css`
:host {
display: block;
padding: 6px 10px;
max-width: 320px;
line-height: 1.5;
font-size: 0.9em;
}
.header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
flex-wrap: wrap;
}
code.key {
font-family: var(--ha-font-family-code);
font-size: 1em;
font-weight: bold;
}
.badge {
display: inline-block;
border-radius: 4px;
padding: 0 5px;
font-size: 0.78em;
line-height: 1.6;
font-family: var(--ha-font-family-body);
}
.badge.required {
background: color-mix(
in srgb,
var(--error-color, #db4437) 15%,
transparent
);
color: var(--error-color, #db4437);
}
.badge.type {
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-text-color);
}
.desc {
color: var(--secondary-text-color);
margin-bottom: 4px;
}
.meta {
display: flex;
gap: 6px;
align-items: baseline;
color: var(--secondary-text-color);
}
.meta-label {
font-size: 0.85em;
opacity: 0.75;
flex-shrink: 0;
}
.meta code {
font-family: var(--ha-font-family-code);
font-size: 0.9em;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-code-editor-yaml-hover": HaCodeEditorYamlHover;
}
}
+140 -94
View File
@@ -32,15 +32,21 @@ import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyleScrollbar } from "../resources/styles";
import {
buildEntityCompletions,
buildDeviceCompletions,
buildAreaCompletions,
buildFloorCompletions,
buildLabelCompletions,
} from "../resources/ha_completion_items";
import type {
JinjaArgType,
HassArgHoverContext,
} from "../resources/jinja_ha_completions";
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
import "./ha-code-editor-yaml-hover";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import { documentationUrl } from "../util/documentation-url";
@@ -89,6 +95,14 @@ export class HaCodeEditor extends ReactiveElement {
@property() public mode = "yaml";
/**
* Optional field schema for YAML mode. When set, the editor will provide
* field-aware key/value completions, hover tooltips, and linting for the
* known fields described by this map.
*/
@property({ attribute: false })
public yamlFieldSchema?: YamlFieldSchemaMap;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -168,6 +182,12 @@ export class HaCodeEditor extends ReactiveElement {
private _completionInfoDestroy?: () => void;
// Stored YAML syntax error set by setYamlError(); consumed by _yamlSyntaxLinter.
private _yamlSyntaxError: {
mark?: { position: number; line: number; column: number };
reason?: string;
} | null = null;
private _completionInfoRequest = 0;
private _completionInfoKey?: string;
@@ -202,6 +222,10 @@ export class HaCodeEditor extends ReactiveElement {
* Push a YAML parse error (or null to clear) into the lint gutter as a
* diagnostic. Avoids re-parsing the document — the caller (ha-yaml-editor)
* already has the error from its own js-yaml load() call.
*
* Stores the error and triggers forceLinting() so the yamlLintCompartment
* linter re-runs and returns it as a diagnostic — rather than calling
* setDiagnostics() which would wipe diagnostics from other linters.
*/
public setYamlError(
err: {
@@ -209,27 +233,10 @@ export class HaCodeEditor extends ReactiveElement {
reason?: string;
} | null
): void {
if (!this.codemirror || !this._loadedCodeMirror) return;
let diagnostics: {
from: number;
to: number;
severity: "error";
message: string;
}[] = [];
if (err) {
const doc = this.codemirror.state.doc;
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this._i18n?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
diagnostics = [{ from: pos, to: line.to, severity: "error", message }];
this._yamlSyntaxError = err;
if (this.codemirror && this._loadedCodeMirror) {
this._loadedCodeMirror.forceLinting(this.codemirror);
}
this.codemirror.dispatch(
this._loadedCodeMirror.setDiagnostics(this.codemirror.state, diagnostics)
);
}
public connectedCallback() {
@@ -290,9 +297,7 @@ export class HaCodeEditor extends ReactiveElement {
effects: [
this._loadedCodeMirror!.langCompartment!.reconfigure(this._mode),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
this._buildYamlSyntaxLinter()
),
],
});
@@ -304,20 +309,23 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
this._buildYamlSyntaxLinter()
),
],
});
this._updateToolbarButtons();
}
if (changedProps.has("lint")) {
if (changedProps.has("lint") || changedProps.has("yamlFieldSchema")) {
transactions.push({
effects: this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
this._buildYamlSyntaxLinter()
),
});
}
if (changedProps.has("yamlFieldSchema") || changedProps.has("readOnly")) {
transactions.push({
effects: this._loadedCodeMirror!.yamlSchemaCompartment!.reconfigure(
this._buildSchemaLinter()
),
});
}
@@ -371,6 +379,65 @@ export class HaCodeEditor extends ReactiveElement {
return this._loadedCodeMirror!.langs[this.mode];
}
private _buildSchemaLinter() {
if (!this._loadedCodeMirror || !this.yamlFieldSchema || this.readOnly) {
return [];
}
const schema = this.yamlFieldSchema;
return [
this._loadedCodeMirror.linter(
(view) =>
this._loadedCodeMirror!.haYamlLintSource(
view,
schema,
this._i18n?.localize
),
{ delay: 500 }
),
];
}
/**
* Builds the yamlLintCompartment extensions: a linter that surfaces the
* stored _yamlSyntaxError (set by setYamlError), plus the lint gutter when
* either syntax linting or schema linting is active.
*
* Using a linter() instead of setDiagnostics() means this linter's
* diagnostics are managed independently of the schema linter's diagnostics —
* they don't overwrite each other.
*/
private _buildYamlSyntaxLinter() {
if (this.readOnly) return [];
const showGutter = this.lint || !!this.yamlFieldSchema;
const extensions: Extension[] = [];
if (showGutter) {
extensions.push(this._loadedCodeMirror!.lintGutter());
}
if (this.lint) {
extensions.push(
this._loadedCodeMirror!.linter(
(view) => {
const err = this._yamlSyntaxError;
if (!err) return [];
const doc = view.state.doc;
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this._i18n?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
return [
{ from: pos, to: line.to, severity: "error" as const, message },
];
},
{ delay: 0 }
)
);
}
return extensions;
}
private _createCodeMirror() {
if (!this._loadedCodeMirror) {
throw new Error("Cannot create editor before CodeMirror is loaded");
@@ -419,7 +486,10 @@ export class HaCodeEditor extends ReactiveElement {
this.linewrap ? this._loadedCodeMirror.EditorView.lineWrapping : []
),
this._loadedCodeMirror.yamlLintCompartment.of(
this.lint && !this.readOnly ? [this._loadedCodeMirror.lintGutter()] : []
this._buildYamlSyntaxLinter()
),
this._loadedCodeMirror.yamlSchemaCompartment.of(
this._buildSchemaLinter()
),
this._loadedCodeMirror.EditorView.updateListener.of(this._onUpdate),
this._loadedCodeMirror.tooltips({
@@ -435,6 +505,21 @@ export class HaCodeEditor extends ReactiveElement {
),
{ hoverTime: 300 }
),
...(this.mode === "yaml"
? [
this._loadedCodeMirror.hoverTooltip(
(view, pos) =>
this.yamlFieldSchema
? this._loadedCodeMirror!.haYamlHoverSource(view, pos, {
schema: this.yamlFieldSchema,
localize: this._i18n?.localize,
hassContext: this._hassArgHoverContext(),
})
: null,
{ hoverTime: 300 }
),
]
: []),
...(this.placeholder ? [placeholder(this.placeholder)] : []),
];
@@ -442,6 +527,19 @@ export class HaCodeEditor extends ReactiveElement {
const completionSources: CompletionSource[] = [
this._loadedCodeMirror.haJinjaCompletionSource,
];
if (this.mode === "yaml") {
completionSources.push(
this._loadedCodeMirror.haYamlCompletionSource(() => ({
schema: this.yamlFieldSchema,
localize: this._i18n?.localize,
states: this._states,
devices: this._registries?.devices,
areas: this._registries?.areas,
floors: this._registries?.floors,
labels: this._labels,
}))
);
}
if (this.autocompleteEntities) {
completionSources.push(this._entityCompletions.bind(this));
}
@@ -452,6 +550,7 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror.autocompletion({
override: completionSources,
maxRenderedOptions: 10,
activateOnCompletion: (completion) => completion.type === "yaml-key",
}),
this._loadedCodeMirror.closeBrackets(),
this._loadedCodeMirror.closeBracketsOverride,
@@ -1007,23 +1106,9 @@ export class HaCodeEditor extends ReactiveElement {
});
};
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
if (!states) {
return [];
}
const options = Object.keys(states).map((key) => ({
type: "variable",
label: states[key].attributes.friendly_name
? `${states[key].attributes.friendly_name} ${key}` // label is used for searching, so include both name and entity_id here
: key,
displayLabel: key,
detail: states[key].attributes.friendly_name,
apply: key,
}));
return options;
});
private _getStates = memoizeOne((states: HassEntities): Completion[] =>
buildEntityCompletions(states)
);
// Map of HA Jinja function name → (arg index → JinjaArgType).
// Derived from the snippet definitions in jinja_ha_completions.ts.
@@ -1418,18 +1503,7 @@ export class HaCodeEditor extends ReactiveElement {
private _getDevices = memoizeOne(
(devices: HomeAssistant["devices"]): Completion[] =>
Object.values(devices)
.filter((device) => !device.disabled_by)
.map((device) => {
const name = computeDeviceName(device);
return {
type: "variable",
label: `${name} ${device.id}`,
displayLabel: name ?? device.id,
detail: device.id,
apply: device.id,
};
})
buildDeviceCompletions(devices)
);
/** Build a CompletionResult for device IDs, with `from` set inside the quotes. */
@@ -1448,17 +1522,7 @@ export class HaCodeEditor extends ReactiveElement {
}
private _getAreas = memoizeOne(
(areas: HomeAssistant["areas"]): Completion[] =>
Object.values(areas).map((area) => {
const name = computeAreaName(area) ?? area.area_id;
return {
type: "variable",
label: `${name} ${area.area_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: area.area_id,
apply: area.area_id,
};
})
(areas: HomeAssistant["areas"]): Completion[] => buildAreaCompletions(areas)
);
/** Build a CompletionResult for area IDs, with `from` set inside the quotes. */
@@ -1478,16 +1542,7 @@ export class HaCodeEditor extends ReactiveElement {
private _getFloors = memoizeOne(
(floors: HomeAssistant["floors"]): Completion[] =>
Object.values(floors).map((floor) => {
const name = computeFloorName(floor) ?? floor.floor_id;
return {
type: "variable",
label: `${name} ${floor.floor_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: floor.floor_id,
apply: floor.floor_id,
};
})
buildFloorCompletions(floors)
);
/** Build a CompletionResult for floor IDs, with `from` set inside the quotes. */
@@ -1507,16 +1562,7 @@ export class HaCodeEditor extends ReactiveElement {
private _getLabels = memoizeOne(
(labels: LabelRegistryEntry[]): Completion[] =>
labels.map((label) => {
const name = label.name.trim() || label.label_id;
return {
type: "variable",
label: `${name} ${label.label_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: label.label_id,
apply: label.label_id,
};
})
buildLabelCompletions(labels)
);
/** Build a CompletionResult for label IDs, with `from` set inside the quotes. */
+3 -11
View File
@@ -143,8 +143,7 @@ export class HaControlSelect extends LitElement {
? repeat(
this.options,
(option) => option.value,
(option, index) =>
this._renderOption(option, index === this._tabbableIndex)
(option) => this._renderOption(option)
)
: nothing
}
@@ -152,14 +151,7 @@ export class HaControlSelect extends LitElement {
`;
}
/* a radio group with no selection puts its first option in the tab sequence */
private get _tabbableIndex() {
const selectedIndex =
this.options?.findIndex((option) => option.value === this.value) ?? -1;
return selectedIndex === -1 ? 0 : selectedIndex;
}
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
private _renderOption(option: ControlSelectOption) {
const isSelected = this.value === option.value;
return html`
@@ -170,7 +162,7 @@ export class HaControlSelect extends LitElement {
selected: isSelected,
})}
role="radio"
tabindex=${tabbable ? "0" : "-1"}
tabindex=${isSelected ? "0" : "-1"}
.value=${option.value}
aria-checked=${isSelected ? "true" : "false"}
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
+49 -43
View File
@@ -2,12 +2,7 @@ import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { customElement, property, query, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
@@ -39,11 +34,11 @@ export class HaFilterBlueprints extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _blueprints?: Blueprints;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -61,6 +56,7 @@ export class HaFilterBlueprints extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -75,38 +71,29 @@ export class HaFilterBlueprints extends LitElement {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
${
this._blueprints
? html`
<ha-list
@selected=${this._blueprintsSelected}
multi
class="ha-scrollbar"
${
this._blueprints && this._shouldRender
? html`
<ha-list
@selected=${this._blueprintsSelected}
multi
class="ha-scrollbar"
>
${Object.entries(this._blueprints).map(([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
.value=${id}
.selected=${(this.value || []).includes(id)}
>
${Object.entries(this._blueprints).map(
([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
.value=${id}
.selected=${(this.value || []).includes(id)}
>
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</ha-list>
`
: nothing
}
</div>
`
: nothing
}
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</ha-list>
`
: nothing
}
</ha-expansion-panel>
`;
}
@@ -117,6 +104,19 @@ export class HaFilterBlueprints extends LitElement {
this._blueprints = await fetchBlueprints(this.hass, this.type);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (this.narrow || !this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -191,11 +191,17 @@ export class HaFilterBlueprints extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
+53 -23
View File
@@ -8,14 +8,9 @@ import {
mdiTag,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { CategoryRegistryEntry } from "../data/category_registry";
@@ -52,9 +47,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
@state() private _categories: CategoryRegistryEntry[] = [];
private _content = createRef<HTMLElement>();
@state() private _shouldRender = false;
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
protected hassSubscribeRequiredHostProps = ["scope"];
@@ -75,6 +70,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -89,11 +85,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
${
this._shouldRender
? html`
<ha-list
@selected=${this._categorySelected}
class="ha-scrollbar"
@@ -164,17 +158,34 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
</ha-list-item>`
)}
</ha-list>
<ha-list-item graphic="icon" @click=${this._addCategory}>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-list-item>
</div>
`
`
: nothing
}
</ha-expansion-panel>
${
this.expanded
? html`<ha-list-item
graphic="icon"
@click=${this._addCategory}
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-list-item>`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - (49 + 48)}px`;
}, 300);
}
}
private _handleAction(ev: HaDropdownSelectEvent) {
const categoryId = (ev.currentTarget as any).categoryId;
const action = ev.detail.item.value;
@@ -233,6 +244,10 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
});
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -272,8 +287,19 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
position: relative;
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
@@ -299,8 +325,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
color: var(--text-primary-color);
}
ha-list {
flex: 1;
min-height: 0;
--mdc-list-item-meta-size: auto;
--mdc-list-side-padding-right: var(--ha-space-1);
--mdc-list-side-padding-left: var(--ha-space-4);
@@ -315,6 +339,12 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
.warning {
color: var(--error-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
`,
];
}
+97 -72
View File
@@ -3,12 +3,7 @@ import { mdiFilterVariantRemove } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
@@ -64,15 +59,13 @@ export class HaFilterDevices extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list-selectable-virtualized")
private _listElement?: HaListSelectableVirtualized;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -84,11 +77,26 @@ export class HaFilterDevices extends LitElement {
}
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded || !this._listElement) {
return;
}
this._listElement.style.height = `${this.clientHeight - 49 - 4 - 38}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 38px - height of the search input
}, 300);
}
}
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -104,33 +112,31 @@ export class HaFilterDevices extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>`
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>
</div>`
: nothing
}
`;
}
@@ -171,6 +177,10 @@ export class HaFilterDevices extends LitElement {
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -261,43 +271,58 @@ export class HaFilterDevices extends LitElement {
this._listElement?.clearSelection();
}
static styles = [
filterPanelStyles,
css`
ha-list-selectable-virtualized {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
static styles = css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
:host([expanded]) ha-expansion-panel {
flex: 1;
min-height: 0;
}
ha-list-selectable-virtualized {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`;
}
declare global {
+70 -54
View File
@@ -1,16 +1,11 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
@@ -46,17 +41,18 @@ export class HaFilterDomains extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -71,48 +67,46 @@ export class HaFilterDomains extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
),
(i) => i,
(domain) =>
html`<ha-check-list-item
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this._localize, domain)}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
),
(i) => i,
(domain) =>
html`<ha-check-list-item
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this._localize, domain)}
</ha-check-list-item>`
)}
</ha-list>
</div>`
: nothing
}
`;
}
@@ -145,6 +139,22 @@ export class HaFilterDomains extends LitElement {
}
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -189,11 +199,17 @@ export class HaFilterDomains extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
+37 -21
View File
@@ -2,13 +2,8 @@ import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
@@ -57,11 +52,11 @@ export class HaFilterEntities extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -83,6 +78,7 @@ export class HaFilterEntities extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -97,11 +93,9 @@ export class HaFilterEntities extends LitElement {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
${
this._shouldRender
? html`
<ha-input-search
appearance="outlined"
.value=${this._filter}
@@ -124,13 +118,25 @@ export class HaFilterEntities extends LitElement {
>
</lit-virtualizer>
</ha-list>
</div>
`
: nothing
}
`
: nothing
}
</ha-expansion-panel>
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _keyFunction = (entity) => entity?.entity_id;
private _renderItem = (entity) =>
@@ -167,6 +173,10 @@ export class HaFilterEntities extends LitElement {
listItem.selected = this.value?.includes(value);
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -245,11 +255,17 @@ export class HaFilterEntities extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
+32 -21
View File
@@ -4,13 +4,8 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
@@ -69,12 +64,10 @@ export class HaFilterFloorAreas extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@query("ha-list-selectable") private _list?: HaListSelectable;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -93,6 +86,7 @@ export class HaFilterFloorAreas extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -113,11 +107,9 @@ export class HaFilterFloorAreas extends LitElement {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
${
this._shouldRender
? html`
<ha-list-selectable
class="ha-scrollbar"
multi
@@ -162,10 +154,10 @@ export class HaFilterFloorAreas extends LitElement {
(area) => this._renderArea(area)
)}
</ha-list-selectable>
</div>
`
: nothing
}
`
: nothing
}
</ha-expansion-panel>
`;
}
@@ -251,6 +243,19 @@ export class HaFilterFloorAreas extends LitElement {
};
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -342,11 +347,17 @@ export class HaFilterFloorAreas extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list-selectable {
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
+72 -60
View File
@@ -1,16 +1,11 @@
import type { SelectedDetail } from "@material/mwc-list";
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
@@ -49,11 +44,11 @@ export class HaFilterIntegrations extends LitElement {
Object.values(manifests)
);
@state() private _shouldRender = false;
@state() private _filter?: string;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
protected render() {
const manifests = this._manifests
@@ -64,6 +59,7 @@ export class HaFilterIntegrations extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -78,57 +74,67 @@ export class HaFilterIntegrations extends LitElement {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
${
manifests
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._itemSelected}
multi
>
${repeat(
this._integrations(
this._localize,
manifests,
this._filter,
this.value,
this._i18n.locale.language
),
(i) => i.domain,
(integration) =>
html`<ha-check-list-item
.value=${integration.domain}
.selected=${(this.value || []).includes(
integration.domain
)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${integration.domain}
brand-fallback
></ha-domain-icon>
${integration.name}
</ha-check-list-item>`
${
manifests && this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._itemSelected}
multi
>
${repeat(
this._integrations(
this._localize,
manifests,
this._filter,
this.value,
this._i18n.locale.language
),
(i) => i.domain,
(integration) =>
html`<ha-check-list-item
.value=${integration.domain}
.selected=${(this.value || []).includes(
integration.domain
)}
</ha-list>`
: nothing
}
</div>`
: nothing
}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${integration.domain}
brand-fallback
></ha-domain-icon>
${integration.name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -207,11 +213,17 @@ export class HaFilterIntegrations extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
+94 -62
View File
@@ -1,16 +1,11 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
@@ -49,11 +44,11 @@ export class HaFilterLabels extends LitElement {
@state()
private _labels?: LabelRegistryEntry[];
@state() private _shouldRender = false;
@state() private _filter?: string;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
private _filteredLabels = memoizeOne(
// `_value` used to recalculate the memoization when the selection changes
@@ -80,6 +75,7 @@ export class HaFilterLabels extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -94,66 +90,89 @@ export class HaFilterLabels extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
multi
>
${repeat(
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
),
(label) => label.label_id,
(label) =>
html`<ha-check-list-item
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
.color=${label.color}
.description=${label.description}
>
${
label.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing
}
${label.name}
</ha-label>
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
multi
>
${repeat(
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
),
(label) => label.label_id,
(label) =>
html`<ha-check-list-item
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
.color=${label.color}
.description=${label.description}
>
${
label.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing
}
${label.name}
</ha-label>
</ha-check-list-item>`
)}
</ha-list>
<ha-list-item graphic="icon" @click=${this._manageLabels}>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>
</div>`
this.expanded
? html`<ha-list-item
graphic="icon"
@click=${this._manageLabels}
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - (49 + 48 + 32 + 4)}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
// 48px - height of ha-list-item
}, 300);
}
}
private _manageLabels() {
navigate("/config/labels");
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -208,11 +227,18 @@ export class HaFilterLabels extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
@@ -241,6 +267,12 @@ export class HaFilterLabels extends LitElement {
.warning {
color: var(--error-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
+45 -22
View File
@@ -1,13 +1,8 @@
import type { SelectedDetail } from "@material/mwc-list";
import type { List, SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
@@ -32,9 +27,9 @@ export class HaFilterStates extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
private _content = createRef<HTMLElement>();
@state() private _shouldRender = false;
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list!: List;
protected render() {
if (!this.states) {
@@ -45,6 +40,7 @@ export class HaFilterStates extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -59,11 +55,9 @@ export class HaFilterStates extends LitElement {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
${
this._shouldRender
? html`
<ha-list
@selected=${this._statesSelected}
multi
@@ -88,13 +82,36 @@ export class HaFilterStates extends LitElement {
</ha-check-list-item>`
)}
</ha-list>
</div>
`
: nothing
}
`
: nothing
}
</ha-expansion-panel>
`;
}
protected willUpdate(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
this._shouldRender = true;
}
}
protected updated(changed: PropertyValues<this>) {
if ((changed.has("expanded") || changed.has("states")) && this.expanded) {
setTimeout(async () => {
if (!this.expanded) return;
const list = this._list;
if (!list) {
return;
}
list.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -135,11 +152,17 @@ export class HaFilterStates extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
+34 -20
View File
@@ -2,13 +2,8 @@ import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
@@ -39,15 +34,16 @@ export class HaFilterVoiceAssistants extends LitElement {
@state() private _voiceAssistantOptions: string[] = [];
private _content = createRef<HTMLElement>();
@state() private _shouldRender = false;
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -62,11 +58,9 @@ export class HaFilterVoiceAssistants extends LitElement {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-list
${
this._shouldRender
? html`<ha-list
@selected=${this._assistantsSelected}
class="ha-scrollbar"
multi
@@ -89,10 +83,10 @@ export class HaFilterVoiceAssistants extends LitElement {
${voiceAssistants[voiceAssistantId].name}
</ha-check-list-item>`
)}
</ha-list>
</div>`
: nothing
}
</ha-list> `
: nothing
}
</ha-expansion-panel>
`;
}
@@ -101,6 +95,19 @@ export class HaFilterVoiceAssistants extends LitElement {
this._voiceAssistantOptions = Object.keys(voiceAssistants);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -141,11 +148,18 @@ export class HaFilterVoiceAssistants extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
@@ -1,25 +0,0 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type { Selector } from "../../data/selector";
/**
* Value a selector already displays when no field value is set.
* Used when enabling an optional service/trigger/condition field.
*/
export const getSelectorFallbackValue = (selector: Selector): unknown => {
if ("constant" in selector) {
return selector.constant?.value;
}
if ("boolean" in selector) {
return false;
}
if ("number" in selector) {
return selector.number?.min ?? 0;
}
if ("color_temp" in selector) {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
}
return undefined;
};
@@ -16,13 +16,16 @@ import "../ha-input-helper-text";
import "../ha-select-box";
import type { SelectBoxOption } from "../ha-select-box";
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
export const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
"each",
"first",
"all",
];
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
export const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = [
"any",
"all",
];
@customElement("ha-selector-automation_behavior")
export class HaSelectorAutomationBehavior extends LitElement {
+14 -3
View File
@@ -35,7 +35,6 @@ import {
} from "../data/selector";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url";
import { getSelectorFallbackValue } from "./ha-form/get-selector-fallback-value";
import "./ha-checkbox";
import type { HaCheckbox } from "./ha-checkbox";
import "./ha-icon-button";
@@ -800,8 +799,20 @@ export class HaServiceControl extends LitElement {
let defaultValue = field?.default;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
}
if (defaultValue != null) {
+19 -70
View File
@@ -28,7 +28,6 @@ import {
type DevicePickerItem,
} from "../data/device/device_picker";
import {
devicesInEffectiveArea,
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
@@ -159,7 +158,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
excludeDevices,
value,
idPrefix,
nested: true,
})
);
@@ -684,10 +682,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
});
} else if (type === "area") {
// Splitting an area yields its effective-area devices, so a child device
// that belongs to a different area is not pulled into this area.
devicesInEffectiveArea(this.hass.devices, itemId).forEach((device) => {
Object.values(this.hass.devices).forEach((device) => {
if (
device.area_id === itemId &&
!this.value!.device_id?.includes(device.id) &&
deviceMeetsFilter(
device,
@@ -719,18 +716,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
});
} else if (type === "device") {
// Splitting a device into entities includes its child devices' entities,
// since targeting the device would target its children too.
const deviceIds = new Set([
itemId,
...Object.values(this.hass.devices)
.filter((device) => device.parent_device_id === itemId)
.map((device) => device.id),
]);
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.device_id &&
deviceIds.has(entity.device_id) &&
entity.device_id === itemId &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
@@ -1013,28 +1001,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
if (!filterType || filterType === "device") {
const selectedDeviceIds = targetValue?.device_id
? replacingDeviceId
? ensureArray(targetValue.device_id).filter(
(deviceId) => deviceId !== replacingDeviceId
)
: ensureArray(targetValue.device_id)
: undefined;
// A selected parent device already targets its children, so exclude
// those children from the picker too (mirrors selecting a floor
// removing its areas from the list).
const excludeDeviceIds = selectedDeviceIds
? [
...selectedDeviceIds,
...Object.values(this.hass.devices)
.filter(
(device) =>
device.parent_device_id !== null &&
selectedDeviceIds.includes(device.parent_device_id)
)
.map((device) => device.id),
]
: undefined;
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
@@ -1042,41 +1008,26 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeDeviceIds,
targetValue?.device_id
? replacingDeviceId
? ensureArray(targetValue.device_id).filter(
(deviceId) => deviceId !== replacingDeviceId
)
: ensureArray(targetValue.device_id)
: undefined,
replacingDeviceId,
`device${SEPARATOR}`
);
// getDevices already returns child devices nested under their parent
// with the top-level devices sorted; keep that order rather than
// re-sorting by label, which would separate children from their parent.
).sort(this._sortBySortingLabel);
if (searchTerm) {
// Keep the nested parent-then-children order (sort=false), matching
// the areas group; the default sorted search would reorder matches by
// relevance and pull children above their parent.
deviceItems = this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys,
false
deviceComboBoxKeys
);
}
// Recompute the tree "last child" flag over the (possibly filtered)
// list so the last visible child of each parent draws its end connector.
deviceItems = deviceItems.map((item, index) => {
if (!(item as DevicePickerItem).is_child) {
return item;
}
const nextItem = deviceItems[index + 1] as
DevicePickerItem | undefined;
return {
...item,
last: !nextItem || !nextItem.is_child,
};
});
if (!filterType && deviceItems.length) {
// show group title
items.push(localize("ui.components.target-picker.type.devices"));
@@ -1294,9 +1245,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
let hasFloor = false;
let rtl = false;
let showEntityId = false;
const isChildDeviceRow =
type === "device" && !!(item as DevicePickerItem).is_child;
if (type === "area" || type === "floor" || isChildDeviceRow) {
if (type === "area" || type === "floor") {
rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
@@ -1316,27 +1265,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.type=${type === "empty" ? "text" : "button"}
class=${type === "empty" ? "empty" : ""}
style=${
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
(item as FloorComboBoxItem).type === "area" && hasFloor
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""
}
>
${
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
(item as FloorComboBoxItem).type === "area" && hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${(item as { last?: boolean }).last}
.end=${
(item as FloorComboBoxItem & { last?: boolean | undefined })
.last
}
slot="start"
></ha-tree-indicator>
`
+3 -24
View File
@@ -8,31 +8,10 @@ export class HaTreeIndicator extends LitElement {
public end?: boolean = false;
protected render(): TemplateResult {
// preserveAspectRatio="none" lets the connector stretch to the host box, so
// it can span the full height of a taller row instead of being letterboxed
// to a square in the middle. non-scaling-stroke keeps the line width and
// dash pattern identical no matter how far it is stretched.
return html`
<svg
width="100%"
height="100%"
viewBox="0 0 48 48"
preserveAspectRatio="none"
>
<line
x1="24"
y1="0"
x2="24"
y2=${this.end ? "24" : "48"}
vector-effect="non-scaling-stroke"
></line>
<line
x1="24"
y1="24"
x2="36"
y2="24"
vector-effect="non-scaling-stroke"
></line>
<svg width="100%" height="100%" viewBox="0 0 48 48">
<line x1="24" y1="0" x2="24" y2=${this.end ? "24" : "48"}></line>
<line x1="24" y1="24" x2="36" y2="24"></line>
</svg>
`;
}
+11 -1
View File
@@ -8,6 +8,7 @@ import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyle } from "../resources/styles";
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
import { showToast } from "../util/toast";
import "./ha-button";
import "./ha-code-editor";
@@ -32,6 +33,14 @@ export class HaYamlEditor extends LitElement {
@property({ attribute: false }) public yamlSchema: Schema = YAML11_SCHEMA;
/**
* Optional field schema for YAML mode. When provided, the code editor will
* offer field-aware key/value completions, hover tooltips, and linting.
* This is forwarded directly to ha-code-editor.
*/
@property({ attribute: false })
public yamlFieldSchema?: YamlFieldSchemaMap;
@property({ attribute: false }) public defaultValue?: any;
@property({ attribute: "is-valid", type: Boolean }) public isValid = true;
@@ -123,8 +132,9 @@ export class HaYamlEditor extends LitElement {
.inDialog=${this.inDialog}
mode="yaml"
lint
autocomplete-entities
.autocompleteEntities=${!this.yamlFieldSchema}
autocomplete-icons
.yamlFieldSchema=${this.yamlFieldSchema}
.error=${this.isValid === false}
@value-changed=${this._onChange}
@editor-save=${this._onEditorSave}
@@ -393,9 +393,6 @@ export class HaTargetPickerItemRow extends LitElement {
nextEntries.referenced_entities =
entries?.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.area_id === rowItem ||
!entity.device_id ||
@@ -419,9 +416,6 @@ export class HaTargetPickerItemRow extends LitElement {
this.type === "label" && entries
? entries.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.labels.includes(this.itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
@@ -430,7 +424,7 @@ export class HaTargetPickerItemRow extends LitElement {
: nextType === "device" && entries
? entries.referenced_entities.filter(
(entity_id) =>
this.hass.entities[entity_id]?.area_id === this.itemId
this.hass.entities[entity_id].area_id === this.itemId
)
: [];
@@ -439,7 +433,7 @@ export class HaTargetPickerItemRow extends LitElement {
? entries.referenced_devices.filter(
(device_id) =>
!devicesInAreas.includes(device_id) &&
this.hass.devices[device_id]?.labels.includes(this.itemId)
this.hass.devices[device_id].labels.includes(this.itemId)
)
: [];
@@ -534,12 +528,6 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_areas = entries.referenced_areas.filter(
(area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
@@ -572,9 +560,6 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_devices = entries.referenced_devices.filter(
(device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
@@ -600,11 +585,6 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_entities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
// Core can reference entities that are absent from the display
// registry (e.g. disabled ones expanded from an area).
if (!entity) {
return false;
}
if (hiddenDeviceIds.includes(entity.device_id || "")) {
return false;
}
+11 -51
View File
@@ -1,5 +1,5 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { stopPropagation } from "../../common/dom/stop_propagation";
@@ -25,13 +25,6 @@ export class HaTileContainer extends LitElement {
@property({ attribute: false })
public actionHandlerOptions?: ActionHandlerOptions;
@state() private _hasFeatures = false;
private _handleFeaturesSlotChange(ev: Event) {
this._hasFeatures =
(ev.target as HTMLSlotElement).assignedElements().length > 0;
}
private _handleFocus(ev: FocusEvent) {
if ((ev.target as HTMLElement).matches(":focus-visible")) {
this.setAttribute("focused", "");
@@ -43,12 +36,8 @@ export class HaTileContainer extends LitElement {
}
protected render() {
const isInline = this.featurePosition === "inline";
const containerClasses = {
inline: isInline,
"has-features-below": isInline && this._hasFeatures,
"fixed-height": this.fixedInfoHeight,
};
const containerOrientationClass =
this.featurePosition === "inline" ? "horizontal" : "";
const contentClasses = {
vertical: this.vertical,
"fixed-info-height": this.fixedInfoHeight,
@@ -67,21 +56,15 @@ export class HaTileContainer extends LitElement {
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
</div>
<div
class="container ${classMap(containerClasses)}"
class="container ${containerOrientationClass}"
@action=${stopPropagation}
@click=${stopPropagation}
>
<div class="row">
<div class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>
</div>
<slot name="features-inline"></slot>
<div class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>
</div>
<slot
name="features"
@slotchange=${this._handleFeaturesSlotChange}
></slot>
<slot name="features"></slot>
</div>
`;
}
@@ -114,13 +97,7 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
flex: 1;
}
.row {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.container.inline .row {
.container.horizontal {
flex-direction: row;
}
@@ -176,30 +153,13 @@ export class HaTileContainer extends LitElement {
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
}
.container.inline ::slotted([slot="features-inline"]) {
/* size the feature on the 6 column grid track, so it lines up with neighbouring tiles */
.container.horizontal ::slotted([slot="features"]) {
width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3));
flex: none;
--feature-height: var(--ha-space-9);
padding: 0 var(--ha-space-3);
padding-inline-start: 0;
}
/* the inline feature keeps the icon height, unless the card reserves a row it can fill */
.container.inline:not(.has-features-below)
::slotted([slot="features-inline"]),
.container.inline:not(.fixed-height) ::slotted([slot="features-inline"]) {
--feature-height: var(--ha-space-9);
}
.container.inline.has-features-below ::slotted([slot="features"]) {
/* keep both columns under the inline feature, which sits on the grid track */
--ha-card-feature-column-gap: calc(
var(--column-gap, 0px) + var(--ha-space-3) * 2
);
--ha-card-feature-divider: 1px solid var(--ha-color-border-neutral-quiet);
--ha-card-feature-divider-inset: calc(
var(--ha-space-3) + var(--column-gap, 0px) / 2
);
}
[role="button"] {
cursor: pointer;
pointer-events: auto;
-1
View File
@@ -29,7 +29,6 @@ export class HaTraceLogbook extends LitElement {
.hass=${this.hass}
.entries=${this.logbookEntries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
@@ -437,7 +437,6 @@ export class HaTracePathDetails extends LitElement {
.hass=${this.hass}
.entries=${entries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
+20 -152
View File
@@ -3,7 +3,6 @@ import { computeDeviceNameDisplay } from "../../common/entity/compute_device_nam
import { computeDomain } from "../../common/entity/compute_domain";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import type { LocalizeFunc } from "../../common/translations/localize";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
@@ -25,17 +24,12 @@ import {
export interface DevicePickerItem extends PickerComboBoxItem {
domain?: string;
domain_name?: string;
// Set when this device is a child rendered indented under its parent.
is_child?: boolean;
// Set on the last child of a parent so the tree connector draws its end.
last?: boolean;
}
export interface DeviceAreaLabel {
areaName?: string;
viaDeviceName?: string;
viaDeviceAreaName?: string;
parentDeviceName?: string;
}
export interface GetDevicesOptions {
@@ -47,10 +41,6 @@ export interface GetDevicesOptions {
excludeDevices?: string[];
value?: string;
idPrefix?: string;
// When set, order the result so children directly follow their parent and
// flag them for indented rendering. Requires the picker to disable its own
// sorting (no-sort) so this order is preserved.
nested?: boolean;
}
export const computeDeviceAreaLabel = (
@@ -79,16 +69,6 @@ export const computeDeviceAreaLabel = (
? computeAreaName(viaDeviceArea)
: undefined;
// A child device is a logical part of its parent. We surface the parent name
// only as a search term (below) — not in the area label, which stays the pure
// (inherited) area. The nested tree rendering communicates the relationship.
const parentDevice = device.parent_device_id
? devices[device.parent_device_id]
: undefined;
const parentDeviceName = parentDevice
? computeDeviceNameDisplay(parentDevice, localize, states)
: undefined;
const isRTL = computeRTL(language, translationMetadata.translations);
const areaName = area
@@ -97,7 +77,7 @@ export const computeDeviceAreaLabel = (
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
: viaDeviceName || undefined;
return { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName };
return { areaName, viaDeviceName, viaDeviceAreaName };
};
export const deviceComboBoxKeys: FuseWeightedKey[] = [
@@ -125,14 +105,6 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
name: "search_labels.viaDeviceArea",
weight: 3,
},
{
name: "search_labels.parentDeviceName",
weight: 3,
},
{
name: "search_labels.childDeviceNames",
weight: 3,
},
];
export const getDevices = (
@@ -149,7 +121,6 @@ export const getDevices = (
excludeDevices,
value,
idPrefix = "",
nested,
} = options ?? {};
const devices = Object.values(hass.devices);
@@ -157,60 +128,26 @@ export const getDevices = (
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
const filtersEntities =
includeDomains || excludeDomains || includeDeviceClasses || entityFilter;
if (filtersEntities) {
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
}
// Targeting a device also targets its child devices (a parent inherits its
// children's entities), so a device should match an entity-based filter when
// it OR any of its children has a matching entity. Build a parent -> children
// map and resolve each device's effective entity set accordingly. Nesting is
// single-level, so one hop covers it.
const filterChildrenByParent = new Map<string, DeviceRegistryEntry[]>();
if (filtersEntities) {
for (const device of devices) {
if (device.parent_device_id) {
const siblings = filterChildrenByParent.get(device.parent_device_id);
if (siblings) {
siblings.push(device);
} else {
filterChildrenByParent.set(device.parent_device_id, [device]);
}
}
}
}
const effectiveEntities = (
deviceId: string
): EntityRegistryDisplayEntry[] => {
const own = deviceEntityLookup[deviceId] ?? [];
const children = filterChildrenByParent.get(deviceId);
if (!children) {
return own;
}
const combined = [...own];
for (const child of children) {
const childEntities = deviceEntityLookup[child.id];
if (childEntities) {
combined.push(...childEntities);
}
}
return combined;
};
let inputDevices = devices.filter(
(device) => device.id === value || !device.disabled_by
);
if (includeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return devEntities.some((entity) =>
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -218,11 +155,11 @@ export const getDevices = (
if (excludeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return devEntities.every(
return entities.every(
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -236,11 +173,11 @@ export const getDevices = (
if (includeDeviceClasses) {
inputDevices = inputDevices.filter((device) => {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return devEntities.some((entity) => {
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
@@ -255,8 +192,8 @@ export const getDevices = (
if (entityFilter) {
inputDevices = inputDevices.filter((device) => {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return devEntities.some((entity) => {
@@ -285,7 +222,7 @@ export const getDevices = (
deviceEntityLookup[device.id]
);
const { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName } =
const { areaName, viaDeviceName, viaDeviceAreaName } =
computeDeviceAreaLabel(
device,
hass.areas,
@@ -322,79 +259,10 @@ export const getDevices = (
domainName: domainName || null,
viaDeviceName: viaDeviceName || null,
viaDeviceArea: viaDeviceAreaName || null,
parentDeviceName: parentDeviceName || null,
},
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
};
});
if (!nested) {
return outputDevices;
}
// Order children directly after their parent and flag them for indented
// rendering. outputDevices is 1:1 with inputDevices, so we can pair them up.
const itemByDeviceId = new Map<string, DevicePickerItem>();
inputDevices.forEach((device, index) => {
itemByDeviceId.set(device.id, outputDevices[index]);
});
const presentIds = new Set(inputDevices.map((device) => device.id));
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
const topLevel: DeviceRegistryEntry[] = [];
for (const device of inputDevices) {
const parentId = device.parent_device_id;
// A child whose parent was filtered out is shown as a top-level row.
if (parentId && presentIds.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(device);
} else {
childrenByParent.set(parentId, [device]);
}
} else {
topLevel.push(device);
}
}
const compareByName = (a: DeviceRegistryEntry, b: DeviceRegistryEntry) =>
caseInsensitiveStringCompare(
itemByDeviceId.get(a.id)!.primary,
itemByDeviceId.get(b.id)!.primary,
hass.locale.language
);
topLevel.sort(compareByName);
const ordered: DevicePickerItem[] = [];
for (const device of topLevel) {
const parentItem = itemByDeviceId.get(device.id)!;
const children = childrenByParent.get(device.id);
if (children) {
children.sort(compareByName);
// Add the children's names to the parent's search terms so a search that
// matches a child keeps the parent visible (mirrors how a floor stays
// visible when one of its areas matches).
ordered.push({
...parentItem,
search_labels: {
...parentItem.search_labels,
childDeviceNames: children
.map((child) => itemByDeviceId.get(child.id)!.primary)
.join(" "),
},
});
children.forEach((child, index) => {
ordered.push({
...itemByDeviceId.get(child.id)!,
is_child: true,
last: index === children.length - 1,
});
});
} else {
ordered.push(parentItem);
}
}
return ordered;
return outputDevices;
};
-69
View File
@@ -81,75 +81,6 @@ export type DeviceRegistryListEntry =
export const isChildDevice = (device: DeviceRegistryEntry): boolean =>
device.parent_device_id !== null;
/**
* Devices whose effective area is the given area: devices with that area, and
* child devices that inherit it because they have no area of their own. Mirrors
* core's dr.async_entries_for_area, so a child device with a different explicit
* area is not part of its parent's area.
*/
export const devicesInEffectiveArea = (
devices: Record<string, DeviceRegistryEntry>,
areaId: string
): DeviceRegistryEntry[] =>
Object.values(devices).filter((device) => {
if (device.area_id) {
return device.area_id === areaId;
}
if (device.parent_device_id) {
return devices[device.parent_device_id]?.area_id === areaId;
}
return false;
});
export interface DeviceRowItem {
device: DeviceRegistryEntry;
isChild: boolean;
// True for the last child of a parent, so the tree connector draws its end.
isLastChild: boolean;
}
/**
* Order a flat device list so each child directly follows its parent, flagging
* children for indented rendering. The incoming order of the top-level devices
* (and of the children within each parent) is preserved. A child whose parent
* is not in the list is treated as a top-level device.
*/
export const groupDevicesByParent = (
devices: DeviceRegistryEntry[]
): DeviceRowItem[] => {
const presentIds = new Set(devices.map((device) => device.id));
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
const topLevel: DeviceRegistryEntry[] = [];
for (const device of devices) {
const parentId = device.parent_device_id;
if (parentId && presentIds.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(device);
} else {
childrenByParent.set(parentId, [device]);
}
} else {
topLevel.push(device);
}
}
const result: DeviceRowItem[] = [];
for (const device of topLevel) {
result.push({ device, isChild: false, isLastChild: false });
const children = childrenByParent.get(device.id) ?? [];
children.forEach((child, index) => {
result.push({
device: child,
isChild: true,
isLastChild: index === children.length - 1,
});
});
}
return result;
};
export type DeviceEntityDisplayLookup = Record<
string,
EntityRegistryDisplayEntry[]
-55
View File
@@ -22,7 +22,6 @@ import {
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { formatTime24h } from "../common/datetime/format_time";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
import { groupBy } from "../common/util/group-by";
@@ -37,7 +36,6 @@ import type {
import {
fetchStatistics,
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
VOLUME_UNITS,
} from "./recorder";
@@ -313,59 +311,6 @@ export interface EnergySourceByType {
export const energySourcesByType = (prefs: EnergyPreferences) =>
groupBy(prefs.energy_sources, (item) => item.type) as EnergySourceByType;
/**
* Display name of a configured statistic. A name set by the user always wins;
* otherwise the entity is named the same way the rest of the UI names
* entities, so devices sharing an entity name stay distinguishable.
* Statistics without an entity (external or removed) keep the statistic label.
*/
export const computeEnergyLabel = (
hass: HomeAssistant,
statisticId: string,
statisticsMetaData?: StatisticsMetaData,
customName?: string
): string => {
if (customName) {
return customName;
}
const stateObj = hass.states[statisticId];
if (stateObj) {
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
}
return getStatisticLabel(hass, statisticId, statisticsMetaData);
};
/**
* Device labels keyed by statistic id. Cards that show live power or flow
* key their nodes by `stat_rate` instead of `stat_consumption`; devices
* without the requested statistic are left out.
*/
export const computeEnergyDeviceLabels = (
hass: HomeAssistant,
devices: DeviceConsumptionEnergyPreference[],
statsMetadata?: Record<string, StatisticsMetaData>,
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
): Record<string, string> => {
const labels: Record<string, string> = {};
for (const device of devices) {
const statisticId = device[statisticKey];
if (statisticId) {
labels[statisticId] = computeEnergyLabel(
hass,
statisticId,
statsMetadata?.[statisticId],
device.name
);
}
}
return labels;
};
export interface EnergyData {
start: Date;
end?: Date;
+1 -1
View File
@@ -76,7 +76,7 @@ export const getLogbookDataForContext = async (
): Promise<LogbookEntry[]> =>
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
export const getLogbookDataFromServer = (
const getLogbookDataFromServer = (
hass: HomeAssistant,
startDate: string,
endDate?: string,
+2 -5
View File
@@ -2,15 +2,12 @@ import type { Connection } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../../types";
import type { LovelaceResource } from "../resource";
import type { LovelaceStrategyConfig } from "./strategy";
import type {
LovelaceDashboardBackgroundConfig,
LovelaceViewRawConfig,
} from "./view";
import type { LovelaceViewRawConfig } from "./view";
export interface LovelaceDashboardBaseConfig {}
export interface LovelaceConfig extends LovelaceDashboardBaseConfig {
background?: LovelaceDashboardBackgroundConfig;
background?: string;
views: LovelaceViewRawConfig[];
}
+1 -4
View File
@@ -31,9 +31,6 @@ export interface LovelaceViewBackgroundConfig {
attachment?: "scroll" | "fixed";
}
export type LovelaceDashboardBackgroundConfig =
string | LovelaceViewBackgroundConfig;
export interface LovelaceViewHeaderConfig {
card?: LovelaceCardConfig;
layout?: "start" | "center" | "responsive";
@@ -63,7 +60,7 @@ export interface LovelaceBaseViewConfig {
show_icon_and_title?: boolean;
theme?: string;
panel?: boolean;
background?: LovelaceDashboardBackgroundConfig;
background?: string | LovelaceViewBackgroundConfig;
visible?: boolean | ShowViewConfig[];
subview?: boolean;
back_path?: string;
+10 -23
View File
@@ -14,7 +14,6 @@ import type {
import type { HomeAssistant } from "../types";
import {
type DeviceRegistryEntry,
devicesInEffectiveArea,
getDeviceIntegrationLookup,
} from "./device/device_registry";
import type {
@@ -727,10 +726,9 @@ export const expandAreaTarget = (
) => {
const newEntities: string[] = [];
const newDevices: string[] = [];
// Devices of an area are its effective-area members: a child device inheriting
// this area counts, a child with a different explicit area does not.
devicesInEffectiveArea(devices, areaId).forEach((device) => {
Object.values(devices).forEach((device) => {
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -792,8 +790,9 @@ export const areaMeetsTargetSelector = (
targetSelector: TargetSelector,
entitySources?: EntitySources
): boolean => {
const hasMatchingdevice = devicesInEffectiveArea(devices, areaId).some(
(device) =>
const hasMatchingdevice = Object.values(devices).some((device) => {
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -801,7 +800,11 @@ export const areaMeetsTargetSelector = (
targetSelector,
entitySources
)
);
) {
return true;
}
return false;
});
if (hasMatchingdevice) {
return true;
}
@@ -843,8 +846,6 @@ export const deviceMeetsTargetSelector = (
}
}
if (targetSelector.target?.entity) {
// Only the device's own entities: a child device is reached through the
// device target itself, so a parent must not match on a child's behalf.
const entities = entityRegistry.filter(
(reg) => reg.device_id === device.id
);
@@ -1111,11 +1112,6 @@ export const resolveEntityIDs = (
const targetFloors = new Set(ensureArray(targetPickerValue.floor_id));
const targetLabels = new Set(ensureArray(targetPickerValue.label_id));
// Only a directly targeted device pulls in its child devices. Devices that are
// only reached through a label or an area must not, because core does not
// inherit labels to children and resolves areas by effective area membership.
const directDevices = new Set(targetDevices);
targetLabels.forEach((labelId) => {
const expanded = expandLabelTarget(
hass,
@@ -1147,15 +1143,6 @@ export const resolveEntityIDs = (
expanded.entities.forEach((id) => targetEntities.add(id));
});
// Targeting a device also targets its child devices, matching core's
// server-side target resolution. Only direct device targets expand this way;
// nesting is single-level, so one pass is enough.
Object.values(devices).forEach((device) => {
if (device.parent_device_id && directDevices.has(device.parent_device_id)) {
targetDevices.add(device.id);
}
});
targetDevices.forEach((deviceId) => {
const expanded = expandDeviceTarget(
hass,
+4 -8
View File
@@ -7,10 +7,7 @@ import type { CallWS, HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area/area_registry";
import type { FloorComboBoxItem } from "./area_floor_picker";
import type { DevicePickerItem } from "./device/device_picker";
import {
devicesInEffectiveArea,
type DeviceRegistryEntry,
} from "./device/device_registry";
import type { DeviceRegistryEntry } from "./device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
import type { EntityComboBoxItem } from "./entity/entity_picker";
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
@@ -128,7 +125,9 @@ export const areaMeetsFilter = (
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
const areaDevices = devicesInEffectiveArea(devices, area.area_id);
const areaDevices = Object.values(devices).filter(
(device) => device.area_id === area.area_id
);
if (
areaDevices.some((device) =>
@@ -179,9 +178,6 @@ export const deviceMeetsFilter = (
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
// Only the device's own entities: child devices are targeted through the
// device itself (see core's target resolution), not by making a parent match
// on behalf of a child.
const devEntities = Object.values(entities).filter(
(entity) => entity.device_id === device.id
);
+15 -5
View File
@@ -7,11 +7,21 @@ import { isNumericEntity } from "../../data/history";
import { CONTINUOUS_DOMAINS } from "../../data/logbook";
import type { HomeAssistant } from "../../types";
export {
isMoreInfoView,
MORE_INFO_VIEWS,
type MoreInfoView,
} from "./more-info-view";
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
export const DOMAINS_NO_INFO = ["camera", "configurator"];
/**
-9
View File
@@ -1,9 +0,0 @@
import { createContext } from "@lit/context";
export interface MoreInfoContext {
hash: URLSearchParams;
setHashParam: (key: string, value?: string) => void;
}
export const moreInfoContext =
createContext<MoreInfoContext>("more-info-context");
@@ -11,7 +11,6 @@ import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
import { formatTime } from "../../../common/datetime/format_time";
import { transform } from "../../../common/decorators/transform";
import { formatNumber } from "../../../common/number/format_number";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-relative-time";
import "../../../components/ha-spinner";
@@ -49,16 +48,11 @@ import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import { moreInfoContext, type MoreInfoContext } from "../context";
@customElement("more-info-weather")
class MoreInfoWeather extends LitElement {
@property({ attribute: false }) public stateObj?: WeatherEntity;
@state()
@consume({ context: moreInfoContext, subscribe: true })
private _moreInfoContext?: MoreInfoContext;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@@ -133,33 +127,15 @@ class MoreInfoWeather extends LitElement {
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
(changedProps.has("stateObj") ||
changedProps.has("_moreInfoContext") ||
!this._subscribed) &&
this.stateObj
) {
if ((changedProps.has("stateObj") || !this._subscribed) && this.stateObj) {
const oldState = changedProps.get("stateObj") as
WeatherEntity | undefined;
if (
oldState?.entity_id !== this.stateObj?.entity_id ||
changedProps.has("_moreInfoContext") ||
!this._subscribed
) {
const supportedForecastTypes = getSupportedForecastTypes(this.stateObj);
const requestedForecastType =
this._moreInfoContext?.hash.get("forecast");
const selectedForecastType =
supportedForecastTypes.find(
(forecastType) => forecastType === requestedForecastType
) ?? getDefaultForecastType(this.stateObj);
if (selectedForecastType !== requestedForecastType) {
this._moreInfoContext?.setHashParam("forecast", selectedForecastType);
}
if (this._forecastType !== selectedForecastType || !this._subscribed) {
this._forecastType = selectedForecastType;
this._subscribeForecastEvents();
}
this._forecastType = getDefaultForecastType(this.stateObj);
this._subscribeForecastEvents();
}
} else if (changedProps.has("_forecastType")) {
this._subscribeForecastEvents();
@@ -533,17 +509,8 @@ class MoreInfoWeather extends LitElement {
`;
}
private _handleForecastTypeChanged(
ev: HASSDomEvent<{ name: ModernForecastType }>
): void {
if (
!this.stateObj ||
!getSupportedForecastTypes(this.stateObj).includes(ev.detail.name)
) {
return;
}
private _handleForecastTypeChanged(ev: CustomEvent): void {
this._forecastType = ev.detail.name;
this._moreInfoContext?.setHashParam("forecast", this._forecastType);
}
static get styles(): CSSResultGroup {
+2 -62
View File
@@ -17,7 +17,6 @@ import {
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { provide } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -44,10 +43,8 @@ import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-reques
import {
getHistoryState,
navigate,
replaceCurrentUrl,
updateHistoryState,
} from "../../common/navigate";
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -88,7 +85,6 @@ import {
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
type MoreInfoView,
} from "./const";
import { moreInfoContext, type MoreInfoContext } from "./context";
import "./controls/more-info-default";
import type { FavoritesDialogContext } from "./favorites";
import { getFavoritesDialogHandler } from "./favorites";
@@ -106,9 +102,6 @@ export interface MoreInfoDialogParams {
tab?: MoreInfoView;
large?: boolean;
data?: Record<string, any>;
hash?: URLSearchParams;
fromUrl?: boolean;
returnUrl?: string;
parentElement?: LitElement;
}
@@ -154,12 +147,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _data?: Record<string, any>;
@provide({ context: moreInfoContext })
@state()
private _moreInfoContext: MoreInfoContext = this._createMoreInfoContext();
private _returnUrl?: string;
@state() private _currView: MoreInfoView = DEFAULT_VIEW;
@state() private _initialView: MoreInfoView = DEFAULT_VIEW;
@@ -194,8 +181,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const view = params.view || params.tab || DEFAULT_VIEW;
this._data = params.data;
this._moreInfoContext = this._createMoreInfoContext(params.hash);
this._returnUrl = params.returnUrl;
this._currView = view;
this._initialView = view;
this._childViewStack = [];
@@ -231,9 +216,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _dialogClosed() {
if (this._returnUrl) {
replaceCurrentUrl(this._returnUrl);
}
this._entityId = undefined;
this._parentEntityIds = [];
this._entry = undefined;
@@ -242,8 +224,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this._initialView = DEFAULT_VIEW;
this._currView = DEFAULT_VIEW;
this._childViewStack = [];
this._moreInfoContext = this._createMoreInfoContext();
this._returnUrl = undefined;
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
@@ -291,10 +271,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
return entity?.device_id ?? null;
}
private _setView(view: MoreInfoView, preserveHash = false) {
if (view !== this._currView && !preserveHash) {
this._moreInfoContext = this._createMoreInfoContext();
}
private _setView(view: MoreInfoView) {
updateHistoryState({
dialogParams: {
...getHistoryState()?.dialogParams,
@@ -302,38 +279,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
},
});
this._currView = view;
this._syncUrl();
}
private _syncUrl() {
if (!this._returnUrl || !this._entityId) {
return;
}
replaceCurrentUrl(
createMoreInfoUrl(this._returnUrl, {
entityId: this._entityId,
view: this._currView,
hash: this._moreInfoContext.hash,
})
);
}
private _createMoreInfoContext(hash?: URLSearchParams): MoreInfoContext {
return {
hash: new URLSearchParams(hash),
setHashParam: (key, value) => this._setHashParam(key, value),
};
}
private _setHashParam(key: string, value?: string) {
const hash = new URLSearchParams(this._moreInfoContext.hash);
if (value) {
hash.set(key, value);
} else {
hash.delete(key);
}
this._moreInfoContext = this._createMoreInfoContext(hash);
this._syncUrl();
}
private _goBack() {
@@ -361,9 +306,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
if (this._parentEntityIds.length > 0) {
this._entityId = this._parentEntityIds.pop();
this._currView = DEFAULT_VIEW;
this._moreInfoContext = this._createMoreInfoContext();
this._loadEntityRegistryEntry();
this._syncUrl();
}
}
@@ -1064,22 +1007,19 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
const view = ev.detail.view || ev.detail.tab || DEFAULT_VIEW;
if (entityId === this._entityId) {
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._infoEditMode = false;
this._detailsYamlMode = false;
this._setView(view, true);
this._setView(view);
return;
}
this._parentEntityIds = [...this._parentEntityIds, this._entityId!];
this._entityId = entityId;
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._currView = view === "details" ? view : DEFAULT_VIEW;
this._initialView = view;
this._infoEditMode = false;
this._detailsYamlMode = false;
this._childViewStack = [];
this._loadEntityRegistryEntry();
this._syncUrl();
}
private _enableEscapeKeyClose = () => {
-15
View File
@@ -1,15 +0,0 @@
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
+14 -14
View File
@@ -28,8 +28,8 @@ import type {
SortingDirection,
} from "../components/data-table/ha-data-table";
import { showDataTableSettingsDialog } from "../components/data-table/show-dialog-data-table-settings";
import "../components/ha-adaptive-dialog";
import "../components/ha-button";
import "../components/ha-dialog";
import "../components/ha-dialog-footer";
import "../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
@@ -576,17 +576,16 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
</hass-tabs-subpage>
${
this.showFilters && !showPane
? html`<ha-adaptive-dialog
open
flexcontent
? html`<ha-dialog
.open=${true}
width="full"
header-title=${localize("ui.components.subpage-data-table.filters")}
@closed=${this._closeFilters}
>
<ha-icon-button
slot="headerNavigationIcon"
data-dialog="close"
.path=${mdiClose}
@click=${this._closeFilters}
.label=${localize(
"ui.components.subpage-data-table.close_filter"
)}
@@ -607,13 +606,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
<slot name="filter-pane"></slot>
</div>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" data-dialog="close">
<ha-button slot="primaryAction" @click=${this._closeFilters}>
${localize("ui.components.subpage-data-table.show_results", {
number: this.data.length,
})}
</ha-button>
</ha-dialog-footer>
</ha-adaptive-dialog>`
</ha-dialog>`
: nothing
}
`;
@@ -966,19 +965,20 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
--md-assist-chip-trailing-space: 8px;
}
ha-adaptive-dialog {
ha-dialog {
--dialog-content-padding: 0;
/* Fixed height so the sheet does not resize while filtering. */
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
--ha-dialog-min-height: calc(var(--safe-height) - var(--ha-space-20));
}
.filter-dialog-content {
height: calc(
100vh -
70px - var(--header-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
ha-dropdown ha-assist-chip {
-40
View File
@@ -6,16 +6,12 @@ import { storage } from "../common/decorators/storage";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { decodeMoreInfoUrl } from "../common/url/more-info-query-params";
import { extractSearchParamsObject } from "../common/url/search-params";
import { afterNextRender } from "../common/util/render-status";
import { fetchHttpConfig } from "../data/http";
import type { HttpConfigState } from "../data/http";
import type { WindowWithPreloads } from "../data/preloads";
import type { RecorderInfo } from "../data/recorder";
import { getRecorderInfo } from "../data/recorder";
import { showHttpPendingConfigDialog } from "../dialogs/http-pending-config/show-dialog-http-pending-config";
import { showMoreInfoDialog } from "../dialogs/more-info/show-ha-more-info-dialog";
import "../resources/custom-card-support";
import { HassElement } from "../state/hass-element";
import QuickBarMixin from "../state/quick-bar-mixin";
@@ -148,7 +144,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// the shadow root. Appending the dialog before that render would let it
// tear the freshly-added dialog straight back out of the DOM.
this.checkHttpPendingConfig();
this._restoreMoreInfoFromUrl();
}
}
@@ -183,12 +178,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// Handle history changes
window.addEventListener("popstate", () => updateRoute());
// Restore a more-info dialog deep-linked in the current URL, if any.
window.addEventListener("location-changed", () =>
this._restoreMoreInfoFromUrl()
);
window.addEventListener("popstate", () => this._restoreMoreInfoFromUrl());
// Handle clicking on links
window.addEventListener("click", (ev) => {
const href = isNavigationClick(ev);
@@ -308,35 +297,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
});
}
private _restoreMoreInfoFromUrl() {
// Only restore once the main UI is rendered so the dialog has access to
// the loaded entities.
if (this.render !== this.renderHass) {
return;
}
const searchParams = extractSearchParamsObject();
if (!searchParams["more-info-entity-id"]) {
return;
}
const { entityId, view, hash } = decodeMoreInfoUrl(
window.location.search,
window.location.hash
);
if (!entityId) {
return;
}
// Wait for the next render to ensure the view is fully loaded
// because the more info dialog is closed when the url changes
afterNextRender(() => {
showMoreInfoDialog(this, {
entityId,
view,
hash,
fromUrl: true,
});
});
}
protected async checkDataBaseMigration() {
if (__DEMO__) {
this._databaseMigration = false;
@@ -1,6 +1,7 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
@@ -8,6 +9,7 @@ import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
import { migrateAutomationAction, type Action } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import { actionSchemaKey, actionToYamlSchema } from "../yaml_schema_helpers";
import "../ha-automation-editor-warning";
import { editorStyles, indentStyle } from "../styles";
import {
@@ -41,6 +43,14 @@ export default class HaAutomationActionEditor extends LitElement {
@query(COLLAPSIBLE_ACTION_ELEMENTS.join(", "))
private _collapsibleElement?: ActionElement;
private _actionYamlSchema = memoizeOne(
(
actionKey: string | undefined,
services: HomeAssistant["services"],
localize: HomeAssistant["localize"]
) => actionToYamlSchema(actionKey, services, localize)
);
protected render() {
const yamlMode = this.yamlMode || !this.uiSupported;
const type = getAutomationActionType(this.action);
@@ -74,6 +84,11 @@ export default class HaAutomationActionEditor extends LitElement {
.defaultValue=${this.action}
@value-changed=${this._onYamlChange}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._actionYamlSchema(
actionSchemaKey(this.action),
this.hass.services,
this.hass.localize
)}
></ha-yaml-editor>
`
: html`
@@ -370,7 +370,6 @@ export class HaAutomationAddSearch extends LitElement {
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
@@ -11,6 +11,10 @@ import { expandConditionWithShorthand } from "../../../../data/automation";
import type { ConditionDescription } from "../../../../data/condition";
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
import type { HomeAssistant } from "../../../../types";
import {
builtInConditionSchema,
conditionDescriptionToSchema,
} from "../yaml_schema_helpers";
import "../ha-automation-editor-warning";
import { editorStyles, indentStyle } from "../styles";
import type { ConditionElement } from "./ha-automation-condition-row";
@@ -44,6 +48,22 @@ export default class HaAutomationConditionEditor extends LitElement {
@query(COLLAPSIBLE_CONDITION_ELEMENTS.join(", "))
private _collapsibleElement?: ConditionElement;
// Memoized on the condition type rather than the condition itself: the
// condition object gets a new identity on every keystroke, and the schema
// only depends on its type.
private _conditionYamlSchema = memoizeOne(
(
conditionType: string,
description: ConditionDescription | undefined,
localize: HomeAssistant["localize"]
) => {
if (!description) {
return builtInConditionSchema(conditionType, localize);
}
return conditionDescriptionToSchema(conditionType, description, localize);
}
);
private _processedCondition = memoizeOne((condition) =>
expandConditionWithShorthand(condition)
);
@@ -82,6 +102,11 @@ export default class HaAutomationConditionEditor extends LitElement {
.defaultValue=${this.condition}
@value-changed=${this._onYamlChange}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._conditionYamlSchema(
condition.condition,
this.description,
this.hass.localize
)}
></ha-yaml-editor>
`
: html`
@@ -32,6 +32,54 @@ const numericStateConditionStruct = object({
enabled: optional(boolean()),
});
export const YAML_SCHEMA = [
{ name: "entity_id", required: true, selector: { entity: {} } },
{
name: "attribute",
selector: { attribute: { hide_attributes: NON_NUMERIC_ATTRIBUTES } },
context: { filter_entity: "entity_id" },
},
{
name: "above",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{
name: "below",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{ name: "value_template", selector: { template: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "attribute":
return localize("ui.components.entity.entity-attribute-picker.attribute");
default:
return localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${fieldName}` as any
);
}
};
@customElement("ha-automation-condition-numeric_state")
export default class HaNumericStateCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -242,20 +290,7 @@ export default class HaNumericStateCondition extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "attribute":
return this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute"
);
default:
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${schema.name}`
);
}
};
): string => computeLabel(schema.name, this.hass.localize);
static styles = css`
:host {
@@ -7,7 +7,6 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
@@ -430,8 +429,20 @@ export class HaPlatformCondition extends LitElement {
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
}
if (defaultValue != null) {
@@ -19,6 +19,7 @@ import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { StateCondition } from "../../../../../data/automation";
import { STATE_CONDITION_HIDDEN_ATTRIBUTES } from "../../../../../data/entity/entity_attributes";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
import { forDictStruct } from "../../structs";
import type { ConditionElement } from "../ha-automation-condition-row";
@@ -34,7 +35,7 @@ const stateConditionStruct = object({
enabled: optional(boolean()),
});
const SCHEMA = [
export const SCHEMA = [
{ name: "entity_id", required: true, selector: { entity: {} } },
{
name: "attribute",
@@ -61,6 +62,26 @@ const SCHEMA = [
{ name: "for", selector: { duration: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "attribute":
return localize("ui.components.entity.entity-attribute-picker.attribute");
case "for":
return localize(
"ui.panel.config.automation.editor.triggers.type.state.for"
);
default:
return localize(
`ui.panel.config.automation.editor.conditions.type.state.${fieldName}` as any
);
}
};
@customElement("ha-automation-condition-state")
export class HaStateCondition extends LitElement implements ConditionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -125,24 +146,7 @@ export class HaStateCondition extends LitElement implements ConditionElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "attribute":
return this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute"
);
case "for":
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.state.for`
);
default:
return this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.state.${schema.name}`
);
}
};
): string => computeLabel(schema.name, this.hass.localize);
static styles = css`
:host {
@@ -14,6 +14,29 @@ type FormType = "before" | "after" | "between";
const BEFORE_DEFAULT = "sunrise";
const AFTER_DEFAULT = "sunset";
export const YAML_SCHEMA = [
{
name: "before",
type: "select" as const,
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
},
{ name: "before_offset", selector: { duration: { allow_negative: true } } },
{
name: "after",
type: "select" as const,
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
},
{ name: "after_offset", selector: { duration: { allow_negative: true } } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.sun.${fieldName}` as any
);
@customElement("ha-automation-condition-sun")
export class HaSunCondition extends LitElement implements ConditionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -154,10 +177,7 @@ export class HaSunCondition extends LitElement implements ConditionElement {
private _computeLabelCallback = (schema: {
name: "before" | "after";
}): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.sun.${schema.name}`
);
}): string => computeLabel(schema.name, this.hass.localize);
private _typeSelected(ev: HaSelectSelectEvent): void {
const value = ev.detail.value as FormType;
@@ -5,11 +5,20 @@ import type { HomeAssistant } from "../../../../../types";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import "../../../../../components/ha-form/ha-form";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
const SCHEMA = [
export const SCHEMA = [
{ name: "value_template", required: true, selector: { template: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.template.${fieldName}` as any
);
@customElement("ha-automation-condition-template")
export class HaTemplateCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -43,10 +52,7 @@ export class HaTemplateCondition extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.template.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -13,6 +13,24 @@ import type { ConditionElement } from "../ha-automation-condition-row";
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
export const YAML_SCHEMA = [
{ name: "after", selector: { time: {} } },
{ name: "before", selector: { time: {} } },
{
name: "weekday",
type: "multi_select" as const,
options: DAYS.map((d) => [d, d] as const),
},
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.time.${fieldName}` as any
);
@customElement("ha-automation-condition-time")
export class HaTimeCondition extends LitElement implements ConditionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -184,10 +202,7 @@ export class HaTimeCondition extends LitElement implements ConditionElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.time.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -13,6 +13,7 @@ import {
type Trigger,
type TriggerCondition,
} from "../../../../../data/automation";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
const getTriggersIds = (triggers: Trigger[]): string[] => {
@@ -22,6 +23,20 @@ const getTriggersIds = (triggers: Trigger[]): string[] => {
return Array.from(new Set(triggerIds));
};
// Static YAML schema — trigger IDs are dynamic at runtime, so we use a
// plain text selector here to at least provide key completion.
export const YAML_SCHEMA = [
{ name: "id", required: true, selector: { text: { multiple: true } } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.trigger.${fieldName}` as any
);
@customElement("ha-automation-condition-trigger")
export class HaTriggerCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -94,10 +109,7 @@ export class HaTriggerCondition extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.trigger.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
private _automationUpdated(config?: AutomationConfig) {
this._triggerIds = config?.triggers
@@ -2,12 +2,10 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { consume } from "@lit/context";
import {
mdiCloseThick,
mdiCog,
mdiContentDuplicate,
mdiDelete,
mdiDotsVertical,
mdiExclamationThick,
mdiHelpCircleOutline,
mdiInformationOutline,
mdiMenuDown,
@@ -131,28 +129,6 @@ import {
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
import { showNewAutomationDialog } from "./show-dialog-new-automation";
const renderIconBadge = (path: string, color: string) => html`
<div
style=${styleMap({
position: "absolute",
top: "-5px",
insetInlineEnd: "-7px",
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "18px",
height: "18px",
borderRadius: "50%",
backgroundColor: color,
boxShadow: "0 0 0 2px var(--data-table-background-color)",
color: "var(--data-table-background-color)",
"--mdc-icon-size": "12px",
})}
>
<ha-svg-icon style="margin: 0;" .path=${path}></ha-svg-icon>
</div>
`;
type AutomationItem = AutomationEntity & {
name: string;
area: string | undefined;
@@ -327,11 +303,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
localize: LocalizeFunc,
entitiesToCheck?: any[]
): DataTableColumnContainer<AutomationItem> => {
const triggeredAtColumn = getTriggeredAtTableColumn<AutomationItem>(
localize,
this.hass
);
const columns: DataTableColumnContainer<AutomationItem> = {
icon: {
title: "",
@@ -339,34 +310,18 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
type: "icon",
moveable: false,
showNarrow: true,
template: (automation) => {
const unavailable = automation.state === UNAVAILABLE;
const disabled = automation.state === "off";
return html`<div
style="position: relative; display: inline-flex; width: 24px; height: 24px;"
>
<ha-state-icon
.stateObj=${automation}
.stateValue=${unavailable || disabled ? "on" : undefined}
style=${styleMap({
display: "flex",
margin: "0",
color: unavailable
template: (automation) =>
html`<ha-state-icon
.stateObj=${automation}
style=${styleMap({
color:
automation.state === UNAVAILABLE
? "var(--error-color)"
: disabled
? "var(--disabled-color)"
: automation.state === "on"
? "var(--state-active-color)"
: "unset",
})}
></ha-state-icon>
${
unavailable
? renderIconBadge(mdiExclamationThick, "var(--error-color)")
: disabled
? renderIconBadge(mdiCloseThick, "var(--disabled-color)")
: nothing
}
</div>`;
},
})}
></ha-state-icon>`,
},
entity_id: getEntityIdHiddenTableColumn(),
name: {
@@ -387,33 +342,23 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
area: getAreaTableColumn(localize),
category: getCategoryTableColumn(localize),
labels: getLabelsTableColumn(),
last_triggered: {
...triggeredAtColumn,
template: (automation) =>
narrow && automation.state === "off"
? nothing
: triggeredAtColumn.template!(automation),
},
last_triggered: getTriggeredAtTableColumn(localize, this.hass),
formatted_state: {
minWidth: "82px",
maxWidth: "82px",
sortable: true,
groupable: true,
hidden: narrow,
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) =>
narrow
? automation.state === "off"
? localize("ui.panel.config.automation.picker.disabled")
: nothing
: html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
template: (automation) => html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
lastFixed: true,
@@ -2,6 +2,7 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
@@ -13,6 +14,10 @@ import type { TriggerDescription } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import {
builtInTriggerSchema,
triggerDescriptionToSchema,
} from "../yaml_schema_helpers";
import "../ha-automation-editor-warning";
import "./types/ha-automation-trigger-platform";
@@ -37,6 +42,23 @@ export default class HaAutomationTriggerEditor extends LitElement {
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
// Memoized on the trigger type rather than the trigger itself: the trigger
// object gets a new identity on every keystroke, and the schema only depends
// on its type.
private _triggerYamlSchema = memoizeOne(
(
triggerType: string | undefined,
description: TriggerDescription | undefined,
localize: HomeAssistant["localize"]
) => {
if (triggerType === undefined) return undefined;
if (!description) {
return builtInTriggerSchema(triggerType, localize);
}
return triggerDescriptionToSchema(triggerType, description, localize);
}
);
protected render() {
const type = isTriggerList(this.trigger) ? "list" : this.trigger.trigger;
@@ -72,6 +94,13 @@ export default class HaAutomationTriggerEditor extends LitElement {
<ha-yaml-editor
.defaultValue=${this.trigger}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._triggerYamlSchema(
isTriggerList(this.trigger)
? undefined
: this.trigger.trigger,
this.description,
this.hass.localize
)}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>
`
@@ -11,6 +11,37 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "event":
return localize(
"ui.panel.config.automation.editor.triggers.type.calendar.event"
);
default:
return "";
}
};
export const YAML_SCHEMA = [
{
name: "entity_id",
required: true,
selector: { entity: { domain: "calendar" } },
},
{
name: "event",
type: "select" as const,
required: true,
options: [["start", "start"] as const, ["end", "end"] as const],
},
{ name: "offset", selector: { text: {} } },
] as const;
@customElement("ha-automation-trigger-calendar")
export class HaCalendarTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -125,17 +156,7 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "event":
return this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.calendar.event"
);
}
return "";
};
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -8,6 +8,25 @@ import type { HomeAssistant } from "../../../../../types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.geo_location.${fieldName}` as any
);
export const YAML_SCHEMA = [
{ name: "source", selector: { text: {} } },
{ name: "zone", selector: { entity: { domain: "zone" } } },
{
name: "event",
type: "select" as const,
required: true,
options: [["enter", "enter"] as const, ["leave", "leave"] as const],
},
] as const;
@customElement("ha-automation-trigger-geo_location")
export class HaGeolocationTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -73,10 +92,7 @@ export class HaGeolocationTrigger extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.geo_location.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -8,6 +8,23 @@ import type { HomeAssistant } from "../../../../../types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.homeassistant.${fieldName}` as any
);
export const YAML_SCHEMA = [
{
name: "event",
type: "select" as const,
required: true,
options: [["start", "start"] as const, ["shutdown", "shutdown"] as const],
},
] as const;
@customElement("ha-automation-trigger-homeassistant")
export class HaHassTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -69,10 +86,7 @@ export class HaHassTrigger extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.homeassistant.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
static styles = css`
label {
@@ -8,6 +8,7 @@ import { hasTemplate } from "../../../../../common/string/has-template";
import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { NumericStateTrigger } from "../../../../../data/automation";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
const SCHEMA = [
@@ -183,6 +184,63 @@ const SCHEMA = [
},
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "attribute":
return localize("ui.components.entity.entity-attribute-picker.attribute");
case "for":
return localize(
"ui.panel.config.automation.editor.triggers.type.state.for"
);
default:
return localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${fieldName}` as any
);
}
};
export const YAML_SCHEMA = [
{
name: "entity_id",
required: true,
selector: { entity: { multiple: true } },
},
{
name: "attribute",
selector: { attribute: {} },
context: { filter_entity: "entity_id" },
},
{
name: "above",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{
name: "below",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{ name: "value_template", selector: { template: {} } },
{ name: "for", selector: { duration: {} } },
] as const;
@customElement("ha-automation-trigger-numeric_state")
export class HaNumericStateTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -276,24 +334,7 @@ export class HaNumericStateTrigger extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "attribute":
return this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute"
);
case "for":
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.state.for`
);
default:
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${schema.name}`
);
}
};
): string => computeLabel(schema.name, this.hass.localize);
static styles = css`
:host {
@@ -12,9 +12,31 @@ import type { PersistentNotificationTrigger } from "../../../../../data/automati
import type { HomeAssistant } from "../../../../../types";
import type { TriggerElement } from "../ha-automation-trigger-row";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.persistent_notification.${fieldName}` as any
);
const DEFAULT_UPDATE_TYPES = ["added", "removed"];
const DEFAULT_NOTIFICATION_ID = "";
export const YAML_SCHEMA = [
{ name: "notification_id", selector: { text: {} } },
{
name: "update_type",
type: "multi_select" as const,
options: [
["added", "added"] as const,
["removed", "removed"] as const,
["current", "current"] as const,
["updated", "updated"] as const,
],
},
] as const;
@customElement("ha-automation-trigger-persistent_notification")
export class HaPersistentNotificationTrigger
extends LitElement
@@ -99,10 +121,7 @@ export class HaPersistentNotificationTrigger
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.persistent_notification.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -4,7 +4,6 @@ import { css, 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 { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
@@ -423,8 +422,20 @@ export class HaPlatformTrigger extends LitElement {
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
}
if (defaultValue != null) {
@@ -43,6 +43,40 @@ const stateTriggerStruct = assign(
})
);
export const YAML_SCHEMA = [
{
name: "entity_id",
required: true,
selector: { entity: { multiple: true } },
},
{
name: "attribute",
selector: { attribute: {} },
context: { filter_entity: "entity_id" },
},
{
name: "from",
selector: { state: { multiple: true } },
context: { filter_entity: "entity_id" },
},
{
name: "to",
selector: { state: { multiple: true } },
context: { filter_entity: "entity_id" },
},
{ name: "for", selector: { duration: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
fieldName === "entity_id"
? "ui.components.entity.entity-picker.entity"
: (`ui.panel.config.automation.editor.triggers.type.state.${fieldName}` as any)
);
@customElement("ha-automation-trigger-state")
export class HaStateTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -338,12 +372,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
schema.name === "entity_id"
? "ui.components.entity.entity-picker.entity"
: `ui.panel.config.automation.editor.triggers.type.state.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
static styles = css`
:host {
@@ -9,6 +9,24 @@ import "../../../../../components/ha-form/ha-form";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.sun.${fieldName}` as any
);
export const YAML_SCHEMA = [
{
name: "event",
type: "select" as const,
required: true,
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
},
{ name: "offset", selector: { text: {} } },
] as const;
@customElement("ha-automation-trigger-sun")
export class HaSunTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -73,10 +91,7 @@ export class HaSunTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.sun.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -7,8 +7,9 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
import { fireEvent } from "../../../../../common/dom/fire_event";
import { hasTemplate } from "../../../../../common/string/has-template";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
const SCHEMA = [
export const SCHEMA = [
{ name: "value_template", required: true, selector: { template: {} } },
{
name: "for",
@@ -25,6 +26,14 @@ const SCHEMA = [
},
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.template.${fieldName}` as any
);
@customElement("ha-automation-trigger-template")
export class HaTemplateTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -102,10 +111,7 @@ export class HaTemplateTrigger extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.template.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
}
declare global {
@@ -18,6 +18,37 @@ const MODE_ENTITY = "entity";
const VALID_DOMAINS = ["sensor", "input_datetime"];
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
// Real YAML keys for the time trigger (the UI uses time/entity/mode/offset
// as an abstraction, but the actual YAML uses `at` and `weekday`).
export const YAML_SCHEMA = [
{ name: "at", required: true, selector: { text: {} } },
{
name: "weekday",
type: "multi_select" as const,
options: DAYS.map((d) => [d, d] as const),
},
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "time":
return localize(
"ui.panel.config.automation.editor.triggers.type.time.at"
);
case "weekday":
return localize(
"ui.panel.config.automation.editor.triggers.type.time.weekday"
);
default:
return localize(
`ui.panel.config.automation.editor.triggers.type.time.${fieldName}` as any
);
}
};
@customElement("ha-automation-trigger-time")
export class HaTimeTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -207,21 +238,7 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => {
switch (schema.name) {
case "time":
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time.at`
);
case "weekday":
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time.weekday`
);
}
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time.${schema.name}`
);
};
): string => computeLabel(schema.name, this.hass.localize);
static styles = css`
:host {
@@ -6,13 +6,28 @@ import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { TimePatternTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import type { TriggerElement } from "../ha-automation-trigger-row";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
const SCHEMA = [
export const SCHEMA = [
{ name: "hours", selector: { text: {} } },
{ name: "minutes", selector: { text: {} } },
{ name: "seconds", selector: { text: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.time_pattern.${fieldName}` as any
);
export const computeHelper = (
_fieldName: string,
localize: LocalizeFunc
): string =>
localize("ui.panel.config.automation.editor.triggers.type.time_pattern.help");
@customElement("ha-automation-trigger-time_pattern")
export class HaTimePatternTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -47,17 +62,11 @@ export class HaTimePatternTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time_pattern.${schema.name}`
);
): string => computeLabel(schema.name, this.hass.localize);
private _computeHelperCallback = (
_schema: SchemaUnion<typeof SCHEMA>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time_pattern.help`
);
schema: SchemaUnion<typeof SCHEMA>
): string => computeHelper(schema.name, this.hass.localize);
}
declare global {
File diff suppressed because it is too large Load Diff
@@ -1,168 +0,0 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { getDeviceArea } from "../../../../common/entity/context/get_device_context";
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list-item";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
const MAX_VISIBLE_CHILD_DEVICES = 10;
@customElement("ha-device-child-devices-card")
export class HaDeviceChildDevicesCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public deviceId!: string;
@state() public _showAll = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
private _entityCounts = memoizeOne(
(entities: EntityRegistryEntry[]): Record<string, number> => {
const counts: Record<string, number> = {};
for (const entity of entities) {
if (entity.device_id) {
counts[entity.device_id] = (counts[entity.device_id] ?? 0) + 1;
}
}
return counts;
}
);
private _childDevices = memoizeOne(
(
deviceId: string,
devices: Record<string, DeviceRegistryEntry>
): DeviceRegistryEntry[] =>
Object.values(devices)
.filter((device) => device.parent_device_id === deviceId)
.sort((d1, d2) =>
caseInsensitiveStringCompare(
computeDeviceNameDisplay(d1, this.hass.localize, this.hass.states),
computeDeviceNameDisplay(d2, this.hass.localize, this.hass.states),
this.hass.locale.language
)
)
);
protected render() {
const childDevices = this._childDevices(this.deviceId, this.hass.devices);
if (childDevices.length === 0) {
return nothing;
}
const entityCounts = this._entityCounts(this._entityReg);
return html`
<ha-card>
<h1 class="card-header">
${this.hass.localize("ui.panel.config.devices.child_devices.heading")}
</h1>
${(this._showAll
? childDevices
: childDevices.slice(0, MAX_VISIBLE_CHILD_DEVICES)
).map((childDevice) => {
const area = getDeviceArea(
childDevice,
this.hass.areas,
this.hass.devices
);
const entityCount = entityCounts[childDevice.id] ?? 0;
const secondary = [
area?.name,
entityCount
? this.hass.localize(
"ui.panel.config.common.quick_links.entities",
{ count: entityCount }
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<a href=${`/config/devices/device/${childDevice.id}`}>
<ha-list-item hasMeta .twoline=${!!secondary}>
${computeDeviceNameDisplay(
childDevice,
this.hass.localize,
this.hass.states
)}
${
secondary
? html`<span slot="secondary">${secondary}</span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`;
})}
${
!this._showAll && childDevices.length > MAX_VISIBLE_CHILD_DEVICES
? html`
<button class="show-more" @click=${this._toggleShowAll}>
${this.hass.localize(
"ui.panel.config.devices.child_devices.show_more",
{ count: childDevices.length - MAX_VISIBLE_CHILD_DEVICES }
)}
</button>
`
: ""
}
</ha-card>
`;
}
private _toggleShowAll() {
this._showAll = !this._showAll;
}
static styles = css`
:host {
display: block;
}
.card-header {
padding-bottom: 0;
}
a {
text-decoration: none;
color: var(--primary-text-color);
}
button.show-more {
color: var(--primary-color);
text-align: left;
cursor: pointer;
background: none;
border-width: initial;
border-style: none;
border-color: initial;
border-image: initial;
padding: 16px;
font: inherit;
}
button.show-more:focus {
outline: none;
text-decoration: underline;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-child-devices-card": HaDeviceChildDevicesCard;
}
}
@@ -93,27 +93,6 @@ export class HaDeviceCard extends LitElement {
`
: ""
}
${
this.device.parent_device_id
? html`
<div class="extra-info">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.part_of"
)}
<span class="hub"
><a
href="/config/devices/device/${
this.device.parent_device_id
}"
>${this._computeDeviceNameDisplay(
this.device.parent_device_id
)}</a
></span
>
</div>
`
: ""
}
${
this.device.via_device_id
? html`
@@ -107,7 +107,6 @@ import { createSearchParam } from "../../../common/url/search-params";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
import "../../logbook/ha-logbook";
import "./device-detail/ha-device-child-devices-card";
import "./device-detail/ha-device-entities-card";
import "./device-detail/ha-device-info-card";
import "./device-detail/ha-device-linked-devices-card";
@@ -901,10 +900,6 @@ export class HaConfigDevicePage extends LitElement {
: ""
}
</ha-device-info-card>
<ha-device-child-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
></ha-device-child-devices-card>
<ha-device-linked-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
@@ -459,15 +459,6 @@ export class HaConfigDeviceDashboard extends LitElement {
? new Map(labelReg.map((label) => [label.label_id, label]))
: undefined;
// Ids of devices that have at least one child device, so a parent can be
// grouped together with its children.
const deviceIdsWithChildren = new Set<string>();
for (const dev of Object.values(devices)) {
if (dev.parent_device_id) {
deviceIdsWithChildren.add(dev.parent_device_id);
}
}
const formattedOutputDevices = outputDevices.map((device) => {
const deviceEntries = sortConfigEntries(
device.config_entries
@@ -481,15 +472,6 @@ export class HaConfigDeviceDashboard extends LitElement {
.map((lbl) => labelLookup!.get(lbl))
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
const parentDevice = device.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
// The device that identifies this device's family: its parent for a
// child device, itself for a device that has children.
const familyParentDevice =
parentDevice ??
(deviceIdsWithChildren.has(device.id) ? device : undefined);
const { areaName } = computeDeviceAreaLabel(
device,
this.hass.areas,
@@ -545,29 +527,6 @@ export class HaConfigDeviceDashboard extends LitElement {
"ui.panel.config.devices.data_table.no_integration"
),
domains: deviceEntries.map((entry) => entry.domain),
parent_device_name: parentDevice
? computeDeviceNameDisplay(
parentDevice,
this.hass.localize,
this.hass.states,
deviceEntityLookup[parentDevice.id]
)
: "",
// Grouping key that keeps a device with its family: children group
// under their parent's name, a parent groups under its own name, and
// standalone devices stay ungrouped. The name is always computed from
// the family's parent device with the same arguments, so a parent and
// its children can never end up in different groups. Like the area and
// floor columns, this groups on the display name rather than the id,
// because the data table renders the raw group value as its header.
device_family_name: familyParentDevice
? computeDeviceNameDisplay(
familyParentDevice,
this.hass.localize,
this.hass.states,
deviceEntityLookup[familyParentDevice.id]
)
: undefined,
firmware_version: device.sw_version || undefined,
battery_entity: [
this._batteryEntity(device.id, deviceEntityLookup),
@@ -628,16 +587,6 @@ export class HaConfigDeviceDashboard extends LitElement {
flex: 2,
minWidth: "150px",
extraTemplate: (device) => html`
${
device.parent_device_name
? html`<div style="color: var(--secondary-text-color);">
${localize(
"ui.panel.config.devices.data_table.part_of_device",
{ name: device.parent_device_name }
)}
</div>`
: nothing
}
${
device.label_entries.length
? html`
@@ -658,19 +607,6 @@ export class HaConfigDeviceDashboard extends LitElement {
groupable: true,
minWidth: "120px",
},
device_family_name: {
title: localize("ui.panel.config.devices.data_table.parent_device"),
// Keyed on the family name so grouping/sorting keeps a parent together
// with its children (grouping uses the column key directly). The cell
// only shows the parent name for child devices. Filterable stays on
// even when hidden, so searching a parent's name surfaces its children.
sortable: true,
filterable: true,
groupable: true,
defaultHidden: true,
minWidth: "120px",
template: (device) => device.parent_device_name || "",
},
manufacturer: {
title: localize("ui.panel.config.devices.data_table.manufacturer"),
sortable: true,
@@ -11,8 +11,6 @@ import { css, html, LitElement, nothing } from "lit";
import { repeat } from "lit/directives/repeat";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
@@ -25,11 +23,9 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -108,7 +104,18 @@ export class EnergyDeviceSettingsWater extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
${this._renderName(device)}
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderIssueIndicator(
this.validationResult?.device_consumption_water[
index
@@ -148,32 +155,6 @@ export class EnergyDeviceSettingsWater extends LitElement {
`;
}
private _renderName(device: DeviceConsumptionEnergyPreference) {
const name = computeEnergyLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption],
device.name
);
const areaId = getEntityAreaId(
device.stat_consumption,
this.hass.entities,
this.hass.devices
);
const area = areaId ? this.hass.areas[areaId] : undefined;
const areaName = area ? computeAreaName(area) : undefined;
return html`
<div class="content">
<span class="label">${name}</span>
${
areaName
? html`<span class="label secondary">${areaName}</span>`
: nothing
}
</div>
`;
}
private _renderIssueIndicator(
issues: EnergyValidationIssue[] | undefined,
index: number
@@ -299,22 +280,6 @@ export class EnergyDeviceSettingsWater extends LitElement {
haStyle,
energyCardStyles,
css`
.row {
height: 58px;
}
.content {
display: flex;
flex-direction: column;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.secondary {
color: var(--secondary-text-color);
font-size: 0.9em;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
@@ -11,8 +11,6 @@ import { css, html, LitElement, nothing } from "lit";
import { repeat } from "lit/directives/repeat";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
@@ -25,11 +23,9 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -108,7 +104,18 @@ export class EnergyDeviceSettings extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
${this._renderName(device)}
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderIssueIndicator(
this.validationResult?.device_consumption[
index
@@ -148,32 +155,6 @@ export class EnergyDeviceSettings extends LitElement {
`;
}
private _renderName(device: DeviceConsumptionEnergyPreference) {
const name = computeEnergyLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption],
device.name
);
const areaId = getEntityAreaId(
device.stat_consumption,
this.hass.entities,
this.hass.devices
);
const area = areaId ? this.hass.areas[areaId] : undefined;
const areaName = area ? computeAreaName(area) : undefined;
return html`
<div class="content">
<span class="label">${name}</span>
${
areaName
? html`<span class="label secondary">${areaName}</span>`
: nothing
}
</div>
`;
}
private _renderIssueIndicator(
issues: EnergyValidationIssue[] | undefined,
index: number
@@ -295,22 +276,6 @@ export class EnergyDeviceSettings extends LitElement {
haStyle,
energyCardStyles,
css`
.row {
height: 58px;
}
.content {
display: flex;
flex-direction: column;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.secondary {
color: var(--secondary-text-color);
font-size: 0.9em;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
@@ -11,11 +11,9 @@ import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import {
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -176,7 +174,7 @@ export class DialogEnergyDeviceSettingsWater
.value=${this._device?.name || ""}
.placeholder=${
this._device
? computeEnergyLabel(
? getStatisticLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -11,11 +11,9 @@ import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import {
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -172,7 +170,7 @@ export class DialogEnergyDeviceSettings
.value=${this._device?.name || ""}
.placeholder=${
this._device
? computeEnergyLabel(
? getStatisticLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -7,6 +7,7 @@ import memoizeOne from "memoize-one";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-combo-box-item";
import "../../../../components/ha-generic-picker";
@@ -14,7 +15,6 @@ import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-
import type { PickerValueRenderer } from "../../../../components/ha-picker-field";
import "../../../../components/ha-svg-icon";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { computeEnergyLabel } from "../../../../data/energy";
import { domainToName } from "../../../../data/integration";
import {
getStatisticLabel,
@@ -73,18 +73,20 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const friendlyName = computeStateName(stateObj); // Keep this for search
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: statisticId,
// Match the label shown in the device list and the graphs.
primary: computeEnergyLabel(
this.hass,
statisticId,
this.statsMetadata?.[statisticId],
name
),
secondary: areaName,
primary: name || entityName || deviceName || statisticId,
secondary,
stateObj,
search_labels: {
entityName: entityName || null,
@@ -10,15 +10,12 @@ import {
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
import { navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-tree-indicator";
import {
disableConfigEntry,
type ConfigEntry,
@@ -51,13 +48,6 @@ class HaConfigEntryDeviceRow extends LitElement {
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
// Rendered indented under its parent device.
@property({ type: Boolean, reflect: true, attribute: "is-child" })
public isChild = false;
// The last child of its parent, so the tree connector draws its end.
@property({ attribute: false }) public isLastChild = false;
protected render() {
const device = this.device;
@@ -65,11 +55,6 @@ class HaConfigEntryDeviceRow extends LitElement {
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const supportingText = [
device.model || device.sw_version || device.manufacturer,
area ? area.name : undefined,
@@ -80,25 +65,6 @@ class HaConfigEntryDeviceRow extends LitElement {
@click=${this._handleNavigateToDevice}
class=${classMap({ disabled: Boolean(device.disabled_by) })}
>
${
this.isChild
? html`<ha-tree-indicator
style=${styleMap({
position: "absolute",
// Span the full row height so consecutive children form one
// continuous line; the elbow sits at the vertical centre.
top: "0",
// Align the connector under the parent device icon; the leading
// space (and thus the icon column) is smaller in narrow mode.
left: rtl ? undefined : this.narrow ? "4px" : "44px",
right: rtl ? (this.narrow ? "4px" : "44px") : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${this.isLastChild}
slot="start"
></ha-tree-indicator>`
: nothing
}
<ha-svg-icon
.path=${
device.entry_type === "service"
@@ -385,22 +351,12 @@ class HaConfigEntryDeviceRow extends LitElement {
--md-ripple-hover-color: transparent;
--md-ripple-pressed-color: transparent;
}
:host([is-child]) ha-md-list-item {
--md-list-item-leading-space: 88px;
}
.disabled {
opacity: 0.5;
}
:host([narrow]) ha-md-list-item {
--md-list-item-leading-space: 16px;
}
:host([narrow][is-child]) ha-md-list-item {
--md-list-item-leading-space: 48px;
}
ha-tree-indicator {
width: 48px;
height: 100%;
}
.vertical-divider {
height: 100%;
width: 1px;
@@ -45,7 +45,6 @@ import {
} from "../../../data/config_entries";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics";
import { groupDevicesByParent } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import {
@@ -480,16 +479,14 @@ export class HaConfigEntryRow extends LitElement {
</ha-md-list-item>
${
this._devicesExpanded
? groupDevicesByParent(ownDevices).map(
({ device, isChild, isLastChild }) =>
? ownDevices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)
: nothing
@@ -512,16 +509,14 @@ export class HaConfigEntryRow extends LitElement {
`
)}`
: html`
${groupDevicesByParent(ownDevices).map(
({ device, isChild, isLastChild }) =>
${ownDevices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)}
`
@@ -15,7 +15,6 @@ import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import type { ConfigEntry } from "../../../data/config_entries";
import { deleteSubEntry, updateSubEntry } from "../../../data/config_entries";
import { groupDevicesByParent } from "../../../data/device/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
@@ -192,16 +191,14 @@ class HaConfigSubEntryRow extends LitElement {
${
this._expanded
? html`
${groupDevicesByParent(devices).map(
({ device, isChild, isLastChild }) =>
${devices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${this.entry}
.device=${device}
.entities=${this.entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)}
${services.map(
@@ -1,46 +1,29 @@
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { slugify } from "../../../../common/string/slugify";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../../lovelace/editor/view-editor/hui-view-background-editor";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
import { pickAvailableDashboardUrlPath } from "./pick-available-dashboard-url-path";
const TABS = ["tab-settings", "tab-background"] as const;
interface DashboardDetailState {
dashboard: Partial<LovelaceDashboard>;
background?: LovelaceConfig["background"];
}
@customElement("dialog-lovelace-dashboard-detail")
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<DashboardDetailState>()(
LitElement
) {
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
Partial<LovelaceDashboard>
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardDetailsDialogParams;
@@ -55,59 +38,39 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
@state() private _submitting = false;
@state() private _currTab: (typeof TABS)[number] = TABS[0];
@state() private _backgroundConfig?: LovelaceConfig;
public showDialog(params: LovelaceDashboardDetailsDialogParams) {
public showDialog(params: LovelaceDashboardDetailsDialogParams): void {
this._params = params;
this._error = undefined;
this._urlPathChanged = false;
this._currTab = TABS[0];
this._backgroundConfig = params.lovelaceConfig;
this._open = true;
if (this._params.dashboard) {
this._data = this._params.dashboard;
this._initDirtyTracking(
{ type: "deep" },
{
dashboard: this._params.dashboard,
background: this._params.lovelaceConfig?.background,
}
);
this._initDirtyTracking({ type: "deep" }, this._data);
} else {
const suggestions = this._params.suggestions;
this._data = {
show_in_sidebar: true,
icon: this._params.suggestions?.icon,
title: this._params.suggestions?.title ?? "",
icon: suggestions?.icon,
title: suggestions?.title ?? "",
require_admin: false,
mode: "storage",
};
this._initDirtyTracking(
{ type: "deep" },
{
dashboard: {},
background: this._params.lovelaceConfig?.background,
}
);
if (this._params.suggestions?.title) {
this._fillUrlPath(this._params.suggestions.title);
// New dashboards have no saved baseline, so track against an emptyobject to mark them dirty from the outset (keeps Create enabled).
this._initDirtyTracking({ type: "deep" }, {});
if (suggestions?.title) {
this._fillUrlPath(suggestions.title);
}
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
this._updateDirtyState(this._data!);
}
}
public closeDialog() {
public closeDialog(): void {
this._open = false;
}
private _dialogClosed() {
private _dialogClosed(): void {
this._params = undefined;
this._data = undefined;
this._backgroundConfig = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -119,18 +82,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
const yamlMode = this._params.dashboard?.mode === "yaml";
const titleInvalid = !this._data.title || !this._data.title.trim();
const dialogTitle = this._params.urlPath
? this._data.title ||
this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
);
const showBackgroundTab =
this._params.dashboard?.mode !== "yaml" &&
Boolean(this._params.lovelaceConfig) &&
Boolean(this._params.saveConfig);
const cancelButton = html`
<ha-button
@@ -145,43 +96,41 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
return html`
<ha-dialog
.open=${this._open}
header-title=${showBackgroundTab ? nothing : dialogTitle}
width=${showBackgroundTab ? "large" : "medium"}
header-title=${
this._params.urlPath
? this._data.title ||
this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)
}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${
showBackgroundTab
? html`
<ha-dialog-header show-border slot="header">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<h2 slot="title">${dialogTitle}</h2>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${this._currTab === tab}
>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_view.${tab.replace("-", "_")}`
)}
</ha-tab-group-tab>
`
)}
</ha-tab-group>
</ha-dialog-header>
`
: nothing
}
<div>
${this._renderContent(this._params, this._data, showBackgroundTab)}
${
yamlMode
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
)
: html`
<ha-form
autofocus
.schema=${this._schema(
this._params,
this._data?.require_admin
)}
.data=${this._data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`
}
</div>
<ha-dialog-footer slot="footer">
${
@@ -236,46 +185,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
`;
}
private _renderContent(
params: LovelaceDashboardDetailsDialogParams,
data: Partial<LovelaceDashboard>,
showBackgroundTab: boolean
): string | TemplateResult<1> | typeof nothing {
if (params.dashboard?.mode === "yaml") {
return this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
);
}
if (this._currTab === "tab-background" && showBackgroundTab) {
return html`
${
this._error?.base
? html`<ha-alert alert-type="error">${this._error.base}</ha-alert>`
: nothing
}
<hui-view-background-editor
.hass=${this.hass}
.config=${this._backgroundConfig}
@background-config-changed=${this._backgroundConfigChanged}
></hui-view-background-editor>
`;
}
return html`
<ha-form
autofocus
.schema=${this._schema(params, data.require_admin)}
.data=${data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _schema = memoizeOne(
(params: LovelaceDashboardDetailsDialogParams, requireAdmin?: boolean) =>
[
@@ -342,16 +251,15 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
)
: "";
private _valueChanged(
ev: HASSDomEvent<{ value: Partial<LovelaceDashboard> }>
) {
private _valueChanged(ev: CustomEvent) {
this._error = undefined;
if (ev.detail.value.url_path !== this._data?.url_path) {
const value = ev.detail.value;
if (value.url_path !== this._data?.url_path) {
this._urlPathChanged = true;
if (
!ev.detail.value.url_path ||
ev.detail.value.url_path === "lovelace" ||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(ev.detail.value.url_path)
!value.url_path ||
value.url_path === "lovelace" ||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(value.url_path)
) {
this._error = {
url_path: this.hass.localize(
@@ -360,30 +268,13 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
};
}
}
if (ev.detail.value.title !== this._data?.title) {
this._data = ev.detail.value;
if (ev.detail.value.title) {
this._fillUrlPath(ev.detail.value.title);
}
if (value.title !== this._data?.title) {
this._data = value;
this._fillUrlPath(value.title);
} else {
this._data = ev.detail.value;
}
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
private _backgroundConfigChanged(
ev: HASSDomEvent<{ config: LovelaceConfig }>
) {
this._backgroundConfig = ev.detail.config;
if (this._data) {
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig.background,
});
this._data = value;
}
this._updateDirtyState(this._data!);
}
private _fillUrlPath(title: string) {
@@ -395,55 +286,36 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
const baseSlug = slugifyTitle.includes("-")
? slugifyTitle
: `dashboard-${slugifyTitle}`;
const taken = this._params?.takenUrlPaths;
this._data = {
...this._data,
url_path:
this._params?.takenUrlPaths !== undefined
? pickAvailableDashboardUrlPath(baseSlug, this._params.takenUrlPaths)
taken !== undefined
? pickAvailableDashboardUrlPath(baseSlug, taken)
: baseSlug,
};
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
this._updateDirtyState(this._data!);
}
private async _updateDashboard() {
if (!this._params || !this._data) {
return;
}
if (this._params.urlPath && this._params.dashboard?.mode === "yaml") {
if (this._params?.urlPath && this._params.dashboard?.mode === "yaml") {
this.closeDialog();
return;
}
this._submitting = true;
try {
if (this._params.dashboard) {
await this._params.updateDashboard({
require_admin: this._data.require_admin ?? false,
show_in_sidebar: this._data.show_in_sidebar ?? true,
icon: this._data.icon || undefined,
title: this._data.title ?? "",
});
} else if (this._params.createDashboard) {
await this._params.createDashboard({
require_admin: this._data.require_admin ?? false,
show_in_sidebar: this._data.show_in_sidebar ?? true,
icon: this._data.icon || undefined,
title: this._data.title ?? "",
url_path: this._data.url_path ?? "",
mode: "storage",
});
}
if (
this._backgroundConfig &&
this._params.saveConfig &&
this._params.lovelaceConfig &&
this._backgroundConfig.background !==
this._params.lovelaceConfig.background
) {
await this._params.saveConfig(this._backgroundConfig);
if (this._params!.dashboard) {
const values: Partial<LovelaceDashboardMutableParams> = {
require_admin: this._data!.require_admin,
show_in_sidebar: this._data!.show_in_sidebar,
icon: this._data!.icon || undefined,
title: this._data!.title,
};
await this._params!.updateDashboard(values);
} else if (this._params!.createDashboard) {
await this._params!.createDashboard(
this._data as LovelaceDashboardCreateParams
);
}
this._markDirtyStateClean();
this.closeDialog();
@@ -467,25 +339,10 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
}
}
private _handleTabChanged(
ev: HASSDomEvent<{
name: (typeof TABS)[number];
}>
) {
if (ev.detail.name === this._currTab) {
return;
}
this._currTab = ev.detail.name;
}
private async _deleteDashboard() {
if (!this._params) {
return;
}
this._submitting = true;
try {
if (await this._params.removeDashboard()) {
if (await this._params!.removeDashboard()) {
this.closeDialog();
}
} finally {
@@ -494,30 +351,7 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-content-padding: var(--ha-space-6);
}
h2 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
ha-tab-group-tab {
flex: 1;
}
ha-tab-group-tab::part(base) {
width: 100%;
justify-content: center;
}
`,
];
return [haStyleDialog, css``];
}
}
@@ -1,5 +1,4 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
@@ -18,8 +17,6 @@ export interface LovelaceDashboardDetailsDialogParams {
* auto-generated paths avoid collisions by appending -2, -3, and so on.
*/
takenUrlPaths?: ReadonlySet<string>;
lovelaceConfig?: LovelaceConfig;
saveConfig?: (config: LovelaceConfig) => Promise<void>;
createDashboard?: (values: LovelaceDashboardCreateParams) => Promise<unknown>;
updateDashboard: (
updates: Partial<LovelaceDashboardMutableParams>
@@ -50,6 +50,7 @@ import { documentationUrl } from "../../../../util/documentation-url";
import { resolveMediaSource } from "../../../../data/media_source";
import { MatchMinHeightMixin } from "../../../../mixins/match-min-height-mixin";
import { withViewTransition } from "../../../../common/util/view-transition";
import { serviceActionSchema } from "../../automation/yaml_schema_helpers";
@customElement("tools-action")
class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
@@ -197,6 +198,11 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
<ha-yaml-editor
id="yaml-editor"
.defaultValue=${this._serviceData}
.yamlFieldSchema=${this._yamlFieldSchema(
this._serviceData?.action,
this.hass.services,
this.hass.localize
)}
@value-changed=${this._yamlChanged}
></ha-yaml-editor>
</div>`
@@ -397,6 +403,20 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
fields.filter((field) => !field.selector)
);
private _yamlFieldSchema = memoizeOne(
(
action: string | undefined,
services: HomeAssistant["services"],
localize: HomeAssistant["localize"]
) => {
if (!action) return undefined;
const domain = computeDomain(action);
const service = computeObjectId(action);
if (!domain || !service) return undefined;
return serviceActionSchema(domain, service, services, localize);
}
);
private _validateServiceData = (
serviceData: ServiceAction | undefined,
fields,
-406
View File
@@ -1,406 +0,0 @@
import { mdiPuzzle } from "@mdi/js";
import type { HassEntity } 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 memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-adaptive-dialog";
import "../../components/ha-alert";
import "../../components/ha-icon";
import "../../components/ha-icon-next";
import "../../components/ha-relative-time";
import "../../components/ha-spinner";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-base";
import "../../components/item/ha-list-item-button";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import { fetchDateWS } from "../../data/history";
import type { LogbookEntry } from "../../data/logbook";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "./ha-logbook-chain";
import type { LogbookChain } from "./logbook-chain-resolver";
import { resolveLogbookChain } from "./logbook-chain-resolver";
import type { LogbookItem } from "./logbook-entry-model";
import { computeLogbookItem } from "./logbook-entry-model";
import { renderLogbookGlyph, transitionArrow } from "./logbook-entry-templates";
import type { LogbookDetailDialogParams } from "./show-dialog-logbook-detail";
@customElement("dialog-logbook-detail")
class DialogLogbookDetail
extends LitElement
implements HassDialog<LogbookDetailDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LogbookDetailDialogParams;
@state() private _open = false;
@state() private _chain?: LogbookChain;
@state() private _previousState?: string;
@state() private _error = false;
public showDialog(params: LogbookDetailDialogParams): void {
this._params = params;
this._open = true;
this._chain = undefined;
this._previousState = undefined;
this._error = false;
if (
params.entry.context_event_type === "call_service" &&
params.entry.context_domain
) {
this.hass.loadBackendTranslation("services", params.entry.context_domain);
}
this._loadDetails();
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._chain = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
// Both fetches resolve into a single render so the dialog reflows once.
private async _loadDetails() {
const { entry } = this._params!;
const [{ chain, errored }, previousState] = await Promise.all([
this._fetchChain(entry),
this._fetchPreviousState(entry),
]);
if (this._params?.entry !== entry) {
return;
}
this._error = errored;
this._chain = chain;
this._previousState = previousState;
}
private async _fetchChain(
entry: LogbookEntry
): Promise<{ chain: LogbookChain; errored: boolean }> {
const { userIdToName, systemUserIds } = this._params!;
const options = { userIdToName, systemUserIds };
const resolveWithoutFetch = () =>
resolveLogbookChain(this.hass, entry, options, async () => []);
try {
const chain = isComponentLoaded(this.hass.config, "logbook")
? await resolveLogbookChain(this.hass, entry, options)
: await resolveWithoutFetch();
return { chain, errored: false };
} catch {
return { chain: await resolveWithoutFetch(), errored: true };
}
}
// The feed the row was clicked in can be filtered or partially loaded, so
// the state active just before the entry is resolved from history instead.
private async _fetchPreviousState(
entry: LogbookEntry
): Promise<string | undefined> {
if (
!entry.entity_id ||
entry.state === undefined ||
!isComponentLoaded(this.hass.config, "history")
) {
return undefined;
}
const end = new Date(entry.when * 1000);
const start = new Date(end.getTime() - 1);
try {
const states = await fetchDateWS(this.hass, start, end, [
entry.entity_id,
]);
return states[entry.entity_id]?.[0]?.s;
} catch {
// The row is still useful without an old state.
return undefined;
}
}
protected render() {
if (!this._params) {
return nothing;
}
const { entry } = this._params;
const item = computeLogbookItem(this.hass, entry);
return html`
<ha-adaptive-dialog
.open=${this._open}
header-title=${this.hass.localize("ui.dialogs.logbook_detail.title")}
@closed=${this._dialogClosed}
@hass-more-info=${this._moreInfoOpened}
>
<div class="content">
${this._renderFacts(item, entry)} ${this._renderWhatHappened(entry)}
</div>
</ha-adaptive-dialog>
`;
}
private _renderFacts(item: LogbookItem, entry: LogbookEntry) {
const stateObj = entry.entity_id
? this.hass.states[entry.entity_id]
: undefined;
const transition = this._transitionValues(item, entry, stateObj);
const when = this._entryDate(item.when);
return html`
<ha-grouped-list>
${this._renderSubjectRow(item, entry)}
${
transition
? html`
<ha-list-item-value
.label=${this.hass.localize(
"ui.dialogs.logbook_detail.state"
)}
>
${
transition.oldState
? html`<span class="old-state"
>${transition.oldState}</span
><span class="arrow"
>${transitionArrow(this.hass)}</span
>`
: nothing
}<span class="new-state">${transition.newState}</span>
</ha-list-item-value>
`
: item.value
? html`
<ha-list-item-value
.label=${this.hass.localize(
"ui.dialogs.logbook_detail.event"
)}
>
${item.value.text}
</ha-list-item-value>
`
: nothing
}
<ha-list-item-value
class="time-value"
.label=${this.hass.localize("ui.dialogs.logbook_detail.time")}
>
${formatDateTimeWithSeconds(when, this.hass.locale, this.hass.config)}
<span class="sub">
<ha-relative-time .datetime=${when} capitalize></ha-relative-time>
</span>
</ha-list-item-value>
</ha-grouped-list>
`;
}
// Mirrors the target picker: icon, name, area ▸ device.
private _renderSubjectRow(item: LogbookItem, entry: LogbookEntry) {
const icon = html`<span class="subject-icon" slot="start">
${this._renderSubjectIcon(item, entry)}
</span>`;
if (!entry.entity_id || !(entry.entity_id in this.hass.states)) {
return html`
<ha-list-item-base
.headline=${item.name}
.supportingText=${item.context}
>
${icon}
</ha-list-item-base>
`;
}
return html`
<ha-list-item-button
.headline=${item.name}
.supportingText=${item.context}
.entityId=${entry.entity_id}
@click=${this._entityClicked}
>
${icon}
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
}
// The feed draws brand rows as a brands image, which stays blank for the
// integrations that have none. An integration domain resolves to no domain
// icon either, so fall back to the chain's own integration glyph.
private _renderSubjectIcon(item: LogbookItem, entry: LogbookEntry) {
if (item.glyph.type !== "brand") {
return renderLogbookGlyph(this.hass, entry, item.glyph);
}
return item.glyph.icon
? html`<ha-icon .icon=${item.glyph.icon}></ha-icon>`
: html`<ha-svg-icon .path=${mdiPuzzle}></ha-svg-icon>`;
}
private _entityClicked(ev: Event) {
const target = ev.currentTarget as HTMLElement & { entityId: string };
fireEvent(target, "hass-more-info", { entityId: target.entityId });
}
private _renderWhatHappened(entry: LogbookEntry) {
return html`
${
this._error
? html`<ha-alert alert-type="warning">
${this.hass.localize("ui.components.logbook.retrieval_error")}
</ha-alert>`
: nothing
}
<ha-grouped-list
.header=${this.hass.localize("ui.dialogs.logbook_detail.what_happened")}
>
<div class="chain-area">
${
this._chain === undefined
? html`<div class="loading"><ha-spinner></ha-spinner></div>`
: html`<ha-logbook-chain
.hass=${this.hass}
.chain=${this._chain}
.subject=${entry}
.traceContexts=${this._params?.traceContexts ?? {}}
></ha-logbook-chain>`
}
</div>
</ha-grouped-list>
`;
}
private _entryDate = memoizeOne((when: number) => new Date(when));
private _transitionValues(
item: LogbookItem,
entry: LogbookEntry,
stateObj?: HassEntity
): { oldState?: string; newState: string } | undefined {
if (item.category !== "entity" || entry.state === undefined) {
return undefined;
}
const newState = stateObj
? this.hass.formatEntityState(stateObj, entry.state)
: entry.state;
const previousState = this._previousState;
const oldState =
previousState !== undefined && previousState !== entry.state
? stateObj
? this.hass.formatEntityState(stateObj, previousState)
: previousState
: undefined;
return { oldState, newState };
}
private _moreInfoOpened() {
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
.content {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
ha-list-item-button,
ha-list-item-base {
--ha-row-item-gap: var(--ha-space-3);
--ha-row-item-min-height: 56px;
}
/* Match the weight the chain gives its own row names. */
ha-list-item-button::part(headline),
ha-list-item-base::part(headline) {
font-weight: var(--ha-font-weight-medium);
}
.subject-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
color: var(--secondary-text-color);
--state-icon-color: var(--secondary-text-color);
}
.subject-icon state-badge {
width: 32px;
height: 32px;
margin: 0;
}
ha-icon-next {
color: var(--secondary-text-color);
}
.sub {
display: block;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.old-state {
color: var(--secondary-text-color);
}
.arrow {
color: var(--disabled-color);
padding: 0 4px;
}
.new-state {
font-weight: var(--ha-font-weight-medium);
}
.time-value {
font-variant-numeric: tabular-nums;
}
ha-relative-time {
display: contents;
}
/* minmax(0, 1fr) lets the chain shrink: a grid item defaults to its
min-content width, which a long automation name blows past. */
.chain-area {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
/* Held at two chain rows, the typical chain height, so the swap from
spinner to content barely moves the dialog. The resolved content
sizes itself a lone "no cause" line must not sit in a tall box. */
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 114px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-logbook-detail": DialogLogbookDetail;
}
}
-510
View File
@@ -1,510 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { mdiStateMachine } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatTimeWithMilliseconds } from "../../common/datetime/format_time";
import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import { navigate } from "../../common/navigate";
import "../../components/ha-icon-next";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import { computeServiceLabel } from "../../data/compute-service-info";
import type { LogbookEntry } from "../../data/logbook";
import { createHistoricState, localizeTriggerSource } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { LogbookChain } from "./logbook-chain-resolver";
import type { LogbookCause } from "./logbook-entry-model";
import {
computeLogbookItem,
computeTraceLink,
entityDisplay,
isRunRow,
isSameLogbookEntry,
nodeColor,
} from "./logbook-entry-model";
import {
renderLogbookCauseIcon,
renderLogbookGlyph,
} from "./logbook-entry-templates";
@customElement("ha-logbook-chain")
class HaLogbookChain extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public chain!: LogbookChain;
@property({ attribute: false }) public subject!: LogbookEntry;
@property({ attribute: false }) public traceContexts: TraceContexts = {};
protected render() {
const { rows, runRow, origins, syntheticRun, triggerRow } = this.chain;
if (!origins.length && !runRow && !syntheticRun && rows.length <= 1) {
return html`
<p class="no-cause">
${this.hass.localize("ui.dialogs.logbook_detail.no_known_cause")}
</p>
`;
}
// The chain is the subject's cause path: the runs recorded before its
// rows (user → automation → script → entity). Sibling effects and runs
// recorded after the subject are not causes and stay out.
const isSubjectRow = (row: LogbookEntry) =>
this.subject.entity_id
? row.entity_id === this.subject.entity_id
: isSameLogbookEntry(row, this.subject);
const firstSubjectIndex = rows.findIndex(isSubjectRow);
const visibleRows = rows.filter(
(row, index) =>
isSubjectRow(row) ||
(isRunRow(row) &&
(firstSubjectIndex === -1 || index < firstSubjectIndex))
);
return html`
<div class="chain">
${origins.map((origin) =>
this._renderOriginNode(
origin,
runRow ?? this.subject,
origin.type === "state" ? triggerRow : undefined
)
)}
${syntheticRun ? this._renderSyntheticRunNode(syntheticRun) : nothing}
${visibleRows.map((row) =>
isRunRow(row) ? this._renderRunNode(row) : this._renderEffectNode(row)
)}
</div>
`;
}
// A div rather than a button: a run row nests the trace link inside it.
private _renderRow(entityId: string | undefined, content: TemplateResult) {
if (!entityId || !(entityId in this.hass.states)) {
return html`<div class="chain-row">${content}</div>`;
}
return html`
<div
class="chain-row clickable"
role="button"
tabindex="0"
.entityId=${entityId}
@click=${this._entityClicked}
@keydown=${this._rowKeydown}
>
${content}
<ha-icon-next class="chevron"></ha-icon-next>
</div>
`;
}
private _renderTraceLink(traceLink?: string) {
if (!traceLink) {
return nothing;
}
return html`<a
class="trace-link"
href=${traceLink}
@click=${this._traceClicked}
>${this.hass.localize("ui.components.logbook.view_trace")}</a
>`;
}
private _renderSyntheticRunNode(cause: LogbookCause) {
const mechanism = this.subject.context_source
? localizeTriggerSource(this.hass.localize, this.subject.context_source)
: this.hass.localize(
cause.type === "script"
? "ui.components.logbook.script_ran"
: "ui.components.logbook.automation_triggered"
);
const traceLink = computeTraceLink(
this.traceContexts,
this.subject.context_id
);
return this._renderRow(
cause.entityId,
html`
<span class="chain-node run">${renderLogbookCauseIcon(cause)}</span>
<span class="chain-content">
<span class="name">${cause.name}</span>
${
mechanism
? html`<span class="chain-secondary">${mechanism}</span>`
: nothing
}
${this._renderTraceLink(traceLink)}
</span>
`
);
}
private _renderOriginNode(
origin: LogbookCause,
originRow: LogbookEntry,
triggerRow?: LogbookEntry
) {
const name =
origin.name ||
this.hass.localize("ui.components.logbook.cause.scheduled");
const isState = origin.type === "state";
const triggerState = isState
? (triggerRow?.state ?? originRow.context_state)
: undefined;
const stateObj = origin.entityId
? this.hass.states[origin.entityId]
: undefined;
const triggerValue = triggerState
? stateObj
? this.hass.formatEntityState(stateObj, triggerState)
: triggerState
: undefined;
const secondary = isState
? origin.entityId
? entityDisplay(this.hass, origin.entityId).secondary
: undefined
: this._actionUsedText(originRow);
const isAvatar = origin.type === "user" && !origin.systemUser;
const historicStateObj =
stateObj && triggerState
? createHistoricState(stateObj, triggerState)
: stateObj;
const color =
isState && historicStateObj
? nodeColor("entity", historicStateObj)
: undefined;
return this._renderRow(
origin.entityId,
html`
<span
class="chain-node ${isAvatar ? "avatar" : ""}"
style=${color ? styleMap({ "--node-color": color }) : nothing}
>
${this._renderOriginIcon(origin, historicStateObj)}
</span>
<span class="chain-content">
<span class="name">${name}</span>
${
secondary
? html`<span class="chain-secondary">${secondary}</span>`
: nothing
}
</span>
${
triggerValue
? html`<span class="chain-trailing">
<span class="trailing-state">${triggerValue}</span>
${
triggerRow
? html`<span class="trailing-time"
>${this._formatTimeWithMs(triggerRow.when * 1000)}</span
>`
: nothing
}
</span>`
: nothing
}
`
);
}
private _actionUsedText(originRow: LogbookEntry) {
if (
originRow.context_event_type === "call_service" &&
originRow.context_domain &&
originRow.context_service
) {
return this.hass.localize("ui.dialogs.logbook_detail.action_used", {
name: computeServiceLabel(
this.hass.localize,
this.hass.services,
`${originRow.context_domain}.${originRow.context_service}`
),
});
}
return "";
}
private _renderOriginIcon(origin: LogbookCause, stateObj?: HassEntity) {
if (origin.type === "state") {
return stateObj
? html`<ha-state-icon .stateObj=${stateObj}></ha-state-icon>`
: html`<ha-svg-icon .path=${mdiStateMachine}></ha-svg-icon>`;
}
return renderLogbookCauseIcon(origin);
}
private _formatTimeWithMs(when: number) {
return formatTimeWithMilliseconds(
new Date(when),
this.hass.locale,
this.hass.config
);
}
private _renderRunNode(row: LogbookEntry) {
const item = computeLogbookItem(this.hass, row);
const time = this._formatTimeWithMs(item.when);
const traceLink = computeTraceLink(this.traceContexts, row.context_id);
return this._renderRow(
row.entity_id,
html`
<span class="chain-node run">
${renderLogbookGlyph(this.hass, row, item.glyph)}
</span>
<span class="chain-content">
<span class="name">${item.name}</span>
${this._renderTraceLink(traceLink)}
</span>
<span class="chain-trailing">
${
item.value
? html`<span class="trailing-state">${item.value.text}</span>`
: nothing
}
<span class="trailing-time">${time}</span>
</span>
`
);
}
private _renderEffectNode(row: LogbookEntry) {
const item = computeLogbookItem(this.hass, row);
const stateObj = row.entity_id
? this.hass.states[row.entity_id]
: undefined;
const historicStateObj = stateObj
? createHistoricState(stateObj, row.state)
: undefined;
const color = nodeColor(item.category, historicStateObj);
const time = this._formatTimeWithMs(item.when);
return this._renderRow(
row.entity_id,
html`
<span
class="chain-node"
style=${color ? styleMap({ "--node-color": color }) : nothing}
>
${renderLogbookGlyph(this.hass, row, item.glyph)}
</span>
<span class="chain-content">
<span class="name">${item.name}</span>
${
item.context
? html`<span class="chain-secondary">${item.context}</span>`
: nothing
}
</span>
<span class="chain-trailing">
${
item.value
? html`<span class="trailing-state">${item.value.text}</span>`
: nothing
}
<span class="trailing-time">${time}</span>
</span>
`
);
}
private _entityClicked(ev: Event) {
const target = ev.currentTarget as HTMLElement & { entityId: string };
fireEvent(target, "hass-more-info", { entityId: target.entityId });
}
private _rowKeydown(ev: KeyboardEvent) {
if (ev.key !== "Enter" && ev.key !== " ") {
return;
}
if (ev.target !== ev.currentTarget) {
return;
}
ev.preventDefault();
this._entityClicked(ev);
}
private _traceClicked(ev: MouseEvent) {
// The row underneath opens the more info; the link owns its own click.
ev.stopPropagation();
const href = isNavigationClick(ev);
if (!href) {
return;
}
// navigate() closes the dialogs above this chain.
navigate(href);
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
}
/* The enclosing ha-grouped-list owns the frame; the rows only have to
line up with its own items. */
.chain-row {
position: relative;
display: flex;
align-items: center;
gap: var(--ha-space-3);
width: 100%;
min-height: 56px;
padding: var(--ha-space-2)
var(--ha-row-item-padding-inline, var(--ha-space-3));
box-sizing: border-box;
}
.chain-row.clickable {
cursor: pointer;
}
.chain-row.clickable:hover {
background-color: rgba(var(--rgb-primary-text-color), 0.04);
}
.chain-row.clickable:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
/* Caret between rows: the chain reads top-down, cause to effects.
Centered on the node: row padding + half the 32px node - half the
caret. */
.chain-row + .chain-row::before {
content: "";
position: absolute;
top: -3px;
inset-inline-start: calc(
var(--ha-row-item-padding-inline, var(--ha-space-3)) + 11px
);
border-inline-start: 5px solid transparent;
border-inline-end: 5px solid transparent;
border-top: 6px solid var(--divider-color);
}
.chain-node {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: var(--ha-border-radius-circle);
background-color: var(--card-background-color);
color: var(--node-color, var(--secondary-text-color));
--mdc-icon-size: 18px;
}
.chain-node state-badge {
margin: 0;
color: inherit;
}
.chain-node::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background-color: currentColor;
opacity: 0.15;
}
.chain-node > * {
position: relative;
}
.chain-node.run {
color: var(
--logbook-category-automation-color,
var(--light-blue-color)
);
border-radius: var(--ha-border-radius-md);
}
.chain-node.avatar::before {
display: none;
}
.chain-node.avatar ha-user-badge {
width: 32px;
height: 32px;
font-size: 14px;
}
.chain-content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.chain-content .name {
font-weight: var(--ha-font-weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chain-secondary {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.trace-link {
align-self: flex-start;
color: var(--primary-color);
font-size: var(--ha-font-size-s);
text-decoration: none;
}
.trace-link:hover {
text-decoration: underline;
}
.chain-trailing {
text-align: end;
flex-shrink: 0;
}
.trailing-state {
display: block;
}
.trailing-time {
display: block;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-variant-numeric: tabular-nums;
}
.chevron {
flex-shrink: 0;
color: var(--secondary-text-color);
}
.no-cause {
margin: 0;
padding: var(--ha-space-3)
var(--ha-row-item-padding-inline, var(--ha-space-3));
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-logbook-chain": HaLogbookChain;
}
}
+380 -154
View File
@@ -1,50 +1,58 @@
import { mdiCast, mdiCloud, mdiPuzzle, mdiRobot, mdiScriptText } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeTimelineColor } from "../../components/chart/timeline-color";
import { computeDomain } from "../../common/entity/compute_domain";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { useAmPm } from "../../common/datetime/use_am_pm";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-icon-next";
import { navigate } from "../../common/navigate";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/entity/state-badge";
import "../../components/ha-relative-time";
import "../../components/ha-domain-icon";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/ha-tooltip";
import "../../components/user/ha-user-badge";
import { UNAVAILABLE } from "../../data/entity/entity";
import type { LogbookEntry } from "../../data/logbook";
import { haStyle } from "../../resources/styles";
import type { TraceContexts } from "../../data/trace";
import type { User } from "../../data/user";
import { buttonLinkStyle, haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type {
LogbookCause,
LogbookCauseType,
LogbookGlyph,
LogbookItem,
LogbookNameDetail,
LogbookValue,
} from "./logbook-entry-model";
import { computeLogbookItem, nodeColor } from "./logbook-entry-model";
import {
renderLogbookCauseIcon,
renderLogbookGlyph,
transitionArrow,
} from "./logbook-entry-templates";
computeLogbookItem,
nodeColor,
TRIGGER_DOMAINS,
} from "./logbook-entry-model";
type EntryLayout = "timeline" | "list" | "inline";
interface LogbookRenderItem extends LogbookItem {
renderedTime: string;
traceLink: string | undefined;
renderedTime: TemplateResult | string;
renderedValue: TemplateResult | string;
}
export interface LogbookEntrySelectedDetail {
item: LogbookEntry;
}
declare global {
interface HASSDomEvents {
"logbook-entry-selected": LogbookEntrySelectedDetail;
}
}
// Names are the fixed system user names set by core (cloud/cast integrations).
const SYSTEM_USER_ICONS: Record<string, string> = {
"Home Assistant Cloud": mdiCloud,
"Home Assistant Cast": mdiCast,
};
@customElement("ha-logbook-entry")
class HaLogbookEntry extends LitElement {
@@ -57,7 +65,7 @@ class HaLogbookEntry extends LitElement {
@property({ attribute: false }) public systemUserIds = new Set<string>();
@property({ type: Boolean, attribute: "no-detail" }) public noDetail = false;
@property({ attribute: false }) public traceContexts: TraceContexts = {};
@property({ type: Boolean }) public narrow = false;
@@ -72,6 +80,8 @@ class HaLogbookEntry extends LitElement {
@property({ type: Boolean, attribute: false }) public lastOfDay = false;
@property({ type: Boolean, attribute: false }) public showRelative = false;
@property({ type: Boolean, attribute: "show-cause" }) public showCause =
false;
@@ -89,28 +99,37 @@ class HaLogbookEntry extends LitElement {
systemUserIds: this.systemUserIds,
});
const traceContext =
entry.domain &&
TRIGGER_DOMAINS.includes(entry.domain) &&
entry.context_id &&
entry.context_id in this.traceContexts
? this.traceContexts[entry.context_id]
: undefined;
const traceLink = traceContext
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
: undefined;
const hideName = this.nameDetail === "none";
const layout: EntryLayout =
!this.narrow && !this.noIcon ? "timeline" : hideName ? "inline" : "list";
const node = layout === "timeline" ? "icon" : "dot";
const renderedTime = formatTimeWithSeconds(
new Date(item.when),
this.hass.locale,
this.hass.config
);
const when = new Date(item.when);
const renderedTime = this.showRelative
? html`<ha-relative-time
.datetime=${when}
format="short"
></ha-relative-time>`
: formatTimeWithSeconds(when, this.hass.locale, this.hass.config);
const ctx: LogbookRenderItem = {
...item,
traceLink,
renderedTime,
renderedValue: this._renderValue(item.value, seenEntityIds),
renderedValue: this._renderValue(item.value, seenEntityIds, !!traceLink),
};
const clickable = !this.noDetail;
// On wide screens the hover state carries the affordance; a chevron would
// sit far away from the text it belongs to.
const chevron = clickable && layout !== "timeline";
return html`
<div
class="entry ${classMap({
@@ -119,16 +138,17 @@ class HaLogbookEntry extends LitElement {
"last-of-day": this.lastOfDay,
[`category-${ctx.category}`]: true,
"time-am-pm": useAmPm(this.hass.locale),
clickable,
})}"
role=${ifDefined(clickable ? "button" : undefined)}
tabindex=${ifDefined(clickable ? "0" : undefined)}
@click=${this._rowClicked}
@keydown=${this._rowKeydown}
>
${
layout === "timeline"
? html`<div class="time">
? html`<div
class="time"
role="button"
tabindex="0"
@click=${this._toggleTime}
@keydown=${this._timeKeydown}
>
<span class="time-content">${renderedTime}</span>
</div>`
: nothing
@@ -141,73 +161,109 @@ class HaLogbookEntry extends LitElement {
>
${this._renderNode(ctx, layout)}
</div>
<div class="body">
<div class="content">
${
layout === "timeline"
? this._renderTimeline(ctx)
: layout === "list"
? this._renderList(ctx)
: this._renderInline(ctx)
}
</div>
${chevron ? html`<ha-icon-next class="chevron"></ha-icon-next>` : nothing}
<div class="content">
${
layout === "timeline"
? this._renderTimeline(ctx)
: layout === "list"
? this._renderList(ctx)
: this._renderInline(ctx)
}
</div>
</div>
`;
}
private _rowClicked() {
if (this.noDetail) {
return;
}
fireEvent(this, "logbook-entry-selected", { item: this.item });
private _toggleTime(e: Event) {
e.stopPropagation();
fireEvent(this, "logbook-toggle-time" as any);
}
private _rowKeydown(ev: KeyboardEvent) {
if (ev.key !== "Enter" && ev.key !== " ") {
return;
private _timeKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fireEvent(this, "logbook-toggle-time" as any);
}
if (ev.target !== ev.currentTarget) {
}
private _handleTraceClick(ev: MouseEvent) {
// Let modified clicks open in a new tab; otherwise route in-app.
if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey) {
return;
}
ev.preventDefault();
this._rowClicked();
navigate((ev.currentTarget as HTMLAnchorElement).getAttribute("href")!);
fireEvent(this, "closed");
}
private _entityClicked(ev: Event) {
const entityId = (ev.currentTarget as any).entityId;
if (!entityId) return;
ev.preventDefault();
ev.stopPropagation();
fireEvent(this, "hass-more-info", { entityId });
}
private _renderTimeChip(renderedTime: TemplateResult | string) {
return html`<span
class="time-chip"
role="button"
tabindex="0"
@click=${this._toggleTime}
@keydown=${this._timeKeydown}
>${renderedTime}</span
>`;
}
private _renderTrailing(
cause: LogbookCause | undefined,
renderedTime: string
traceLink: string | undefined,
renderedTime: TemplateResult | string
) {
return html`<span class="trailing">
${cause ? this._renderCauseBadge(cause) : nothing}
<span class="time-chip">${renderedTime}</span>
${
cause
? html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
<span class="cause-badge" id="cause-badge"
>${this._renderCauseIcon(cause)}</span
>`
: nothing
}
${traceLink ? this._renderTraceLink(traceLink) : nothing}
${this._renderTimeChip(renderedTime)}
</span>`;
}
private _renderCauseBadge(cause: LogbookCause) {
return html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
<span class="cause-badge" id="cause-badge"
>${renderLogbookCauseIcon(cause)}</span
>`;
private _renderTraceLink(traceLink: string) {
return html`<a
class="trace-link"
href=${traceLink}
@click=${this._handleTraceClick}
>${this.hass.localize("ui.components.logbook.view_trace")}</a
>`;
}
private _renderTimeline(ctx: LogbookRenderItem) {
const hideName = this.nameDetail === "none";
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const valueIsState = ctx.value?.type === "state";
const causePhrase = ctx.cause
? this._renderCausePhrase(ctx.cause)
: undefined;
return html`
<div class="primary">
<span class="primary-text"
>${
!hideName
? html`<span class="subject"
>${this._entityName(ctx.entityId, ctx.name)}</span
>${this._renderEntity(ctx.entityId, ctx.name)}</span
>${
ctx.renderedValue
? valueIsState
? html`<span class="arrow"
>${transitionArrow(this.hass)}</span
>`
? html`<span class="arrow">${rtl ? "←" : "→"}</span>`
: " "
: nothing
}`
@@ -223,9 +279,15 @@ class HaLogbookEntry extends LitElement {
: nothing
}
${
ctx.cause
causePhrase || ctx.traceLink
? html`<div class="secondary">
${this._renderCauseLine(ctx.cause)}
${
causePhrase
? html`<span class="cause-phrase">${causePhrase}</span>`
: nothing
}
${causePhrase && ctx.traceLink ? html`·` : nothing}
${ctx.traceLink ? this._renderTraceLink(ctx.traceLink) : nothing}
</div>`
: nothing
}
@@ -233,11 +295,16 @@ class HaLogbookEntry extends LitElement {
}
private _renderList(ctx: LogbookRenderItem) {
const cause = ctx.cause;
const showThirdLine = this.showCause && cause;
const cause =
this.showCause || ctx.category === "entity" ? ctx.cause : undefined;
const trailingTrace = this.showCause ? undefined : ctx.traceLink;
const thirdLineTrace = this.showCause ? ctx.traceLink : undefined;
const showThirdLine = this.showCause && (cause || thirdLineTrace);
return html`
<div class="primary">
<span class="subject">${this._entityName(ctx.entityId, ctx.name)}</span>
<span class="subject"
>${this._renderEntity(ctx.entityId, ctx.name)}</span
>
<span class="value" title=${ctx.value?.text ?? ""}
>${ctx.renderedValue}</span
>
@@ -246,18 +313,27 @@ class HaLogbookEntry extends LitElement {
<span class="secondary-text">${ctx.context ?? nothing}</span>
${this._renderTrailing(
showThirdLine ? undefined : cause,
trailingTrace,
ctx.renderedTime
)}
</div>
${
showThirdLine
? html`<div class="secondary">${this._renderCauseLine(cause)}</div>`
? html`<div class="secondary">
${this._renderListCauseLine(cause, thirdLineTrace)}
</div>`
: nothing
}
`;
}
private _renderCauseLine(cause: LogbookCause) {
private _renderListCauseLine(
cause: LogbookCause | undefined,
traceLink: string | undefined
) {
if (!cause) {
return traceLink ? this._renderTraceLink(traceLink) : nothing;
}
const { localize } = this.hass;
if (cause.entityId) {
const prefixMap: Partial<Record<LogbookCauseType, string>> = {
@@ -272,67 +348,115 @@ class HaLogbookEntry extends LitElement {
}),
};
const prefix = prefixMap[cause.type];
return html`<span class="cause-line">
${prefix ?? nothing}<span class="cause-entity">${cause.name}</span>
</span>`;
return html`
${prefix ? html`<span class="cause-prefix">${prefix}</span>` : nothing}
<button
class="link cause-entity"
@click=${this._entityClicked}
.entityId=${cause.entityId}
>
${cause.name}
</button>
${traceLink ? this._renderTraceLink(traceLink) : nothing}
`;
}
return html`<span class="cause-line"
>${this._renderCausePhrase(cause)}</span
>`;
return html`
<span class="secondary-text">${this._renderCausePhrase(cause)}</span>
${traceLink ? this._renderTraceLink(traceLink) : nothing}
`;
}
private _renderInline(ctx: LogbookRenderItem) {
return html`
<div class="primary">
<span class="primary-text">${ctx.renderedValue}</span>
${this._renderTrailing(ctx.cause, ctx.renderedTime)}
${this._renderTrailing(
ctx.category === "entity" ? ctx.cause : undefined,
ctx.traceLink,
ctx.renderedTime
)}
</div>
`;
}
private _renderValue(
value: LogbookValue | undefined,
seenEntityIds: string[]
seenEntityIds: string[],
noLink: boolean
): TemplateResult | string {
if (!value) {
return "";
}
return value.type === "message"
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds)
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds, noLink)
: value.text;
}
private _entityName(
private _renderEntity(
entityId: string | undefined,
entityName: string | undefined
entityName: string | undefined,
noLink?: boolean
) {
return (
const hasState = entityId && entityId in this.hass.states;
const displayName =
entityName ||
(entityId && entityId in this.hass.states
(hasState
? this.hass.states[entityId].attributes.friendly_name || entityId
: entityId)
);
: entityId);
if (!hasState) {
return displayName;
}
return noLink
? displayName
: html`<button
class="link"
@click=${this._entityClicked}
.entityId=${entityId}
>
${displayName}
</button>`;
}
private _renderCausePhrase(cause: LogbookCause): string {
private _renderCausePhrase(cause: LogbookCause): TemplateResult | string {
const { localize } = this.hass;
const nameEl = cause.entityId
? html`<button
class="link"
@click=${this._entityClicked}
.entityId=${cause.entityId}
>
${cause.name}
</button>`
: cause.name;
switch (cause.type) {
case "user":
return localize("ui.components.logbook.cause.by", {
name: cause.name,
});
case "automation":
return localize("ui.components.logbook.cause.by_automation", {
name: cause.name,
});
return cause.entityId
? html`${localize("ui.components.logbook.cause.by_automation", {
name: "",
})}${nameEl}`
: localize("ui.components.logbook.cause.by_automation", {
name: cause.name,
});
case "script":
return localize("ui.components.logbook.cause.by_script", {
name: cause.name,
});
return cause.entityId
? html`${localize("ui.components.logbook.cause.by_script", {
name: "",
})}${nameEl}`
: localize("ui.components.logbook.cause.by_script", {
name: cause.name,
});
case "state":
return localize("ui.components.logbook.cause.by_state_change", {
name: cause.name,
});
return cause.entityId
? html`${localize("ui.components.logbook.cause.by_state_change", {
name: "",
})}${nameEl}`
: localize("ui.components.logbook.cause.by_state_change", {
name: cause.name,
});
case "scheduled":
return localize("ui.components.logbook.cause.scheduled");
case "homeassistant":
@@ -346,9 +470,54 @@ class HaLogbookEntry extends LitElement {
}
}
private _renderCauseIcon(cause: LogbookCause) {
if (cause.type === "user") {
const systemIcon = cause.systemUser
? SYSTEM_USER_ICONS[cause.name]
: undefined;
if (systemIcon) {
return html`<ha-svg-icon
class="cause-icon"
.path=${systemIcon}
></ha-svg-icon>`;
}
return html`<ha-user-badge
class="cause-icon cause-avatar"
.user=${{ id: cause.userId!, name: cause.name } as User}
></ha-user-badge>`;
}
if (cause.type === "automation") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiRobot}
></ha-svg-icon>`;
}
if (cause.type === "script") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiScriptText}
></ha-svg-icon>`;
}
if (cause.type === "state") {
return nothing;
}
if (cause.brandDomain) {
return html`<ha-domain-icon
class="cause-icon"
.domain=${cause.brandDomain}
brand-fallback
></ha-domain-icon>`;
}
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiPuzzle}
></ha-svg-icon>`;
}
private _formatMessageWithPossibleEntity(
message: string,
seenEntities: string[]
seenEntities: string[],
noLink?: boolean
) {
if (message.indexOf(".") !== -1) {
const messageParts = message.split(" ");
@@ -362,9 +531,10 @@ class HaLogbookEntry extends LitElement {
const messageEnd = messageParts.splice(i);
messageEnd.shift();
return html`${messageParts.join(" ")}
${this._entityName(
${this._renderEntity(
entityId,
this.hass.states[entityId].attributes.friendly_name
this.hass.states[entityId].attributes.friendly_name,
noLink
)}
${messageEnd.join(" ")}`;
}
@@ -401,14 +571,53 @@ class HaLogbookEntry extends LitElement {
const unavailable =
item.glyph.type === "state" && item.glyph.stateObj.state === UNAVAILABLE;
return html`<div class="node-glyph" style=${style}>
${renderLogbookGlyph(this.hass, this.item, item.glyph)}
${this._renderGlyph(item.glyph)}
${unavailable ? html`<span class="node-badge"></span>` : nothing}
</div>`;
}
private _renderGlyph(glyph: LogbookGlyph) {
if (glyph.type === "automation") {
return html`<ha-svg-icon
.path=${glyph.script ? mdiScriptText : mdiRobot}
></ha-svg-icon>`;
}
if (glyph.type === "state") {
return html`<ha-state-icon
.stateObj=${glyph.stateObj}
.icon=${glyph.icon}
></ha-state-icon>`;
}
return html`<state-badge
.overrideIcon=${glyph.icon}
.overrideImage=${this._brandImage(glyph.domain)}
.stateColor=${false}
></state-badge>`;
}
private _brandImage(domain?: string): string | undefined {
if (
!domain ||
this.item.icon ||
this.item.state ||
!isComponentLoaded(this.hass.config, domain)
) {
return undefined;
}
return brandsUrl(
{
domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
}
static get styles(): CSSResultGroup {
return [
haStyle,
buttonLinkStyle,
css`
:host {
display: block;
@@ -463,20 +672,6 @@ class HaLogbookEntry extends LitElement {
);
}
.entry.clickable {
cursor: pointer;
user-select: none;
}
.entry.clickable:hover {
background-color: rgba(var(--rgb-primary-text-color), 0.04);
}
.entry.clickable:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
.time {
display: flex;
flex-direction: column;
@@ -486,6 +681,12 @@ class HaLogbookEntry extends LitElement {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
overflow: hidden;
cursor: pointer;
user-select: none;
}
.time:hover {
opacity: 0.75;
}
.time-content {
@@ -630,33 +831,19 @@ class HaLogbookEntry extends LitElement {
box-sizing: border-box;
}
/* Discreet divider between rows, aligned to the content so it does
not cross the rail. Suppressed on the last row of each day. */
.body {
display: flex;
align-items: stretch;
min-width: 0;
border-bottom: 1px solid var(--divider-color);
}
.entry.last-of-day .body {
border-bottom: none;
}
.chevron {
align-self: center;
flex-shrink: 0;
margin-inline-start: var(--ha-space-2);
color: var(--secondary-text-color);
}
.content {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
min-width: 0;
gap: 2px;
/* Discreet divider between rows, aligned to the content so it does
not cross the rail. Suppressed on the last row of each day. */
border-bottom: 1px solid var(--divider-color);
}
.entry.last-of-day .content {
border-bottom: none;
}
/* List/inline: fixed padding instead of justify-content:center so
@@ -687,13 +874,6 @@ class HaLogbookEntry extends LitElement {
color: var(--primary-text-color);
}
/* Inline is the only layout carrying the trailing block in .primary,
and an icon badge has no baseline of its own it would ride 5px
above the text and miss the chevron's centerline. */
.entry.layout-inline .primary {
align-items: center;
}
.entry.layout-inline .primary-text:first-letter {
text-transform: capitalize;
}
@@ -715,6 +895,14 @@ class HaLogbookEntry extends LitElement {
font-weight: var(--ha-font-weight-medium);
}
.primary > .subject button.link {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.subject {
font-weight: var(--ha-font-weight-medium);
}
@@ -763,20 +951,33 @@ class HaLogbookEntry extends LitElement {
align-items: center;
}
ha-relative-time {
display: contents;
}
.time-chip {
flex-shrink: 0;
min-width: 4.5em;
text-align: end;
line-height: 1;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
font-variant-numeric: tabular-nums;
cursor: pointer;
user-select: none;
}
.time-chip {
min-width: 4.5em;
}
.entry.time-am-pm .time-chip {
min-width: 6em;
}
.time-chip:hover {
opacity: 0.75;
}
.cause-icon {
flex-shrink: 0;
--mdc-icon-size: var(--cause-icon-size);
@@ -790,19 +991,44 @@ class HaLogbookEntry extends LitElement {
font-size: 9px;
}
/* The cause reads as one sentence on one line, truncating as a whole. */
.cause-line {
flex: 1;
min-width: 0;
.cause-prefix {
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cause-entity {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color);
}
/* The trace link sits after the cause; it never shrinks, so a long
cause truncates instead. */
.trace-link {
flex-shrink: 0;
color: var(--primary-color);
text-decoration: none;
}
.trace-link:hover {
text-decoration: underline;
}
/* Entity names read as the subject, not a wall of blue links the
colored node is the scan anchor. */
button.link {
color: var(--primary-text-color);
font-weight: var(--ha-font-weight-medium);
text-decoration: none;
}
button.link:hover {
text-decoration: underline;
}
`,
];
}
+14 -31
View File
@@ -2,11 +2,10 @@ import type { VisibilityChangedEvent } from "@lit-labs/virtualizer";
import memoizeOne from "memoize-one";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property } from "lit/decorators";
import { customElement, eventOptions, property, state } from "lit/decorators";
import { formatDate } from "../../common/datetime/format_date";
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import type { LogbookEntry } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
@@ -14,14 +13,13 @@ import { haStyle, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import "./ha-logbook-entry";
import type { LogbookEntrySelectedDetail } from "./ha-logbook-entry";
import type { LogbookNameDetail } from "./logbook-entry-model";
import { sameDay } from "./logbook-entry-model";
import { showLogbookDetailDialog } from "./show-dialog-logbook-detail";
declare global {
interface HASSDomEvents {
"hass-logbook-live": { enable: boolean };
"logbook-toggle-time": undefined;
}
}
@@ -34,7 +32,6 @@ class HaLogbookRenderer extends LitElement {
@property({ attribute: false }) public systemUserIds = new Set<string>();
// Not rendered by rows; read at click time and handed to the detail dialog.
@property({ attribute: false }) public traceContexts: TraceContexts = {};
@property({ attribute: false }) public entries: LogbookEntry[] = [];
@@ -52,14 +49,14 @@ class HaLogbookRenderer extends LitElement {
@property({ type: Boolean, attribute: "show-cause" }) public showCause =
false;
@property({ type: Boolean, attribute: "no-detail" }) public noDetail = false;
@property({ type: String, attribute: "name-detail" })
public nameDetail?: LogbookNameDetail;
// @ts-ignore
@restoreScroll(".container") private _savedScrollPos?: number;
@state() private _showRelative = false;
protected willUpdate(changedProps: PropertyValues<this>) {
if (
(!this.hasUpdated && this.virtualize) ||
@@ -80,9 +77,8 @@ class HaLogbookRenderer extends LitElement {
return (
changedProps.has("entries") ||
changedProps.has("noDetail") ||
changedProps.has("userIdToName") ||
changedProps.has("systemUserIds") ||
changedProps.has("traceContexts") ||
changedProps.has("_showRelative" as never) ||
languageChanged
);
}
@@ -100,7 +96,7 @@ class HaLogbookRenderer extends LitElement {
<div
class="container ha-scrollbar"
@scroll=${this._saveScrollPos}
@logbook-entry-selected=${this._handleEntrySelected}
@logbook-toggle-time=${this._handleToggleTime}
>
${
this.virtualize
@@ -109,12 +105,7 @@ class HaLogbookRenderer extends LitElement {
scroller
class="ha-scrollbar"
.items=${this.entries}
.renderItem=${
this._getRenderRow(
this.userIdToName,
this.systemUserIds
) as any
}
.renderItem=${this._getRenderRow(this._showRelative) as any}
>
</lit-virtualizer>`
: this.entries.map((item, index) => this._renderItem(item, index))
@@ -123,12 +114,9 @@ class HaLogbookRenderer extends LitElement {
`;
}
// Memoized on every input the rows render from, so the virtualizer sees a
// new renderItem and refreshes already-rendered rows when one changes.
private _getRenderRow = memoizeOne(
(_userIdToName: Record<string, string>, _systemUserIds: Set<string>) =>
(item: LogbookEntry, index: number) =>
this._renderItem(item, index)
(_showRelative: boolean) => (item: LogbookEntry, index: number) =>
this._renderItem(item, index)
);
private _renderItem = (item: LogbookEntry, index: number) => {
@@ -153,27 +141,22 @@ class HaLogbookRenderer extends LitElement {
.item=${item}
.userIdToName=${this.userIdToName}
.systemUserIds=${this.systemUserIds}
.traceContexts=${this.traceContexts}
.narrow=${this.narrow}
.noIcon=${this.noIcon}
.graphColor=${this.graphColor}
.nameDetail=${this.nameDetail}
.firstOfDay=${firstOfDay}
.lastOfDay=${lastOfDay}
.showRelative=${this._showRelative}
.showCause=${this.showCause}
.noDetail=${this.noDetail}
></ha-logbook-entry>
</div>
`;
};
private _handleEntrySelected(ev: HASSDomEvent<LogbookEntrySelectedDetail>) {
ev.stopPropagation();
showLogbookDetailDialog(this, {
entry: ev.detail.item,
traceContexts: this.traceContexts,
userIdToName: this.userIdToName,
systemUserIds: this.systemUserIds,
});
private _handleToggleTime() {
this._showRelative = !this._showRelative;
}
private _formatDateHeader(date: Date): string {
@@ -1,184 +0,0 @@
import type { LogbookEntry } from "../../data/logbook";
import { getLogbookDataFromServer } from "../../data/logbook";
import type { HomeAssistant } from "../../types";
import type { LogbookCause } from "./logbook-entry-model";
import {
computeContextCause,
computeLogbookCause,
computeUserCause,
isRunCause,
isRunRow,
isSameLogbookEntry,
} from "./logbook-entry-model";
// No run start is available and a delayed script can start long before the
// clicked entry.
const LOOKBACK_MS = 24 * 60 * 60 * 1000;
const WHEN_EPSILON = 0.001;
const MAX_RUN_CANDIDATES = 3;
export interface LogbookChain {
rows: LogbookEntry[];
runRow?: LogbookEntry;
// Causes shown above the run, topmost first.
origins: LogbookCause[];
// Stands in for the run row when it could not be fetched.
syntheticRun?: LogbookCause;
// The state change that fired a state trigger in `origins`.
triggerRow?: LogbookEntry;
}
export type LogbookFetcher = (
startDate: string,
endDate?: string,
entityIds?: string[],
contextId?: string
) => Promise<LogbookEntry[]>;
interface ResolveOptions {
userIdToName?: Record<string, string>;
systemUserIds?: Set<string>;
}
const lookbackIso = (when: number) =>
new Date(when * 1000 - LOOKBACK_MS).toISOString();
const justAfterIso = (when: number) =>
new Date(when * 1000 + 1000).toISOString();
// Effect rows never carry a context_id, run rows do: the logbook processor
// only emits it on rows described by the automation/script platforms. This
// candidate discovery + verification is a workaround for that gap; once core
// emits context_id on described rows too, the fast path by entry.context_id
// covers every click and this can be deleted.
const resolveRowsByContextEntity = async (
entry: LogbookEntry,
fetchEvents: LogbookFetcher
): Promise<LogbookEntry[]> => {
const contextEntityId = entry.context_entity_id!;
const runs = (
await fetchEvents(lookbackIso(entry.when), justAfterIso(entry.when), [
contextEntityId,
])
)
.filter((row) => row.context_id && row.when <= entry.when + WHEN_EPSILON)
.sort((a, b) => b.when - a.when)
.slice(0, MAX_RUN_CANDIDATES);
for (const run of runs) {
// eslint-disable-next-line no-await-in-loop
const rows = await fetchEvents(
lookbackIso(entry.when),
undefined,
undefined,
run.context_id
);
if (rows.some((row) => isSameLogbookEntry(row, entry))) {
return rows;
}
}
return [];
};
const resolveTriggerRow = async (
entityId: string,
beforeWhen: number,
fetchEvents: LogbookFetcher
): Promise<LogbookEntry | undefined> => {
const rows = await fetchEvents(
lookbackIso(beforeWhen),
justAfterIso(beforeWhen),
[entityId]
);
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (row.state !== undefined && row.when <= beforeWhen + WHEN_EPSILON) {
return row;
}
}
return undefined;
};
export const resolveLogbookChain = async (
hass: HomeAssistant,
entry: LogbookEntry,
options: ResolveOptions = {},
fetchEvents: LogbookFetcher = (startDate, endDate, entityIds, contextId) =>
getLogbookDataFromServer(hass, startDate, endDate, entityIds, contextId)
): Promise<LogbookChain> => {
const userIdToName = options.userIdToName ?? {};
const { systemUserIds } = options;
let rows: LogbookEntry[] = [];
if (entry.context_id) {
rows = await fetchEvents(
lookbackIso(entry.when),
undefined,
undefined,
entry.context_id
);
} else if (entry.context_entity_id) {
rows = await resolveRowsByContextEntity(entry, fetchEvents);
}
if (!rows.length) {
rows = [entry];
}
let runRow = rows.find(isRunRow);
if (runRow && runRow !== entry && isSameLogbookEntry(entry, runRow)) {
// The clicked feed copy carries the call_service description that the
// fetched copy of the run row never has.
rows = rows.map((row) => (row === runRow ? entry : row));
runRow = entry;
}
const origins: LogbookCause[] = [];
let syntheticRun: LogbookCause | undefined;
if (runRow) {
const runCause = computeLogbookCause(
hass,
runRow,
userIdToName,
systemUserIds
);
if (runCause?.type !== "user" && !isSameLogbookEntry(entry, runRow)) {
// The run row is its own context origin and comes back without the
// user its effects carry: read the user from the clicked entry.
const userCause = computeUserCause(entry, userIdToName, systemUserIds);
if (userCause) {
origins.push(userCause);
}
}
if (runCause) {
origins.push(runCause);
}
} else {
const userCause = computeUserCause(entry, userIdToName, systemUserIds);
const contextCause = computeContextCause(hass, entry);
if (isRunCause(contextCause)) {
syntheticRun = contextCause;
if (userCause) {
origins.push(userCause);
}
} else {
const cause = userCause ?? contextCause;
if (cause) {
origins.push(cause);
}
}
}
const stateOrigin = origins.find(
(cause) => cause.type === "state" && cause.entityId
);
const triggerRow = stateOrigin
? await resolveTriggerRow(
stateOrigin.entityId!,
(runRow ?? entry).when,
fetchEvents
)
: undefined;
return { rows, runRow, origins, syntheticRun, triggerRow };
};
+21 -68
View File
@@ -12,14 +12,13 @@ import {
localizeStateMessage,
parseTriggerSource,
} from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
import type { HomeAssistant } from "../../types";
export type LogbookEntryCategory = "entity" | "automation" | "integration";
const TRIGGER_DOMAINS = ["automation", "script"];
export const TRIGGER_DOMAINS = ["automation", "script"];
const stripEntityId = (message: string, entityId?: string) =>
export const stripEntityId = (message: string, entityId?: string) =>
entityId ? message.replace(entityId, " ") : message;
export const classifyLogbookEntry = (
@@ -36,10 +35,6 @@ export const classifyLogbookEntry = (
return "integration";
};
// A run row is the acting automation or script; every other row is an effect.
export const isRunRow = (item: LogbookEntry): boolean =>
classifyLogbookEntry(item) === "automation";
// How much naming detail an entity row shows, from least to most. The value is
// the broadest part shown: `none` (name hidden), `entity`, `device` (device ▸
// entity), `area` (area ▸ device ▸ entity).
@@ -101,31 +96,12 @@ export const entityDisplay = (
return { primary, secondary };
};
const hasContext = (item: LogbookEntry) =>
export const hasContext = (item: LogbookEntry) =>
item.context_event_type || item.context_state || item.context_message;
export const sameDay = (a?: LogbookEntry, b?: LogbookEntry) =>
!!a?.when && !!b?.when && isSameDay(a.when * 1000, b.when * 1000);
export const isSameLogbookEntry = (a: LogbookEntry, b: LogbookEntry) =>
a.when === b.when &&
a.entity_id === b.entity_id &&
a.state === b.state &&
a.message === b.message &&
a.name === b.name;
// Every entry of a run shares the run's context id, so effect rows resolve
// to their cause's trace too.
export const computeTraceLink = (
traceContexts: TraceContexts,
contextId?: string
): string | undefined => {
const traceContext = contextId ? traceContexts[contextId] : undefined;
return traceContext
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
: undefined;
};
// Unavailable is flagged with an orange badge by the row, not a color change.
export const nodeColor = (
category: LogbookEntryCategory,
@@ -155,7 +131,8 @@ export interface LogbookCause {
brandDomain?: string;
}
export const computeUserCause = (
export const computeLogbookCause = (
hass: HomeAssistant,
item: LogbookEntry,
userIdToName: Record<string, string>,
systemUserIds?: Set<string>
@@ -163,21 +140,15 @@ export const computeUserCause = (
const userName = item.context_user_id
? userIdToName[item.context_user_id]
: undefined;
if (!userName) {
return undefined;
if (userName) {
return {
type: "user",
name: userName,
userId: item.context_user_id,
systemUser: systemUserIds?.has(item.context_user_id!),
};
}
return {
type: "user",
name: userName,
userId: item.context_user_id,
systemUser: systemUserIds?.has(item.context_user_id!),
};
};
export const computeContextCause = (
hass: HomeAssistant,
item: LogbookEntry
): LogbookCause | undefined => {
if (
item.context_event_type === "automation_triggered" ||
item.context_event_type === "script_started"
@@ -268,18 +239,6 @@ export const computeContextCause = (
return undefined;
};
export const computeLogbookCause = (
hass: HomeAssistant,
item: LogbookEntry,
userIdToName: Record<string, string>,
systemUserIds?: Set<string>
): LogbookCause | undefined =>
computeUserCause(item, userIdToName, systemUserIds) ??
computeContextCause(hass, item);
export const isRunCause = (cause?: LogbookCause): boolean =>
cause?.type === "automation" || cause?.type === "script";
export type LogbookGlyph =
| { type: "state"; stateObj: HassEntity; icon?: string }
| { type: "automation"; script: boolean }
@@ -325,11 +284,10 @@ const computeLogbookValue = (
type: "state",
};
}
// Core sends run rows (carrying the automation/script entity) with a raw
// English message; use our own label. Domain-only entries (e.g. logbook.log)
// keep their custom message.
const isAutomationRun =
item.entity_id && domain && TRIGGER_DOMAINS.includes(domain);
domain &&
TRIGGER_DOMAINS.includes(domain) &&
(item.source || hasContext(item) || !!item.context_user_id);
if (isAutomationRun) {
return {
text: hass.localize(
@@ -390,13 +348,6 @@ export const computeLogbookItem = (
? entityDisplay(hass, entry.entity_id, opts.nameDetail)
: undefined;
const userCause = computeUserCause(
entry,
opts.userIdToName ?? {},
opts.systemUserIds
);
const contextCause = computeContextCause(hass, entry);
return {
category,
glyph: computeLogbookGlyph(entry, category, historicStateObj, domain),
@@ -404,10 +355,12 @@ export const computeLogbookItem = (
name: display?.primary ?? entry.name,
context: display?.secondary,
value: computeLogbookValue(hass, entry, domain, historicStateObj),
// A row shows the run over the user who started it; the dialog shows both.
cause: isRunCause(contextCause)
? contextCause
: (userCause ?? contextCause),
cause: computeLogbookCause(
hass,
entry,
opts.userIdToName ?? {},
opts.systemUserIds
),
when: entry.when * 1000,
};
};
@@ -1,137 +0,0 @@
import {
mdiCast,
mdiClockOutline,
mdiCloud,
mdiPuzzle,
mdiRobot,
mdiScriptText,
mdiStateMachine,
} from "@mdi/js";
import { html } from "lit";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/entity/state-badge";
import "../../components/ha-domain-icon";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/user/ha-user-badge";
import { mdiHomeAssistant } from "../../resources/home-assistant-logo-svg";
import type { LogbookEntry } from "../../data/logbook";
import type { User } from "../../data/user";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { LogbookCause, LogbookGlyph } from "./logbook-entry-model";
// Names are the fixed system user names set by core (cloud/cast integrations).
const SYSTEM_USER_ICONS: Record<string, string> = {
"Home Assistant Cloud": mdiCloud,
"Home Assistant Cast": mdiCast,
};
export const renderLogbookCauseIcon = (cause: LogbookCause) => {
if (cause.type === "user") {
const systemIcon = cause.systemUser
? SYSTEM_USER_ICONS[cause.name]
: undefined;
if (systemIcon) {
return html`<ha-svg-icon
class="cause-icon"
.path=${systemIcon}
></ha-svg-icon>`;
}
return html`<ha-user-badge
class="cause-icon cause-avatar"
.user=${{ id: cause.userId!, name: cause.name } as User}
></ha-user-badge>`;
}
if (cause.type === "automation") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiRobot}
></ha-svg-icon>`;
}
if (cause.type === "script") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiScriptText}
></ha-svg-icon>`;
}
if (cause.type === "state") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiStateMachine}
></ha-svg-icon>`;
}
if (cause.type === "scheduled") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiClockOutline}
></ha-svg-icon>`;
}
if (cause.type === "homeassistant") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiHomeAssistant}
></ha-svg-icon>`;
}
if (cause.brandDomain) {
return html`<ha-domain-icon
class="cause-icon"
.domain=${cause.brandDomain}
brand-fallback
></ha-domain-icon>`;
}
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiPuzzle}
></ha-svg-icon>`;
};
const brandImage = (
hass: HomeAssistant,
entry: LogbookEntry,
domain?: string
): string | undefined => {
if (
!domain ||
entry.icon ||
entry.state ||
!isComponentLoaded(hass.config, domain)
) {
return undefined;
}
return brandsUrl(
{
domain,
type: "icon",
darkOptimized: hass.themes?.darkMode,
},
hass.auth.data.hassUrl
);
};
export const transitionArrow = (hass: HomeAssistant) =>
computeRTL(hass.language, hass.translationMetadata.translations) ? "←" : "→";
export const renderLogbookGlyph = (
hass: HomeAssistant,
entry: LogbookEntry,
glyph: LogbookGlyph
) => {
if (glyph.type === "automation") {
return html`<ha-svg-icon
.path=${glyph.script ? mdiScriptText : mdiRobot}
></ha-svg-icon>`;
}
if (glyph.type === "state") {
return html`<ha-state-icon
.stateObj=${glyph.stateObj}
.icon=${glyph.icon}
></ha-state-icon>`;
}
return html`<state-badge
.overrideIcon=${glyph.icon}
.overrideImage=${brandImage(hass, entry, glyph.domain)}
.stateColor=${false}
></state-badge>`;
};
@@ -1,23 +0,0 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { LogbookEntry } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
export interface LogbookDetailDialogParams {
entry: LogbookEntry;
traceContexts?: TraceContexts;
userIdToName?: Record<string, string>;
systemUserIds?: Set<string>;
}
export const loadLogbookDetailDialog = () => import("./dialog-logbook-detail");
export const showLogbookDetailDialog = (
element: HTMLElement,
params: LogbookDetailDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-logbook-detail",
dialogImport: loadLogbookDetailDialog,
dialogParams: params,
});
};
@@ -1,33 +0,0 @@
import type {
LovelaceCardFeatureConfig,
LovelaceCardFeaturePosition,
} from "../types";
export interface CardFeatureLayout {
inline: LovelaceCardFeatureConfig[];
below: LovelaceCardFeatureConfig[];
/** Columns filled by the below features, 0 when there are none */
columns: number;
}
const INLINE_COLUMNS = 2;
export const computeCardFeatureLayout = (
features: LovelaceCardFeatureConfig[] | undefined,
position: LovelaceCardFeaturePosition
): CardFeatureLayout => {
if (position !== "inline") {
return { inline: [], below: features ?? [], columns: 1 };
}
const inline = features?.slice(0, 1) ?? [];
const below = features?.slice(1) ?? [];
return { inline, below, columns: Math.min(below.length, INLINE_COLUMNS) };
};
export const computeCardFeatureRows = (
features: LovelaceCardFeatureConfig[] | undefined,
position: LovelaceCardFeaturePosition
): number => {
const { below, columns } = computeCardFeatureLayout(features, position);
return Math.ceil(below.length / Math.max(columns, 1));
};
@@ -1,6 +1,5 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HomeAssistant } from "../../../types";
import "./hui-card-feature";
import type {
@@ -33,32 +32,22 @@ export class HuiCardFeatures extends LitElement {
@property({ attribute: false })
public position?: LovelaceCardFeaturePosition;
@property({ type: Number, reflect: true })
public columns = 1;
protected render() {
if (!this.features) {
return nothing;
}
const lastIndex = this.features.length - 1;
const columns = Math.max(this.columns, 1);
return html`
${this.features.map((feature, index) => {
const column = index % columns;
return html`
${this.features.map(
(feature) => html`
<hui-card-feature
class=${classMap({
divided: column > 0,
wide: column === 0 && index === lastIndex,
})}
.hass=${this.hass}
.context=${this.context}
.color=${this.color}
.feature=${feature}
.position=${this.position}
></hui-card-feature>
`;
})}
`
)}
`;
}
@@ -66,38 +55,20 @@ export class HuiCardFeatures extends LitElement {
:host {
--feature-color: var(--state-icon-color);
--feature-height: 42px;
--feature-columns: 1;
--feature-border-radius: var(
--ha-card-features-border-radius,
var(--ha-border-radius-lg)
);
--feature-button-spacing: 12px;
--feature-column-gap: var(
--ha-card-feature-column-gap,
var(--ha-card-feature-gap, 12px)
);
--feature-divider-inset: var(--ha-card-feature-divider-inset, 0px);
pointer-events: none;
position: relative;
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--ha-card-feature-gap, 12px) var(--feature-column-gap);
display: flex;
flex-direction: column;
gap: var(--ha-card-feature-gap, 12px);
width: 100%;
box-sizing: border-box;
align-content: space-evenly;
}
:host([columns="2"]) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.wide {
grid-column: 1 / -1;
}
/* pull the divider out of the column and into the middle of the gutter */
.divided {
box-sizing: border-box;
margin-inline-start: calc(-1 * var(--feature-divider-inset));
padding-inline-start: var(--feature-divider-inset);
border-inline-start: var(--ha-card-feature-divider, none);
justify-content: space-evenly;
}
`;
}
@@ -1,7 +1,7 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
consumeEntityState,
@@ -202,7 +202,14 @@ class HuiMediaPlayerPlaybackCardFeature
});
}
static styles = cardFeatureStyles;
static styles = [
cardFeatureStyles,
css`
ha-control-button-group {
overflow: hidden;
}
`,
];
}
declare global {
@@ -8,13 +8,13 @@ import type {
} from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
getSuggestedPeriod,
getSummedData,
} from "../../../../data/energy";
import type { Statistics } from "../../../../data/recorder";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import {
calculateStatisticSumGrowth,
getStatisticLabel,
isExternalStatistic,
} from "../../../../data/recorder";
import type { HomeAssistant } from "../../../../types";
@@ -69,13 +69,13 @@ interface ProcessContext {
end: Date;
compareStart?: Date;
untrackedOrder: number;
deviceLabels: Record<string, string>;
}
function processDataSet(
ctx: ProcessContext,
computedStyle: CSSStyleDeclaration,
statistics: Statistics,
statisticsMetaData: Record<string, StatisticsMetaData>,
devices: DeviceConsumptionEnergyPreference[],
sorted_devices: string[],
childMap: Record<string, string[]>,
@@ -167,7 +167,12 @@ function processDataSet(
}
const name =
ctx.deviceLabels[source.stat_consumption] +
(source.name ||
getStatisticLabel(
ctx.hass,
source.stat_consumption,
statisticsMetaData[source.stat_consumption]
)) +
(source.stat_consumption in childMap
? ` (${ctx.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked")})`
: "");
@@ -346,8 +351,6 @@ export function generateEnergyDevicesDetailGraphData(
const data = energyData.stats;
const compareData = energyData.statsCompare;
const devices = energyData.prefs.device_consumption;
const ctx: ProcessContext = {
hass,
config,
@@ -355,13 +358,10 @@ export function generateEnergyDevicesDetailGraphData(
end,
compareStart,
untrackedOrder,
deviceLabels: computeEnergyDeviceLabels(
hass,
devices,
energyData.statsMetadata
),
};
const devices = energyData.prefs.device_consumption;
const childMap: Record<string, string[]> = {};
devices.forEach((d) => {
if (d.included_in_stat) {
@@ -425,6 +425,7 @@ export function generateEnergyDevicesDetailGraphData(
ctx,
computedStyles,
compareData,
energyData.statsMetadata,
energyData.prefs.device_consumption,
sorted_devices,
childMap,
@@ -467,6 +468,7 @@ export function generateEnergyDevicesDetailGraphData(
ctx,
computedStyles,
data,
energyData.statsMetadata,
energyData.prefs.device_consumption,
sorted_devices,
childMap,
@@ -16,7 +16,6 @@ import "../../../../components/chart/ha-chart-tooltip-marker";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
getEnergyDataCollection,
getSummedData,
validateEnergyCollectionKey,
@@ -92,8 +91,6 @@ export class HuiEnergyDevicesGraphCard
private _compoundStats: string[] = [];
private _deviceLabels: Record<string, string> = {};
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
@@ -298,8 +295,9 @@ export class HuiEnergyDevicesGraphCard
? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})`
: "";
return (
// The untracked slice is not a statistic, so it has no label.
(this._deviceLabels[statisticId] ||
(this._data?.prefs.device_consumption.find(
(d) => d.stat_consumption === statisticId
)?.name ||
getStatisticLabel(
this.hass,
statisticId,
@@ -379,12 +377,6 @@ export class HuiEnergyDevicesGraphCard
.map((d) => d.included_in_stat)
.filter(Boolean) as string[];
this._deviceLabels = computeEnergyDeviceLabels(
this.hass,
energyData.prefs.device_consumption,
energyData.statsMetadata
);
const devices = energyData.prefs.device_consumption;
const devicesTotals: Record<string, number> = {};
devices.forEach((device) => {

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