mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-15 19:07:49 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62c9e3026a | ||
|
|
7e0c572f69 | ||
|
|
e4438a20cb | ||
|
|
ec418498a7 | ||
|
|
8122baca00 | ||
|
|
79738dd257 | ||
|
|
f23c8f0e23 | ||
|
|
f578cb0526 | ||
|
|
78878897cc | ||
|
|
9472d8ad12 | ||
|
|
94cbe4467b | ||
|
|
043abb69af | ||
|
|
b6b8fe641f | ||
|
|
70bb08b101 | ||
|
|
4259ddee24 | ||
|
|
6c5ac1ea84 | ||
|
|
5224fa2048 |
@@ -51,4 +51,39 @@ 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,6 +152,84 @@ 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[] = [
|
||||
|
||||
@@ -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,6 +340,84 @@ 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
@@ -152,7 +152,7 @@
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.8",
|
||||
"@rspack/core": "2.1.9",
|
||||
"@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.66.0",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
|
||||
@@ -39,6 +39,25 @@ 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,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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}`;
|
||||
};
|
||||
@@ -117,7 +117,7 @@ export class HaDataTable extends LitElement {
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ type: Object }) public columns: DataTableColumnContainer = {};
|
||||
|
||||
@@ -1158,6 +1158,11 @@ 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);
|
||||
@@ -1238,8 +1243,7 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
.mdc-data-table__cell,
|
||||
.mdc-data-table__header-cell {
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
padding-inline: var(--_cell-padding-inline);
|
||||
min-width: 150px;
|
||||
align-self: center;
|
||||
overflow: hidden;
|
||||
@@ -1259,14 +1263,8 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
.mdc-data-table__header-cell--checkbox,
|
||||
.mdc-data-table__cell--checkbox {
|
||||
/* @noflip */
|
||||
padding-left: 16px;
|
||||
/* @noflip */
|
||||
padding-right: 0;
|
||||
/* @noflip */
|
||||
padding-inline-start: 16px;
|
||||
/* @noflip */
|
||||
padding-inline-end: initial;
|
||||
padding-inline-start: var(--_cell-padding-inline);
|
||||
padding-inline-end: 0;
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
}
|
||||
@@ -1379,8 +1377,7 @@ 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-left: 16px;
|
||||
padding-inline-start: 16px;
|
||||
padding-inline-start: var(--_cell-padding-inline);
|
||||
padding-inline-end: initial;
|
||||
}
|
||||
|
||||
@@ -1388,8 +1385,7 @@ 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-right: 16px;
|
||||
padding-inline-end: 16px;
|
||||
padding-inline-end: var(--_cell-padding-inline);
|
||||
padding-inline-start: initial;
|
||||
}
|
||||
.mdc-data-table__cell--overflow-menu,
|
||||
@@ -1516,11 +1512,17 @@ 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);
|
||||
|
||||
@@ -8,6 +8,7 @@ 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);
|
||||
|
||||
@@ -3,11 +3,13 @@ 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,
|
||||
@@ -26,7 +28,9 @@ 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 = (
|
||||
@@ -128,6 +132,7 @@ export class HaDevicePicker extends LitElement {
|
||||
entityFilter,
|
||||
excludeDevices,
|
||||
value,
|
||||
nested: true,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -216,6 +221,34 @@ 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>,
|
||||
@@ -279,46 +312,76 @@ export class HaDevicePicker extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
|
||||
<ha-combo-box-item type="button">
|
||||
${
|
||||
item.domain
|
||||
? html`
|
||||
<img
|
||||
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}
|
||||
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
|
||||
}
|
||||
></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
|
||||
}
|
||||
|
||||
<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 =
|
||||
@@ -375,6 +438,8 @@ 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}
|
||||
|
||||
@@ -143,7 +143,8 @@ export class HaControlSelect extends LitElement {
|
||||
? repeat(
|
||||
this.options,
|
||||
(option) => option.value,
|
||||
(option) => this._renderOption(option)
|
||||
(option, index) =>
|
||||
this._renderOption(option, index === this._tabbableIndex)
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
@@ -151,7 +152,14 @@ export class HaControlSelect extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOption(option: ControlSelectOption) {
|
||||
/* 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) {
|
||||
const isSelected = this.value === option.value;
|
||||
|
||||
return html`
|
||||
@@ -162,7 +170,7 @@ export class HaControlSelect extends LitElement {
|
||||
selected: isSelected,
|
||||
})}
|
||||
role="radio"
|
||||
tabindex=${isSelected ? "0" : "-1"}
|
||||
tabindex=${tabbable ? "0" : "-1"}
|
||||
.value=${option.value}
|
||||
aria-checked=${isSelected ? "true" : "false"}
|
||||
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
};
|
||||
@@ -35,6 +35,7 @@ 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";
|
||||
@@ -799,20 +800,8 @@ export class HaServiceControl extends LitElement {
|
||||
|
||||
let defaultValue = field?.default;
|
||||
|
||||
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 && field?.selector) {
|
||||
defaultValue = getSelectorFallbackValue(field.selector);
|
||||
}
|
||||
|
||||
if (defaultValue != null) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type DevicePickerItem,
|
||||
} from "../data/device/device_picker";
|
||||
import {
|
||||
devicesInEffectiveArea,
|
||||
fetchDeviceCompositeSplits,
|
||||
type DeviceCompositeSplits,
|
||||
} from "../data/device/device_registry";
|
||||
@@ -158,6 +159,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
excludeDevices,
|
||||
value,
|
||||
idPrefix,
|
||||
nested: true,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -682,9 +684,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
});
|
||||
} else if (type === "area") {
|
||||
Object.values(this.hass.devices).forEach((device) => {
|
||||
// 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) => {
|
||||
if (
|
||||
device.area_id === itemId &&
|
||||
!this.value!.device_id?.includes(device.id) &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
@@ -716,9 +719,18 @@ 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 === itemId &&
|
||||
entity.device_id &&
|
||||
deviceIds.has(entity.device_id) &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
@@ -1001,6 +1013,28 @@ 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,
|
||||
@@ -1008,26 +1042,41 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
targetValue?.device_id
|
||||
? replacingDeviceId
|
||||
? ensureArray(targetValue.device_id).filter(
|
||||
(deviceId) => deviceId !== replacingDeviceId
|
||||
)
|
||||
: ensureArray(targetValue.device_id)
|
||||
: undefined,
|
||||
excludeDeviceIds,
|
||||
replacingDeviceId,
|
||||
`device${SEPARATOR}`
|
||||
).sort(this._sortBySortingLabel);
|
||||
);
|
||||
// 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.
|
||||
|
||||
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
|
||||
deviceComboBoxKeys,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// 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"));
|
||||
@@ -1245,7 +1294,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
let hasFloor = false;
|
||||
let rtl = false;
|
||||
let showEntityId = false;
|
||||
if (type === "area" || type === "floor") {
|
||||
const isChildDeviceRow =
|
||||
type === "device" && !!(item as DevicePickerItem).is_child;
|
||||
if (type === "area" || type === "floor" || isChildDeviceRow) {
|
||||
rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
@@ -1265,27 +1316,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.type=${type === "empty" ? "text" : "button"}
|
||||
class=${type === "empty" ? "empty" : ""}
|
||||
style=${
|
||||
(item as FloorComboBoxItem).type === "area" && hasFloor
|
||||
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
|
||||
isChildDeviceRow
|
||||
? "--md-list-item-leading-space: var(--ha-space-12);"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
${
|
||||
(item as FloorComboBoxItem).type === "area" && hasFloor
|
||||
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
|
||||
isChildDeviceRow
|
||||
? 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 FloorComboBoxItem & { last?: boolean | undefined })
|
||||
.last
|
||||
}
|
||||
.end=${(item as { last?: boolean }).last}
|
||||
slot="start"
|
||||
></ha-tree-indicator>
|
||||
`
|
||||
|
||||
@@ -8,10 +8,31 @@ 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">
|
||||
<line x1="24" y1="0" x2="24" y2=${this.end ? "24" : "48"}></line>
|
||||
<line x1="24" y1="24" x2="36" y2="24"></line>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -393,6 +393,9 @@ 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 ||
|
||||
@@ -416,6 +419,9 @@ 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 || "")
|
||||
@@ -424,7 +430,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
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -433,7 +439,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)
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -528,6 +534,12 @@ 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(
|
||||
@@ -560,6 +572,9 @@ 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(
|
||||
@@ -585,6 +600,11 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } 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,6 +25,13 @@ 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", "");
|
||||
@@ -36,8 +43,12 @@ export class HaTileContainer extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const containerOrientationClass =
|
||||
this.featurePosition === "inline" ? "horizontal" : "";
|
||||
const isInline = this.featurePosition === "inline";
|
||||
const containerClasses = {
|
||||
inline: isInline,
|
||||
"has-features-below": isInline && this._hasFeatures,
|
||||
"fixed-height": this.fixedInfoHeight,
|
||||
};
|
||||
const contentClasses = {
|
||||
vertical: this.vertical,
|
||||
"fixed-info-height": this.fixedInfoHeight,
|
||||
@@ -56,15 +67,21 @@ export class HaTileContainer extends LitElement {
|
||||
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
|
||||
</div>
|
||||
<div
|
||||
class="container ${containerOrientationClass}"
|
||||
class="container ${classMap(containerClasses)}"
|
||||
@action=${stopPropagation}
|
||||
@click=${stopPropagation}
|
||||
>
|
||||
<div class="content ${classMap(contentClasses)}">
|
||||
<slot name="icon"></slot>
|
||||
<slot name="info" id="info"></slot>
|
||||
<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>
|
||||
<slot name="features"></slot>
|
||||
<slot
|
||||
name="features"
|
||||
@slotchange=${this._handleFeaturesSlotChange}
|
||||
></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -97,7 +114,13 @@ export class HaTileContainer extends LitElement {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
.container.horizontal {
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.container.inline .row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -153,13 +176,30 @@ export class HaTileContainer extends LitElement {
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
|
||||
.container.horizontal ::slotted([slot="features"]) {
|
||||
.container.inline ::slotted([slot="features-inline"]) {
|
||||
/* size the feature on the 6 column grid track, so it lines up with neighbouring tiles */
|
||||
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;
|
||||
|
||||
@@ -29,6 +29,7 @@ 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,6 +437,7 @@ 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>
|
||||
`
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -24,12 +25,17 @@ 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 {
|
||||
@@ -41,6 +47,10 @@ 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 = (
|
||||
@@ -69,6 +79,16 @@ 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
|
||||
@@ -77,7 +97,7 @@ export const computeDeviceAreaLabel = (
|
||||
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
|
||||
: viaDeviceName || undefined;
|
||||
|
||||
return { areaName, viaDeviceName, viaDeviceAreaName };
|
||||
return { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName };
|
||||
};
|
||||
|
||||
export const deviceComboBoxKeys: FuseWeightedKey[] = [
|
||||
@@ -105,6 +125,14 @@ 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 = (
|
||||
@@ -121,6 +149,7 @@ export const getDevices = (
|
||||
excludeDevices,
|
||||
value,
|
||||
idPrefix = "",
|
||||
nested,
|
||||
} = options ?? {};
|
||||
|
||||
const devices = Object.values(hass.devices);
|
||||
@@ -128,26 +157,60 @@ export const getDevices = (
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
|
||||
if (
|
||||
includeDomains ||
|
||||
excludeDomains ||
|
||||
includeDeviceClasses ||
|
||||
entityFilter
|
||||
) {
|
||||
const filtersEntities =
|
||||
includeDomains || excludeDomains || includeDeviceClasses || entityFilter;
|
||||
|
||||
if (filtersEntities) {
|
||||
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 = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) =>
|
||||
return devEntities.some((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
@@ -155,11 +218,11 @@ export const getDevices = (
|
||||
|
||||
if (excludeDomains) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
return true;
|
||||
}
|
||||
return entities.every(
|
||||
return devEntities.every(
|
||||
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
@@ -173,11 +236,11 @@ export const getDevices = (
|
||||
|
||||
if (includeDeviceClasses) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
return devEntities.some((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
@@ -192,8 +255,8 @@ export const getDevices = (
|
||||
|
||||
if (entityFilter) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return devEntities.some((entity) => {
|
||||
@@ -222,7 +285,7 @@ export const getDevices = (
|
||||
deviceEntityLookup[device.id]
|
||||
);
|
||||
|
||||
const { areaName, viaDeviceName, viaDeviceAreaName } =
|
||||
const { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName } =
|
||||
computeDeviceAreaLabel(
|
||||
device,
|
||||
hass.areas,
|
||||
@@ -259,10 +322,79 @@ export const getDevices = (
|
||||
domainName: domainName || null,
|
||||
viaDeviceName: viaDeviceName || null,
|
||||
viaDeviceArea: viaDeviceAreaName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
},
|
||||
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
|
||||
};
|
||||
});
|
||||
|
||||
return outputDevices;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -81,6 +81,75 @@ 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[]
|
||||
|
||||
@@ -22,6 +22,7 @@ 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";
|
||||
@@ -36,6 +37,7 @@ import type {
|
||||
import {
|
||||
fetchStatistics,
|
||||
getDisplayUnit,
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
VOLUME_UNITS,
|
||||
} from "./recorder";
|
||||
@@ -311,6 +313,59 @@ 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
@@ -76,7 +76,7 @@ export const getLogbookDataForContext = async (
|
||||
): Promise<LogbookEntry[]> =>
|
||||
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
|
||||
|
||||
const getLogbookDataFromServer = (
|
||||
export const getLogbookDataFromServer = (
|
||||
hass: HomeAssistant,
|
||||
startDate: string,
|
||||
endDate?: string,
|
||||
|
||||
@@ -2,12 +2,15 @@ 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 { LovelaceViewRawConfig } from "./view";
|
||||
import type {
|
||||
LovelaceDashboardBackgroundConfig,
|
||||
LovelaceViewRawConfig,
|
||||
} from "./view";
|
||||
|
||||
export interface LovelaceDashboardBaseConfig {}
|
||||
|
||||
export interface LovelaceConfig extends LovelaceDashboardBaseConfig {
|
||||
background?: string;
|
||||
background?: LovelaceDashboardBackgroundConfig;
|
||||
views: LovelaceViewRawConfig[];
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ export interface LovelaceViewBackgroundConfig {
|
||||
attachment?: "scroll" | "fixed";
|
||||
}
|
||||
|
||||
export type LovelaceDashboardBackgroundConfig =
|
||||
string | LovelaceViewBackgroundConfig;
|
||||
|
||||
export interface LovelaceViewHeaderConfig {
|
||||
card?: LovelaceCardConfig;
|
||||
layout?: "start" | "center" | "responsive";
|
||||
@@ -60,7 +63,7 @@ export interface LovelaceBaseViewConfig {
|
||||
show_icon_and_title?: boolean;
|
||||
theme?: string;
|
||||
panel?: boolean;
|
||||
background?: string | LovelaceViewBackgroundConfig;
|
||||
background?: LovelaceDashboardBackgroundConfig;
|
||||
visible?: boolean | ShowViewConfig[];
|
||||
subview?: boolean;
|
||||
back_path?: string;
|
||||
|
||||
+23
-10
@@ -14,6 +14,7 @@ import type {
|
||||
import type { HomeAssistant } from "../types";
|
||||
import {
|
||||
type DeviceRegistryEntry,
|
||||
devicesInEffectiveArea,
|
||||
getDeviceIntegrationLookup,
|
||||
} from "./device/device_registry";
|
||||
import type {
|
||||
@@ -726,9 +727,10 @@ export const expandAreaTarget = (
|
||||
) => {
|
||||
const newEntities: string[] = [];
|
||||
const newDevices: string[] = [];
|
||||
Object.values(devices).forEach((device) => {
|
||||
// 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) => {
|
||||
if (
|
||||
device.area_id === areaId &&
|
||||
deviceMeetsTargetSelector(
|
||||
hass.states,
|
||||
Object.values(entities),
|
||||
@@ -790,9 +792,8 @@ export const areaMeetsTargetSelector = (
|
||||
targetSelector: TargetSelector,
|
||||
entitySources?: EntitySources
|
||||
): boolean => {
|
||||
const hasMatchingdevice = Object.values(devices).some((device) => {
|
||||
if (
|
||||
device.area_id === areaId &&
|
||||
const hasMatchingdevice = devicesInEffectiveArea(devices, areaId).some(
|
||||
(device) =>
|
||||
deviceMeetsTargetSelector(
|
||||
hass.states,
|
||||
Object.values(entities),
|
||||
@@ -800,11 +801,7 @@ export const areaMeetsTargetSelector = (
|
||||
targetSelector,
|
||||
entitySources
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
);
|
||||
if (hasMatchingdevice) {
|
||||
return true;
|
||||
}
|
||||
@@ -846,6 +843,8 @@ 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
|
||||
);
|
||||
@@ -1112,6 +1111,11 @@ 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,
|
||||
@@ -1143,6 +1147,15 @@ 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,
|
||||
|
||||
+8
-4
@@ -7,7 +7,10 @@ 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 type { DeviceRegistryEntry } from "./device/device_registry";
|
||||
import {
|
||||
devicesInEffectiveArea,
|
||||
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";
|
||||
@@ -125,9 +128,7 @@ export const areaMeetsFilter = (
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
includeSecondary = false
|
||||
): boolean => {
|
||||
const areaDevices = Object.values(devices).filter(
|
||||
(device) => device.area_id === area.area_id
|
||||
);
|
||||
const areaDevices = devicesInEffectiveArea(devices, area.area_id);
|
||||
|
||||
if (
|
||||
areaDevices.some((device) =>
|
||||
@@ -178,6 +179,9 @@ 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
|
||||
);
|
||||
|
||||
@@ -7,21 +7,11 @@ import { isNumericEntity } from "../../data/history";
|
||||
import { CONTINUOUS_DOMAINS } from "../../data/logbook";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
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 {
|
||||
isMoreInfoView,
|
||||
MORE_INFO_VIEWS,
|
||||
type MoreInfoView,
|
||||
} from "./more-info-view";
|
||||
|
||||
export const DOMAINS_NO_INFO = ["camera", "configurator"];
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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,6 +11,7 @@ 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";
|
||||
@@ -48,11 +49,16 @@ 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;
|
||||
@@ -127,15 +133,33 @@ class MoreInfoWeather extends LitElement {
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if ((changedProps.has("stateObj") || !this._subscribed) && this.stateObj) {
|
||||
if (
|
||||
(changedProps.has("stateObj") ||
|
||||
changedProps.has("_moreInfoContext") ||
|
||||
!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
|
||||
) {
|
||||
this._forecastType = getDefaultForecastType(this.stateObj);
|
||||
this._subscribeForecastEvents();
|
||||
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();
|
||||
}
|
||||
}
|
||||
} else if (changedProps.has("_forecastType")) {
|
||||
this._subscribeForecastEvents();
|
||||
@@ -509,8 +533,17 @@ class MoreInfoWeather extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleForecastTypeChanged(ev: CustomEvent): void {
|
||||
private _handleForecastTypeChanged(
|
||||
ev: HASSDomEvent<{ name: ModernForecastType }>
|
||||
): void {
|
||||
if (
|
||||
!this.stateObj ||
|
||||
!getSupportedForecastTypes(this.stateObj).includes(ev.detail.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._forecastType = ev.detail.name;
|
||||
this._moreInfoContext?.setHashParam("forecast", this._forecastType);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -17,6 +17,7 @@ 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";
|
||||
@@ -43,8 +44,10 @@ 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";
|
||||
@@ -85,6 +88,7 @@ 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";
|
||||
@@ -102,6 +106,9 @@ export interface MoreInfoDialogParams {
|
||||
tab?: MoreInfoView;
|
||||
large?: boolean;
|
||||
data?: Record<string, any>;
|
||||
hash?: URLSearchParams;
|
||||
fromUrl?: boolean;
|
||||
returnUrl?: string;
|
||||
parentElement?: LitElement;
|
||||
}
|
||||
|
||||
@@ -147,6 +154,12 @@ 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;
|
||||
@@ -181,6 +194,8 @@ 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 = [];
|
||||
@@ -216,6 +231,9 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
if (this._returnUrl) {
|
||||
replaceCurrentUrl(this._returnUrl);
|
||||
}
|
||||
this._entityId = undefined;
|
||||
this._parentEntityIds = [];
|
||||
this._entry = undefined;
|
||||
@@ -224,6 +242,8 @@ 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);
|
||||
@@ -271,7 +291,10 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
return entity?.device_id ?? null;
|
||||
}
|
||||
|
||||
private _setView(view: MoreInfoView) {
|
||||
private _setView(view: MoreInfoView, preserveHash = false) {
|
||||
if (view !== this._currView && !preserveHash) {
|
||||
this._moreInfoContext = this._createMoreInfoContext();
|
||||
}
|
||||
updateHistoryState({
|
||||
dialogParams: {
|
||||
...getHistoryState()?.dialogParams,
|
||||
@@ -279,6 +302,38 @@ 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() {
|
||||
@@ -306,7 +361,9 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,19 +1064,22 @@ 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);
|
||||
this._setView(view, true);
|
||||
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 = () => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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);
|
||||
@@ -6,12 +6,16 @@ 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";
|
||||
@@ -144,6 +148,7 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +183,12 @@ 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);
|
||||
@@ -297,6 +308,35 @@ 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;
|
||||
|
||||
@@ -370,6 +370,7 @@ 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)" : "",
|
||||
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
@@ -429,20 +430,8 @@ export class HaPlatformCondition extends LitElement {
|
||||
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
|
||||
let defaultValue = field?.default;
|
||||
|
||||
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 && field?.selector) {
|
||||
defaultValue = getSelectorFallbackValue(field.selector);
|
||||
}
|
||||
|
||||
if (defaultValue != null) {
|
||||
|
||||
@@ -2,10 +2,12 @@ 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,
|
||||
@@ -129,6 +131,28 @@ 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;
|
||||
@@ -303,6 +327,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
localize: LocalizeFunc,
|
||||
entitiesToCheck?: any[]
|
||||
): DataTableColumnContainer<AutomationItem> => {
|
||||
const triggeredAtColumn = getTriggeredAtTableColumn<AutomationItem>(
|
||||
localize,
|
||||
this.hass
|
||||
);
|
||||
|
||||
const columns: DataTableColumnContainer<AutomationItem> = {
|
||||
icon: {
|
||||
title: "",
|
||||
@@ -310,18 +339,34 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
type: "icon",
|
||||
moveable: false,
|
||||
showNarrow: true,
|
||||
template: (automation) =>
|
||||
html`<ha-state-icon
|
||||
.stateObj=${automation}
|
||||
style=${styleMap({
|
||||
color:
|
||||
automation.state === UNAVAILABLE
|
||||
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
|
||||
? "var(--error-color)"
|
||||
: automation.state === "on"
|
||||
? "var(--state-active-color)"
|
||||
: disabled
|
||||
? "var(--disabled-color)"
|
||||
: "unset",
|
||||
})}
|
||||
></ha-state-icon>`,
|
||||
})}
|
||||
></ha-state-icon>
|
||||
${
|
||||
unavailable
|
||||
? renderIconBadge(mdiExclamationThick, "var(--error-color)")
|
||||
: disabled
|
||||
? renderIconBadge(mdiCloseThick, "var(--disabled-color)")
|
||||
: nothing
|
||||
}
|
||||
</div>`;
|
||||
},
|
||||
},
|
||||
entity_id: getEntityIdHiddenTableColumn(),
|
||||
name: {
|
||||
@@ -342,23 +387,33 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
area: getAreaTableColumn(localize),
|
||||
category: getCategoryTableColumn(localize),
|
||||
labels: getLabelsTableColumn(),
|
||||
last_triggered: getTriggeredAtTableColumn(localize, this.hass),
|
||||
last_triggered: {
|
||||
...triggeredAtColumn,
|
||||
template: (automation) =>
|
||||
narrow && automation.state === "off"
|
||||
? nothing
|
||||
: triggeredAtColumn.template!(automation),
|
||||
},
|
||||
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) => html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
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>
|
||||
`,
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -422,20 +423,8 @@ export class HaPlatformTrigger extends LitElement {
|
||||
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
|
||||
let defaultValue = field?.default;
|
||||
|
||||
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 && field?.selector) {
|
||||
defaultValue = getSelectorFallbackValue(field.selector);
|
||||
}
|
||||
|
||||
if (defaultValue != null) {
|
||||
|
||||
@@ -39,17 +39,22 @@ const ADDON_STAGES: CreateBackupStage[] = [
|
||||
"addon_repositories",
|
||||
"app_repositories",
|
||||
"docker_config",
|
||||
];
|
||||
|
||||
// Add-on restarts are awaited after the backup file is finished, so those
|
||||
// stages belong to the last creation group to keep the progress monotonic
|
||||
const MEDIA_STAGES: CreateBackupStage[] = [
|
||||
"folders",
|
||||
"finishing_file",
|
||||
"await_addon_restarts",
|
||||
"await_app_restarts",
|
||||
];
|
||||
|
||||
const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"];
|
||||
|
||||
// Ordered groups matching actual backend execution order
|
||||
const STAGE_ORDER: CreateBackupStage[][] = [
|
||||
HA_STAGES,
|
||||
ADDON_STAGES,
|
||||
MEDIA_STAGES,
|
||||
HA_STAGES,
|
||||
["upload_to_agents"],
|
||||
["cleaning_up"],
|
||||
];
|
||||
@@ -165,21 +170,21 @@ export class HaBackupOverviewProgress extends LitElement {
|
||||
return [
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.backup.overview.progress.segments.apps"
|
||||
"ui.panel.config.backup.overview.progress.segments.home_assistant"
|
||||
),
|
||||
state: this._getSegmentState(0, currentGroupIndex),
|
||||
flex: 2,
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.backup.overview.progress.segments.media"
|
||||
"ui.panel.config.backup.overview.progress.segments.apps"
|
||||
),
|
||||
state: this._getSegmentState(1, currentGroupIndex),
|
||||
flex: 2,
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.backup.overview.progress.segments.home_assistant"
|
||||
"ui.panel.config.backup.overview.progress.segments.media"
|
||||
),
|
||||
state: this._getSegmentState(2, currentGroupIndex),
|
||||
flex: 2,
|
||||
@@ -201,18 +206,18 @@ export class HaBackupOverviewProgress extends LitElement {
|
||||
];
|
||||
}
|
||||
|
||||
// Non-HAOS: No app segment, just Media, HA, Upload and Cleaning up
|
||||
// Non-HAOS: No app segment, just HA, Media, Upload and Cleaning up
|
||||
return [
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.backup.overview.progress.segments.media"
|
||||
"ui.panel.config.backup.overview.progress.segments.home_assistant"
|
||||
),
|
||||
state: this._getSegmentState(1, currentGroupIndex),
|
||||
state: this._getSegmentState(0, currentGroupIndex),
|
||||
flex: 2,
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.backup.overview.progress.segments.home_assistant"
|
||||
"ui.panel.config.backup.overview.progress.segments.media"
|
||||
),
|
||||
state: this._getSegmentState(2, currentGroupIndex),
|
||||
flex: 2,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
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,6 +93,27 @@ 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,6 +107,7 @@ 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";
|
||||
@@ -900,6 +901,10 @@ 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,6 +459,15 @@ 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
|
||||
@@ -472,6 +481,15 @@ 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,
|
||||
@@ -527,6 +545,29 @@ 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),
|
||||
@@ -587,6 +628,16 @@ 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`
|
||||
@@ -607,6 +658,19 @@ 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,6 +11,8 @@ 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";
|
||||
@@ -23,9 +25,11 @@ import type {
|
||||
EnergyPreferencesValidation,
|
||||
EnergyValidationIssue,
|
||||
} from "../../../../data/energy";
|
||||
import { saveEnergyPreferences } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
saveEnergyPreferences,
|
||||
} from "../../../../data/energy";
|
||||
import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
import { getStatisticLabel } from "../../../../data/recorder";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
@@ -104,18 +108,7 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
<span class="content"
|
||||
>${
|
||||
device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this.statsMetadata?.[
|
||||
device.stat_consumption
|
||||
]
|
||||
)
|
||||
}</span
|
||||
>
|
||||
${this._renderName(device)}
|
||||
${this._renderIssueIndicator(
|
||||
this.validationResult?.device_consumption_water[
|
||||
index
|
||||
@@ -155,6 +148,32 @@ 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
|
||||
@@ -280,6 +299,22 @@ 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,6 +11,8 @@ 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";
|
||||
@@ -23,9 +25,11 @@ import type {
|
||||
EnergyPreferencesValidation,
|
||||
EnergyValidationIssue,
|
||||
} from "../../../../data/energy";
|
||||
import { saveEnergyPreferences } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
saveEnergyPreferences,
|
||||
} from "../../../../data/energy";
|
||||
import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
import { getStatisticLabel } from "../../../../data/recorder";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
@@ -104,18 +108,7 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
<span class="content"
|
||||
>${
|
||||
device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this.statsMetadata?.[
|
||||
device.stat_consumption
|
||||
]
|
||||
)
|
||||
}</span
|
||||
>
|
||||
${this._renderName(device)}
|
||||
${this._renderIssueIndicator(
|
||||
this.validationResult?.device_consumption[
|
||||
index
|
||||
@@ -155,6 +148,32 @@ 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
|
||||
@@ -276,6 +295,22 @@ 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,9 +11,11 @@ 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 {
|
||||
getStatisticLabel,
|
||||
computeEnergyLabel,
|
||||
energyStatisticHelpUrl,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
} from "../../../../data/recorder";
|
||||
@@ -174,7 +176,7 @@ export class DialogEnergyDeviceSettingsWater
|
||||
.value=${this._device?.name || ""}
|
||||
.placeholder=${
|
||||
this._device
|
||||
? getStatisticLabel(
|
||||
? computeEnergyLabel(
|
||||
this.hass,
|
||||
this._device.stat_consumption,
|
||||
this._params?.statsMetadata?.[this._device.stat_consumption]
|
||||
|
||||
@@ -11,9 +11,11 @@ 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 {
|
||||
getStatisticLabel,
|
||||
computeEnergyLabel,
|
||||
energyStatisticHelpUrl,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
} from "../../../../data/recorder";
|
||||
@@ -170,7 +172,7 @@ export class DialogEnergyDeviceSettings
|
||||
.value=${this._device?.name || ""}
|
||||
.placeholder=${
|
||||
this._device
|
||||
? getStatisticLabel(
|
||||
? computeEnergyLabel(
|
||||
this.hass,
|
||||
this._device.stat_consumption,
|
||||
this._params?.statsMetadata?.[this._device.stat_consumption]
|
||||
|
||||
@@ -7,7 +7,6 @@ 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";
|
||||
@@ -15,6 +14,7 @@ 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,20 +73,18 @@ 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,
|
||||
primary: name || entityName || deviceName || statisticId,
|
||||
secondary,
|
||||
// Match the label shown in the device list and the graphs.
|
||||
primary: computeEnergyLabel(
|
||||
this.hass,
|
||||
statisticId,
|
||||
this.statsMetadata?.[statisticId],
|
||||
name
|
||||
),
|
||||
secondary: areaName,
|
||||
stateObj,
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
|
||||
@@ -10,12 +10,15 @@ 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,
|
||||
@@ -48,6 +51,13 @@ 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;
|
||||
|
||||
@@ -55,6 +65,11 @@ 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,
|
||||
@@ -65,6 +80,25 @@ 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"
|
||||
@@ -351,12 +385,22 @@ 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,6 +45,7 @@ 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 {
|
||||
@@ -479,14 +480,16 @@ export class HaConfigEntryRow extends LitElement {
|
||||
</ha-md-list-item>
|
||||
${
|
||||
this._devicesExpanded
|
||||
? ownDevices.map(
|
||||
(device) =>
|
||||
? groupDevicesByParent(ownDevices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
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
|
||||
@@ -509,14 +512,16 @@ export class HaConfigEntryRow extends LitElement {
|
||||
`
|
||||
)}`
|
||||
: html`
|
||||
${ownDevices.map(
|
||||
(device) =>
|
||||
${groupDevicesByParent(ownDevices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
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,6 +15,7 @@ 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";
|
||||
@@ -191,14 +192,16 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
${
|
||||
this._expanded
|
||||
? html`
|
||||
${devices.map(
|
||||
(device) =>
|
||||
${groupDevicesByParent(devices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
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,29 +1,46 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
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 {
|
||||
fireEvent,
|
||||
type HASSDomEvent,
|
||||
} 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 {
|
||||
LovelaceDashboard,
|
||||
LovelaceDashboardCreateParams,
|
||||
LovelaceDashboardMutableParams,
|
||||
} from "../../../../data/lovelace/dashboard";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
|
||||
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
} 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<
|
||||
Partial<LovelaceDashboard>
|
||||
>()(LitElement) {
|
||||
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<DashboardDetailState>()(
|
||||
LitElement
|
||||
) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: LovelaceDashboardDetailsDialogParams;
|
||||
@@ -38,39 +55,59 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
public showDialog(params: LovelaceDashboardDetailsDialogParams): void {
|
||||
@state() private _currTab: (typeof TABS)[number] = TABS[0];
|
||||
|
||||
@state() private _backgroundConfig?: LovelaceConfig;
|
||||
|
||||
public showDialog(params: LovelaceDashboardDetailsDialogParams) {
|
||||
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" }, this._data);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
dashboard: this._params.dashboard,
|
||||
background: this._params.lovelaceConfig?.background,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const suggestions = this._params.suggestions;
|
||||
this._data = {
|
||||
show_in_sidebar: true,
|
||||
icon: suggestions?.icon,
|
||||
title: suggestions?.title ?? "",
|
||||
icon: this._params.suggestions?.icon,
|
||||
title: this._params.suggestions?.title ?? "",
|
||||
require_admin: false,
|
||||
mode: "storage",
|
||||
};
|
||||
// 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._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
dashboard: {},
|
||||
background: this._params.lovelaceConfig?.background,
|
||||
}
|
||||
);
|
||||
if (this._params.suggestions?.title) {
|
||||
this._fillUrlPath(this._params.suggestions.title);
|
||||
}
|
||||
this._updateDirtyState(this._data!);
|
||||
this._updateDirtyState({
|
||||
dashboard: this._data,
|
||||
background: this._backgroundConfig?.background,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
public closeDialog() {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
private _dialogClosed() {
|
||||
this._params = undefined;
|
||||
this._data = undefined;
|
||||
this._backgroundConfig = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
@@ -82,6 +119,18 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
|
||||
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
|
||||
@@ -96,41 +145,43 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
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"
|
||||
)
|
||||
}
|
||||
header-title=${showBackgroundTab ? nothing : dialogTitle}
|
||||
width=${showBackgroundTab ? "large" : "medium"}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<div>
|
||||
${
|
||||
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
|
||||
${
|
||||
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>
|
||||
`
|
||||
)}
|
||||
.data=${this._data}
|
||||
.hass=${this.hass}
|
||||
.error=${this._error}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`
|
||||
}
|
||||
</ha-tab-group>
|
||||
</ha-dialog-header>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div>
|
||||
${this._renderContent(this._params, this._data, showBackgroundTab)}
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
${
|
||||
@@ -185,6 +236,46 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
|
||||
`;
|
||||
}
|
||||
|
||||
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) =>
|
||||
[
|
||||
@@ -251,15 +342,16 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
|
||||
)
|
||||
: "";
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
private _valueChanged(
|
||||
ev: HASSDomEvent<{ value: Partial<LovelaceDashboard> }>
|
||||
) {
|
||||
this._error = undefined;
|
||||
const value = ev.detail.value;
|
||||
if (value.url_path !== this._data?.url_path) {
|
||||
if (ev.detail.value.url_path !== this._data?.url_path) {
|
||||
this._urlPathChanged = true;
|
||||
if (
|
||||
!value.url_path ||
|
||||
value.url_path === "lovelace" ||
|
||||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(value.url_path)
|
||||
!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)
|
||||
) {
|
||||
this._error = {
|
||||
url_path: this.hass.localize(
|
||||
@@ -268,13 +360,30 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
|
||||
};
|
||||
}
|
||||
}
|
||||
if (value.title !== this._data?.title) {
|
||||
this._data = value;
|
||||
this._fillUrlPath(value.title);
|
||||
if (ev.detail.value.title !== this._data?.title) {
|
||||
this._data = ev.detail.value;
|
||||
if (ev.detail.value.title) {
|
||||
this._fillUrlPath(ev.detail.value.title);
|
||||
}
|
||||
} else {
|
||||
this._data = value;
|
||||
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._updateDirtyState(this._data!);
|
||||
}
|
||||
|
||||
private _fillUrlPath(title: string) {
|
||||
@@ -286,36 +395,55 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
|
||||
const baseSlug = slugifyTitle.includes("-")
|
||||
? slugifyTitle
|
||||
: `dashboard-${slugifyTitle}`;
|
||||
const taken = this._params?.takenUrlPaths;
|
||||
this._data = {
|
||||
...this._data,
|
||||
url_path:
|
||||
taken !== undefined
|
||||
? pickAvailableDashboardUrlPath(baseSlug, taken)
|
||||
this._params?.takenUrlPaths !== undefined
|
||||
? pickAvailableDashboardUrlPath(baseSlug, this._params.takenUrlPaths)
|
||||
: baseSlug,
|
||||
};
|
||||
this._updateDirtyState(this._data!);
|
||||
this._updateDirtyState({
|
||||
dashboard: this._data,
|
||||
background: this._backgroundConfig?.background,
|
||||
});
|
||||
}
|
||||
|
||||
private async _updateDashboard() {
|
||||
if (this._params?.urlPath && this._params.dashboard?.mode === "yaml") {
|
||||
if (!this._params || !this._data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._params.urlPath && this._params.dashboard?.mode === "yaml") {
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
this._submitting = true;
|
||||
try {
|
||||
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
|
||||
);
|
||||
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);
|
||||
}
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
@@ -339,10 +467,25 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -351,7 +494,30 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [haStyleDialog, css``];
|
||||
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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import type {
|
||||
LovelaceDashboard,
|
||||
LovelaceDashboardCreateParams,
|
||||
@@ -17,6 +18,8 @@ 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>
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,50 @@
|
||||
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 { 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-icon-next";
|
||||
import "../../components/ha-tooltip";
|
||||
import "../../components/user/ha-user-badge";
|
||||
import { UNAVAILABLE } from "../../data/entity/entity";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import type { TraceContexts } from "../../data/trace";
|
||||
import type { User } from "../../data/user";
|
||||
import { buttonLinkStyle, haStyle } from "../../resources/styles";
|
||||
import { 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 {
|
||||
computeLogbookItem,
|
||||
nodeColor,
|
||||
TRIGGER_DOMAINS,
|
||||
} from "./logbook-entry-model";
|
||||
renderLogbookCauseIcon,
|
||||
renderLogbookGlyph,
|
||||
transitionArrow,
|
||||
} from "./logbook-entry-templates";
|
||||
|
||||
type EntryLayout = "timeline" | "list" | "inline";
|
||||
|
||||
interface LogbookRenderItem extends LogbookItem {
|
||||
traceLink: string | undefined;
|
||||
renderedTime: TemplateResult | string;
|
||||
renderedTime: string;
|
||||
renderedValue: TemplateResult | string;
|
||||
}
|
||||
|
||||
// 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 interface LogbookEntrySelectedDetail {
|
||||
item: LogbookEntry;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"logbook-entry-selected": LogbookEntrySelectedDetail;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("ha-logbook-entry")
|
||||
class HaLogbookEntry extends LitElement {
|
||||
@@ -65,7 +57,7 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public systemUserIds = new Set<string>();
|
||||
|
||||
@property({ attribute: false }) public traceContexts: TraceContexts = {};
|
||||
@property({ type: Boolean, attribute: "no-detail" }) public noDetail = false;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@@ -80,8 +72,6 @@ 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;
|
||||
|
||||
@@ -99,37 +89,28 @@ 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 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 renderedTime = formatTimeWithSeconds(
|
||||
new Date(item.when),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
);
|
||||
|
||||
const ctx: LogbookRenderItem = {
|
||||
...item,
|
||||
traceLink,
|
||||
renderedTime,
|
||||
renderedValue: this._renderValue(item.value, seenEntityIds, !!traceLink),
|
||||
renderedValue: this._renderValue(item.value, seenEntityIds),
|
||||
};
|
||||
|
||||
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({
|
||||
@@ -138,17 +119,16 @@ 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"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click=${this._toggleTime}
|
||||
@keydown=${this._timeKeydown}
|
||||
>
|
||||
? html`<div class="time">
|
||||
<span class="time-content">${renderedTime}</span>
|
||||
</div>`
|
||||
: nothing
|
||||
@@ -161,109 +141,73 @@ class HaLogbookEntry extends LitElement {
|
||||
>
|
||||
${this._renderNode(ctx, layout)}
|
||||
</div>
|
||||
<div class="content">
|
||||
${
|
||||
layout === "timeline"
|
||||
? this._renderTimeline(ctx)
|
||||
: layout === "list"
|
||||
? this._renderList(ctx)
|
||||
: this._renderInline(ctx)
|
||||
}
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleTime(e: Event) {
|
||||
e.stopPropagation();
|
||||
fireEvent(this, "logbook-toggle-time" as any);
|
||||
}
|
||||
|
||||
private _timeKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
fireEvent(this, "logbook-toggle-time" as any);
|
||||
private _rowClicked() {
|
||||
if (this.noDetail) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "logbook-entry-selected", { item: this.item });
|
||||
}
|
||||
|
||||
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) {
|
||||
private _rowKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key !== "Enter" && ev.key !== " ") {
|
||||
return;
|
||||
}
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
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
|
||||
>`;
|
||||
this._rowClicked();
|
||||
}
|
||||
|
||||
private _renderTrailing(
|
||||
cause: LogbookCause | undefined,
|
||||
traceLink: string | undefined,
|
||||
renderedTime: TemplateResult | string
|
||||
renderedTime: string
|
||||
) {
|
||||
return html`<span class="trailing">
|
||||
${
|
||||
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)}
|
||||
${cause ? this._renderCauseBadge(cause) : nothing}
|
||||
<span class="time-chip">${renderedTime}</span>
|
||||
</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 _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 _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._renderEntity(ctx.entityId, ctx.name)}</span
|
||||
>${this._entityName(ctx.entityId, ctx.name)}</span
|
||||
>${
|
||||
ctx.renderedValue
|
||||
? valueIsState
|
||||
? html`<span class="arrow">${rtl ? "←" : "→"}</span>`
|
||||
? html`<span class="arrow"
|
||||
>${transitionArrow(this.hass)}</span
|
||||
>`
|
||||
: " "
|
||||
: nothing
|
||||
}`
|
||||
@@ -279,15 +223,9 @@ class HaLogbookEntry extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
causePhrase || ctx.traceLink
|
||||
ctx.cause
|
||||
? html`<div class="secondary">
|
||||
${
|
||||
causePhrase
|
||||
? html`<span class="cause-phrase">${causePhrase}</span>`
|
||||
: nothing
|
||||
}
|
||||
${causePhrase && ctx.traceLink ? html`·` : nothing}
|
||||
${ctx.traceLink ? this._renderTraceLink(ctx.traceLink) : nothing}
|
||||
${this._renderCauseLine(ctx.cause)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
@@ -295,16 +233,11 @@ class HaLogbookEntry extends LitElement {
|
||||
}
|
||||
|
||||
private _renderList(ctx: LogbookRenderItem) {
|
||||
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);
|
||||
const cause = ctx.cause;
|
||||
const showThirdLine = this.showCause && cause;
|
||||
return html`
|
||||
<div class="primary">
|
||||
<span class="subject"
|
||||
>${this._renderEntity(ctx.entityId, ctx.name)}</span
|
||||
>
|
||||
<span class="subject">${this._entityName(ctx.entityId, ctx.name)}</span>
|
||||
<span class="value" title=${ctx.value?.text ?? ""}
|
||||
>${ctx.renderedValue}</span
|
||||
>
|
||||
@@ -313,27 +246,18 @@ 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._renderListCauseLine(cause, thirdLineTrace)}
|
||||
</div>`
|
||||
? html`<div class="secondary">${this._renderCauseLine(cause)}</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderListCauseLine(
|
||||
cause: LogbookCause | undefined,
|
||||
traceLink: string | undefined
|
||||
) {
|
||||
if (!cause) {
|
||||
return traceLink ? this._renderTraceLink(traceLink) : nothing;
|
||||
}
|
||||
private _renderCauseLine(cause: LogbookCause) {
|
||||
const { localize } = this.hass;
|
||||
if (cause.entityId) {
|
||||
const prefixMap: Partial<Record<LogbookCauseType, string>> = {
|
||||
@@ -348,115 +272,67 @@ class HaLogbookEntry extends LitElement {
|
||||
}),
|
||||
};
|
||||
const prefix = prefixMap[cause.type];
|
||||
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">
|
||||
${prefix ?? nothing}<span class="cause-entity">${cause.name}</span>
|
||||
</span>`;
|
||||
}
|
||||
return html`
|
||||
<span class="secondary-text">${this._renderCausePhrase(cause)}</span>
|
||||
${traceLink ? this._renderTraceLink(traceLink) : nothing}
|
||||
`;
|
||||
return html`<span class="cause-line"
|
||||
>${this._renderCausePhrase(cause)}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
private _renderInline(ctx: LogbookRenderItem) {
|
||||
return html`
|
||||
<div class="primary">
|
||||
<span class="primary-text">${ctx.renderedValue}</span>
|
||||
${this._renderTrailing(
|
||||
ctx.category === "entity" ? ctx.cause : undefined,
|
||||
ctx.traceLink,
|
||||
ctx.renderedTime
|
||||
)}
|
||||
${this._renderTrailing(ctx.cause, ctx.renderedTime)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderValue(
|
||||
value: LogbookValue | undefined,
|
||||
seenEntityIds: string[],
|
||||
noLink: boolean
|
||||
seenEntityIds: string[]
|
||||
): TemplateResult | string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
return value.type === "message"
|
||||
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds, noLink)
|
||||
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds)
|
||||
: value.text;
|
||||
}
|
||||
|
||||
private _renderEntity(
|
||||
private _entityName(
|
||||
entityId: string | undefined,
|
||||
entityName: string | undefined,
|
||||
noLink?: boolean
|
||||
entityName: string | undefined
|
||||
) {
|
||||
const hasState = entityId && entityId in this.hass.states;
|
||||
const displayName =
|
||||
return (
|
||||
entityName ||
|
||||
(hasState
|
||||
(entityId && entityId in this.hass.states
|
||||
? this.hass.states[entityId].attributes.friendly_name || entityId
|
||||
: entityId);
|
||||
if (!hasState) {
|
||||
return displayName;
|
||||
}
|
||||
return noLink
|
||||
? displayName
|
||||
: html`<button
|
||||
class="link"
|
||||
@click=${this._entityClicked}
|
||||
.entityId=${entityId}
|
||||
>
|
||||
${displayName}
|
||||
</button>`;
|
||||
: entityId)
|
||||
);
|
||||
}
|
||||
|
||||
private _renderCausePhrase(cause: LogbookCause): TemplateResult | string {
|
||||
private _renderCausePhrase(cause: LogbookCause): 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 cause.entityId
|
||||
? html`${localize("ui.components.logbook.cause.by_automation", {
|
||||
name: "",
|
||||
})}${nameEl}`
|
||||
: localize("ui.components.logbook.cause.by_automation", {
|
||||
name: cause.name,
|
||||
});
|
||||
return localize("ui.components.logbook.cause.by_automation", {
|
||||
name: cause.name,
|
||||
});
|
||||
case "script":
|
||||
return cause.entityId
|
||||
? html`${localize("ui.components.logbook.cause.by_script", {
|
||||
name: "",
|
||||
})}${nameEl}`
|
||||
: localize("ui.components.logbook.cause.by_script", {
|
||||
name: cause.name,
|
||||
});
|
||||
return localize("ui.components.logbook.cause.by_script", {
|
||||
name: cause.name,
|
||||
});
|
||||
case "state":
|
||||
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,
|
||||
});
|
||||
return localize("ui.components.logbook.cause.by_state_change", {
|
||||
name: cause.name,
|
||||
});
|
||||
case "scheduled":
|
||||
return localize("ui.components.logbook.cause.scheduled");
|
||||
case "homeassistant":
|
||||
@@ -470,54 +346,9 @@ 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[],
|
||||
noLink?: boolean
|
||||
seenEntities: string[]
|
||||
) {
|
||||
if (message.indexOf(".") !== -1) {
|
||||
const messageParts = message.split(" ");
|
||||
@@ -531,10 +362,9 @@ class HaLogbookEntry extends LitElement {
|
||||
const messageEnd = messageParts.splice(i);
|
||||
messageEnd.shift();
|
||||
return html`${messageParts.join(" ")}
|
||||
${this._renderEntity(
|
||||
${this._entityName(
|
||||
entityId,
|
||||
this.hass.states[entityId].attributes.friendly_name,
|
||||
noLink
|
||||
this.hass.states[entityId].attributes.friendly_name
|
||||
)}
|
||||
${messageEnd.join(" ")}`;
|
||||
}
|
||||
@@ -571,53 +401,14 @@ class HaLogbookEntry extends LitElement {
|
||||
const unavailable =
|
||||
item.glyph.type === "state" && item.glyph.stateObj.state === UNAVAILABLE;
|
||||
return html`<div class="node-glyph" style=${style}>
|
||||
${this._renderGlyph(item.glyph)}
|
||||
${renderLogbookGlyph(this.hass, this.item, 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;
|
||||
@@ -672,6 +463,20 @@ 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;
|
||||
@@ -681,12 +486,6 @@ 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 {
|
||||
@@ -831,19 +630,33 @@ 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
|
||||
@@ -874,6 +687,13 @@ 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;
|
||||
}
|
||||
@@ -895,14 +715,6 @@ 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);
|
||||
}
|
||||
@@ -951,33 +763,20 @@ 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);
|
||||
@@ -991,44 +790,19 @@ class HaLogbookEntry extends LitElement {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.cause-prefix {
|
||||
flex-shrink: 0;
|
||||
/* The cause reads as one sentence on one line, truncating as a whole. */
|
||||
.cause-line {
|
||||
flex: 1;
|
||||
min-width: 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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ 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, state } from "lit/decorators";
|
||||
import { customElement, eventOptions, property } 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";
|
||||
@@ -13,13 +14,14 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +34,7 @@ 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[] = [];
|
||||
@@ -49,14 +52,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) ||
|
||||
@@ -77,8 +80,9 @@ class HaLogbookRenderer extends LitElement {
|
||||
|
||||
return (
|
||||
changedProps.has("entries") ||
|
||||
changedProps.has("traceContexts") ||
|
||||
changedProps.has("_showRelative" as never) ||
|
||||
changedProps.has("noDetail") ||
|
||||
changedProps.has("userIdToName") ||
|
||||
changedProps.has("systemUserIds") ||
|
||||
languageChanged
|
||||
);
|
||||
}
|
||||
@@ -96,7 +100,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
<div
|
||||
class="container ha-scrollbar"
|
||||
@scroll=${this._saveScrollPos}
|
||||
@logbook-toggle-time=${this._handleToggleTime}
|
||||
@logbook-entry-selected=${this._handleEntrySelected}
|
||||
>
|
||||
${
|
||||
this.virtualize
|
||||
@@ -105,7 +109,12 @@ class HaLogbookRenderer extends LitElement {
|
||||
scroller
|
||||
class="ha-scrollbar"
|
||||
.items=${this.entries}
|
||||
.renderItem=${this._getRenderRow(this._showRelative) as any}
|
||||
.renderItem=${
|
||||
this._getRenderRow(
|
||||
this.userIdToName,
|
||||
this.systemUserIds
|
||||
) as any
|
||||
}
|
||||
>
|
||||
</lit-virtualizer>`
|
||||
: this.entries.map((item, index) => this._renderItem(item, index))
|
||||
@@ -114,9 +123,12 @@ 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(
|
||||
(_showRelative: boolean) => (item: LogbookEntry, index: number) =>
|
||||
this._renderItem(item, index)
|
||||
(_userIdToName: Record<string, string>, _systemUserIds: Set<string>) =>
|
||||
(item: LogbookEntry, index: number) =>
|
||||
this._renderItem(item, index)
|
||||
);
|
||||
|
||||
private _renderItem = (item: LogbookEntry, index: number) => {
|
||||
@@ -141,22 +153,27 @@ 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 _handleToggleTime() {
|
||||
this._showRelative = !this._showRelative;
|
||||
private _handleEntrySelected(ev: HASSDomEvent<LogbookEntrySelectedDetail>) {
|
||||
ev.stopPropagation();
|
||||
showLogbookDetailDialog(this, {
|
||||
entry: ev.detail.item,
|
||||
traceContexts: this.traceContexts,
|
||||
userIdToName: this.userIdToName,
|
||||
systemUserIds: this.systemUserIds,
|
||||
});
|
||||
}
|
||||
|
||||
private _formatDateHeader(date: Date): string {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
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 };
|
||||
};
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
localizeStateMessage,
|
||||
parseTriggerSource,
|
||||
} from "../../data/logbook";
|
||||
import type { TraceContexts } from "../../data/trace";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export type LogbookEntryCategory = "entity" | "automation" | "integration";
|
||||
|
||||
export const TRIGGER_DOMAINS = ["automation", "script"];
|
||||
const TRIGGER_DOMAINS = ["automation", "script"];
|
||||
|
||||
export const stripEntityId = (message: string, entityId?: string) =>
|
||||
const stripEntityId = (message: string, entityId?: string) =>
|
||||
entityId ? message.replace(entityId, " ") : message;
|
||||
|
||||
export const classifyLogbookEntry = (
|
||||
@@ -35,6 +36,10 @@ 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).
|
||||
@@ -96,12 +101,31 @@ export const entityDisplay = (
|
||||
return { primary, secondary };
|
||||
};
|
||||
|
||||
export const hasContext = (item: LogbookEntry) =>
|
||||
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,
|
||||
@@ -131,8 +155,7 @@ export interface LogbookCause {
|
||||
brandDomain?: string;
|
||||
}
|
||||
|
||||
export const computeLogbookCause = (
|
||||
hass: HomeAssistant,
|
||||
export const computeUserCause = (
|
||||
item: LogbookEntry,
|
||||
userIdToName: Record<string, string>,
|
||||
systemUserIds?: Set<string>
|
||||
@@ -140,15 +163,21 @@ export const computeLogbookCause = (
|
||||
const userName = item.context_user_id
|
||||
? userIdToName[item.context_user_id]
|
||||
: undefined;
|
||||
if (userName) {
|
||||
return {
|
||||
type: "user",
|
||||
name: userName,
|
||||
userId: item.context_user_id,
|
||||
systemUser: systemUserIds?.has(item.context_user_id!),
|
||||
};
|
||||
if (!userName) {
|
||||
return undefined;
|
||||
}
|
||||
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"
|
||||
@@ -239,6 +268,18 @@ export const computeLogbookCause = (
|
||||
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 }
|
||||
@@ -284,10 +325,11 @@ 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 =
|
||||
domain &&
|
||||
TRIGGER_DOMAINS.includes(domain) &&
|
||||
(item.source || hasContext(item) || !!item.context_user_id);
|
||||
item.entity_id && domain && TRIGGER_DOMAINS.includes(domain);
|
||||
if (isAutomationRun) {
|
||||
return {
|
||||
text: hass.localize(
|
||||
@@ -348,6 +390,13 @@ 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),
|
||||
@@ -355,12 +404,10 @@ export const computeLogbookItem = (
|
||||
name: display?.primary ?? entry.name,
|
||||
context: display?.secondary,
|
||||
value: computeLogbookValue(hass, entry, domain, historicStateObj),
|
||||
cause: computeLogbookCause(
|
||||
hass,
|
||||
entry,
|
||||
opts.userIdToName ?? {},
|
||||
opts.systemUserIds
|
||||
),
|
||||
// A row shows the run over the user who started it; the dialog shows both.
|
||||
cause: isRunCause(contextCause)
|
||||
? contextCause
|
||||
: (userCause ?? contextCause),
|
||||
when: entry.when * 1000,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
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>`;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
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,5 +1,6 @@
|
||||
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 {
|
||||
@@ -32,22 +33,32 @@ 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) => html`
|
||||
${this.features.map((feature, index) => {
|
||||
const column = index % columns;
|
||||
return 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>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -55,20 +66,38 @@ 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: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-card-feature-gap, 12px);
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--ha-card-feature-gap, 12px) var(--feature-column-gap);
|
||||
box-sizing: border-box;
|
||||
justify-content: space-evenly;
|
||||
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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import {
|
||||
consumeEntityState,
|
||||
@@ -202,14 +202,7 @@ class HuiMediaPlayerPlaybackCardFeature
|
||||
});
|
||||
}
|
||||
|
||||
static styles = [
|
||||
cardFeatureStyles,
|
||||
css`
|
||||
ha-control-button-group {
|
||||
overflow: hidden;
|
||||
}
|
||||
`,
|
||||
];
|
||||
static styles = cardFeatureStyles;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -8,13 +8,13 @@ import type {
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
computeEnergyDeviceLabels,
|
||||
getSuggestedPeriod,
|
||||
getSummedData,
|
||||
} from "../../../../data/energy";
|
||||
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
|
||||
import type { Statistics } 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,12 +167,7 @@ function processDataSet(
|
||||
}
|
||||
|
||||
const name =
|
||||
(source.name ||
|
||||
getStatisticLabel(
|
||||
ctx.hass,
|
||||
source.stat_consumption,
|
||||
statisticsMetaData[source.stat_consumption]
|
||||
)) +
|
||||
ctx.deviceLabels[source.stat_consumption] +
|
||||
(source.stat_consumption in childMap
|
||||
? ` (${ctx.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked")})`
|
||||
: "");
|
||||
@@ -351,6 +346,8 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
const data = energyData.stats;
|
||||
const compareData = energyData.statsCompare;
|
||||
|
||||
const devices = energyData.prefs.device_consumption;
|
||||
|
||||
const ctx: ProcessContext = {
|
||||
hass,
|
||||
config,
|
||||
@@ -358,10 +355,13 @@ 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,7 +425,6 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
ctx,
|
||||
computedStyles,
|
||||
compareData,
|
||||
energyData.statsMetadata,
|
||||
energyData.prefs.device_consumption,
|
||||
sorted_devices,
|
||||
childMap,
|
||||
@@ -468,7 +467,6 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
ctx,
|
||||
computedStyles,
|
||||
data,
|
||||
energyData.statsMetadata,
|
||||
energyData.prefs.device_consumption,
|
||||
sorted_devices,
|
||||
childMap,
|
||||
|
||||
@@ -16,6 +16,7 @@ import "../../../../components/chart/ha-chart-tooltip-marker";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
computeEnergyDeviceLabels,
|
||||
getEnergyDataCollection,
|
||||
getSummedData,
|
||||
validateEnergyCollectionKey,
|
||||
@@ -91,6 +92,8 @@ export class HuiEnergyDevicesGraphCard
|
||||
|
||||
private _compoundStats: string[] = [];
|
||||
|
||||
private _deviceLabels: Record<string, string> = {};
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
@@ -295,9 +298,8 @@ export class HuiEnergyDevicesGraphCard
|
||||
? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})`
|
||||
: "";
|
||||
return (
|
||||
(this._data?.prefs.device_consumption.find(
|
||||
(d) => d.stat_consumption === statisticId
|
||||
)?.name ||
|
||||
// The untracked slice is not a statistic, so it has no label.
|
||||
(this._deviceLabels[statisticId] ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statisticId,
|
||||
@@ -377,6 +379,12 @@ 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) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import "../../../../components/ha-svg-icon";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
computeEnergyDeviceLabels,
|
||||
energySourcesByType,
|
||||
getEnergyDataCollection,
|
||||
getSummedData,
|
||||
@@ -272,8 +273,14 @@ class HuiEnergySankeyCard
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
prefs.device_consumption,
|
||||
this._data.statsMetadata
|
||||
);
|
||||
|
||||
const deviceLabel = (statConsumption: string) =>
|
||||
deviceLabels[statConsumption] ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statConsumption,
|
||||
|
||||
@@ -7,6 +7,7 @@ import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyDeviceLabels,
|
||||
formatPowerShort,
|
||||
getEnergyDataCollection,
|
||||
getPowerFromState,
|
||||
@@ -278,6 +279,13 @@ class HuiPowerSankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
prefs.device_consumption,
|
||||
this._data.statsMetadata,
|
||||
"stat_rate"
|
||||
);
|
||||
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
@@ -294,7 +302,7 @@ class HuiPowerSankeyCard
|
||||
initialUntracked: homeNode.value,
|
||||
getId: (device) => device.stat_rate,
|
||||
getValue: (id) => this._getCurrentPower(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
@@ -36,6 +36,10 @@ import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../card-features/hui-card-features";
|
||||
import {
|
||||
computeCardFeatureLayout,
|
||||
computeCardFeatureRows,
|
||||
} from "../card-features/common/feature-layout";
|
||||
import type { LovelaceCardFeatureContext } from "../card-features/types";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
@@ -151,14 +155,12 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
const featuresPosition =
|
||||
this._config && this._featurePosition(this._config);
|
||||
const displayType = this._config?.display_type || "picture";
|
||||
const featuresCount = this._config?.features?.length || 0;
|
||||
const featureRows = this._config ? this._featureRows(this._config) : 0;
|
||||
return (
|
||||
1 +
|
||||
(displayType === "compact" ? (this._config?.vertical ? 1 : 0) : 2) +
|
||||
(featuresPosition === "inline" ? 0 : featuresCount)
|
||||
featureRows
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,13 +172,12 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
? this._featurePosition(this._config)
|
||||
: "bottom";
|
||||
const featuresCount = this._config?.features?.length || 0;
|
||||
if (featuresCount) {
|
||||
if (this._config && featuresCount) {
|
||||
if (featurePosition === "inline") {
|
||||
min_columns = 12;
|
||||
columns = 12;
|
||||
} else {
|
||||
rows += featuresCount;
|
||||
}
|
||||
rows += this._featureRows(this._config);
|
||||
}
|
||||
|
||||
const displayType = this._config?.display_type || "picture";
|
||||
@@ -555,15 +556,13 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
return config.features_position || "bottom";
|
||||
});
|
||||
|
||||
private _displayedFeatures = memoizeOne((config: AreaCardConfig) => {
|
||||
const features = config.features || [];
|
||||
const featurePosition = this._featurePosition(config);
|
||||
private _featureLayout = memoizeOne((config: AreaCardConfig) =>
|
||||
computeCardFeatureLayout(config.features, this._featurePosition(config))
|
||||
);
|
||||
|
||||
if (featurePosition === "inline") {
|
||||
return features.slice(0, 1);
|
||||
}
|
||||
return features;
|
||||
});
|
||||
private _featureRows = memoizeOne((config: AreaCardConfig) =>
|
||||
computeCardFeatureRows(config.features, this._featurePosition(config))
|
||||
);
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (changedProps.has("_config") || this._ratio === null) {
|
||||
@@ -601,7 +600,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
const secondary = this._computeSensorsDisplay();
|
||||
|
||||
const featurePosition = this._featurePosition(this._config);
|
||||
const features = this._displayedFeatures(this._config);
|
||||
const features = this._featureLayout(this._config);
|
||||
|
||||
const displayType = this._config.display_type || "picture";
|
||||
|
||||
@@ -620,8 +619,11 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
"--tile-color": color,
|
||||
};
|
||||
|
||||
/* the picture takes the extra height, so only the compact type reserves a row */
|
||||
const fixedInfoHeight =
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
displayType === "compact" &&
|
||||
this.layout === "grid" &&
|
||||
this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)}>
|
||||
@@ -710,19 +712,34 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
.secondary=${secondary}
|
||||
></ha-tile-info>
|
||||
${
|
||||
features.length > 0
|
||||
features.inline.length > 0
|
||||
? html`
|
||||
<hui-card-features
|
||||
slot="features"
|
||||
slot="features-inline"
|
||||
.hass=${this.hass}
|
||||
.context=${this._featureContext}
|
||||
.color=${this._config.color}
|
||||
.features=${features}
|
||||
.features=${features.inline}
|
||||
.position=${featurePosition}
|
||||
></hui-card-features>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
features.below.length > 0
|
||||
? html`
|
||||
<hui-card-features
|
||||
slot="features"
|
||||
.columns=${features.columns}
|
||||
.hass=${this.hass}
|
||||
.context=${this._featureContext}
|
||||
.color=${this._config.color}
|
||||
.features=${features.below}
|
||||
.position=${"bottom"}
|
||||
></hui-card-features>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-tile-container>
|
||||
</ha-card>
|
||||
`;
|
||||
|
||||
@@ -22,6 +22,10 @@ import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import "../../../state-display/state-display";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../card-features/hui-card-features";
|
||||
import {
|
||||
computeCardFeatureLayout,
|
||||
computeCardFeatureRows,
|
||||
} from "../card-features/common/feature-layout";
|
||||
import type { LovelaceCardFeatureContext } from "../card-features/types";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
@@ -107,14 +111,8 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
const featuresPosition =
|
||||
this._config && this._featurePosition(this._config);
|
||||
const featuresCount = this._config?.features?.length || 0;
|
||||
return (
|
||||
1 +
|
||||
(this._config?.vertical ? 1 : 0) +
|
||||
(featuresPosition === "inline" ? 0 : featuresCount)
|
||||
);
|
||||
const featureRows = this._config ? this._featureRows(this._config) : 0;
|
||||
return 1 + (this._config?.vertical ? 1 : 0) + featureRows;
|
||||
}
|
||||
|
||||
public getGridOptions(): LovelaceGridOptions {
|
||||
@@ -123,12 +121,11 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
let rows = 1;
|
||||
const featurePosition = this._config && this._featurePosition(this._config);
|
||||
const featuresCount = this._config?.features?.length || 0;
|
||||
if (featuresCount) {
|
||||
if (this._config && featuresCount) {
|
||||
if (featurePosition === "inline") {
|
||||
min_columns = 12;
|
||||
} else {
|
||||
rows += featuresCount;
|
||||
}
|
||||
rows += this._featureRows(this._config);
|
||||
}
|
||||
|
||||
if (this._config?.vertical) {
|
||||
@@ -234,15 +231,13 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
return config.features_position || "bottom";
|
||||
});
|
||||
|
||||
private _displayedFeatures = memoizeOne((config: TileCardConfig) => {
|
||||
const features = config.features || [];
|
||||
const featurePosition = this._featurePosition(config);
|
||||
private _featureLayout = memoizeOne((config: TileCardConfig) =>
|
||||
computeCardFeatureLayout(config.features, this._featurePosition(config))
|
||||
);
|
||||
|
||||
if (featurePosition === "inline") {
|
||||
return features.slice(0, 1);
|
||||
}
|
||||
return features;
|
||||
});
|
||||
private _featureRows = memoizeOne((config: TileCardConfig) =>
|
||||
computeCardFeatureRows(config.features, this._featurePosition(config))
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this.hass) {
|
||||
@@ -286,7 +281,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
: undefined;
|
||||
|
||||
const featurePosition = this._featurePosition(this._config);
|
||||
const features = this._displayedFeatures(this._config);
|
||||
const features = this._featureLayout(this._config);
|
||||
|
||||
const hasImage = Boolean(imageUrl);
|
||||
|
||||
@@ -341,14 +336,28 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
</ha-tile-info>
|
||||
${
|
||||
features.length > 0
|
||||
features.inline.length > 0
|
||||
? html`
|
||||
<hui-card-features
|
||||
slot="features"
|
||||
slot="features-inline"
|
||||
.hass=${this.hass}
|
||||
.context=${this._featureContext}
|
||||
.color=${this._config.color}
|
||||
.features=${features}
|
||||
.features=${features.inline}
|
||||
></hui-card-features>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
features.below.length > 0
|
||||
? html`
|
||||
<hui-card-features
|
||||
slot="features"
|
||||
.columns=${features.columns}
|
||||
.hass=${this.hass}
|
||||
.context=${this._featureContext}
|
||||
.color=${this._config.color}
|
||||
.features=${features.below}
|
||||
></hui-card-features>
|
||||
`
|
||||
: nothing
|
||||
|
||||
@@ -6,6 +6,7 @@ import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyDeviceLabels,
|
||||
formatFlowRateShort,
|
||||
getEnergyDataCollection,
|
||||
getFlowRateFromState,
|
||||
@@ -241,6 +242,13 @@ class HuiWaterFlowSankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
prefs.device_consumption_water,
|
||||
this._data.statsMetadata,
|
||||
"stat_rate"
|
||||
);
|
||||
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
@@ -257,7 +265,7 @@ class HuiWaterFlowSankeyCard
|
||||
initialUntracked: effectiveTotalInflow,
|
||||
getId: (device) => device.stat_rate,
|
||||
getValue: (id) => this._getCurrentFlowRate(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
@@ -7,6 +7,7 @@ import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyDeviceLabels,
|
||||
getEnergyDataCollection,
|
||||
validateEnergyCollectionKey,
|
||||
} from "../../../../data/energy";
|
||||
@@ -215,8 +216,14 @@ class HuiWaterSankeyCard
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
prefs.device_consumption_water,
|
||||
this._data!.statsMetadata
|
||||
);
|
||||
|
||||
const deviceLabel = (statConsumption: string) =>
|
||||
deviceLabels[statConsumption] ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statConsumption,
|
||||
|
||||
@@ -181,7 +181,7 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
|
||||
<hui-view-background-editor
|
||||
.hass=${this.hass}
|
||||
.config=${this._config}
|
||||
@view-config-changed=${this._viewConfigChanged}
|
||||
@background-config-changed=${this._viewConfigChanged}
|
||||
></hui-view-background-editor>
|
||||
`;
|
||||
break;
|
||||
|
||||
@@ -2,35 +2,41 @@ import memoizeOne from "memoize-one";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomEvent,
|
||||
} from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
|
||||
import type {
|
||||
LovelaceDashboardBackgroundConfig,
|
||||
LovelaceViewBackgroundConfig,
|
||||
} from "../../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
|
||||
import {
|
||||
isMediaSourceContentId,
|
||||
resolveMediaSource,
|
||||
} from "../../../../data/media_source";
|
||||
|
||||
export interface BackgroundConfigTarget {
|
||||
background?: LovelaceDashboardBackgroundConfig;
|
||||
}
|
||||
|
||||
@customElement("hui-view-background-editor")
|
||||
export class HuiViewBackgroundEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _config!: LovelaceViewConfig;
|
||||
@property({ attribute: false }) public config?: BackgroundConfigTarget;
|
||||
|
||||
@state({ attribute: false }) private _resolvedImage?: string;
|
||||
|
||||
set config(config: LovelaceViewConfig) {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _localizeValueCallback = (key: string) =>
|
||||
this.hass.localize(key as Parameters<LocalizeFunc>[0]);
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc, showSettings: boolean) =>
|
||||
(showSettings: boolean) =>
|
||||
[
|
||||
{
|
||||
name: "image",
|
||||
@@ -40,7 +46,7 @@ export class HuiViewBackgroundEditor extends LitElement {
|
||||
clearable: true,
|
||||
image_upload: true,
|
||||
hide_content_type: true,
|
||||
content_id_helper: localize(
|
||||
content_id_helper: this.hass.localize(
|
||||
"ui.panel.lovelace.editor.card.picture.content_id_helper"
|
||||
),
|
||||
},
|
||||
@@ -123,14 +129,14 @@ export class HuiViewBackgroundEditor extends LitElement {
|
||||
] as const
|
||||
);
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
if (
|
||||
this._config &&
|
||||
this.config &&
|
||||
this.hass &&
|
||||
(changedProps.has("_config") ||
|
||||
(changedProps.has("config") ||
|
||||
(changedProps.has("hass") && !changedProps.get("hass")))
|
||||
) {
|
||||
const background = this._backgroundData(this._config);
|
||||
const background = this._backgroundData(this.config);
|
||||
this.style.setProperty(
|
||||
"--picture-opacity",
|
||||
`${(background.opacity ?? 100) / 100}`
|
||||
@@ -156,7 +162,7 @@ export class HuiViewBackgroundEditor extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const background = this._backgroundData(this._config);
|
||||
const background = this._backgroundData(this.config);
|
||||
|
||||
return html`
|
||||
${
|
||||
@@ -174,7 +180,7 @@ export class HuiViewBackgroundEditor extends LitElement {
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${background}
|
||||
.schema=${this._schema(this.hass.localize, true)}
|
||||
.schema=${this._schema(true)}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
.localizeValue=${this._localizeValueCallback}
|
||||
@@ -183,7 +189,7 @@ export class HuiViewBackgroundEditor extends LitElement {
|
||||
}
|
||||
|
||||
private _backgroundData = memoizeOne(
|
||||
(backgroundConfig?: LovelaceViewConfig) => {
|
||||
(backgroundConfig?: BackgroundConfigTarget) => {
|
||||
let background = backgroundConfig?.background;
|
||||
if (typeof background === "string") {
|
||||
const backgroundUrl = background.match(
|
||||
@@ -220,12 +226,15 @@ export class HuiViewBackgroundEditor extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
const config = {
|
||||
...this._config,
|
||||
background: ev.detail.value,
|
||||
};
|
||||
fireEvent(this, "view-config-changed", { config });
|
||||
private _valueChanged(
|
||||
ev: HASSDomEvent<{ value: LovelaceViewBackgroundConfig }>
|
||||
) {
|
||||
fireEvent(this, "background-config-changed", {
|
||||
config: {
|
||||
...(this.config || {}),
|
||||
background: ev.detail.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
@@ -292,4 +301,10 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-view-background-editor": HuiViewBackgroundEditor;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"background-config-changed": {
|
||||
config: BackgroundConfigTarget;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@ import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../dialogs/generic/show-dialog-box";
|
||||
import { isMoreInfoView } from "../../dialogs/more-info/const";
|
||||
import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import { showQuickBar } from "../../dialogs/quick-bar/show-dialog-quick-bar";
|
||||
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
@@ -723,21 +721,6 @@ class HUIRoot extends LitElement {
|
||||
} else if (searchParams.conversation === "1") {
|
||||
this._clearParam("conversation");
|
||||
this._showVoiceCommandDialog();
|
||||
} else if (searchParams["more-info-entity-id"]) {
|
||||
const entityId = searchParams["more-info-entity-id"];
|
||||
const view = searchParams["more-info-view"];
|
||||
this._clearParam("more-info-entity-id");
|
||||
if (view) {
|
||||
this._clearParam("more-info-view");
|
||||
}
|
||||
// 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: isMoreInfoView(view) ? view : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1081,10 +1064,18 @@ class HUIRoot extends LitElement {
|
||||
await this.hass.loadFragmentTranslation("config");
|
||||
const dashboards = await fetchDashboards(this.hass);
|
||||
const dashboard = dashboards.find((d) => d.url_path === urlPath);
|
||||
const lovelace = this.lovelace;
|
||||
const lovelaceConfig =
|
||||
lovelace && !isStrategyDashboard(lovelace.rawConfig)
|
||||
? lovelace.rawConfig
|
||||
: undefined;
|
||||
|
||||
showDashboardDetailDialog(this, {
|
||||
dashboard,
|
||||
urlPath,
|
||||
...(lovelace && lovelaceConfig
|
||||
? { lovelaceConfig, saveConfig: lovelace.saveConfig }
|
||||
: {}),
|
||||
updateDashboard: async (values) => {
|
||||
await updateDashboard(this.hass!, dashboard!.id, values);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { replaceCurrentUrl } from "../common/navigate";
|
||||
import {
|
||||
createMoreInfoUrl,
|
||||
removeMoreInfoUrl,
|
||||
} from "../common/url/more-info-query-params";
|
||||
import { showDialog } from "../dialogs/make-dialog-manager";
|
||||
import type { MoreInfoDialogParams } from "../dialogs/more-info/ha-more-info-dialog";
|
||||
import type { Constructor } from "../types";
|
||||
@@ -28,12 +34,19 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
|
||||
private async _handleMoreInfo(
|
||||
ev: HASSDomEvent<HASSDomEvents["hass-more-info"]>
|
||||
) {
|
||||
showDialog(
|
||||
const view = ev.detail.view || ev.detail.tab || "info";
|
||||
const currentUrl = `${mainWindow.location.pathname}${mainWindow.location.search}${mainWindow.location.hash}`;
|
||||
const returnUrl = ev.detail.fromUrl
|
||||
? removeMoreInfoUrl(currentUrl)
|
||||
: currentUrl;
|
||||
|
||||
replaceCurrentUrl(returnUrl);
|
||||
const shown = await showDialog(
|
||||
this,
|
||||
"ha-more-info-dialog",
|
||||
{
|
||||
entityId: ev.detail.entityId,
|
||||
view: ev.detail.view || ev.detail.tab,
|
||||
view,
|
||||
large:
|
||||
ev.detail.large ??
|
||||
(ev.detail.entityId
|
||||
@@ -42,9 +55,20 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
|
||||
)
|
||||
: false),
|
||||
data: ev.detail.data,
|
||||
hash: ev.detail.hash,
|
||||
returnUrl,
|
||||
},
|
||||
() => import("../dialogs/more-info/ha-more-info-dialog"),
|
||||
ev.detail.parentElement
|
||||
);
|
||||
if (shown && ev.detail.entityId) {
|
||||
replaceCurrentUrl(
|
||||
createMoreInfoUrl(returnUrl, {
|
||||
entityId: ev.detail.entityId,
|
||||
view,
|
||||
hash: ev.detail.hash,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2054,6 +2054,15 @@
|
||||
"description": "Description",
|
||||
"required_error_msg": "[%key:ui::panel::config::zone::detail::required_error_msg%]"
|
||||
},
|
||||
"logbook_detail": {
|
||||
"title": "Activity details",
|
||||
"state": "State",
|
||||
"event": "Event",
|
||||
"time": "Time",
|
||||
"what_happened": "What happened",
|
||||
"no_known_cause": "No cause was recorded for this activity.",
|
||||
"action_used": "Action used: {name}"
|
||||
},
|
||||
"voice-settings": {
|
||||
"expose_header": "Expose",
|
||||
"aliases_header": "Aliases",
|
||||
@@ -6814,6 +6823,10 @@
|
||||
"heading": "Connected devices",
|
||||
"show_more": "+{count} devices not shown"
|
||||
},
|
||||
"child_devices": {
|
||||
"heading": "Sub-devices",
|
||||
"show_more": "+{count} devices not shown"
|
||||
},
|
||||
"linked_devices": {
|
||||
"heading": "Linked devices",
|
||||
"description": "These devices share hardware with this device and are managed by other integrations."
|
||||
@@ -6895,6 +6908,8 @@
|
||||
"manufacturer": "Manufacturer",
|
||||
"model": "Model",
|
||||
"integration": "Integration",
|
||||
"parent_device": "Parent device",
|
||||
"part_of_device": "Part of {name}",
|
||||
"firmware_version": "Firmware",
|
||||
"battery": "Battery",
|
||||
"disabled_by": "Disabled",
|
||||
@@ -7187,6 +7202,7 @@
|
||||
"disable_error": "Enabling or disabling of the integration failed",
|
||||
"manuf": "by {manufacturer}",
|
||||
"via": "Connected via",
|
||||
"part_of": "Part of",
|
||||
"firmware": "Firmware: {version}",
|
||||
"hardware": "Hardware: {version}",
|
||||
"version": "Version {version}",
|
||||
@@ -10465,7 +10481,7 @@
|
||||
"bottom": "Bottom",
|
||||
"bottom_description": "Displays all features stacked",
|
||||
"inline": "Inline",
|
||||
"inline_description": "Displays only the first feature"
|
||||
"inline_description": "Displays features in two columns, starting next to the name"
|
||||
},
|
||||
"features_position_helper_vertical": "Always displayed at the bottom if the content layout is vertical",
|
||||
"content_layout": "Content layout",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, describe, it } from "vitest";
|
||||
import {
|
||||
formatTime,
|
||||
formatTimeWithSeconds,
|
||||
formatTimeWithMilliseconds,
|
||||
formatTimeWeekday,
|
||||
formatTime24h,
|
||||
} from "../../../src/common/datetime/format_time";
|
||||
@@ -85,6 +86,58 @@ describe("formatTimeWithSeconds", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTimeWithMilliseconds", () => {
|
||||
const dateObj = new Date(2017, 10, 18, 23, 12, 13, 400);
|
||||
|
||||
it("Formats English times with milliseconds", () => {
|
||||
expect(
|
||||
formatTimeWithMilliseconds(
|
||||
dateObj,
|
||||
{
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.am_pm,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.local,
|
||||
first_weekday: FirstWeekday.language,
|
||||
},
|
||||
demoConfig
|
||||
)
|
||||
).toBe("11:12:13.400 PM");
|
||||
expect(
|
||||
formatTimeWithMilliseconds(
|
||||
dateObj,
|
||||
{
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.twenty_four,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.local,
|
||||
first_weekday: FirstWeekday.language,
|
||||
},
|
||||
demoConfig
|
||||
)
|
||||
).toBe("23:12:13.400");
|
||||
});
|
||||
|
||||
it("Uses the locale decimal separator", () => {
|
||||
expect(
|
||||
formatTimeWithMilliseconds(
|
||||
dateObj,
|
||||
{
|
||||
language: "fr",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.twenty_four,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.local,
|
||||
first_weekday: FirstWeekday.language,
|
||||
},
|
||||
demoConfig
|
||||
)
|
||||
).toBe("23:12:13,400");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTimeWeekday", () => {
|
||||
const dateObj = new Date(2017, 10, 18, 23, 12, 13, 1400);
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
serviceTargetFromQueryParams,
|
||||
type QueryParamConfig,
|
||||
} from "../../../src/common/url/query-params";
|
||||
import {
|
||||
createMoreInfoUrl,
|
||||
decodeMoreInfoUrl,
|
||||
removeMoreInfoUrl,
|
||||
} from "../../../src/common/url/more-info-query-params";
|
||||
import {
|
||||
createTodoQueryString,
|
||||
decodeTodoQueryParams,
|
||||
@@ -240,3 +245,67 @@ describe("todo query params", () => {
|
||||
expect(encoded).toBe("add_item=true&entity_id=todo.tasks");
|
||||
});
|
||||
});
|
||||
|
||||
describe("more-info query params", () => {
|
||||
it("decodes the entity, view, and named hash params", () => {
|
||||
const params = decodeMoreInfoUrl(
|
||||
"?more-info-entity-id=weather.home&more-info-view=info",
|
||||
"#forecast=hourly"
|
||||
);
|
||||
|
||||
expect(params.entityId).toBe("weather.home");
|
||||
expect(params.view).toBe("info");
|
||||
expect(params.hash.get("forecast")).toBe("hourly");
|
||||
});
|
||||
|
||||
it("ignores invalid views", () => {
|
||||
expect(
|
||||
decodeMoreInfoUrl(
|
||||
"?more-info-entity-id=weather.home&more-info-view=forecast"
|
||||
).view
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates a link without dropping unrelated query params", () => {
|
||||
expect(
|
||||
createMoreInfoUrl("/lovelace/home?theme=dark", {
|
||||
entityId: "weather.home",
|
||||
view: "info",
|
||||
hash: new URLSearchParams({ forecast: "hourly" }),
|
||||
})
|
||||
).toBe(
|
||||
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#forecast=hourly"
|
||||
);
|
||||
});
|
||||
|
||||
it("removes more-info query params but preserves other hash state", () => {
|
||||
expect(
|
||||
removeMoreInfoUrl(
|
||||
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#forecast=hourly"
|
||||
)
|
||||
).toBe("/lovelace/home?theme=dark#forecast=hourly");
|
||||
});
|
||||
|
||||
it("preserves an unrelated hash when creating a more-info url without a dialog hash", () => {
|
||||
expect(
|
||||
createMoreInfoUrl("/lovelace/home?theme=dark#some-anchor", {
|
||||
entityId: "light.kitchen",
|
||||
view: "info",
|
||||
})
|
||||
).toBe(
|
||||
"/lovelace/home?theme=dark&more-info-entity-id=light.kitchen&more-info-view=info#some-anchor"
|
||||
);
|
||||
});
|
||||
|
||||
it("clears an existing hash when an empty dialog hash is explicitly supplied", () => {
|
||||
expect(
|
||||
createMoreInfoUrl("/lovelace/home?theme=dark#some-anchor", {
|
||||
entityId: "light.kitchen",
|
||||
view: "info",
|
||||
hash: new URLSearchParams(),
|
||||
})
|
||||
).toBe(
|
||||
"/lovelace/home?theme=dark&more-info-entity-id=light.kitchen&more-info-view=info"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_MIN_KELVIN } from "../../../src/common/color/convert-light-color";
|
||||
import { getSelectorFallbackValue } from "../../../src/components/ha-form/get-selector-fallback-value";
|
||||
|
||||
describe("getSelectorFallbackValue", () => {
|
||||
it("returns the constant selector value", () => {
|
||||
expect(getSelectorFallbackValue({ constant: { value: "fixed" } })).toBe(
|
||||
"fixed"
|
||||
);
|
||||
expect(getSelectorFallbackValue({ constant: { value: 0 } })).toBe(0);
|
||||
expect(getSelectorFallbackValue({ constant: { value: false } })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for boolean selectors", () => {
|
||||
expect(getSelectorFallbackValue({ boolean: null })).toBe(false);
|
||||
expect(getSelectorFallbackValue({ boolean: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns number min, or 0 when min is omitted", () => {
|
||||
expect(getSelectorFallbackValue({ number: { min: 2000 } })).toBe(2000);
|
||||
expect(getSelectorFallbackValue({ number: { min: 0, max: 100 } })).toBe(0);
|
||||
expect(getSelectorFallbackValue({ number: null })).toBe(0);
|
||||
});
|
||||
|
||||
it("returns kelvin min for color_temp, falling back to DEFAULT_MIN_KELVIN", () => {
|
||||
expect(
|
||||
getSelectorFallbackValue({
|
||||
color_temp: { unit: "kelvin", min: 2000, max: 6500 },
|
||||
})
|
||||
).toBe(2000);
|
||||
expect(getSelectorFallbackValue({ color_temp: { unit: "kelvin" } })).toBe(
|
||||
DEFAULT_MIN_KELVIN
|
||||
);
|
||||
});
|
||||
|
||||
it("returns mired min for color_temp, including legacy min_mireds", () => {
|
||||
expect(
|
||||
getSelectorFallbackValue({
|
||||
color_temp: { unit: "mired", min: 154, max: 500 },
|
||||
})
|
||||
).toBe(154);
|
||||
expect(getSelectorFallbackValue({ color_temp: { min_mireds: 160 } })).toBe(
|
||||
160
|
||||
);
|
||||
expect(getSelectorFallbackValue({ color_temp: null })).toBe(153);
|
||||
});
|
||||
|
||||
it("returns undefined when the selector has no displayed fallback", () => {
|
||||
expect(getSelectorFallbackValue({ text: null })).toBeUndefined();
|
||||
expect(getSelectorFallbackValue({ entity: null })).toBeUndefined();
|
||||
expect(getSelectorFallbackValue({ target: {} })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "../../../src/components/target-picker/ha-target-picker-item-row";
|
||||
import type { HaTargetPickerItemRow } from "../../../src/components/target-picker/ha-target-picker-item-row";
|
||||
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type {
|
||||
ExtractFromTargetResult,
|
||||
TargetType,
|
||||
} from "../../../src/data/target";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
|
||||
const extractResult = (
|
||||
referenced: Partial<ExtractFromTargetResult>
|
||||
): ExtractFromTargetResult => ({
|
||||
missing_areas: [],
|
||||
missing_devices: [],
|
||||
missing_floors: [],
|
||||
missing_labels: [],
|
||||
referenced_areas: [],
|
||||
referenced_devices: [],
|
||||
referenced_entities: [],
|
||||
...referenced,
|
||||
});
|
||||
|
||||
const mkEntity = (
|
||||
entity_id: string,
|
||||
rest: Partial<EntityRegistryDisplayEntry> = {}
|
||||
): EntityRegistryDisplayEntry => ({ entity_id, labels: [], ...rest });
|
||||
|
||||
const mkDevice = (
|
||||
id: string,
|
||||
rest: Partial<DeviceRegistryEntry> = {}
|
||||
): DeviceRegistryEntry =>
|
||||
({ id, area_id: null, labels: [], ...rest }) as DeviceRegistryEntry;
|
||||
|
||||
interface Registries {
|
||||
entities?: Record<string, EntityRegistryDisplayEntry>;
|
||||
devices?: Record<string, DeviceRegistryEntry>;
|
||||
areas?: Record<string, AreaRegistryEntry>;
|
||||
}
|
||||
|
||||
// Runs the row's extraction against `result`, with only the registry entries in
|
||||
// `registries` available to filter it.
|
||||
const extractedBy = async (
|
||||
result: ExtractFromTargetResult,
|
||||
registries: Registries,
|
||||
{
|
||||
type = "area",
|
||||
itemId = "area_1",
|
||||
}: { type?: TargetType; itemId?: string } = {}
|
||||
) => {
|
||||
const el = document.createElement(
|
||||
"ha-target-picker-item-row"
|
||||
) as HaTargetPickerItemRow;
|
||||
el.type = type;
|
||||
el.itemId = itemId;
|
||||
el.hass = {
|
||||
areas: {},
|
||||
devices: {},
|
||||
entities: {},
|
||||
states: {},
|
||||
callWS: async () => result,
|
||||
...registries,
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
await (el as any)._updateItemData();
|
||||
return (el as any)._entries as ExtractFromTargetResult | undefined;
|
||||
};
|
||||
|
||||
describe("ha-target-picker-item-row target extraction", () => {
|
||||
it("drops entities that are missing from the entity registry", async () => {
|
||||
// Disabled entities are referenced by core but never reach the display
|
||||
// registry, which is the crash in #52964.
|
||||
const entries = await extractedBy(
|
||||
extractResult({
|
||||
referenced_entities: ["light.known", "light.disabled"],
|
||||
}),
|
||||
{
|
||||
entities: {
|
||||
"light.known": mkEntity("light.known", { area_id: "area_1" }),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(entries).toBeDefined();
|
||||
expect(entries!.referenced_entities).toEqual(["light.known"]);
|
||||
});
|
||||
|
||||
it("keeps every entity when all registry entries resolve", async () => {
|
||||
const entries = await extractedBy(
|
||||
extractResult({
|
||||
referenced_entities: ["light.one", "light.two"],
|
||||
}),
|
||||
{
|
||||
entities: {
|
||||
"light.one": mkEntity("light.one", { area_id: "area_1" }),
|
||||
"light.two": mkEntity("light.two", { area_id: "area_1" }),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(entries).toBeDefined();
|
||||
expect(entries!.referenced_entities).toEqual(["light.one", "light.two"]);
|
||||
});
|
||||
|
||||
it("drops a device missing from the registry, and entities linked only through it", async () => {
|
||||
const entries = await extractedBy(
|
||||
extractResult({
|
||||
referenced_devices: ["dev_known", "dev_missing"],
|
||||
referenced_entities: ["light.on_known_dev", "light.on_missing_dev"],
|
||||
}),
|
||||
{
|
||||
devices: { dev_known: mkDevice("dev_known") },
|
||||
entities: {
|
||||
"light.on_known_dev": mkEntity("light.on_known_dev", {
|
||||
device_id: "dev_known",
|
||||
}),
|
||||
"light.on_missing_dev": mkEntity("light.on_missing_dev", {
|
||||
device_id: "dev_missing",
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(entries).toBeDefined();
|
||||
expect(entries!.referenced_devices).toEqual(["dev_known"]);
|
||||
expect(entries!.referenced_entities).toEqual(["light.on_known_dev"]);
|
||||
});
|
||||
|
||||
it("keeps an entity targeted through its own area when its device is missing", async () => {
|
||||
// A device we do not know about is not a filter decision, so it must not
|
||||
// take an entity that the area targets directly down with it.
|
||||
const entries = await extractedBy(
|
||||
extractResult({
|
||||
referenced_devices: ["dev_missing"],
|
||||
referenced_entities: ["light.explicit_area"],
|
||||
}),
|
||||
{
|
||||
entities: {
|
||||
"light.explicit_area": mkEntity("light.explicit_area", {
|
||||
area_id: "area_1",
|
||||
device_id: "dev_missing",
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(entries).toBeDefined();
|
||||
expect(entries!.referenced_entities).toEqual(["light.explicit_area"]);
|
||||
});
|
||||
|
||||
it("keeps devices of a floor whose area is missing from the registry", async () => {
|
||||
// Same rule for areas: an area we do not know about must not mark itself
|
||||
// hidden and drop the devices the floor references through it.
|
||||
const entries = await extractedBy(
|
||||
extractResult({
|
||||
referenced_areas: ["area_missing"],
|
||||
referenced_devices: ["dev_1"],
|
||||
referenced_entities: ["light.on_dev1"],
|
||||
}),
|
||||
{
|
||||
areas: {},
|
||||
devices: { dev_1: mkDevice("dev_1", { area_id: "area_missing" }) },
|
||||
entities: {
|
||||
"light.on_dev1": mkEntity("light.on_dev1", { device_id: "dev_1" }),
|
||||
},
|
||||
},
|
||||
{ type: "floor", itemId: "floor_1" }
|
||||
);
|
||||
|
||||
expect(entries).toBeDefined();
|
||||
expect(entries!.referenced_devices).toEqual(["dev_1"]);
|
||||
expect(entries!.referenced_entities).toEqual(["light.on_dev1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { assert, describe, it } from "vitest";
|
||||
import { devicesInEffectiveArea } from "../../src/data/device/device_registry";
|
||||
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
|
||||
|
||||
const device = (
|
||||
partial: Partial<DeviceRegistryEntry> & { id: string }
|
||||
): DeviceRegistryEntry =>
|
||||
({
|
||||
area_id: null,
|
||||
parent_device_id: null,
|
||||
...partial,
|
||||
}) as DeviceRegistryEntry;
|
||||
|
||||
describe("devicesInEffectiveArea", () => {
|
||||
it("includes devices with the area set", () => {
|
||||
const devices = {
|
||||
a: device({ id: "a", area_id: "kitchen" }),
|
||||
b: device({ id: "b", area_id: "bedroom" }),
|
||||
};
|
||||
assert.deepEqual(
|
||||
devicesInEffectiveArea(devices, "kitchen").map((d) => d.id),
|
||||
["a"]
|
||||
);
|
||||
});
|
||||
|
||||
it("includes a child device inheriting its parent's area", () => {
|
||||
const devices = {
|
||||
parent: device({ id: "parent", area_id: "kitchen" }),
|
||||
child: device({ id: "child", parent_device_id: "parent" }),
|
||||
};
|
||||
assert.deepEqual(
|
||||
devicesInEffectiveArea(devices, "kitchen")
|
||||
.map((d) => d.id)
|
||||
.sort(),
|
||||
["child", "parent"]
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes a child device with a different explicit area", () => {
|
||||
const devices = {
|
||||
parent: device({ id: "parent", area_id: "kitchen" }),
|
||||
child: device({
|
||||
id: "child",
|
||||
area_id: "bedroom",
|
||||
parent_device_id: "parent",
|
||||
}),
|
||||
};
|
||||
assert.deepEqual(
|
||||
devicesInEffectiveArea(devices, "kitchen").map((d) => d.id),
|
||||
["parent"]
|
||||
);
|
||||
// ...and the child belongs to its own area instead.
|
||||
assert.deepEqual(
|
||||
devicesInEffectiveArea(devices, "bedroom").map((d) => d.id),
|
||||
["child"]
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes a child whose parent has no area", () => {
|
||||
const devices = {
|
||||
parent: device({ id: "parent" }),
|
||||
child: device({ id: "child", parent_device_id: "parent" }),
|
||||
};
|
||||
assert.deepEqual(devicesInEffectiveArea(devices, "kitchen"), []);
|
||||
});
|
||||
});
|
||||
@@ -13,13 +13,19 @@ import {
|
||||
} from "../../src/data/translation";
|
||||
import {
|
||||
computeConsumptionSingle,
|
||||
computeEnergyLabel,
|
||||
computeEnergyDeviceLabels,
|
||||
formatConsumptionShort,
|
||||
calculateSolarConsumedGauge,
|
||||
formatPowerShort,
|
||||
getNextEnergyPeriodStart,
|
||||
getEnergyDefaultPeriodStorageKey,
|
||||
} from "../../src/data/energy";
|
||||
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../src/data/entity/entity_registry";
|
||||
import type { StatisticsMetaData } from "../../src/data/recorder";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { createMockEntityState, createMockHass } from "../fixtures/hass";
|
||||
|
||||
const checkConsumptionResult = (
|
||||
input: {
|
||||
@@ -944,3 +950,151 @@ describe("getEnergyDefaultPeriodStorageKey", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEnergyLabel", () => {
|
||||
const ENTITY_ID = "sensor.washer_energy";
|
||||
|
||||
const createEntry = (
|
||||
entry: Partial<EntityRegistryDisplayEntry>
|
||||
): EntityRegistryDisplayEntry =>
|
||||
({
|
||||
entity_id: ENTITY_ID,
|
||||
labels: [],
|
||||
...entry,
|
||||
}) as EntityRegistryDisplayEntry;
|
||||
|
||||
const createDevice = (
|
||||
device: Partial<DeviceRegistryEntry>
|
||||
): DeviceRegistryEntry =>
|
||||
({ id: "device1", name_by_user: null, ...device }) as DeviceRegistryEntry;
|
||||
|
||||
const createHass = (
|
||||
friendlyName: string,
|
||||
entry?: Partial<EntityRegistryDisplayEntry>,
|
||||
device?: Partial<DeviceRegistryEntry>
|
||||
) =>
|
||||
createMockHass(
|
||||
{
|
||||
[ENTITY_ID]: createMockEntityState(ENTITY_ID, "1", {
|
||||
friendly_name: friendlyName,
|
||||
}),
|
||||
},
|
||||
{
|
||||
entities: entry ? { [ENTITY_ID]: createEntry(entry) } : {},
|
||||
devices: device ? { device1: createDevice(device) } : {},
|
||||
}
|
||||
);
|
||||
|
||||
it("composes the device and entity name", () => {
|
||||
const hass = createHass(
|
||||
"Washer Energy",
|
||||
{ name: "Energy", device_id: "device1" },
|
||||
{ name: "Washer" }
|
||||
);
|
||||
|
||||
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer Energy");
|
||||
});
|
||||
|
||||
it("uses the device name alone when the entity has no name of its own", () => {
|
||||
const hass = createHass(
|
||||
"Washer",
|
||||
{ name: "Washer", device_id: "device1" },
|
||||
{ name: "Washer" }
|
||||
);
|
||||
|
||||
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer");
|
||||
});
|
||||
|
||||
it("distinguishes entities sharing a name by their device", () => {
|
||||
const hass = createHass(
|
||||
"Energy",
|
||||
{ name: "Energy", device_id: "device1" },
|
||||
{ name: "Dishwasher" }
|
||||
);
|
||||
|
||||
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Dishwasher Energy");
|
||||
});
|
||||
|
||||
it("keeps a name set by the user", () => {
|
||||
const hass = createHass(
|
||||
"Washer Energy",
|
||||
{ name: "Energy", device_id: "device1" },
|
||||
{ name: "Washer" }
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
computeEnergyLabel(hass, ENTITY_ID, undefined, "Laundry"),
|
||||
"Laundry"
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores an empty name", () => {
|
||||
const hass = createHass(
|
||||
"Washer Energy",
|
||||
{ name: "Energy", device_id: "device1" },
|
||||
{ name: "Washer" }
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
computeEnergyLabel(hass, ENTITY_ID, undefined, ""),
|
||||
"Washer Energy"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the friendly name for an entity outside the registry", () => {
|
||||
const hass = createHass("Washer Energy");
|
||||
|
||||
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer Energy");
|
||||
});
|
||||
|
||||
it("uses the statistic metadata name when there is no entity", () => {
|
||||
const hass = createMockHass();
|
||||
|
||||
assert.equal(
|
||||
computeEnergyLabel(hass, "external:solar", {
|
||||
statistic_id: "external:solar",
|
||||
name: "Solar production",
|
||||
} as StatisticsMetaData),
|
||||
"Solar production"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the statistic id when there is nothing to name it with", () => {
|
||||
const hass = createMockHass();
|
||||
|
||||
assert.equal(computeEnergyLabel(hass, "external:solar"), "external:solar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEnergyDeviceLabels", () => {
|
||||
const DEVICES = [
|
||||
{
|
||||
stat_consumption: "sensor.washer_energy",
|
||||
stat_rate: "sensor.washer_power",
|
||||
},
|
||||
{ stat_consumption: "sensor.heater_energy", name: "Heater" },
|
||||
];
|
||||
|
||||
const hass = createMockHass({
|
||||
"sensor.washer_energy": createMockEntityState("sensor.washer_energy", "1", {
|
||||
friendly_name: "Washer Energy",
|
||||
}),
|
||||
"sensor.washer_power": createMockEntityState("sensor.washer_power", "5", {
|
||||
friendly_name: "Washer Power",
|
||||
}),
|
||||
});
|
||||
|
||||
it("keys labels by the consumption statistic", () => {
|
||||
assert.deepEqual(computeEnergyDeviceLabels(hass, DEVICES), {
|
||||
"sensor.washer_energy": "Washer Energy",
|
||||
"sensor.heater_energy": "Heater",
|
||||
});
|
||||
});
|
||||
|
||||
it("keys labels by the rate statistic, skipping devices without one", () => {
|
||||
assert.deepEqual(
|
||||
computeEnergyDeviceLabels(hass, DEVICES, undefined, "stat_rate"),
|
||||
{ "sensor.washer_power": "Washer Power" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+59
-1
@@ -211,7 +211,7 @@ test("keeps the launch screen until initial panel content renders", async ({
|
||||
name: "generated dashboard",
|
||||
path: "/?scenario=delayed-generated-dashboard#/climate",
|
||||
loadingSelector: "#ha-launch-screen",
|
||||
readySelector: "hui-view",
|
||||
readySelector: "hui-card",
|
||||
resolvers: ["resolveGeneratedDashboard"],
|
||||
},
|
||||
{
|
||||
@@ -327,6 +327,64 @@ test.describe("Light more-info dialog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Weather more-info deep link", () => {
|
||||
test("opens and synchronizes the selected forecast", async ({ page }) => {
|
||||
await goToPanel(
|
||||
page,
|
||||
"/?scenario=weather-more-info&more-info-entity-id=weather.test_weather&more-info-view=info#/lovelace"
|
||||
);
|
||||
|
||||
const dialog = page.locator("ha-more-info-dialog");
|
||||
const weather = dialog.locator("more-info-weather");
|
||||
await expect(weather).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(page).toHaveURL(
|
||||
/more-info-entity-id=weather\.test_weather&more-info-view=info/
|
||||
);
|
||||
await expect(
|
||||
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
|
||||
).toBeAttached();
|
||||
|
||||
await page.locator("ha-test").evaluate((el) => {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("hass-more-info", {
|
||||
detail: {
|
||||
entityId: "weather.test_weather",
|
||||
hash: new URLSearchParams({ forecast: "hourly" }),
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Hourly" })
|
||||
).toBeAttached();
|
||||
|
||||
await dialog.getByRole("button", { name: "History" }).click();
|
||||
await expect(page).toHaveURL(/more-info-view=history/);
|
||||
|
||||
await dialog.getByRole("button", { name: "Back" }).click();
|
||||
|
||||
await expect(
|
||||
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
|
||||
).toBeAttached();
|
||||
|
||||
await weather
|
||||
.locator("ha-tab-group-tab")
|
||||
.filter({ hasText: "Daily" })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
|
||||
).toBeAttached();
|
||||
|
||||
await dialog.getByRole("button", { name: "Close" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page).not.toHaveURL(/more-info-entity-id/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,6 +5,10 @@ import type {
|
||||
} from "../../../../../src/data/entity/entity_registry";
|
||||
import type { LovelaceRawConfig } from "../../../../../src/data/lovelace/config/types";
|
||||
import type { MediaPlayerItem } from "../../../../../src/data/media-player";
|
||||
import {
|
||||
WeatherEntityFeature,
|
||||
type ForecastEvent,
|
||||
} from "../../../../../src/data/weather";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
|
||||
@@ -95,6 +99,51 @@ const lightMoreInfoScenario: Scenario = async (hass) => {
|
||||
hass.mockWS("config/entity_registry/get", () => registryEntry);
|
||||
};
|
||||
|
||||
const weatherMoreInfoScenario: Scenario = (hass) => {
|
||||
hass.addEntities([
|
||||
{
|
||||
entity_id: "weather.test_weather",
|
||||
state: "sunny",
|
||||
attributes: {
|
||||
friendly_name: "Test Weather",
|
||||
supported_features:
|
||||
WeatherEntityFeature.FORECAST_DAILY +
|
||||
WeatherEntityFeature.FORECAST_HOURLY,
|
||||
precipitation_unit: "mm",
|
||||
pressure_unit: "hPa",
|
||||
temperature: 20,
|
||||
temperature_unit: "°C",
|
||||
visibility_unit: "km",
|
||||
wind_speed_unit: "km/h",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
hass.mockWS("weather/subscribe_forecast", (message, _hass, onChange) => {
|
||||
onChange?.({
|
||||
type: message.forecast_type,
|
||||
forecast: [
|
||||
{
|
||||
datetime: "2026-08-13T10:00:00Z",
|
||||
temperature: 20,
|
||||
condition: "sunny",
|
||||
},
|
||||
{
|
||||
datetime: "2026-08-13T11:00:00Z",
|
||||
temperature: 21,
|
||||
condition: "sunny",
|
||||
},
|
||||
{
|
||||
datetime: "2026-08-13T12:00:00Z",
|
||||
temperature: 22,
|
||||
condition: "sunny",
|
||||
},
|
||||
],
|
||||
} satisfies ForecastEvent);
|
||||
return () => undefined;
|
||||
});
|
||||
};
|
||||
|
||||
const quickSearchAssistScenario: Scenario = async (hass) => {
|
||||
const pipeline: AssistPipeline = {
|
||||
id: "test-pipeline",
|
||||
@@ -253,6 +302,7 @@ export const scenarios: Record<string, Scenario> = {
|
||||
"delayed-media-browse": delayedMediaBrowseScenario,
|
||||
"delayed-media-browse-error": delayedMediaBrowseErrorScenario,
|
||||
"light-more-info": lightMoreInfoScenario,
|
||||
"weather-more-info": weatherMoreInfoScenario,
|
||||
"quick-search-assist": quickSearchAssistScenario,
|
||||
"delayed-lovelace": delayedLovelaceScenario,
|
||||
};
|
||||
|
||||
Vendored
+32
-9
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Deterministic `HomeAssistant` stub covering exactly what the chart data
|
||||
* transforms read: states, entities, locale, config, localize, and entity
|
||||
* state formatting. Everything is stable across runs.
|
||||
* transforms read: states, registries, locale, config, localize, and entity
|
||||
* state/name formatting. Everything is stable across runs.
|
||||
*/
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeEntityNameDisplay } from "../../src/common/entity/compute_entity_name_display";
|
||||
import type { LocalizeFunc } from "../../src/common/translations/localize";
|
||||
import {
|
||||
DateFormat,
|
||||
@@ -43,21 +44,43 @@ export const createMockEntityState = (
|
||||
context: { id: "fixture", parent_id: null, user_id: null },
|
||||
});
|
||||
|
||||
export const createMockHass = (states: HassEntities = {}): HomeAssistant =>
|
||||
({
|
||||
export const createMockHass = (
|
||||
states: HassEntities = {},
|
||||
registries: Partial<
|
||||
Pick<HomeAssistant, "entities" | "devices" | "areas" | "floors">
|
||||
> = {}
|
||||
): HomeAssistant => {
|
||||
const entities = registries.entities ?? {};
|
||||
const devices = registries.devices ?? {};
|
||||
const areas = registries.areas ?? {};
|
||||
const floors = registries.floors ?? {};
|
||||
|
||||
return {
|
||||
states,
|
||||
entities: {},
|
||||
devices: {},
|
||||
areas: {},
|
||||
floors: {},
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
config: demoConfig,
|
||||
locale: mockLocale,
|
||||
language: "en",
|
||||
localize: mockLocalize,
|
||||
translationMetadata: { translations: {} },
|
||||
formatEntityState: (stateObj: HassEntity, state?: string) =>
|
||||
state ?? stateObj.state,
|
||||
formatEntityAttributeValue: (stateObj: HassEntity, attribute: string) =>
|
||||
String(stateObj.attributes[attribute]),
|
||||
formatEntityAttributeName: (_stateObj: HassEntity, attribute: string) =>
|
||||
attribute,
|
||||
}) as unknown as HomeAssistant;
|
||||
formatEntityName: ((stateObj, name, options) =>
|
||||
computeEntityNameDisplay(
|
||||
stateObj,
|
||||
name,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
options
|
||||
)) satisfies HomeAssistant["formatEntityName"],
|
||||
} as unknown as HomeAssistant;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { LogbookEntry } from "../../../src/data/logbook";
|
||||
import type { LogbookFetcher } from "../../../src/panels/logbook/logbook-chain-resolver";
|
||||
import { resolveLogbookChain } from "../../../src/panels/logbook/logbook-chain-resolver";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
|
||||
const hass = {
|
||||
language: "en",
|
||||
translationMetadata: { translations: {} },
|
||||
states: {},
|
||||
entities: {},
|
||||
devices: {},
|
||||
areas: {},
|
||||
floors: {},
|
||||
localize: () => "",
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const USERS = { user_1: "Alice" };
|
||||
|
||||
const entry = (partial: Partial<LogbookEntry>): LogbookEntry => ({
|
||||
when: 0,
|
||||
name: "",
|
||||
...partial,
|
||||
});
|
||||
|
||||
const runRow = (when: number, contextId: string): LogbookEntry =>
|
||||
entry({
|
||||
when,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
source: "state of binary_sensor.motion",
|
||||
context_id: contextId,
|
||||
});
|
||||
|
||||
const effectRow = (when: number): LogbookEntry =>
|
||||
entry({
|
||||
when,
|
||||
name: "Ceiling light",
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_event_type: "automation_triggered",
|
||||
context_name: "Wake up",
|
||||
context_entity_id: "automation.wake_up",
|
||||
context_source: "state of binary_sensor.motion",
|
||||
});
|
||||
|
||||
interface FetchCall {
|
||||
entityIds?: string[];
|
||||
contextId?: string;
|
||||
}
|
||||
|
||||
const makeFetcher = (
|
||||
handler: (entityIds?: string[], contextId?: string) => LogbookEntry[]
|
||||
): { fetch: LogbookFetcher; calls: FetchCall[] } => {
|
||||
const calls: FetchCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
fetch: async (_start, _end, entityIds, contextId) => {
|
||||
calls.push({ entityIds, contextId });
|
||||
return handler(entityIds, contextId);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("resolveLogbookChain", () => {
|
||||
it("fetches by context id when the entry has one", async () => {
|
||||
const run = runRow(10, "ctx_run");
|
||||
const effect = { ...effectRow(11), context_id: "ctx_run" };
|
||||
const { fetch, calls } = makeFetcher((entityIds, contextId) => {
|
||||
if (contextId === "ctx_run") {
|
||||
return [run, effect];
|
||||
}
|
||||
if (entityIds?.includes("binary_sensor.motion")) {
|
||||
return [
|
||||
entry({ when: 9.8, entity_id: "binary_sensor.motion", state: "on" }),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
|
||||
|
||||
// One context fetch + one trigger fetch: no candidate discovery.
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0].contextId).toBe("ctx_run");
|
||||
expect(chain.runRow).toBe(run);
|
||||
expect(chain.rows).toEqual([run, effect]);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("state");
|
||||
expect(chain.origins[0].entityId).toBe("binary_sensor.motion");
|
||||
expect(chain.syntheticRun).toBeUndefined();
|
||||
expect(chain.triggerRow?.when).toBe(9.8);
|
||||
});
|
||||
|
||||
it("resolves the run through the context entity and verifies candidates", async () => {
|
||||
const effect = effectRow(11);
|
||||
const otherRun = runRow(10.9, "ctx_other");
|
||||
const goodRun = runRow(10, "ctx_good");
|
||||
const { fetch, calls } = makeFetcher((entityIds, contextId) => {
|
||||
if (entityIds?.includes("automation.wake_up")) {
|
||||
return [goodRun, otherRun];
|
||||
}
|
||||
if (contextId === "ctx_other") {
|
||||
// The closest run does not contain the clicked entry.
|
||||
return [
|
||||
otherRun,
|
||||
entry({ when: 11, entity_id: "light.desk", state: "on" }),
|
||||
];
|
||||
}
|
||||
if (contextId === "ctx_good") {
|
||||
return [goodRun, effect];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
|
||||
|
||||
expect(calls[0].entityIds).toEqual(["automation.wake_up"]);
|
||||
expect(calls[1].contextId).toBe("ctx_other");
|
||||
expect(calls[2].contextId).toBe("ctx_good");
|
||||
expect(chain.runRow?.context_id).toBe("ctx_good");
|
||||
expect(chain.rows).toEqual([goodRun, effect]);
|
||||
});
|
||||
|
||||
it("falls back to a synthetic run when no candidate matches", async () => {
|
||||
const effect = effectRow(11);
|
||||
const { fetch } = makeFetcher((entityIds) =>
|
||||
entityIds?.includes("automation.wake_up")
|
||||
? [runRow(10, "ctx_unrelated")]
|
||||
: []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
|
||||
|
||||
expect(chain.runRow).toBeUndefined();
|
||||
expect(chain.rows).toEqual([effect]);
|
||||
expect(chain.syntheticRun?.type).toBe("automation");
|
||||
expect(chain.origins).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a direct user action as the only origin", async () => {
|
||||
const direct = entry({
|
||||
when: 5,
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_user_id: "user_1",
|
||||
context_event_type: "call_service",
|
||||
context_domain: "light",
|
||||
context_service: "turn_on",
|
||||
});
|
||||
const { fetch, calls } = makeFetcher(() => []);
|
||||
|
||||
const chain = await resolveLogbookChain(
|
||||
hass,
|
||||
direct,
|
||||
{ userIdToName: USERS },
|
||||
fetch
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(chain.rows).toEqual([direct]);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("user");
|
||||
expect(chain.origins[0].name).toBe("Alice");
|
||||
expect(chain.syntheticRun).toBeUndefined();
|
||||
});
|
||||
|
||||
it("picks the last trigger state before the run, even minutes earlier", async () => {
|
||||
const run = runRow(600, "ctx_run");
|
||||
const effect = { ...effectRow(601), context_id: "ctx_run" };
|
||||
const { fetch } = makeFetcher((entityIds, contextId) => {
|
||||
if (contextId === "ctx_run") {
|
||||
return [run, effect];
|
||||
}
|
||||
if (entityIds?.includes("binary_sensor.motion")) {
|
||||
return [
|
||||
entry({ when: 100, entity_id: "binary_sensor.motion", state: "on" }),
|
||||
entry({ when: 480, entity_id: "binary_sensor.motion", state: "off" }),
|
||||
entry({ when: 640, entity_id: "binary_sensor.motion", state: "on" }),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
|
||||
|
||||
expect(chain.triggerRow?.when).toBe(480);
|
||||
expect(chain.triggerRow?.state).toBe("off");
|
||||
});
|
||||
|
||||
it("prefers the clicked copy of the run row over the fetched one", async () => {
|
||||
const clicked = entry({
|
||||
when: 10,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
context_user_id: "user_1",
|
||||
context_event_type: "call_service",
|
||||
context_domain: "automation",
|
||||
context_service: "trigger",
|
||||
});
|
||||
const fetched = entry({
|
||||
when: 10,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
});
|
||||
const effect = { ...effectRow(11), context_id: "ctx_run" };
|
||||
const { fetch } = makeFetcher((_entityIds, contextId) =>
|
||||
contextId === "ctx_run" ? [fetched, effect] : []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(
|
||||
hass,
|
||||
clicked,
|
||||
{ userIdToName: USERS },
|
||||
fetch
|
||||
);
|
||||
|
||||
expect(chain.runRow).toBe(clicked);
|
||||
expect(chain.rows).toEqual([clicked, effect]);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("user");
|
||||
expect(chain.origins[0].name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("surfaces the integration that triggered the run", async () => {
|
||||
const clicked = entry({
|
||||
when: 20,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
context_event_type: "call_service",
|
||||
context_domain: "homekit",
|
||||
context_service: "turn_on",
|
||||
});
|
||||
const fetched = entry({
|
||||
when: 20,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
});
|
||||
const effect = { ...effectRow(21), context_id: "ctx_run" };
|
||||
const { fetch } = makeFetcher((_entityIds, contextId) =>
|
||||
contextId === "ctx_run" ? [fetched, effect] : []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(hass, clicked, {}, fetch);
|
||||
|
||||
expect(chain.runRow).toBe(clicked);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("integration");
|
||||
expect(chain.origins[0].brandDomain).toBe("homekit");
|
||||
});
|
||||
|
||||
it("keeps the user above a run row that does not carry one", async () => {
|
||||
const fetched = entry({
|
||||
when: 10,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
});
|
||||
const effect = {
|
||||
...effectRow(11),
|
||||
context_id: "ctx_run",
|
||||
context_user_id: "user_1",
|
||||
};
|
||||
const { fetch } = makeFetcher((_entityIds, contextId) =>
|
||||
contextId === "ctx_run" ? [fetched, effect] : []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(
|
||||
hass,
|
||||
effect,
|
||||
{ userIdToName: USERS },
|
||||
fetch
|
||||
);
|
||||
|
||||
expect(chain.runRow).toBe(fetched);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("user");
|
||||
expect(chain.origins[0].name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("does not stack the user twice when the run row resolves it", async () => {
|
||||
const fetched = entry({
|
||||
when: 10,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
context_user_id: "user_1",
|
||||
});
|
||||
const effect = {
|
||||
...effectRow(11),
|
||||
context_id: "ctx_run",
|
||||
context_user_id: "user_1",
|
||||
};
|
||||
const { fetch } = makeFetcher((_entityIds, contextId) =>
|
||||
contextId === "ctx_run" ? [fetched, effect] : []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(
|
||||
hass,
|
||||
effect,
|
||||
{ userIdToName: USERS },
|
||||
fetch
|
||||
);
|
||||
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("user");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeLogbookItem,
|
||||
computeTraceLink,
|
||||
classifyLogbookEntry,
|
||||
entityDisplay,
|
||||
computeLogbookCause,
|
||||
computeLogbookGlyph,
|
||||
isSameLogbookEntry,
|
||||
} from "../../../src/panels/logbook/logbook-entry-model";
|
||||
import type { LogbookEntry } from "../../../src/data/logbook";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
@@ -241,7 +243,7 @@ describe("computeLogbookCause", () => {
|
||||
const cause = computeLogbookCause(
|
||||
hass,
|
||||
entry({ context_user_id: "person_1" }),
|
||||
{ person_1: "Paul" },
|
||||
{ person_1: "Alice" },
|
||||
new Set(["cloud_user"])
|
||||
);
|
||||
expect(cause?.type).toBe("user");
|
||||
@@ -403,3 +405,144 @@ describe("computeLogbookItem", () => {
|
||||
expect(model.value).toEqual({ text: "Ran", type: "state" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSameLogbookEntry", () => {
|
||||
const a = entry({
|
||||
when: 1.234567,
|
||||
entity_id: "light.x",
|
||||
state: "on",
|
||||
name: "Light",
|
||||
});
|
||||
|
||||
it("matches an identical entry", () => {
|
||||
expect(isSameLogbookEntry(a, { ...a })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an entry differing by any field", () => {
|
||||
expect(isSameLogbookEntry(a, { ...a, when: 1.234568 })).toBe(false);
|
||||
expect(isSameLogbookEntry(a, { ...a, entity_id: "light.y" })).toBe(false);
|
||||
expect(isSameLogbookEntry(a, { ...a, state: "off" })).toBe(false);
|
||||
expect(isSameLogbookEntry(a, { ...a, name: "Other" })).toBe(false);
|
||||
expect(isSameLogbookEntry(a, { ...a, message: "turned on" })).toBe(false);
|
||||
});
|
||||
|
||||
it("matches entity-less entries on name/message/when", () => {
|
||||
const b = entry({ when: 2, name: "HACS", message: "2 updates available" });
|
||||
expect(isSameLogbookEntry(b, { ...b })).toBe(true);
|
||||
expect(isSameLogbookEntry(b, { ...b, message: "other" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeTraceLink", () => {
|
||||
const traceContexts = {
|
||||
ctx_1: { run_id: "run_9", domain: "automation", item_id: "auto_1" },
|
||||
};
|
||||
|
||||
it("builds the trace URL for a known context", () => {
|
||||
expect(computeTraceLink(traceContexts, "ctx_1")).toBe(
|
||||
"/config/automation/trace/auto_1?run_id=run_9"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown or missing context", () => {
|
||||
expect(computeTraceLink(traceContexts, "ctx_2")).toBeUndefined();
|
||||
expect(computeTraceLink(traceContexts, undefined)).toBeUndefined();
|
||||
expect(computeTraceLink({}, "ctx_1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeLogbookItem cause", () => {
|
||||
const hass = baseHass({ localize: (() => "") as HomeAssistant["localize"] });
|
||||
const users = { user_1: "Alice" };
|
||||
|
||||
it("prefers the automation over the user who ran it", () => {
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_user_id: "user_1",
|
||||
context_event_type: "automation_triggered",
|
||||
context_entity_id: "automation.wake_up",
|
||||
context_name: "Wake up",
|
||||
}),
|
||||
{ userIdToName: users }
|
||||
);
|
||||
expect(model.cause?.type).toBe("automation");
|
||||
expect(model.cause?.name).toBe("Wake up");
|
||||
});
|
||||
|
||||
it("keeps the user for a direct action call", () => {
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_user_id: "user_1",
|
||||
context_event_type: "call_service",
|
||||
context_domain: "light",
|
||||
context_service: "turn_on",
|
||||
}),
|
||||
{ userIdToName: users }
|
||||
);
|
||||
expect(model.cause?.type).toBe("user");
|
||||
expect(model.cause?.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("falls back to the context cause without a user", () => {
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_event_type: "automation_triggered",
|
||||
context_name: "Wake up",
|
||||
}),
|
||||
{ userIdToName: users }
|
||||
);
|
||||
expect(model.cause?.type).toBe("automation");
|
||||
});
|
||||
|
||||
it("leaves the cause empty without any context", () => {
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({ entity_id: "light.ceiling", state: "on" }),
|
||||
{ userIdToName: users }
|
||||
);
|
||||
expect(model.cause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeLogbookItem run rows", () => {
|
||||
it("uses the run label instead of the raw backend message", () => {
|
||||
const hass = baseHass({
|
||||
localize: ((key: string) => key) as HomeAssistant["localize"],
|
||||
});
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
name: "Wake up",
|
||||
message: "triggered",
|
||||
}),
|
||||
{}
|
||||
);
|
||||
expect(model.value).toEqual({
|
||||
text: "ui.components.logbook.automation_triggered",
|
||||
type: "state",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the custom message of a domain-only entry (logbook.log)", () => {
|
||||
const hass = baseHass({
|
||||
localize: ((key: string) => key) as HomeAssistant["localize"],
|
||||
});
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({ domain: "script", name: "Backup", message: "Backup finished" }),
|
||||
{}
|
||||
);
|
||||
expect(model.value).toEqual({ text: "Backup finished", type: "message" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeCardFeatureLayout,
|
||||
computeCardFeatureRows,
|
||||
} from "../../../../../src/panels/lovelace/card-features/common/feature-layout";
|
||||
import type { LovelaceCardFeatureConfig } from "../../../../../src/panels/lovelace/card-features/types";
|
||||
|
||||
const features = (count: number): LovelaceCardFeatureConfig[] =>
|
||||
Array.from({ length: count }, () => ({ type: "toggle" }) as const);
|
||||
|
||||
describe("computeCardFeatureLayout", () => {
|
||||
it("stacks every feature in bottom position", () => {
|
||||
const layout = computeCardFeatureLayout(features(3), "bottom");
|
||||
expect(layout.inline).toHaveLength(0);
|
||||
expect(layout.below).toHaveLength(3);
|
||||
expect(layout.columns).toBe(1);
|
||||
});
|
||||
|
||||
it("handles a missing feature list", () => {
|
||||
expect(computeCardFeatureLayout(undefined, "inline")).toEqual({
|
||||
inline: [],
|
||||
below: [],
|
||||
columns: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("puts the first feature inline and stacks the rest", () => {
|
||||
const configs: LovelaceCardFeatureConfig[] = [
|
||||
{ type: "toggle" },
|
||||
{ type: "cover-open-close" },
|
||||
{ type: "cover-position" },
|
||||
];
|
||||
const layout = computeCardFeatureLayout(configs, "inline");
|
||||
expect(layout.inline).toEqual([configs[0]]);
|
||||
expect(layout.below).toEqual([configs[1], configs[2]]);
|
||||
});
|
||||
|
||||
it("fills no column when the inline feature is alone", () => {
|
||||
expect(computeCardFeatureLayout(features(1), "inline").columns).toBe(0);
|
||||
});
|
||||
|
||||
it("fills one column for a single feature below", () => {
|
||||
expect(computeCardFeatureLayout(features(2), "inline").columns).toBe(1);
|
||||
});
|
||||
|
||||
it("caps the features below at two columns", () => {
|
||||
expect(computeCardFeatureLayout(features(5), "inline").columns).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCardFeatureRows", () => {
|
||||
it("counts one row per feature in bottom position", () => {
|
||||
const rows = [0, 1, 2, 3].map((count) =>
|
||||
computeCardFeatureRows(features(count), "bottom")
|
||||
);
|
||||
expect(rows).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("pairs the features below the inline one", () => {
|
||||
const rows = [0, 1, 2, 3, 4, 5, 6].map((count) =>
|
||||
computeCardFeatureRows(features(count), "inline")
|
||||
);
|
||||
expect(rows).toEqual([0, 0, 1, 1, 2, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import "../../../../src/panels/lovelace/cards/hui-tile-card";
|
||||
import type { LovelaceCardFeatureConfig } from "../../../../src/panels/lovelace/card-features/types";
|
||||
import type {
|
||||
LovelaceCard,
|
||||
LovelaceGridOptions,
|
||||
} from "../../../../src/panels/lovelace/types";
|
||||
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
|
||||
// getCardSize() and getGridOptions() drive how much space the tile card claims
|
||||
// in masonry and sections views. In "inline" mode the first feature shares the
|
||||
// name row and the remaining features are laid out two per row below it, so the
|
||||
// counting differs from "bottom" mode; these tests pin that arithmetic down.
|
||||
|
||||
// Bundler-defined globals the card's import graph reads at eval time.
|
||||
vi.hoisted(() => {
|
||||
Object.assign(globalThis, {
|
||||
__STATIC_PATH__: "/",
|
||||
__HASS_URL__: "",
|
||||
__BUILD__: "modern",
|
||||
__VERSION__: "test",
|
||||
__BACKWARDS_COMPAT__: false,
|
||||
__SUPERVISOR__: false,
|
||||
__NAMESPACE__: "frontend",
|
||||
});
|
||||
});
|
||||
|
||||
const features = (count: number): LovelaceCardFeatureConfig[] =>
|
||||
Array.from(
|
||||
{ length: count },
|
||||
() => ({ type: "toggle" }) as LovelaceCardFeatureConfig
|
||||
);
|
||||
|
||||
const makeCard = (config: Partial<TileCardConfig>): LovelaceCard => {
|
||||
const card = document.createElement("hui-tile-card") as LovelaceCard;
|
||||
card.setConfig({
|
||||
type: "tile",
|
||||
entity: "light.test",
|
||||
...config,
|
||||
} as TileCardConfig);
|
||||
return card;
|
||||
};
|
||||
|
||||
describe("hui-tile-card getCardSize", () => {
|
||||
it("is 1 for a bare tile", () => {
|
||||
expect(makeCard({}).getCardSize()).toBe(1);
|
||||
});
|
||||
|
||||
it("adds a row for the vertical layout", () => {
|
||||
expect(makeCard({ vertical: true }).getCardSize()).toBe(2);
|
||||
});
|
||||
|
||||
it("counts every feature in bottom mode", () => {
|
||||
expect(
|
||||
makeCard({
|
||||
features_position: "bottom",
|
||||
features: features(3),
|
||||
}).getCardSize()
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it("pairs the features below the inline one", () => {
|
||||
const sizes = [1, 2, 3, 4, 5, 6].map((count) =>
|
||||
makeCard({
|
||||
features_position: "inline",
|
||||
features: features(count),
|
||||
}).getCardSize()
|
||||
);
|
||||
expect(sizes).toEqual([1, 2, 2, 3, 3, 4]);
|
||||
});
|
||||
|
||||
it("does not add rows for a single inline feature", () => {
|
||||
expect(
|
||||
makeCard({
|
||||
features_position: "inline",
|
||||
features: features(1),
|
||||
}).getCardSize()
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("does not add rows for inline mode with no features", () => {
|
||||
expect(
|
||||
makeCard({
|
||||
features_position: "inline",
|
||||
features: features(0),
|
||||
}).getCardSize()
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores inline mode when the layout is vertical", () => {
|
||||
// vertical forces bottom positioning, so all features are stacked
|
||||
expect(
|
||||
makeCard({
|
||||
vertical: true,
|
||||
features_position: "inline",
|
||||
features: features(2),
|
||||
}).getCardSize()
|
||||
).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hui-tile-card getGridOptions", () => {
|
||||
const gridOptions = (config: Partial<TileCardConfig>): LovelaceGridOptions =>
|
||||
makeCard(config).getGridOptions!();
|
||||
|
||||
it("is a single 6-wide row for a bare tile", () => {
|
||||
expect(gridOptions({})).toEqual({
|
||||
columns: 6,
|
||||
rows: 1,
|
||||
min_columns: 6,
|
||||
min_rows: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds one row per feature in bottom mode", () => {
|
||||
expect(
|
||||
gridOptions({ features_position: "bottom", features: features(3) })
|
||||
).toEqual({
|
||||
columns: 6,
|
||||
rows: 4,
|
||||
min_columns: 6,
|
||||
min_rows: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("widens to 12 columns and pairs the extra features in inline mode", () => {
|
||||
expect(
|
||||
gridOptions({ features_position: "inline", features: features(3) })
|
||||
).toEqual({
|
||||
columns: 6,
|
||||
rows: 2,
|
||||
min_columns: 12,
|
||||
min_rows: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a row per pair of features below the inline one", () => {
|
||||
const rows = [1, 2, 3, 4, 5, 6].map(
|
||||
(count) =>
|
||||
gridOptions({ features_position: "inline", features: features(count) })
|
||||
.rows
|
||||
);
|
||||
expect(rows).toEqual([1, 2, 2, 3, 3, 4]);
|
||||
});
|
||||
|
||||
it("keeps a single row for one inline feature", () => {
|
||||
expect(
|
||||
gridOptions({ features_position: "inline", features: features(1) })
|
||||
).toEqual({
|
||||
columns: 6,
|
||||
rows: 1,
|
||||
min_columns: 12,
|
||||
min_rows: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("stacks all features and narrows columns in vertical mode", () => {
|
||||
// vertical forces bottom positioning and adds its own row
|
||||
expect(
|
||||
gridOptions({
|
||||
vertical: true,
|
||||
features_position: "inline",
|
||||
features: features(2),
|
||||
})
|
||||
).toEqual({
|
||||
columns: 6,
|
||||
rows: 4,
|
||||
min_columns: 3,
|
||||
min_rows: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4765,65 +4765,79 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-darwin-arm64@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-darwin-arm64@npm:2.1.8"
|
||||
"@rspack/binding-darwin-arm64@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-darwin-arm64@npm:2.1.9"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-darwin-x64@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-darwin-x64@npm:2.1.8"
|
||||
"@rspack/binding-darwin-x64@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-darwin-x64@npm:2.1.9"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-arm64-gnu@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.8"
|
||||
"@rspack/binding-linux-arm64-gnu@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.9"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-arm64-musl@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.8"
|
||||
"@rspack/binding-linux-arm64-musl@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.9"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-riscv64-gnu@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.8"
|
||||
"@rspack/binding-linux-ppc64-gnu@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-ppc64-gnu@npm:2.1.9"
|
||||
conditions: os=linux & cpu=ppc64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-riscv64-gnu@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.9"
|
||||
conditions: os=linux & cpu=riscv64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-riscv64-musl@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.8"
|
||||
"@rspack/binding-linux-riscv64-musl@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.9"
|
||||
conditions: os=linux & cpu=riscv64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-gnu@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.8"
|
||||
"@rspack/binding-linux-s390x-gnu@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-s390x-gnu@npm:2.1.9"
|
||||
conditions: os=linux & cpu=s390x & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-gnu@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.9"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-musl@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.8"
|
||||
"@rspack/binding-linux-x64-musl@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.9"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-wasm32-wasi@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.8"
|
||||
"@rspack/binding-wasm32-wasi@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.9"
|
||||
dependencies:
|
||||
"@emnapi/core": "npm:1.11.3"
|
||||
"@emnapi/runtime": "npm:1.11.3"
|
||||
@@ -4832,43 +4846,45 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-arm64-msvc@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.8"
|
||||
"@rspack/binding-win32-arm64-msvc@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.9"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-ia32-msvc@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.8"
|
||||
"@rspack/binding-win32-ia32-msvc@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.9"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-x64-msvc@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.8"
|
||||
"@rspack/binding-win32-x64-msvc@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.9"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding@npm:2.1.8"
|
||||
"@rspack/binding@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding@npm:2.1.9"
|
||||
dependencies:
|
||||
"@rspack/binding-darwin-arm64": "npm:2.1.8"
|
||||
"@rspack/binding-darwin-x64": "npm:2.1.8"
|
||||
"@rspack/binding-linux-arm64-gnu": "npm:2.1.8"
|
||||
"@rspack/binding-linux-arm64-musl": "npm:2.1.8"
|
||||
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.8"
|
||||
"@rspack/binding-linux-riscv64-musl": "npm:2.1.8"
|
||||
"@rspack/binding-linux-x64-gnu": "npm:2.1.8"
|
||||
"@rspack/binding-linux-x64-musl": "npm:2.1.8"
|
||||
"@rspack/binding-wasm32-wasi": "npm:2.1.8"
|
||||
"@rspack/binding-win32-arm64-msvc": "npm:2.1.8"
|
||||
"@rspack/binding-win32-ia32-msvc": "npm:2.1.8"
|
||||
"@rspack/binding-win32-x64-msvc": "npm:2.1.8"
|
||||
"@rspack/binding-darwin-arm64": "npm:2.1.9"
|
||||
"@rspack/binding-darwin-x64": "npm:2.1.9"
|
||||
"@rspack/binding-linux-arm64-gnu": "npm:2.1.9"
|
||||
"@rspack/binding-linux-arm64-musl": "npm:2.1.9"
|
||||
"@rspack/binding-linux-ppc64-gnu": "npm:2.1.9"
|
||||
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.9"
|
||||
"@rspack/binding-linux-riscv64-musl": "npm:2.1.9"
|
||||
"@rspack/binding-linux-s390x-gnu": "npm:2.1.9"
|
||||
"@rspack/binding-linux-x64-gnu": "npm:2.1.9"
|
||||
"@rspack/binding-linux-x64-musl": "npm:2.1.9"
|
||||
"@rspack/binding-wasm32-wasi": "npm:2.1.9"
|
||||
"@rspack/binding-win32-arm64-msvc": "npm:2.1.9"
|
||||
"@rspack/binding-win32-ia32-msvc": "npm:2.1.9"
|
||||
"@rspack/binding-win32-x64-msvc": "npm:2.1.9"
|
||||
dependenciesMeta:
|
||||
"@rspack/binding-darwin-arm64":
|
||||
optional: true
|
||||
@@ -4878,10 +4894,14 @@ __metadata:
|
||||
optional: true
|
||||
"@rspack/binding-linux-arm64-musl":
|
||||
optional: true
|
||||
"@rspack/binding-linux-ppc64-gnu":
|
||||
optional: true
|
||||
"@rspack/binding-linux-riscv64-gnu":
|
||||
optional: true
|
||||
"@rspack/binding-linux-riscv64-musl":
|
||||
optional: true
|
||||
"@rspack/binding-linux-s390x-gnu":
|
||||
optional: true
|
||||
"@rspack/binding-linux-x64-gnu":
|
||||
optional: true
|
||||
"@rspack/binding-linux-x64-musl":
|
||||
@@ -4894,15 +4914,15 @@ __metadata:
|
||||
optional: true
|
||||
"@rspack/binding-win32-x64-msvc":
|
||||
optional: true
|
||||
checksum: 10/74c73ac543059fa62365557ca949ef7a84e1cebc42fe51a343126c875d90f557b35a52b33bacd9498d9231e8fdb8ab13907988e1fd39131ffbd56becb4260785
|
||||
checksum: 10/bf3ef6f75b0fa3dafc8dc73bfb49ae31e706e466699a88e0ef35d7273800e3c241489def836041688dded31b9ec3eca61d12bc0c30f8fa18839ae879ab8d2182
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/core@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/core@npm:2.1.8"
|
||||
"@rspack/core@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/core@npm:2.1.9"
|
||||
dependencies:
|
||||
"@rspack/binding": "npm:2.1.8"
|
||||
"@rspack/binding": "npm:2.1.9"
|
||||
peerDependencies:
|
||||
"@module-federation/runtime-tools": ^0.24.1 || ^2.0.0
|
||||
"@swc/helpers": ^0.5.23
|
||||
@@ -4911,7 +4931,7 @@ __metadata:
|
||||
optional: true
|
||||
"@swc/helpers":
|
||||
optional: true
|
||||
checksum: 10/c818257225a24feb22af81eb3ed7d02d4b74d6e6cb70689c8cbcd34e3facfcfe833dfe4078e0a35277e926f431cae9fd7f2101c3944b396a72baa7cf3ce99d19
|
||||
checksum: 10/81a8d5b4855ea8783d18e9e0c422e18337e50b820bf1f867d5287dc5a3643307c03e39011049f485e63e676480ba71f5fb0e7fb7f4ceea72e4bc2eec2651c13d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5771,105 +5791,105 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/eslint-plugin@npm:8.66.0":
|
||||
version: 8.66.0
|
||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.66.0"
|
||||
"@typescript-eslint/eslint-plugin@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.67.0"
|
||||
dependencies:
|
||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||
"@typescript-eslint/scope-manager": "npm:8.66.0"
|
||||
"@typescript-eslint/type-utils": "npm:8.66.0"
|
||||
"@typescript-eslint/utils": "npm:8.66.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.66.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.67.0"
|
||||
"@typescript-eslint/type-utils": "npm:8.67.0"
|
||||
"@typescript-eslint/utils": "npm:8.67.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.67.0"
|
||||
ignore: "npm:^7.0.5"
|
||||
natural-compare: "npm:^1.4.0"
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
"@typescript-eslint/parser": ^8.66.0
|
||||
"@typescript-eslint/parser": ^8.67.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/bb144ff0c27592b6a53d964fe1e2145dd0737f5fe50fe7dcd88217ca5ac4e470d7965d97745a63de16252ac2880d00732a472b9cec14d7d6929ebda97e7bcbb1
|
||||
checksum: 10/d53f9e51c6be98ff4cdcb94de24e2951f5e2b7a1dfc56c4f8a2d81e61346c511c0478117626d9cfd5d5d63f5e7a0e0653dc1abe1e7d926a3f9806aaf1fa11bd2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/parser@npm:8.66.0":
|
||||
version: 8.66.0
|
||||
resolution: "@typescript-eslint/parser@npm:8.66.0"
|
||||
"@typescript-eslint/parser@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/parser@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager": "npm:8.66.0"
|
||||
"@typescript-eslint/types": "npm:8.66.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.66.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.66.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.67.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.67.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.67.0"
|
||||
debug: "npm:^4.4.3"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/a0451abe17d2eeff6ff2e1bf637d5ea19b400378d98f4511672e0f30dc5a7971d03352e32764927176c3632f245d5f85e8b28a0a3e66d020effcf0c61624e9b6
|
||||
checksum: 10/fa156ade066d0886ea0daa0f872940cdb450a2b5dbd1687e0df64ec19aac86711dde51e17e6b20e72387aa38daec811736821e9db950e8557affa7faea63d65c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/project-service@npm:8.66.0":
|
||||
version: 8.66.0
|
||||
resolution: "@typescript-eslint/project-service@npm:8.66.0"
|
||||
"@typescript-eslint/project-service@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/project-service@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils": "npm:^8.66.0"
|
||||
"@typescript-eslint/types": "npm:^8.66.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:^8.67.0"
|
||||
"@typescript-eslint/types": "npm:^8.67.0"
|
||||
debug: "npm:^4.4.3"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/e8a71f69ee7f4bd9ca1c6a0a5110361b9927d17df2f41cdf6d042203b49aabfbddece79dec7882fde8dc016b6278671a2ba3f7bf08e5ae241582627b68be3a8b
|
||||
checksum: 10/3beccdee1e74060eac4e6c5e7edd14a800c8c98e3313dbfe5838a357e83babc22c41c3ebe637c331c4b93db2da50f5479ccf4677db7b6b1a760f3cd27795b36f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/scope-manager@npm:8.66.0":
|
||||
version: 8.66.0
|
||||
resolution: "@typescript-eslint/scope-manager@npm:8.66.0"
|
||||
"@typescript-eslint/scope-manager@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/scope-manager@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.66.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.66.0"
|
||||
checksum: 10/f2024cd2819dc3b967f32089ea1a0b9099acabf12860699432f0ba802d75b01ed35ea488a50576cae544813d5cbe9bd9ee5815bc4f11cefc89fe2fb24559238f
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.67.0"
|
||||
checksum: 10/a0ca8d52e6c5b2538f7df860acd5a36437cce8e6d5bcededdfde04c02489f4b523cb178b502c266d138cff6758ea375ebe311b2b6af373b127ded4876a697ffb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@npm:8.66.0, @typescript-eslint/tsconfig-utils@npm:^8.66.0":
|
||||
version: 8.66.0
|
||||
resolution: "@typescript-eslint/tsconfig-utils@npm:8.66.0"
|
||||
"@typescript-eslint/tsconfig-utils@npm:8.67.0, @typescript-eslint/tsconfig-utils@npm:^8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/tsconfig-utils@npm:8.67.0"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/271b28660edc9e6272ae07a91f8da2c69e8a6b009e8588f1009c485c11fe4e66e09356d50dbeabf8e6a44c95c8adeb630f1b6089bdd68aec2512d0036cef7704
|
||||
checksum: 10/736d0ab8a273b4e4e17d412b390386eec0171cd3f7ba451bacd92c4f931051bba2049e040fdae84603ea0e07430afb05b49c6d8b6d429f9012d3c6b03eb69f54
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/type-utils@npm:8.66.0":
|
||||
version: 8.66.0
|
||||
resolution: "@typescript-eslint/type-utils@npm:8.66.0"
|
||||
"@typescript-eslint/type-utils@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/type-utils@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.66.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.66.0"
|
||||
"@typescript-eslint/utils": "npm:8.66.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.67.0"
|
||||
"@typescript-eslint/utils": "npm:8.67.0"
|
||||
debug: "npm:^4.4.3"
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/6d3c55a591b96cbac4ac845ad1d281bac54df12a631ca550370d266aed30760fa9d498e44b938e454941930e3decda820c6e544e0748254c5089425d120e1ed1
|
||||
checksum: 10/a0b943e5a8f3c2e8490ec28599eb94561d7ad029fcf42f2e9a0e8d2685ce625898371cd0a7d3a906fe8b4077d72fee8329cad512a5b0521bc2167f7d88a984fb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/types@npm:8.66.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.66.0":
|
||||
version: 8.66.0
|
||||
resolution: "@typescript-eslint/types@npm:8.66.0"
|
||||
checksum: 10/de1ea79e7056c11e38e4001a899b88511793a0aa1702508af42ee179bc7303bed2eb02f177a7ae438f20eab00aee9407c7e842bec6fc5b7bb1fdcab4a7cd5c4b
|
||||
"@typescript-eslint/types@npm:8.67.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/types@npm:8.67.0"
|
||||
checksum: 10/8edc1a14a52c7566409c19b34fb72ba3a62a3b409c98b1d496de9fcd83179fb128c8df2fde391c199316a54cdc5d314ba70ce7b440161dd850b73a44f3e54e6d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/typescript-estree@npm:8.66.0":
|
||||
version: 8.66.0
|
||||
resolution: "@typescript-eslint/typescript-estree@npm:8.66.0"
|
||||
"@typescript-eslint/typescript-estree@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/typescript-estree@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service": "npm:8.66.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:8.66.0"
|
||||
"@typescript-eslint/types": "npm:8.66.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.66.0"
|
||||
"@typescript-eslint/project-service": "npm:8.67.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:8.67.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.67.0"
|
||||
debug: "npm:^4.4.3"
|
||||
minimatch: "npm:^10.2.2"
|
||||
semver: "npm:^7.7.3"
|
||||
@@ -5877,32 +5897,32 @@ __metadata:
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/0834efcdd4468c0dd954089daf26b40175e204402bd4075b06cd910786bb6de7a15aec4e79e40f0980f39fce5bfd9c871ec8fcc6871a8895a34a273bb187fd26
|
||||
checksum: 10/31c5be812f67c997b9d1e9324c4289b6268a12e9948492403fdd9c9b5df12dcc00f845ae93ae37c78f717583519060175f2147f165b65112eef1e40acf44ad1d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/utils@npm:8.66.0":
|
||||
version: 8.66.0
|
||||
resolution: "@typescript-eslint/utils@npm:8.66.0"
|
||||
"@typescript-eslint/utils@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/utils@npm:8.67.0"
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": "npm:^4.9.1"
|
||||
"@typescript-eslint/scope-manager": "npm:8.66.0"
|
||||
"@typescript-eslint/types": "npm:8.66.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.66.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.67.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.67.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/e7a24ee0f35d58b4392daa03fb1ea37ab7e4519949a034be89151df1b0e9fc416831f50cb69f54150272e252cba350964632ee9bfbdb7e62ac599cc731de56e1
|
||||
checksum: 10/8703468cdea3630275c6faacc031e7e4bd7fb8f8988e80ab987811ae3c63a9a9677496cee2b4811f49cb9bc9e6f6c0bdb91cb213353019ce31df0a2b351cea51
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/visitor-keys@npm:8.66.0":
|
||||
version: 8.66.0
|
||||
resolution: "@typescript-eslint/visitor-keys@npm:8.66.0"
|
||||
"@typescript-eslint/visitor-keys@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/visitor-keys@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.66.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
eslint-visitor-keys: "npm:^5.0.0"
|
||||
checksum: 10/d47e936b10ec94a96f69f77a62463f4634fe72d12d42dd302d7777f7b98689468e0b67c538a52232b723695208f1b3fdeae5745eb3e71a70a90818fdd827a0bc
|
||||
checksum: 10/c92c9b202105a8f398ab2ad4be58c8c8fd9af6b5ddd28cdcd57d75f7f04f69561a56a05aac9a008668c64faa7222ebb3fefe19097923a18eb2b9f7a90809a5be
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9936,7 +9956,7 @@ __metadata:
|
||||
"@playwright/test": "npm:1.62.1"
|
||||
"@replit/codemirror-indentation-markers": "npm:6.5.3"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.6.1"
|
||||
"@rspack/core": "npm:2.1.8"
|
||||
"@rspack/core": "npm:2.1.9"
|
||||
"@rspack/dev-server": "npm:2.2.0"
|
||||
"@swc/helpers": "npm:0.5.23"
|
||||
"@thomasloven/round-slider": "npm:0.6.0"
|
||||
@@ -10040,7 +10060,7 @@ __metadata:
|
||||
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
|
||||
ts-lit-plugin: "npm:2.0.2"
|
||||
typescript: "npm:6.0.3"
|
||||
typescript-eslint: "npm:8.66.0"
|
||||
typescript-eslint: "npm:8.67.0"
|
||||
vite-tsconfig-paths: "npm:6.1.1"
|
||||
vitest: "npm:4.1.10"
|
||||
webpack-stats-plugin: "npm:1.1.3"
|
||||
@@ -14892,18 +14912,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typescript-eslint@npm:8.66.0":
|
||||
version: 8.66.0
|
||||
resolution: "typescript-eslint@npm:8.66.0"
|
||||
"typescript-eslint@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "typescript-eslint@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin": "npm:8.66.0"
|
||||
"@typescript-eslint/parser": "npm:8.66.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.66.0"
|
||||
"@typescript-eslint/utils": "npm:8.66.0"
|
||||
"@typescript-eslint/eslint-plugin": "npm:8.67.0"
|
||||
"@typescript-eslint/parser": "npm:8.67.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.67.0"
|
||||
"@typescript-eslint/utils": "npm:8.67.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/e76da9c684e4fe012b49a5120dd57553df0788cf1cffcbd5dc72c98de33e549f306495e710292f111ee058366b1e5d8d603c2bb14385b12bb35edc7bbf13ad55
|
||||
checksum: 10/4efc2cea5b067f65d58e010fda3888ec94f2e67ebfa289be78c3a02458787b7125cf8f6095776b912005fb69e8748ad4018777b16436a0658957c163ef213bf9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user