mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-14 02:16:39 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea4dcd294b |
@@ -51,39 +51,4 @@ export const demoDevices: DeviceRegistryEntry[] = [
|
||||
primary_config_entry: "mock-sonos",
|
||||
entry_type: null,
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip",
|
||||
name: "Power strip",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
},
|
||||
// Child devices (logical parts of the power strip). They carry the parent's
|
||||
// inherited hardware fields, mirroring how resolveChildDevices fills them in
|
||||
// from the WebSocket, and reference the parent via parent_device_id.
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip-outlet-1",
|
||||
name: "Outlet 1",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
parent_device_id: "power-strip",
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip-outlet-2",
|
||||
name: "Outlet 2",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
parent_device_id: "power-strip",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
@@ -9,7 +8,6 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import "../../../../src/components/ha-target-picker";
|
||||
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";
|
||||
@@ -155,9 +153,6 @@ interface Sample {
|
||||
description: string;
|
||||
selector: Selector;
|
||||
value: unknown;
|
||||
// Render ha-target-picker directly in compact (chip) mode instead of the
|
||||
// ha-selector, which does not expose the compact option.
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const SAMPLES: Sample[] = [
|
||||
@@ -168,14 +163,6 @@ const SAMPLES: Sample[] = [
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
},
|
||||
{
|
||||
name: "Target (compact)",
|
||||
description:
|
||||
"In compact mode the replaced reference is shown as a warning chip.",
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
compact: true,
|
||||
},
|
||||
{
|
||||
name: "Device (unfiltered, multiple matches)",
|
||||
description:
|
||||
@@ -274,23 +261,13 @@ class DemoHaSelectorReplacedDevice
|
||||
<ha-settings-row narrow slot=${slot}>
|
||||
<span slot="heading">${sample.name}</span>
|
||||
<span slot="description">${sample.description}</span>
|
||||
${
|
||||
sample.compact
|
||||
? html`<ha-target-picker
|
||||
compact
|
||||
.hass=${this.hass}
|
||||
.value=${this._values[idx] as HassServiceTarget}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-target-picker>`
|
||||
: html`<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>`
|
||||
}
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>
|
||||
</ha-settings-row>
|
||||
`
|
||||
)}
|
||||
|
||||
@@ -152,84 +152,6 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "livingroom",
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_power_strip",
|
||||
identifiers: [["demo", "strip1"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Power strip",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
// Child devices of the power strip. They have no area of their own and
|
||||
// inherit the parent's area ("Livingroom"); the picker renders them indented
|
||||
// under the parent with a tree connector.
|
||||
{
|
||||
area_id: null,
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_outlet_1",
|
||||
identifiers: [["demo", "outlet1"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Outlet 1",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: "device_power_strip",
|
||||
},
|
||||
{
|
||||
area_id: null,
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_outlet_2",
|
||||
identifiers: [["demo", "outlet2"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Outlet 2",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: "device_power_strip",
|
||||
},
|
||||
];
|
||||
|
||||
const AREAS: DemoArea[] = [
|
||||
|
||||
@@ -132,12 +132,12 @@ const ENTITIES = [
|
||||
fan_modes: ["on_low", "on_high", "auto_low", "auto_high", "off"],
|
||||
preset_modes: ["home", "eco", "away"],
|
||||
swing_modes: ["auto", "1", "2", "3", "off"],
|
||||
switch_horizontal_modes: ["auto", "4", "5", "6", "off"],
|
||||
current_temperature: 23,
|
||||
target_temp_high: 24,
|
||||
target_temp_low: 21,
|
||||
fan_mode: "auto_low",
|
||||
preset_mode: "home",
|
||||
swing_horizontal_modes: ["auto", "4", "5", "6", "off"],
|
||||
swing_mode: "auto",
|
||||
swing_horizontal_mode: "off",
|
||||
supported_features:
|
||||
@@ -340,84 +340,6 @@ const CONFIGS = [
|
||||
features: [{ type: "fan-oscillate" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: one feature",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [{ type: "climate-hvac-modes", style: "dropdown" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: two features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: three features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: four features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: five features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Bottom features: five features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "bottom",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<TileCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-tile-card")
|
||||
|
||||
@@ -22,6 +22,10 @@ import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
// Rows with their name drawn above the bar need room for both.
|
||||
const ROW_HEIGHT_INSIDE_LABELS = 64;
|
||||
|
||||
@customElement("state-history-chart-timeline")
|
||||
export class StateHistoryChartTimeline extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -38,6 +42,14 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/**
|
||||
* Draw each row's name inside the plot, above its bar, instead of in a label
|
||||
* column next to it. Gives long names the full width, at the cost of taller
|
||||
* rows.
|
||||
*/
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -67,7 +79,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${this.data.length * 30 + 30}px`}
|
||||
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + 30}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@@ -182,6 +194,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("insideLabels") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth")
|
||||
) {
|
||||
@@ -193,14 +206,19 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
const narrow = this.narrow;
|
||||
const showNames = this.chunked || this.showNames;
|
||||
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||
const labelWidth = showNames
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const insideLabels = this.insideLabels;
|
||||
const labelWidth =
|
||||
showNames && !insideLabels
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
// Inside labels take no width of their own, but the plot still lines up
|
||||
// with the line charts that share the y-axis padding.
|
||||
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
|
||||
this._chartOptions = {
|
||||
xAxis: {
|
||||
type: "time",
|
||||
@@ -224,37 +242,52 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
axisLabel: insideLabels
|
||||
? {
|
||||
show: showNames,
|
||||
inside: true,
|
||||
margin: 0,
|
||||
// Sits on the row's baseline, lifted above the bar.
|
||||
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
|
||||
align: rtl ? "right" : "left",
|
||||
verticalAlign: "bottom",
|
||||
formatter: (id: string) =>
|
||||
(this._chartData.find((d) => d.id === id)?.name as string) ??
|
||||
"",
|
||||
hideOverlap: true,
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
// Room for the first row's name above its bar.
|
||||
top: insideLabels ? 20 : 10,
|
||||
bottom: 30,
|
||||
left: rtl ? 1 : labelWidth,
|
||||
right: rtl ? labelWidth : 1,
|
||||
left: rtl ? 1 : plotPadding,
|
||||
right: rtl ? plotPadding : 1,
|
||||
},
|
||||
tooltip: {
|
||||
renderMode: "html",
|
||||
|
||||
@@ -79,6 +79,10 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw timeline row names above their bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -227,6 +231,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
.startTime=${this._computedStartTime}
|
||||
.endTime=${this._computedEndTime}
|
||||
.showNames=${this.showNames}
|
||||
.insideLabels=${this.insideLabels}
|
||||
.names=${this.names}
|
||||
.narrow=${this.narrow}
|
||||
.chunked=${this.virtualize}
|
||||
@@ -424,6 +429,12 @@ export class StateHistoryCharts extends LitElement {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* Names inside the plot sit close to the chart above them, so the groups
|
||||
need more room between them to stay apart. */
|
||||
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
|
||||
margin-top: var(--ha-space-8);
|
||||
}
|
||||
|
||||
.entry-container:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mdiCalendar } from "@mdi/js";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../ha-icon-button-next";
|
||||
import "../ha-icon-button-prev";
|
||||
import "../ha-svg-icon";
|
||||
import {
|
||||
haDateRangePickerStyles,
|
||||
HaDateRangePicker,
|
||||
} from "./ha-date-range-picker";
|
||||
|
||||
/**
|
||||
* Date range picker as a single pill that also steps through ranges: a
|
||||
* previous button, the selected range and a next button. Meant for a toolbar,
|
||||
* next to other chips.
|
||||
*/
|
||||
@customElement("ha-date-range-nav")
|
||||
export class HaDateRangeNav extends HaDateRangePicker {
|
||||
protected override _renderField() {
|
||||
return html`
|
||||
<ha-icon-button-prev
|
||||
class="step"
|
||||
.label=${this._i18n.localize("ui.common.previous")}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._handlePrev}
|
||||
></ha-icon-button-prev>
|
||||
<ha-assist-chip
|
||||
id="field"
|
||||
class="range"
|
||||
.label=${this._formatRange(" – ")}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._openPicker}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiCalendar}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
<ha-icon-button-next
|
||||
class="step"
|
||||
.label=${this._i18n.localize("ui.common.next")}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._handleNext}
|
||||
></ha-icon-button-next>
|
||||
`;
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
haDateRangePickerStyles,
|
||||
css`
|
||||
/* The three controls read as one pill, with the range chip's borders as
|
||||
the dividers between them. */
|
||||
.date-range-inputs {
|
||||
gap: 0;
|
||||
border: 1px solid var(--outline-color);
|
||||
border-radius: var(--ha-assist-chip-container-shape, 10px);
|
||||
background: var(--ha-assist-chip-container-color, transparent);
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.step {
|
||||
--ha-icon-button-size: 32px;
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
|
||||
.range {
|
||||
--md-assist-chip-outline-color: transparent;
|
||||
--ha-assist-chip-container-shape: 0;
|
||||
--ha-assist-chip-container-color: transparent;
|
||||
border-inline: 1px solid var(--divider-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-date-range-nav": HaDateRangeNav;
|
||||
}
|
||||
}
|
||||
@@ -42,18 +42,67 @@ const EXTENDED_RANGE_KEYS: DateRange[] = [
|
||||
"now-30d",
|
||||
];
|
||||
|
||||
export const haDateRangePickerStyles = css`
|
||||
ha-icon-button {
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.date-range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-textarea {
|
||||
display: inline-block;
|
||||
width: 340px;
|
||||
}
|
||||
@media only screen and (max-width: 460px) {
|
||||
ha-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-normal) ease-out;
|
||||
}
|
||||
|
||||
wa-popover.open::part(dialog)::backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
|
||||
background: none;
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
min-width: max(var(--body-width), 250px);
|
||||
max-width: calc(
|
||||
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right) - var(
|
||||
--ha-space-8
|
||||
)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
@customElement("ha-date-range-picker")
|
||||
export class HaDateRangePicker extends LitElement {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
protected _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<HomeAssistantConfig, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _hassConfig!: HassConfig;
|
||||
protected _hassConfig!: HassConfig;
|
||||
|
||||
@property({ attribute: false }) public startDate!: Date;
|
||||
|
||||
@@ -143,73 +192,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="date-range-inputs">
|
||||
${
|
||||
!this.minimal
|
||||
? html`<ha-textarea
|
||||
id="field"
|
||||
rows="1"
|
||||
resize="auto"
|
||||
@click=${this._openPicker}
|
||||
@keydown=${this._handleKeydown}
|
||||
.value=${
|
||||
(isThisYear(this.startDate)
|
||||
? formatShortDateTime(
|
||||
this.startDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)
|
||||
: formatShortDateTimeWithYear(
|
||||
this.startDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)) +
|
||||
(window.innerWidth >= 459 ? " - " : " - \n") +
|
||||
(isThisYear(this.endDate)
|
||||
? formatShortDateTime(
|
||||
this.endDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)
|
||||
: formatShortDateTimeWithYear(
|
||||
this.endDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
))
|
||||
}
|
||||
.label=${
|
||||
this._i18n.localize(
|
||||
"ui.components.date-range-picker.start_date"
|
||||
) +
|
||||
" - " +
|
||||
this._i18n.localize(
|
||||
"ui.components.date-range-picker.end_date"
|
||||
)
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
readonly
|
||||
></ha-textarea>
|
||||
<ha-icon-button-prev
|
||||
.label=${this._i18n.localize("ui.common.previous")}
|
||||
@click=${this._handlePrev}
|
||||
>
|
||||
</ha-icon-button-prev>
|
||||
<ha-icon-button-next
|
||||
.label=${this._i18n.localize("ui.common.next")}
|
||||
@click=${this._handleNext}
|
||||
>
|
||||
</ha-icon-button-next>`
|
||||
: html`<ha-icon-button
|
||||
@click=${this._openPicker}
|
||||
.disabled=${this.disabled}
|
||||
id="field"
|
||||
.label=${this._i18n.localize(
|
||||
"ui.components.date-range-picker.select_date_range"
|
||||
)}
|
||||
.path=${mdiCalendar}
|
||||
></ha-icon-button>`
|
||||
}
|
||||
</div>
|
||||
<div class="date-range-inputs">${this._renderField()}</div>
|
||||
${
|
||||
this._pickerWrapperOpen || this._opened
|
||||
? this._openedNarrow
|
||||
@@ -248,6 +231,63 @@ export class HaDateRangePicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The control that opens the picker. It has to carry `id="field"`, which the
|
||||
* popover anchors to.
|
||||
*/
|
||||
protected _renderField() {
|
||||
if (this.minimal) {
|
||||
return html`<ha-icon-button
|
||||
@click=${this._openPicker}
|
||||
.disabled=${this.disabled}
|
||||
id="field"
|
||||
.label=${this._i18n.localize(
|
||||
"ui.components.date-range-picker.select_date_range"
|
||||
)}
|
||||
.path=${mdiCalendar}
|
||||
></ha-icon-button>`;
|
||||
}
|
||||
|
||||
return html`<ha-textarea
|
||||
id="field"
|
||||
rows="1"
|
||||
resize="auto"
|
||||
@click=${this._openPicker}
|
||||
@keydown=${this._handleKeydown}
|
||||
.value=${this._formatRange(window.innerWidth >= 459 ? " - " : " - \n")}
|
||||
.label=${
|
||||
this._i18n.localize("ui.components.date-range-picker.start_date") +
|
||||
" - " +
|
||||
this._i18n.localize("ui.components.date-range-picker.end_date")
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
readonly
|
||||
></ha-textarea>
|
||||
<ha-icon-button-prev
|
||||
.label=${this._i18n.localize("ui.common.previous")}
|
||||
@click=${this._handlePrev}
|
||||
>
|
||||
</ha-icon-button-prev>
|
||||
<ha-icon-button-next
|
||||
.label=${this._i18n.localize("ui.common.next")}
|
||||
@click=${this._handleNext}
|
||||
>
|
||||
</ha-icon-button-next>`;
|
||||
}
|
||||
|
||||
/** The selected range as text, with the year only when it is not this year. */
|
||||
protected _formatRange(separator: string): string {
|
||||
const format = (date: Date) =>
|
||||
isThisYear(date)
|
||||
? formatShortDateTime(date, this._i18n.locale, this._hassConfig)
|
||||
: formatShortDateTimeWithYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
return format(this.startDate) + separator + format(this.endDate);
|
||||
}
|
||||
|
||||
private _renderPicker() {
|
||||
if (!this._opened) {
|
||||
return nothing;
|
||||
@@ -303,12 +343,12 @@ export class HaDateRangePicker extends LitElement {
|
||||
this._opened = false;
|
||||
};
|
||||
|
||||
private _handleNext(ev: MouseEvent): void {
|
||||
protected _handleNext(ev: MouseEvent): void {
|
||||
if (ev && ev.stopPropagation) ev.stopPropagation();
|
||||
this._shift(true);
|
||||
}
|
||||
|
||||
private _handlePrev(ev: MouseEvent): void {
|
||||
protected _handlePrev(ev: MouseEvent): void {
|
||||
if (ev && ev.stopPropagation) ev.stopPropagation();
|
||||
this._shift(false);
|
||||
}
|
||||
@@ -336,7 +376,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
this._pickerWrapperOpen = false;
|
||||
}
|
||||
|
||||
private _openPicker(ev?: Event) {
|
||||
protected _openPicker(ev?: Event) {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
@@ -352,7 +392,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleKeydown(ev: KeyboardEvent) {
|
||||
protected _handleKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.stopPropagation();
|
||||
this._openPicker(ev);
|
||||
@@ -369,56 +409,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
css`
|
||||
ha-icon-button {
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.date-range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-textarea {
|
||||
display: inline-block;
|
||||
width: 340px;
|
||||
}
|
||||
@media only screen and (max-width: 460px) {
|
||||
ha-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-normal) ease-out;
|
||||
}
|
||||
|
||||
wa-popover.open::part(dialog)::backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
|
||||
background: none;
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
min-width: max(var(--body-width), 250px);
|
||||
max-width: calc(
|
||||
100vw - var(--safe-area-inset-left) - var(
|
||||
--safe-area-inset-right
|
||||
) - var(--ha-space-8)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
`,
|
||||
];
|
||||
static styles = [haDateRangePickerStyles];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -3,13 +3,11 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
|
||||
import {
|
||||
deviceComboBoxKeys,
|
||||
@@ -28,9 +26,7 @@ import "../ha-alert";
|
||||
import "../ha-button";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
|
||||
import "../ha-svg-icon";
|
||||
import "../ha-tree-indicator";
|
||||
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
|
||||
|
||||
export type HaDevicePickerDeviceFilterFunc = (
|
||||
@@ -132,7 +128,6 @@ export class HaDevicePicker extends LitElement {
|
||||
entityFilter,
|
||||
excludeDevices,
|
||||
value,
|
||||
nested: true,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -221,34 +216,6 @@ export class HaDevicePicker extends LitElement {
|
||||
this.value
|
||||
);
|
||||
|
||||
// The fuzzy search ranks matches by relevance, which would pull a child device
|
||||
// above its parent (the parent often only matches through the lower-weighted
|
||||
// child names). Restore the nested order from the full item list and recompute
|
||||
// which child is last, so the tree connectors stay correct while searching.
|
||||
private _searchFn: PickerComboBoxSearchFn<DevicePickerItem> = (
|
||||
_search,
|
||||
filteredItems,
|
||||
allItems
|
||||
) => {
|
||||
const matchedIds = new Set(filteredItems.map((item) => item.id));
|
||||
const ordered = allItems.filter((item) => matchedIds.has(item.id));
|
||||
// Keep any items the search added that are not part of the nested list
|
||||
// (for example the "no items available" placeholder or additional items).
|
||||
const orderedIds = new Set(ordered.map((item) => item.id));
|
||||
const extras = filteredItems.filter((item) => !orderedIds.has(item.id));
|
||||
|
||||
return [
|
||||
...ordered.map((item, index) => {
|
||||
if (!item.is_child) {
|
||||
return item;
|
||||
}
|
||||
const nextItem = ordered[index + 1];
|
||||
return { ...item, last: !nextItem || !nextItem.is_child };
|
||||
}),
|
||||
...extras,
|
||||
];
|
||||
};
|
||||
|
||||
private _valueRenderer = memoizeOne(
|
||||
(
|
||||
configEntriesLookup: Record<string, ConfigEntry>,
|
||||
@@ -312,75 +279,46 @@ export class HaDevicePicker extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => {
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
style=${
|
||||
item.is_child
|
||||
? "--md-list-item-leading-space: var(--ha-space-12);"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
${
|
||||
item.is_child
|
||||
? html`<ha-tree-indicator
|
||||
style=${styleMap({
|
||||
width: "var(--ha-space-12)",
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: rtl ? undefined : "var(--ha-space-1)",
|
||||
right: rtl ? "var(--ha-space-1)" : undefined,
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
.end=${item.last}
|
||||
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
|
||||
<ha-combo-box-item type="button">
|
||||
${
|
||||
item.domain
|
||||
? html`
|
||||
<img
|
||||
slot="start"
|
||||
></ha-tree-indicator>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
item.domain
|
||||
? html`
|
||||
<img
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: item.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: item.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${
|
||||
item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
item.domain_name
|
||||
? html`
|
||||
<div slot="trailing-supporting-text" class="domain">
|
||||
${item.domain_name}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
};
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${
|
||||
item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
item.domain_name
|
||||
? html`
|
||||
<div slot="trailing-supporting-text" class="domain">
|
||||
${item.domain_name}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
protected render() {
|
||||
const placeholder =
|
||||
@@ -437,8 +375,6 @@ export class HaDevicePicker extends LitElement {
|
||||
.value=${this.value}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.getItems=${this._getItems}
|
||||
.searchFn=${this._searchFn}
|
||||
no-sort
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${valueRenderer}
|
||||
.searchKeys=${deviceComboBoxKeys}
|
||||
|
||||
@@ -143,8 +143,7 @@ export class HaControlSelect extends LitElement {
|
||||
? repeat(
|
||||
this.options,
|
||||
(option) => option.value,
|
||||
(option, index) =>
|
||||
this._renderOption(option, index === this._tabbableIndex)
|
||||
(option) => this._renderOption(option)
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
@@ -152,14 +151,7 @@ export class HaControlSelect extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/* a radio group with no selection puts its first option in the tab sequence */
|
||||
private get _tabbableIndex() {
|
||||
const selectedIndex =
|
||||
this.options?.findIndex((option) => option.value === this.value) ?? -1;
|
||||
return selectedIndex === -1 ? 0 : selectedIndex;
|
||||
}
|
||||
|
||||
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
|
||||
private _renderOption(option: ControlSelectOption) {
|
||||
const isSelected = this.value === option.value;
|
||||
|
||||
return html`
|
||||
@@ -170,7 +162,7 @@ export class HaControlSelect extends LitElement {
|
||||
selected: isSelected,
|
||||
})}
|
||||
role="radio"
|
||||
tabindex=${tabbable ? "0" : "-1"}
|
||||
tabindex=${isSelected ? "0" : "-1"}
|
||||
.value=${option.value}
|
||||
aria-checked=${isSelected ? "true" : "false"}
|
||||
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
/**
|
||||
* Centered placeholder for a surface that has nothing to show, with an icon, a
|
||||
* heading, an optional description and optional actions.
|
||||
*
|
||||
* @slot - Actions that help the user fill the surface, e.g. a button.
|
||||
*/
|
||||
@customElement("ha-empty-state")
|
||||
export class HaEmptyState extends LitElement {
|
||||
/** SVG path of the icon shown above the heading. */
|
||||
@property() public icon?: string;
|
||||
|
||||
@property() public heading?: string;
|
||||
|
||||
@property() public description?: string;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="content">
|
||||
${
|
||||
this.icon
|
||||
? html`<ha-svg-icon .path=${this.icon}></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
${this.heading ? html`<h2>${this.heading}</h2>` : nothing}
|
||||
${this.description ? html`<p>${this.description}</p>` : nothing}
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
max-width: 500px;
|
||||
padding: var(--ha-space-8) var(--ha-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
ha-svg-icon {
|
||||
--mdc-icon-size: var(--ha-empty-state-icon-size, 64px);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-empty-state": HaEmptyState;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { internationalizationContext, statesContext } from "../data/context";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-check-list-item";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-list";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
interface DeviceClassItem {
|
||||
deviceClass: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@customElement("ha-filter-device-classes")
|
||||
export class HaFilterDeviceClasses extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
@state()
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@state()
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ attribute: false }) public value?: string[];
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
${this._localize("ui.components.filter-device-classes.caption")}
|
||||
${
|
||||
this.value?.length
|
||||
? html`<div class="badge">${this.value?.length}</div>
|
||||
<ha-icon-button
|
||||
.path=${mdiFilterVariantRemove}
|
||||
@click=${this._clearFilter}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._handleItemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._deviceClasses(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter
|
||||
),
|
||||
(item) => item.deviceClass,
|
||||
(item) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${item.deviceClass}
|
||||
.selected=${(this.value || []).includes(item.deviceClass)}
|
||||
>
|
||||
${item.name}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _deviceClasses = memoizeOne(
|
||||
(
|
||||
states: ContextType<typeof statesContext>,
|
||||
localize: LocalizeFunc,
|
||||
language: string | undefined,
|
||||
filter: string | undefined
|
||||
): DeviceClassItem[] => {
|
||||
// The same device class can be used by multiple domains; label it with
|
||||
// the first domain that has a translation for it.
|
||||
const names = new Map<string, string | undefined>();
|
||||
Object.values(states).forEach((stateObj) => {
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
if (!deviceClass || names.get(deviceClass)) {
|
||||
return;
|
||||
}
|
||||
names.set(
|
||||
deviceClass,
|
||||
localize(
|
||||
`component.${computeStateDomain(stateObj)}.entity_component.${deviceClass}.name`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return Array.from(names.entries())
|
||||
.map(([deviceClass, name]) => ({
|
||||
deviceClass,
|
||||
name: name || deviceClass,
|
||||
}))
|
||||
.filter(
|
||||
(item) =>
|
||||
!filter ||
|
||||
item.deviceClass.toLowerCase().includes(filter) ||
|
||||
item.name.toLowerCase().includes(filter)
|
||||
)
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language));
|
||||
}
|
||||
);
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
|
||||
const deviceClasses = this._deviceClasses(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter
|
||||
);
|
||||
|
||||
const visible = new Set(deviceClasses.map((item) => item.deviceClass));
|
||||
const preserved = (this.value || []).filter((d) => !visible.has(d));
|
||||
const selected = [...ev.detail.index]
|
||||
.map((i) => deviceClasses[i]?.deviceClass)
|
||||
.filter((d): d is string => !!d);
|
||||
|
||||
this.value = [...preserved, ...selected];
|
||||
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: this.value.length ? this.value : undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _clearFilter(ev) {
|
||||
ev.preventDefault();
|
||||
this.value = undefined;
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
const target = ev.target as HaInputSearch;
|
||||
this._filter = (target.value ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-device-classes": HaFilterDeviceClasses;
|
||||
}
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export class HaFilterDomains extends LitElement {
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: initial;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
ha-check-list-item {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "./chips/ha-assist-chip";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
/**
|
||||
* Chip that opens a filter pane, with a badge showing how many filters are
|
||||
* active. Used in the toolbar of a filtered page and in the header of
|
||||
* `ha-filter-pane`.
|
||||
*/
|
||||
@customElement("ha-filter-pane-chip")
|
||||
export class HaFilterPaneChip extends LitElement {
|
||||
@property() public label = "";
|
||||
|
||||
/** SVG path of the leading icon. */
|
||||
@property() public path?: string;
|
||||
|
||||
/** Number of active filters, shown as a badge when there is at least one. */
|
||||
@property({ type: Number }) public count = 0;
|
||||
|
||||
@property({ type: Boolean }) public active = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-assist-chip
|
||||
.label=${this.label}
|
||||
.active=${this.active}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
${
|
||||
this.path
|
||||
? html`<ha-svg-icon slot="icon" .path=${this.path}></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-assist-chip>
|
||||
${this.count ? html`<div class="badge">${this.count}</div>` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
--ha-assist-chip-container-shape: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
inset-inline-end: -4px;
|
||||
inset-inline-start: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0 2px;
|
||||
color: var(--text-primary-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-pane-chip": HaFilterPaneChip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { mdiFilterVariant, mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-adaptive-dialog";
|
||||
import "./ha-button";
|
||||
import "./ha-dialog-footer";
|
||||
import "./ha-filter-pane-chip";
|
||||
import "./ha-icon-button";
|
||||
|
||||
/**
|
||||
* Filter pane for a filtered page: a column next to the content on wide
|
||||
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
|
||||
* `hass-tabs-subpage-data-table` for pages that are not a data table.
|
||||
*
|
||||
* The page keeps ownership of whether the pane is shown, so that it can also
|
||||
* open it from elsewhere (e.g. an empty state) and hide its own toolbar chip
|
||||
* while it is open.
|
||||
*
|
||||
* @slot - Filter panels, e.g. `ha-filter-domains`.
|
||||
*/
|
||||
@customElement("ha-filter-pane")
|
||||
export class HaFilterPane extends LitElement {
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
/** Header label, defaults to "Filters". */
|
||||
@property() public label?: string;
|
||||
|
||||
/** SVG path of the header chip icon. */
|
||||
@property() public path = mdiFilterVariant;
|
||||
|
||||
/** Number of active filters, shown as a badge. */
|
||||
@property({ type: Number }) public count = 0;
|
||||
|
||||
/**
|
||||
* Number of results the current filters resolve to, shown on the narrow
|
||||
* confirm button. Leave undefined when the page shows everything.
|
||||
*/
|
||||
@property({ attribute: false }) public resultCount?: number;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render() {
|
||||
const label =
|
||||
this.label ?? this._localize("ui.components.subpage-data-table.filters");
|
||||
|
||||
if (this.narrow) {
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
open
|
||||
flexcontent
|
||||
.headerTitle=${label}
|
||||
@closed=${this._close}
|
||||
>
|
||||
${this._renderClearButton("headerActionItems")}
|
||||
<div class="sheet-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button slot="primaryAction" @click=${this._close}>
|
||||
${
|
||||
this.resultCount === undefined
|
||||
? this._localize("ui.common.close")
|
||||
: this._localize(
|
||||
"ui.components.subpage-data-table.show_results",
|
||||
{ number: this.resultCount }
|
||||
)
|
||||
}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-adaptive-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<ha-filter-pane-chip
|
||||
active
|
||||
.label=${label}
|
||||
.path=${this.path}
|
||||
.count=${this.count}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._close}
|
||||
></ha-filter-pane-chip>
|
||||
${this._renderClearButton()}
|
||||
</div>
|
||||
<div class="content ha-scrollbar">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderClearButton(slot?: string) {
|
||||
if (!this.count) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-icon-button
|
||||
slot=${ifDefined(slot)}
|
||||
.path=${mdiFilterVariantRemove}
|
||||
.disabled=${this.disabled}
|
||||
.label=${this._localize("ui.components.subpage-data-table.clear_filter")}
|
||||
@click=${this._clear}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _close() {
|
||||
fireEvent(this, "close-filter-pane");
|
||||
}
|
||||
|
||||
private _clear() {
|
||||
fireEvent(this, "clear-filter");
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 var(--ha-filter-pane-width, 320px);
|
||||
width: var(--ha-filter-pane-width, 320px);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-inline-end: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
/* The bottom sheet positions itself, so the pane takes no space. */
|
||||
:host([narrow]) {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--primary-background-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.content,
|
||||
.sheet-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
/* Fixed height so the sheet does not resize while filtering. */
|
||||
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-pane": HaFilterPane;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"close-filter-pane": undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { DataTableFiltersValue } from "../data/data_table_filters";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
|
||||
import type { EntitySources } from "../data/entity/entity_sources";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-filter-device-classes";
|
||||
import "./ha-filter-domains";
|
||||
import "./ha-filter-integrations";
|
||||
import "./ha-target-picker";
|
||||
|
||||
/**
|
||||
* Ways to narrow down the entities a target selection resolves to. Not to be
|
||||
* confused with `EntitySources`, which maps an entity to its integration.
|
||||
*/
|
||||
export interface SourceFilters {
|
||||
domains?: string[];
|
||||
deviceClasses?: string[];
|
||||
integrations?: string[];
|
||||
}
|
||||
|
||||
const TARGET_KEYS = [
|
||||
"floor_id",
|
||||
"area_id",
|
||||
"device_id",
|
||||
"entity_id",
|
||||
"label_id",
|
||||
] as const;
|
||||
|
||||
/** Number of picked targets, no matter which type they are. */
|
||||
export const countTargets = (target: HassServiceTarget): number =>
|
||||
TARGET_KEYS.reduce(
|
||||
(count, key) => count + (target[key] ? ensureArray(target[key]).length : 0),
|
||||
0
|
||||
);
|
||||
|
||||
/** Number of filters that have at least one option selected. */
|
||||
export const countSourceFilters = (filters: SourceFilters): number =>
|
||||
Object.values(filters).filter((value) => value?.length).length;
|
||||
|
||||
/**
|
||||
* Narrows entity IDs down by the selected filters: an entity is kept when it
|
||||
* matches every filter that has a selection. The integration filter is skipped
|
||||
* while the entity sources are still loading.
|
||||
*/
|
||||
export const applySourceFilters = (
|
||||
entityIds: string[],
|
||||
filters: SourceFilters,
|
||||
states: HomeAssistant["states"],
|
||||
entitySources?: EntitySources
|
||||
): string[] => {
|
||||
const domains = filters.domains?.length ? filters.domains : undefined;
|
||||
const deviceClasses = filters.deviceClasses?.length
|
||||
? filters.deviceClasses
|
||||
: undefined;
|
||||
const integrations =
|
||||
filters.integrations?.length && entitySources
|
||||
? filters.integrations
|
||||
: undefined;
|
||||
|
||||
if (!domains && !deviceClasses && !integrations) {
|
||||
return entityIds;
|
||||
}
|
||||
|
||||
return entityIds.filter((entityId) => {
|
||||
if (domains && !domains.includes(computeDomain(entityId))) {
|
||||
return false;
|
||||
}
|
||||
if (deviceClasses) {
|
||||
const deviceClass = states[entityId]?.attributes.device_class;
|
||||
if (!deviceClass || !deviceClasses.includes(deviceClass)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (integrations) {
|
||||
const integration = entitySources![entityId]?.domain;
|
||||
if (!integration || !integrations.includes(integration)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Picker for what a page shows: the targets to include, narrowed down by
|
||||
* domain, device class and integration. Meant to be placed in an
|
||||
* `ha-filter-pane`.
|
||||
*/
|
||||
@customElement("ha-sources-picker")
|
||||
export class HaSourcesPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public value: HassServiceTarget = {};
|
||||
|
||||
@property({ attribute: false }) public filters: SourceFilters = {};
|
||||
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
/** Explains what the page shows while no target is picked. */
|
||||
@property() public description?: string;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
// Only one filter panel is expanded at a time, so that the expanded one can
|
||||
// use the height that is left in the pane.
|
||||
@state() private _expandedFilter?: keyof SourceFilters;
|
||||
|
||||
protected render() {
|
||||
const noTargets = countTargets(this.value) === 0;
|
||||
|
||||
return html`
|
||||
${
|
||||
this.description && noTargets
|
||||
? html`<div class="description">${this.description}</div>`
|
||||
: nothing
|
||||
}
|
||||
<ha-target-picker
|
||||
class=${classMap({ "no-padding-top": noTargets })}
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetsChanged}
|
||||
></ha-target-picker>
|
||||
<div
|
||||
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
|
||||
>
|
||||
<ha-filter-domains
|
||||
.value=${this.filters.domains}
|
||||
.narrow=${this.narrow}
|
||||
.expanded=${this._expandedFilter === "domains"}
|
||||
@data-table-filter-changed=${this._domainsChanged}
|
||||
@expanded-changed=${this._domainsExpanded}
|
||||
></ha-filter-domains>
|
||||
<ha-filter-device-classes
|
||||
.value=${this.filters.deviceClasses}
|
||||
.narrow=${this.narrow}
|
||||
.expanded=${this._expandedFilter === "deviceClasses"}
|
||||
@data-table-filter-changed=${this._deviceClassesChanged}
|
||||
@expanded-changed=${this._deviceClassesExpanded}
|
||||
></ha-filter-device-classes>
|
||||
<ha-filter-integrations
|
||||
.value=${this.filters.integrations}
|
||||
.narrow=${this.narrow}
|
||||
.expanded=${this._expandedFilter === "integrations"}
|
||||
@data-table-filter-changed=${this._integrationsChanged}
|
||||
@expanded-changed=${this._integrationsExpanded}
|
||||
></ha-filter-integrations>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
// The filter panels label themselves with keys from the config panel.
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
}
|
||||
|
||||
private _targetsChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
|
||||
}
|
||||
|
||||
private _domainsChanged(ev: CustomEvent) {
|
||||
this._filterChanged("domains", ev);
|
||||
}
|
||||
|
||||
private _deviceClassesChanged(ev: CustomEvent) {
|
||||
this._filterChanged("deviceClasses", ev);
|
||||
}
|
||||
|
||||
private _integrationsChanged(ev: CustomEvent) {
|
||||
this._filterChanged("integrations", ev);
|
||||
}
|
||||
|
||||
private _filterChanged(key: keyof SourceFilters, ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value as DataTableFiltersValue;
|
||||
fireEvent(this, "source-filters-changed", {
|
||||
value: {
|
||||
...this.filters,
|
||||
[key]: Array.isArray(value) && value.length ? value : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _domainsExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("domains", ev);
|
||||
}
|
||||
|
||||
private _deviceClassesExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("deviceClasses", ev);
|
||||
}
|
||||
|
||||
private _integrationsExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("integrations", ev);
|
||||
}
|
||||
|
||||
private _filterExpanded(key: keyof SourceFilters, ev: CustomEvent) {
|
||||
if (ev.detail.expanded) {
|
||||
this._expandedFilter = key;
|
||||
} else if (this._expandedFilter === key) {
|
||||
this._expandedFilter = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
/* The sections are laid out by the pane, so that an expanded filter
|
||||
panel can use the height that is left. */
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.description {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 92px;
|
||||
margin: var(--ha-space-4) var(--ha-space-4) 0;
|
||||
padding: 0 var(--ha-space-6);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-target-picker {
|
||||
display: block;
|
||||
flex: none;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
/* The description already spaces the picker from the pane header. */
|
||||
ha-target-picker.no-padding-top {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 0 auto;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
/* An expanded panel sizes itself to the space that is left over. */
|
||||
.filters.expanded {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-sources-picker": HaSourcesPicker;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"source-filters-changed": { value: SourceFilters };
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
type DevicePickerItem,
|
||||
} from "../data/device/device_picker";
|
||||
import {
|
||||
devicesInEffectiveArea,
|
||||
fetchDeviceCompositeSplits,
|
||||
type DeviceCompositeSplits,
|
||||
} from "../data/device/device_registry";
|
||||
@@ -42,9 +41,6 @@ import { domainToName } from "../data/integration";
|
||||
import { getLabels, labelComboBoxKeys } from "../data/label/label_picker";
|
||||
import type { LabelRegistryEntry } from "../data/label/label_registry";
|
||||
import {
|
||||
areaMeetsFilter,
|
||||
deviceMeetsFilter,
|
||||
entityRegMeetsFilter,
|
||||
getTargetComboBoxItemType,
|
||||
type TargetItem,
|
||||
type TargetType,
|
||||
@@ -67,7 +63,6 @@ import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tree-indicator";
|
||||
import "./target-picker/ha-target-picker-item-group";
|
||||
import "./target-picker/ha-target-picker-value-chip";
|
||||
|
||||
const SEPARATOR = "________";
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
@@ -86,8 +81,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public compact = false;
|
||||
|
||||
@property({ attribute: false }) public createDomains?: string[];
|
||||
|
||||
@property({ type: Boolean, attribute: "primary-entities-only" })
|
||||
@@ -117,8 +110,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
|
||||
|
||||
@state() private _selectedSection?: TargetTypeFloorless;
|
||||
|
||||
@state() private _replaceTarget?: TargetItem;
|
||||
@@ -159,7 +150,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
excludeDevices,
|
||||
value,
|
||||
idPrefix,
|
||||
nested: true,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -254,119 +244,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
Fuse.createIndex(keys, states);
|
||||
|
||||
protected render() {
|
||||
if (this.addOnTop) {
|
||||
return html` ${this._renderPicker()} ${this._renderItems()} `;
|
||||
}
|
||||
return html` ${this._renderItems()} ${this._renderPicker()} `;
|
||||
}
|
||||
|
||||
private _renderValueChips() {
|
||||
const entityIds = this.value?.entity_id
|
||||
? ensureArray(this.value.entity_id)
|
||||
: [];
|
||||
const deviceIds = this.value?.device_id
|
||||
? ensureArray(this.value.device_id)
|
||||
: [];
|
||||
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
|
||||
const floorIds = this.value?.floor_id
|
||||
? ensureArray(this.value.floor_id)
|
||||
: [];
|
||||
const labelIds = this.value?.label_id
|
||||
? ensureArray(this.value.label_id)
|
||||
: [];
|
||||
|
||||
if (
|
||||
!entityIds.length &&
|
||||
!deviceIds.length &&
|
||||
!areaIds.length &&
|
||||
!floorIds.length &&
|
||||
!labelIds.length
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="items">
|
||||
${
|
||||
floorIds.length
|
||||
? floorIds.map(
|
||||
(floor_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="floor"
|
||||
.itemId=${floor_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
areaIds.length
|
||||
? areaIds.map(
|
||||
(area_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="area"
|
||||
.itemId=${area_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
deviceIds.length
|
||||
? deviceIds.map(
|
||||
(device_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="device"
|
||||
.itemId=${device_id}
|
||||
.compositeSplits=${this._compositeSplits}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
entityIds.length
|
||||
? entityIds.map(
|
||||
(entity_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="entity"
|
||||
.itemId=${entity_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
labelIds.length
|
||||
? labelIds.map(
|
||||
(label_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="label"
|
||||
.itemId=${label_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderValueGroups() {
|
||||
const entityIds = this.value?.entity_id
|
||||
? ensureArray(this.value.entity_id)
|
||||
@@ -480,9 +360,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _renderItems() {
|
||||
return html`
|
||||
${this.compact ? this._renderValueChips() : this._renderValueGroups()}
|
||||
`;
|
||||
return html` ${this._renderValueGroups()} `;
|
||||
}
|
||||
|
||||
private _renderPicker() {
|
||||
@@ -657,162 +535,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleExpand(ev: HASSDomEvent<HASSDomEvents["expand-target-item"]>) {
|
||||
const type = ev.detail.type;
|
||||
const itemId = ev.detail.id;
|
||||
const newAreas: string[] = [];
|
||||
const newDevices: string[] = [];
|
||||
const newEntities: string[] = [];
|
||||
|
||||
if (type === "floor") {
|
||||
Object.values(this.hass.areas).forEach((area) => {
|
||||
if (
|
||||
area.floor_id === itemId &&
|
||||
!this.value!.area_id?.includes(area.area_id) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newAreas.push(area.area_id);
|
||||
}
|
||||
});
|
||||
} else if (type === "area") {
|
||||
// Splitting an area yields its effective-area devices, so a child device
|
||||
// that belongs to a different area is not pulled into this area.
|
||||
devicesInEffectiveArea(this.hass.devices, itemId).forEach((device) => {
|
||||
if (
|
||||
!this.value!.device_id?.includes(device.id) &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newDevices.push(device.id);
|
||||
}
|
||||
});
|
||||
Object.values(this.hass.entities).forEach((entity) => {
|
||||
if (
|
||||
entity.area_id === itemId &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
false,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
}
|
||||
});
|
||||
} else if (type === "device") {
|
||||
// Splitting a device into entities includes its child devices' entities,
|
||||
// since targeting the device would target its children too.
|
||||
const deviceIds = new Set([
|
||||
itemId,
|
||||
...Object.values(this.hass.devices)
|
||||
.filter((device) => device.parent_device_id === itemId)
|
||||
.map((device) => device.id),
|
||||
]);
|
||||
Object.values(this.hass.entities).forEach((entity) => {
|
||||
if (
|
||||
entity.device_id &&
|
||||
deviceIds.has(entity.device_id) &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
false,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
}
|
||||
});
|
||||
} else if (type === "label") {
|
||||
Object.values(this.hass.areas).forEach((area) => {
|
||||
if (
|
||||
area.labels.includes(itemId) &&
|
||||
!this.value!.area_id?.includes(area.area_id) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newAreas.push(area.area_id);
|
||||
}
|
||||
});
|
||||
Object.values(this.hass.devices).forEach((device) => {
|
||||
if (
|
||||
device.labels.includes(itemId) &&
|
||||
!this.value!.device_id?.includes(device.id) &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newDevices.push(device.id);
|
||||
}
|
||||
});
|
||||
Object.values(this.hass.entities).forEach((entity) => {
|
||||
if (
|
||||
entity.labels.includes(itemId) &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
true,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
let value = this.value;
|
||||
if (newEntities.length) {
|
||||
value = this._addItems(value, "entity_id", newEntities);
|
||||
}
|
||||
if (newDevices.length) {
|
||||
value = this._addItems(value, "device_id", newDevices);
|
||||
}
|
||||
if (newAreas.length) {
|
||||
value = this._addItems(value, "area_id", newAreas);
|
||||
}
|
||||
value = this._removeItem(value, type, itemId);
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
private _handleReplace(
|
||||
ev: HASSDomEvent<HASSDomEvents["replace-target-item"]>
|
||||
) {
|
||||
@@ -852,17 +574,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._replaceTargetAnchor = undefined;
|
||||
}
|
||||
|
||||
private _addItems(
|
||||
value: this["value"],
|
||||
type: string,
|
||||
ids: string[]
|
||||
): this["value"] {
|
||||
return {
|
||||
...value,
|
||||
[type]: value![type] ? ensureArray(value![type])!.concat(ids) : ids,
|
||||
};
|
||||
}
|
||||
|
||||
private _removeItem(
|
||||
value: this["value"],
|
||||
type: TargetType,
|
||||
@@ -1013,28 +724,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
if (!filterType || filterType === "device") {
|
||||
const selectedDeviceIds = targetValue?.device_id
|
||||
? replacingDeviceId
|
||||
? ensureArray(targetValue.device_id).filter(
|
||||
(deviceId) => deviceId !== replacingDeviceId
|
||||
)
|
||||
: ensureArray(targetValue.device_id)
|
||||
: undefined;
|
||||
// A selected parent device already targets its children, so exclude
|
||||
// those children from the picker too (mirrors selecting a floor
|
||||
// removing its areas from the list).
|
||||
const excludeDeviceIds = selectedDeviceIds
|
||||
? [
|
||||
...selectedDeviceIds,
|
||||
...Object.values(this.hass.devices)
|
||||
.filter(
|
||||
(device) =>
|
||||
device.parent_device_id !== null &&
|
||||
selectedDeviceIds.includes(device.parent_device_id)
|
||||
)
|
||||
.map((device) => device.id),
|
||||
]
|
||||
: undefined;
|
||||
let deviceItems = this._getDevicesMemoized(
|
||||
this.hass,
|
||||
configEntryLookup,
|
||||
@@ -1042,41 +731,26 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
excludeDeviceIds,
|
||||
targetValue?.device_id
|
||||
? replacingDeviceId
|
||||
? ensureArray(targetValue.device_id).filter(
|
||||
(deviceId) => deviceId !== replacingDeviceId
|
||||
)
|
||||
: ensureArray(targetValue.device_id)
|
||||
: undefined,
|
||||
replacingDeviceId,
|
||||
`device${SEPARATOR}`
|
||||
);
|
||||
// getDevices already returns child devices nested under their parent
|
||||
// with the top-level devices sorted; keep that order rather than
|
||||
// re-sorting by label, which would separate children from their parent.
|
||||
).sort(this._sortBySortingLabel);
|
||||
|
||||
if (searchTerm) {
|
||||
// Keep the nested parent-then-children order (sort=false), matching
|
||||
// the areas group; the default sorted search would reorder matches by
|
||||
// relevance and pull children above their parent.
|
||||
deviceItems = this._filterGroup(
|
||||
"device",
|
||||
deviceItems,
|
||||
searchTerm,
|
||||
deviceComboBoxKeys,
|
||||
false
|
||||
deviceComboBoxKeys
|
||||
);
|
||||
}
|
||||
|
||||
// Recompute the tree "last child" flag over the (possibly filtered)
|
||||
// list so the last visible child of each parent draws its end connector.
|
||||
deviceItems = deviceItems.map((item, index) => {
|
||||
if (!(item as DevicePickerItem).is_child) {
|
||||
return item;
|
||||
}
|
||||
const nextItem = deviceItems[index + 1] as
|
||||
DevicePickerItem | undefined;
|
||||
return {
|
||||
...item,
|
||||
last: !nextItem || !nextItem.is_child,
|
||||
};
|
||||
});
|
||||
|
||||
if (!filterType && deviceItems.length) {
|
||||
// show group title
|
||||
items.push(localize("ui.components.target-picker.type.devices"));
|
||||
@@ -1294,9 +968,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
let hasFloor = false;
|
||||
let rtl = false;
|
||||
let showEntityId = false;
|
||||
const isChildDeviceRow =
|
||||
type === "device" && !!(item as DevicePickerItem).is_child;
|
||||
if (type === "area" || type === "floor" || isChildDeviceRow) {
|
||||
if (type === "area" || type === "floor") {
|
||||
rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
@@ -1316,15 +988,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.type=${type === "empty" ? "text" : "button"}
|
||||
class=${type === "empty" ? "empty" : ""}
|
||||
style=${
|
||||
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
|
||||
isChildDeviceRow
|
||||
(item as FloorComboBoxItem).type === "area" && hasFloor
|
||||
? "--md-list-item-leading-space: var(--ha-space-12);"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
${
|
||||
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
|
||||
isChildDeviceRow
|
||||
(item as FloorComboBoxItem).type === "area" && hasFloor
|
||||
? html`
|
||||
<ha-tree-indicator
|
||||
style=${styleMap({
|
||||
@@ -1335,7 +1005,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
right: rtl ? "var(--ha-space-1)" : undefined,
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
.end=${(item as { last?: boolean }).last}
|
||||
.end=${
|
||||
(item as FloorComboBoxItem & { last?: boolean | undefined })
|
||||
.last
|
||||
}
|
||||
slot="start"
|
||||
></ha-tree-indicator>
|
||||
`
|
||||
@@ -1437,13 +1110,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.items {
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: var(--ha-space-2) 0;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.item-groups {
|
||||
overflow: hidden;
|
||||
border: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
@@ -1459,7 +1125,6 @@ declare global {
|
||||
|
||||
interface HASSDomEvents {
|
||||
"remove-target-item": TargetItem;
|
||||
"expand-target-item": TargetItem;
|
||||
"replace-target-item": TargetItem;
|
||||
"migrate-target-item": { id: string; replacements: string[] };
|
||||
"remove-target-group": string;
|
||||
|
||||
@@ -8,31 +8,10 @@ export class HaTreeIndicator extends LitElement {
|
||||
public end?: boolean = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
// preserveAspectRatio="none" lets the connector stretch to the host box, so
|
||||
// it can span the full height of a taller row instead of being letterboxed
|
||||
// to a square in the middle. non-scaling-stroke keeps the line width and
|
||||
// dash pattern identical no matter how far it is stretched.
|
||||
return html`
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 48 48"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<line
|
||||
x1="24"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2=${this.end ? "24" : "48"}
|
||||
vector-effect="non-scaling-stroke"
|
||||
></line>
|
||||
<line
|
||||
x1="24"
|
||||
y1="24"
|
||||
x2="36"
|
||||
y2="24"
|
||||
vector-effect="non-scaling-stroke"
|
||||
></line>
|
||||
<svg width="100%" height="100%" viewBox="0 0 48 48">
|
||||
<line x1="24" y1="0" x2="24" y2=${this.end ? "24" : "48"}></line>
|
||||
<line x1="24" y1="24" x2="36" y2="24"></line>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
import "@home-assistant/webawesome/dist/components/tag/tag";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAlertOutline,
|
||||
mdiDevices,
|
||||
mdiHome,
|
||||
mdiLabel,
|
||||
mdiTextureBox,
|
||||
mdiUnfoldMoreVertical,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../common/color/compute-color";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { slugify } from "../../common/string/slugify";
|
||||
import { getConfigEntry } from "../../data/config_entries";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../data/label/label_registry";
|
||||
import type { TargetType } from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-domain-icon";
|
||||
import { floorDefaultIconPath } from "../ha-floor-icon";
|
||||
import "../ha-icon";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-state-icon";
|
||||
import "../ha-tooltip";
|
||||
|
||||
@customElement("ha-target-picker-value-chip")
|
||||
export class HaTargetPickerValueChip extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public type!: TargetType;
|
||||
|
||||
@property({ attribute: "item-id" }) public itemId!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _domainName?: string;
|
||||
|
||||
@state() private _iconImg?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
_labelRegistry!: LabelRegistryEntry[];
|
||||
|
||||
protected render() {
|
||||
const { name, iconPath, fallbackIconPath, stateObject, color } =
|
||||
this._itemData(this.type, this.itemId);
|
||||
|
||||
const split =
|
||||
this.type === "device" && !this.hass.devices?.[this.itemId]
|
||||
? this.compositeSplits?.[this.itemId]
|
||||
: undefined;
|
||||
// Show the replaced reference using the primary replacement device's name,
|
||||
// falling back to the first still-existing split device if the primary
|
||||
// device itself was deleted. If no replacement device exists at all, fall
|
||||
// back to the normal "not found" display.
|
||||
const replacementDevice = split
|
||||
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
|
||||
split.split_ids
|
||||
.map((id) => this.hass.devices?.[id])
|
||||
.find((device) => device)
|
||||
: undefined;
|
||||
const replaced = !!replacementDevice;
|
||||
const replacedName = replacementDevice
|
||||
? computeDeviceNameDisplay(
|
||||
replacementDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<wa-tag
|
||||
pill
|
||||
with-remove
|
||||
class=${classMap({ [this.type]: true, replaced })}
|
||||
style=${color ? `--color: rgb(${color});` : ""}
|
||||
@wa-remove=${this._removeItem}
|
||||
>
|
||||
<div class="icon">
|
||||
${
|
||||
replaced
|
||||
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
|
||||
: iconPath
|
||||
? html`<ha-icon .icon=${iconPath}></ha-icon>`
|
||||
: this._iconImg
|
||||
? html`<img
|
||||
alt=${this._domainName || ""}
|
||||
width="24"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${this._iconImg}
|
||||
/>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon
|
||||
.path=${fallbackIconPath}
|
||||
></ha-svg-icon>`
|
||||
: stateObject
|
||||
? html`<ha-state-icon
|
||||
.stateObj=${stateObject}
|
||||
></ha-state-icon>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<span class="name">
|
||||
${
|
||||
replaced
|
||||
? replacedName ||
|
||||
this.hass.localize(
|
||||
"ui.components.target-picker.replaced_device"
|
||||
)
|
||||
: name
|
||||
}
|
||||
</span>
|
||||
${
|
||||
this.type === "entity" || replaced
|
||||
? nothing
|
||||
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
|
||||
>${this.hass.localize(
|
||||
`ui.components.target-picker.expand_${this.type}_id`
|
||||
)}
|
||||
</ha-tooltip>
|
||||
<ha-icon-button
|
||||
class="expand-btn mdc-chip__icon mdc-chip__icon--trailing"
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.target-picker.expand"
|
||||
)}
|
||||
.path=${mdiUnfoldMoreVertical}
|
||||
hide-title
|
||||
.id="expand-${slugify(this.itemId)}"
|
||||
.type=${this.type}
|
||||
@click=${this._handleExpand}
|
||||
></ha-icon-button>`
|
||||
}
|
||||
</wa-tag>
|
||||
`;
|
||||
}
|
||||
|
||||
private _itemData = memoizeOne((type: TargetType, itemId: string) => {
|
||||
if (type === "floor") {
|
||||
const floor = this.hass.floors?.[itemId];
|
||||
return {
|
||||
name: floor?.name || itemId,
|
||||
iconPath: floor?.icon,
|
||||
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
|
||||
};
|
||||
}
|
||||
if (type === "area") {
|
||||
const area = this.hass.areas?.[itemId];
|
||||
return {
|
||||
name: area?.name || itemId,
|
||||
iconPath: area?.icon,
|
||||
fallbackIconPath: mdiTextureBox,
|
||||
};
|
||||
}
|
||||
if (type === "device") {
|
||||
const device = this.hass.devices?.[itemId];
|
||||
|
||||
if (device?.primary_config_entry) {
|
||||
this._getDeviceDomain(device.primary_config_entry);
|
||||
}
|
||||
|
||||
return {
|
||||
name: device
|
||||
? computeDeviceNameDisplay(
|
||||
device,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: itemId,
|
||||
fallbackIconPath: mdiDevices,
|
||||
};
|
||||
}
|
||||
if (type === "entity") {
|
||||
this._setDomainName(computeDomain(itemId));
|
||||
|
||||
const stateObj = this.hass.states[itemId];
|
||||
return {
|
||||
name: computeStateName(stateObj) || itemId,
|
||||
stateObject: stateObj,
|
||||
};
|
||||
}
|
||||
|
||||
// type label
|
||||
const label = this._labelRegistry.find((lab) => lab.label_id === itemId);
|
||||
let color = label?.color ? computeCssColor(label.color) : undefined;
|
||||
if (color?.startsWith("var(")) {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
color = computedStyles.getPropertyValue(
|
||||
color.substring(4, color.length - 1)
|
||||
);
|
||||
}
|
||||
if (color?.startsWith("#")) {
|
||||
color = hex2rgb(color).join(",");
|
||||
}
|
||||
return {
|
||||
name: label?.name || itemId,
|
||||
iconPath: label?.icon,
|
||||
fallbackIconPath: mdiLabel,
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
private _setDomainName(domain: string) {
|
||||
this._domainName = domainToName(this.hass.localize, domain);
|
||||
}
|
||||
|
||||
private async _getDeviceDomain(configEntryId: string) {
|
||||
try {
|
||||
const data = await getConfigEntry(this.hass, configEntryId);
|
||||
const domain = data.config_entry.domain;
|
||||
this._iconImg = brandsUrl(
|
||||
{
|
||||
domain: domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
);
|
||||
|
||||
this._setDomainName(domain);
|
||||
} catch {
|
||||
// failed to load config entry -> ignore
|
||||
}
|
||||
}
|
||||
|
||||
private _removeItem(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "remove-target-item", {
|
||||
type: this.type,
|
||||
id: this.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleExpand(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "expand-target-item", {
|
||||
type: this.type,
|
||||
id: this.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
wa-tag {
|
||||
background-color: var(--card-background-color);
|
||||
border-width: var(--ha-border-width-md);
|
||||
padding-inline-start: 0;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
wa-tag.entity {
|
||||
border-color: var(--ha-color-green-80);
|
||||
--background-color: var(--ha-color-green-80);
|
||||
}
|
||||
wa-tag.device {
|
||||
border-color: var(--ha-color-primary-80);
|
||||
--background-color: var(--ha-color-primary-80);
|
||||
}
|
||||
wa-tag.area {
|
||||
border-color: var(--ha-color-orange-80);
|
||||
--background-color: var(--ha-color-orange-80);
|
||||
}
|
||||
wa-tag.label {
|
||||
border-color: var(--color);
|
||||
--background-color: var(--color);
|
||||
--icon-primary-color: var(--primary-text-color);
|
||||
}
|
||||
wa-tag.replaced {
|
||||
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
|
||||
--background-color: var(--warning-color);
|
||||
color: var(--ha-color-on-warning-normal, var(--warning-color));
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon {
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
padding: var(--ha-space-2) var(--ha-space-1);
|
||||
display: flex;
|
||||
}
|
||||
.expand-btn {
|
||||
--ha-icon-button-size: 16px;
|
||||
--mdc-icon-size: 14px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-target-picker-value-chip": HaTargetPickerValueChip;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
@@ -25,13 +25,6 @@ export class HaTileContainer extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public actionHandlerOptions?: ActionHandlerOptions;
|
||||
|
||||
@state() private _hasFeatures = false;
|
||||
|
||||
private _handleFeaturesSlotChange(ev: Event) {
|
||||
this._hasFeatures =
|
||||
(ev.target as HTMLSlotElement).assignedElements().length > 0;
|
||||
}
|
||||
|
||||
private _handleFocus(ev: FocusEvent) {
|
||||
if ((ev.target as HTMLElement).matches(":focus-visible")) {
|
||||
this.setAttribute("focused", "");
|
||||
@@ -43,12 +36,8 @@ export class HaTileContainer extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const isInline = this.featurePosition === "inline";
|
||||
const containerClasses = {
|
||||
inline: isInline,
|
||||
"has-features-below": isInline && this._hasFeatures,
|
||||
"fixed-height": this.fixedInfoHeight,
|
||||
};
|
||||
const containerOrientationClass =
|
||||
this.featurePosition === "inline" ? "horizontal" : "";
|
||||
const contentClasses = {
|
||||
vertical: this.vertical,
|
||||
"fixed-info-height": this.fixedInfoHeight,
|
||||
@@ -67,21 +56,15 @@ export class HaTileContainer extends LitElement {
|
||||
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
|
||||
</div>
|
||||
<div
|
||||
class="container ${classMap(containerClasses)}"
|
||||
class="container ${containerOrientationClass}"
|
||||
@action=${stopPropagation}
|
||||
@click=${stopPropagation}
|
||||
>
|
||||
<div class="row">
|
||||
<div class="content ${classMap(contentClasses)}">
|
||||
<slot name="icon"></slot>
|
||||
<slot name="info" id="info"></slot>
|
||||
</div>
|
||||
<slot name="features-inline"></slot>
|
||||
<div class="content ${classMap(contentClasses)}">
|
||||
<slot name="icon"></slot>
|
||||
<slot name="info" id="info"></slot>
|
||||
</div>
|
||||
<slot
|
||||
name="features"
|
||||
@slotchange=${this._handleFeaturesSlotChange}
|
||||
></slot>
|
||||
<slot name="features"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -114,13 +97,7 @@ export class HaTileContainer extends LitElement {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.container.inline .row {
|
||||
.container.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -176,30 +153,13 @@ export class HaTileContainer extends LitElement {
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
|
||||
.container.inline ::slotted([slot="features-inline"]) {
|
||||
/* size the feature on the 6 column grid track, so it lines up with neighbouring tiles */
|
||||
.container.horizontal ::slotted([slot="features"]) {
|
||||
width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3));
|
||||
flex: none;
|
||||
--feature-height: var(--ha-space-9);
|
||||
padding: 0 var(--ha-space-3);
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
/* the inline feature keeps the icon height, unless the card reserves a row it can fill */
|
||||
.container.inline:not(.has-features-below)
|
||||
::slotted([slot="features-inline"]),
|
||||
.container.inline:not(.fixed-height) ::slotted([slot="features-inline"]) {
|
||||
--feature-height: var(--ha-space-9);
|
||||
}
|
||||
|
||||
.container.inline.has-features-below ::slotted([slot="features"]) {
|
||||
/* keep both columns under the inline feature, which sits on the grid track */
|
||||
--ha-card-feature-column-gap: calc(
|
||||
var(--column-gap, 0px) + var(--ha-space-3) * 2
|
||||
);
|
||||
--ha-card-feature-divider: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
--ha-card-feature-divider-inset: calc(
|
||||
var(--ha-space-3) + var(--column-gap, 0px) / 2
|
||||
);
|
||||
}
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computeDeviceNameDisplay } from "../../common/entity/compute_device_nam
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
|
||||
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
|
||||
@@ -25,17 +24,12 @@ import {
|
||||
export interface DevicePickerItem extends PickerComboBoxItem {
|
||||
domain?: string;
|
||||
domain_name?: string;
|
||||
// Set when this device is a child rendered indented under its parent.
|
||||
is_child?: boolean;
|
||||
// Set on the last child of a parent so the tree connector draws its end.
|
||||
last?: boolean;
|
||||
}
|
||||
|
||||
export interface DeviceAreaLabel {
|
||||
areaName?: string;
|
||||
viaDeviceName?: string;
|
||||
viaDeviceAreaName?: string;
|
||||
parentDeviceName?: string;
|
||||
}
|
||||
|
||||
export interface GetDevicesOptions {
|
||||
@@ -47,10 +41,6 @@ export interface GetDevicesOptions {
|
||||
excludeDevices?: string[];
|
||||
value?: string;
|
||||
idPrefix?: string;
|
||||
// When set, order the result so children directly follow their parent and
|
||||
// flag them for indented rendering. Requires the picker to disable its own
|
||||
// sorting (no-sort) so this order is preserved.
|
||||
nested?: boolean;
|
||||
}
|
||||
|
||||
export const computeDeviceAreaLabel = (
|
||||
@@ -79,16 +69,6 @@ export const computeDeviceAreaLabel = (
|
||||
? computeAreaName(viaDeviceArea)
|
||||
: undefined;
|
||||
|
||||
// A child device is a logical part of its parent. We surface the parent name
|
||||
// only as a search term (below) — not in the area label, which stays the pure
|
||||
// (inherited) area. The nested tree rendering communicates the relationship.
|
||||
const parentDevice = device.parent_device_id
|
||||
? devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceNameDisplay(parentDevice, localize, states)
|
||||
: undefined;
|
||||
|
||||
const isRTL = computeRTL(language, translationMetadata.translations);
|
||||
|
||||
const areaName = area
|
||||
@@ -97,7 +77,7 @@ export const computeDeviceAreaLabel = (
|
||||
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
|
||||
: viaDeviceName || undefined;
|
||||
|
||||
return { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName };
|
||||
return { areaName, viaDeviceName, viaDeviceAreaName };
|
||||
};
|
||||
|
||||
export const deviceComboBoxKeys: FuseWeightedKey[] = [
|
||||
@@ -125,14 +105,6 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
|
||||
name: "search_labels.viaDeviceArea",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "search_labels.parentDeviceName",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "search_labels.childDeviceNames",
|
||||
weight: 3,
|
||||
},
|
||||
];
|
||||
|
||||
export const getDevices = (
|
||||
@@ -149,7 +121,6 @@ export const getDevices = (
|
||||
excludeDevices,
|
||||
value,
|
||||
idPrefix = "",
|
||||
nested,
|
||||
} = options ?? {};
|
||||
|
||||
const devices = Object.values(hass.devices);
|
||||
@@ -157,60 +128,26 @@ export const getDevices = (
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
|
||||
const filtersEntities =
|
||||
includeDomains || excludeDomains || includeDeviceClasses || entityFilter;
|
||||
|
||||
if (filtersEntities) {
|
||||
if (
|
||||
includeDomains ||
|
||||
excludeDomains ||
|
||||
includeDeviceClasses ||
|
||||
entityFilter
|
||||
) {
|
||||
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
|
||||
}
|
||||
|
||||
// Targeting a device also targets its child devices (a parent inherits its
|
||||
// children's entities), so a device should match an entity-based filter when
|
||||
// it OR any of its children has a matching entity. Build a parent -> children
|
||||
// map and resolve each device's effective entity set accordingly. Nesting is
|
||||
// single-level, so one hop covers it.
|
||||
const filterChildrenByParent = new Map<string, DeviceRegistryEntry[]>();
|
||||
if (filtersEntities) {
|
||||
for (const device of devices) {
|
||||
if (device.parent_device_id) {
|
||||
const siblings = filterChildrenByParent.get(device.parent_device_id);
|
||||
if (siblings) {
|
||||
siblings.push(device);
|
||||
} else {
|
||||
filterChildrenByParent.set(device.parent_device_id, [device]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const effectiveEntities = (
|
||||
deviceId: string
|
||||
): EntityRegistryDisplayEntry[] => {
|
||||
const own = deviceEntityLookup[deviceId] ?? [];
|
||||
const children = filterChildrenByParent.get(deviceId);
|
||||
if (!children) {
|
||||
return own;
|
||||
}
|
||||
const combined = [...own];
|
||||
for (const child of children) {
|
||||
const childEntities = deviceEntityLookup[child.id];
|
||||
if (childEntities) {
|
||||
combined.push(...childEntities);
|
||||
}
|
||||
}
|
||||
return combined;
|
||||
};
|
||||
|
||||
let inputDevices = devices.filter(
|
||||
(device) => device.id === value || !device.disabled_by
|
||||
);
|
||||
|
||||
if (includeDomains) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return devEntities.some((entity) =>
|
||||
return deviceEntityLookup[device.id].some((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
@@ -218,11 +155,11 @@ export const getDevices = (
|
||||
|
||||
if (excludeDomains) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return true;
|
||||
}
|
||||
return devEntities.every(
|
||||
return entities.every(
|
||||
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
@@ -236,11 +173,11 @@ export const getDevices = (
|
||||
|
||||
if (includeDeviceClasses) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return devEntities.some((entity) => {
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
@@ -255,8 +192,8 @@ export const getDevices = (
|
||||
|
||||
if (entityFilter) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return devEntities.some((entity) => {
|
||||
@@ -285,7 +222,7 @@ export const getDevices = (
|
||||
deviceEntityLookup[device.id]
|
||||
);
|
||||
|
||||
const { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName } =
|
||||
const { areaName, viaDeviceName, viaDeviceAreaName } =
|
||||
computeDeviceAreaLabel(
|
||||
device,
|
||||
hass.areas,
|
||||
@@ -322,79 +259,10 @@ export const getDevices = (
|
||||
domainName: domainName || null,
|
||||
viaDeviceName: viaDeviceName || null,
|
||||
viaDeviceArea: viaDeviceAreaName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
},
|
||||
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
|
||||
};
|
||||
});
|
||||
|
||||
if (!nested) {
|
||||
return outputDevices;
|
||||
}
|
||||
|
||||
// Order children directly after their parent and flag them for indented
|
||||
// rendering. outputDevices is 1:1 with inputDevices, so we can pair them up.
|
||||
const itemByDeviceId = new Map<string, DevicePickerItem>();
|
||||
inputDevices.forEach((device, index) => {
|
||||
itemByDeviceId.set(device.id, outputDevices[index]);
|
||||
});
|
||||
const presentIds = new Set(inputDevices.map((device) => device.id));
|
||||
|
||||
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
|
||||
const topLevel: DeviceRegistryEntry[] = [];
|
||||
for (const device of inputDevices) {
|
||||
const parentId = device.parent_device_id;
|
||||
// A child whose parent was filtered out is shown as a top-level row.
|
||||
if (parentId && presentIds.has(parentId)) {
|
||||
const siblings = childrenByParent.get(parentId);
|
||||
if (siblings) {
|
||||
siblings.push(device);
|
||||
} else {
|
||||
childrenByParent.set(parentId, [device]);
|
||||
}
|
||||
} else {
|
||||
topLevel.push(device);
|
||||
}
|
||||
}
|
||||
|
||||
const compareByName = (a: DeviceRegistryEntry, b: DeviceRegistryEntry) =>
|
||||
caseInsensitiveStringCompare(
|
||||
itemByDeviceId.get(a.id)!.primary,
|
||||
itemByDeviceId.get(b.id)!.primary,
|
||||
hass.locale.language
|
||||
);
|
||||
|
||||
topLevel.sort(compareByName);
|
||||
|
||||
const ordered: DevicePickerItem[] = [];
|
||||
for (const device of topLevel) {
|
||||
const parentItem = itemByDeviceId.get(device.id)!;
|
||||
const children = childrenByParent.get(device.id);
|
||||
if (children) {
|
||||
children.sort(compareByName);
|
||||
// Add the children's names to the parent's search terms so a search that
|
||||
// matches a child keeps the parent visible (mirrors how a floor stays
|
||||
// visible when one of its areas matches).
|
||||
ordered.push({
|
||||
...parentItem,
|
||||
search_labels: {
|
||||
...parentItem.search_labels,
|
||||
childDeviceNames: children
|
||||
.map((child) => itemByDeviceId.get(child.id)!.primary)
|
||||
.join(" "),
|
||||
},
|
||||
});
|
||||
children.forEach((child, index) => {
|
||||
ordered.push({
|
||||
...itemByDeviceId.get(child.id)!,
|
||||
is_child: true,
|
||||
last: index === children.length - 1,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
ordered.push(parentItem);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
return outputDevices;
|
||||
};
|
||||
|
||||
@@ -81,75 +81,6 @@ export type DeviceRegistryListEntry =
|
||||
export const isChildDevice = (device: DeviceRegistryEntry): boolean =>
|
||||
device.parent_device_id !== null;
|
||||
|
||||
/**
|
||||
* Devices whose effective area is the given area: devices with that area, and
|
||||
* child devices that inherit it because they have no area of their own. Mirrors
|
||||
* core's dr.async_entries_for_area, so a child device with a different explicit
|
||||
* area is not part of its parent's area.
|
||||
*/
|
||||
export const devicesInEffectiveArea = (
|
||||
devices: Record<string, DeviceRegistryEntry>,
|
||||
areaId: string
|
||||
): DeviceRegistryEntry[] =>
|
||||
Object.values(devices).filter((device) => {
|
||||
if (device.area_id) {
|
||||
return device.area_id === areaId;
|
||||
}
|
||||
if (device.parent_device_id) {
|
||||
return devices[device.parent_device_id]?.area_id === areaId;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
export interface DeviceRowItem {
|
||||
device: DeviceRegistryEntry;
|
||||
isChild: boolean;
|
||||
// True for the last child of a parent, so the tree connector draws its end.
|
||||
isLastChild: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order a flat device list so each child directly follows its parent, flagging
|
||||
* children for indented rendering. The incoming order of the top-level devices
|
||||
* (and of the children within each parent) is preserved. A child whose parent
|
||||
* is not in the list is treated as a top-level device.
|
||||
*/
|
||||
export const groupDevicesByParent = (
|
||||
devices: DeviceRegistryEntry[]
|
||||
): DeviceRowItem[] => {
|
||||
const presentIds = new Set(devices.map((device) => device.id));
|
||||
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
|
||||
const topLevel: DeviceRegistryEntry[] = [];
|
||||
|
||||
for (const device of devices) {
|
||||
const parentId = device.parent_device_id;
|
||||
if (parentId && presentIds.has(parentId)) {
|
||||
const siblings = childrenByParent.get(parentId);
|
||||
if (siblings) {
|
||||
siblings.push(device);
|
||||
} else {
|
||||
childrenByParent.set(parentId, [device]);
|
||||
}
|
||||
} else {
|
||||
topLevel.push(device);
|
||||
}
|
||||
}
|
||||
|
||||
const result: DeviceRowItem[] = [];
|
||||
for (const device of topLevel) {
|
||||
result.push({ device, isChild: false, isLastChild: false });
|
||||
const children = childrenByParent.get(device.id) ?? [];
|
||||
children.forEach((child, index) => {
|
||||
result.push({
|
||||
device: child,
|
||||
isChild: true,
|
||||
isLastChild: index === children.length - 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export type DeviceEntityDisplayLookup = Record<
|
||||
string,
|
||||
EntityRegistryDisplayEntry[]
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import type { DateRange } from "../common/datetime/calc_date_range";
|
||||
import { calcDateRange } from "../common/datetime/calc_date_range";
|
||||
import { formatTime24h } from "../common/datetime/format_time";
|
||||
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
|
||||
import { groupBy } from "../common/util/group-by";
|
||||
@@ -37,7 +36,6 @@ import type {
|
||||
import {
|
||||
fetchStatistics,
|
||||
getDisplayUnit,
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
VOLUME_UNITS,
|
||||
} from "./recorder";
|
||||
@@ -313,59 +311,6 @@ export interface EnergySourceByType {
|
||||
export const energySourcesByType = (prefs: EnergyPreferences) =>
|
||||
groupBy(prefs.energy_sources, (item) => item.type) as EnergySourceByType;
|
||||
|
||||
/**
|
||||
* Display name of a configured statistic. A name set by the user always wins;
|
||||
* otherwise the entity is named the same way the rest of the UI names
|
||||
* entities, so devices sharing an entity name stay distinguishable.
|
||||
* Statistics without an entity (external or removed) keep the statistic label.
|
||||
*/
|
||||
export const computeEnergyLabel = (
|
||||
hass: HomeAssistant,
|
||||
statisticId: string,
|
||||
statisticsMetaData?: StatisticsMetaData,
|
||||
customName?: string
|
||||
): string => {
|
||||
if (customName) {
|
||||
return customName;
|
||||
}
|
||||
|
||||
const stateObj = hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
|
||||
}
|
||||
|
||||
return getStatisticLabel(hass, statisticId, statisticsMetaData);
|
||||
};
|
||||
|
||||
/**
|
||||
* Device labels keyed by statistic id. Cards that show live power or flow
|
||||
* key their nodes by `stat_rate` instead of `stat_consumption`; devices
|
||||
* without the requested statistic are left out.
|
||||
*/
|
||||
export const computeEnergyDeviceLabels = (
|
||||
hass: HomeAssistant,
|
||||
devices: DeviceConsumptionEnergyPreference[],
|
||||
statsMetadata?: Record<string, StatisticsMetaData>,
|
||||
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
|
||||
): Record<string, string> => {
|
||||
const labels: Record<string, string> = {};
|
||||
|
||||
for (const device of devices) {
|
||||
const statisticId = device[statisticKey];
|
||||
if (statisticId) {
|
||||
labels[statisticId] = computeEnergyLabel(
|
||||
hass,
|
||||
statisticId,
|
||||
statsMetadata?.[statisticId],
|
||||
device.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
};
|
||||
|
||||
export interface EnergyData {
|
||||
start: Date;
|
||||
end?: Date;
|
||||
|
||||
+10
-23
@@ -14,7 +14,6 @@ import type {
|
||||
import type { HomeAssistant } from "../types";
|
||||
import {
|
||||
type DeviceRegistryEntry,
|
||||
devicesInEffectiveArea,
|
||||
getDeviceIntegrationLookup,
|
||||
} from "./device/device_registry";
|
||||
import type {
|
||||
@@ -727,10 +726,9 @@ export const expandAreaTarget = (
|
||||
) => {
|
||||
const newEntities: string[] = [];
|
||||
const newDevices: string[] = [];
|
||||
// Devices of an area are its effective-area members: a child device inheriting
|
||||
// this area counts, a child with a different explicit area does not.
|
||||
devicesInEffectiveArea(devices, areaId).forEach((device) => {
|
||||
Object.values(devices).forEach((device) => {
|
||||
if (
|
||||
device.area_id === areaId &&
|
||||
deviceMeetsTargetSelector(
|
||||
hass.states,
|
||||
Object.values(entities),
|
||||
@@ -792,8 +790,9 @@ export const areaMeetsTargetSelector = (
|
||||
targetSelector: TargetSelector,
|
||||
entitySources?: EntitySources
|
||||
): boolean => {
|
||||
const hasMatchingdevice = devicesInEffectiveArea(devices, areaId).some(
|
||||
(device) =>
|
||||
const hasMatchingdevice = Object.values(devices).some((device) => {
|
||||
if (
|
||||
device.area_id === areaId &&
|
||||
deviceMeetsTargetSelector(
|
||||
hass.states,
|
||||
Object.values(entities),
|
||||
@@ -801,7 +800,11 @@ export const areaMeetsTargetSelector = (
|
||||
targetSelector,
|
||||
entitySources
|
||||
)
|
||||
);
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (hasMatchingdevice) {
|
||||
return true;
|
||||
}
|
||||
@@ -843,8 +846,6 @@ export const deviceMeetsTargetSelector = (
|
||||
}
|
||||
}
|
||||
if (targetSelector.target?.entity) {
|
||||
// Only the device's own entities: a child device is reached through the
|
||||
// device target itself, so a parent must not match on a child's behalf.
|
||||
const entities = entityRegistry.filter(
|
||||
(reg) => reg.device_id === device.id
|
||||
);
|
||||
@@ -1111,11 +1112,6 @@ export const resolveEntityIDs = (
|
||||
const targetFloors = new Set(ensureArray(targetPickerValue.floor_id));
|
||||
const targetLabels = new Set(ensureArray(targetPickerValue.label_id));
|
||||
|
||||
// Only a directly targeted device pulls in its child devices. Devices that are
|
||||
// only reached through a label or an area must not, because core does not
|
||||
// inherit labels to children and resolves areas by effective area membership.
|
||||
const directDevices = new Set(targetDevices);
|
||||
|
||||
targetLabels.forEach((labelId) => {
|
||||
const expanded = expandLabelTarget(
|
||||
hass,
|
||||
@@ -1147,15 +1143,6 @@ export const resolveEntityIDs = (
|
||||
expanded.entities.forEach((id) => targetEntities.add(id));
|
||||
});
|
||||
|
||||
// Targeting a device also targets its child devices, matching core's
|
||||
// server-side target resolution. Only direct device targets expand this way;
|
||||
// nesting is single-level, so one pass is enough.
|
||||
Object.values(devices).forEach((device) => {
|
||||
if (device.parent_device_id && directDevices.has(device.parent_device_id)) {
|
||||
targetDevices.add(device.id);
|
||||
}
|
||||
});
|
||||
|
||||
targetDevices.forEach((deviceId) => {
|
||||
const expanded = expandDeviceTarget(
|
||||
hass,
|
||||
|
||||
+4
-8
@@ -7,10 +7,7 @@ import type { CallWS, HomeAssistant } from "../types";
|
||||
import type { AreaRegistryEntry } from "./area/area_registry";
|
||||
import type { FloorComboBoxItem } from "./area_floor_picker";
|
||||
import type { DevicePickerItem } from "./device/device_picker";
|
||||
import {
|
||||
devicesInEffectiveArea,
|
||||
type DeviceRegistryEntry,
|
||||
} from "./device/device_registry";
|
||||
import type { DeviceRegistryEntry } from "./device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
|
||||
import type { EntityComboBoxItem } from "./entity/entity_picker";
|
||||
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
|
||||
@@ -128,7 +125,9 @@ export const areaMeetsFilter = (
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
includeSecondary = false
|
||||
): boolean => {
|
||||
const areaDevices = devicesInEffectiveArea(devices, area.area_id);
|
||||
const areaDevices = Object.values(devices).filter(
|
||||
(device) => device.area_id === area.area_id
|
||||
);
|
||||
|
||||
if (
|
||||
areaDevices.some((device) =>
|
||||
@@ -179,9 +178,6 @@ export const deviceMeetsFilter = (
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
includeSecondary = false
|
||||
): boolean => {
|
||||
// Only the device's own entities: child devices are targeted through the
|
||||
// device itself (see core's target resolution), not by making a parent match
|
||||
// on behalf of a child.
|
||||
const devEntities = Object.values(entities).filter(
|
||||
(entity) => entity.device_id === device.id
|
||||
);
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../../../common/entity/context/get_device_context";
|
||||
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-icon-next";
|
||||
import "../../../../components/ha-list-item";
|
||||
import { fullEntitiesContext } from "../../../../data/context";
|
||||
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
|
||||
const MAX_VISIBLE_CHILD_DEVICES = 10;
|
||||
|
||||
@customElement("ha-device-child-devices-card")
|
||||
export class HaDeviceChildDevicesCard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public deviceId!: string;
|
||||
|
||||
@state() public _showAll = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
private _entityCounts = memoizeOne(
|
||||
(entities: EntityRegistryEntry[]): Record<string, number> => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const entity of entities) {
|
||||
if (entity.device_id) {
|
||||
counts[entity.device_id] = (counts[entity.device_id] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
);
|
||||
|
||||
private _childDevices = memoizeOne(
|
||||
(
|
||||
deviceId: string,
|
||||
devices: Record<string, DeviceRegistryEntry>
|
||||
): DeviceRegistryEntry[] =>
|
||||
Object.values(devices)
|
||||
.filter((device) => device.parent_device_id === deviceId)
|
||||
.sort((d1, d2) =>
|
||||
caseInsensitiveStringCompare(
|
||||
computeDeviceNameDisplay(d1, this.hass.localize, this.hass.states),
|
||||
computeDeviceNameDisplay(d2, this.hass.localize, this.hass.states),
|
||||
this.hass.locale.language
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const childDevices = this._childDevices(this.deviceId, this.hass.devices);
|
||||
|
||||
if (childDevices.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const entityCounts = this._entityCounts(this._entityReg);
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<h1 class="card-header">
|
||||
${this.hass.localize("ui.panel.config.devices.child_devices.heading")}
|
||||
</h1>
|
||||
${(this._showAll
|
||||
? childDevices
|
||||
: childDevices.slice(0, MAX_VISIBLE_CHILD_DEVICES)
|
||||
).map((childDevice) => {
|
||||
const area = getDeviceArea(
|
||||
childDevice,
|
||||
this.hass.areas,
|
||||
this.hass.devices
|
||||
);
|
||||
const entityCount = entityCounts[childDevice.id] ?? 0;
|
||||
const secondary = [
|
||||
area?.name,
|
||||
entityCount
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.common.quick_links.entities",
|
||||
{ count: entityCount }
|
||||
)
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
return html`
|
||||
<a href=${`/config/devices/device/${childDevice.id}`}>
|
||||
<ha-list-item hasMeta .twoline=${!!secondary}>
|
||||
${computeDeviceNameDisplay(
|
||||
childDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)}
|
||||
${
|
||||
secondary
|
||||
? html`<span slot="secondary">${secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
<ha-icon-next slot="meta"></ha-icon-next>
|
||||
</ha-list-item>
|
||||
</a>
|
||||
`;
|
||||
})}
|
||||
${
|
||||
!this._showAll && childDevices.length > MAX_VISIBLE_CHILD_DEVICES
|
||||
? html`
|
||||
<button class="show-more" @click=${this._toggleShowAll}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.child_devices.show_more",
|
||||
{ count: childDevices.length - MAX_VISIBLE_CHILD_DEVICES }
|
||||
)}
|
||||
</button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleShowAll() {
|
||||
this._showAll = !this._showAll;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
button.show-more {
|
||||
color: var(--primary-color);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border-width: initial;
|
||||
border-style: none;
|
||||
border-color: initial;
|
||||
border-image: initial;
|
||||
padding: 16px;
|
||||
font: inherit;
|
||||
}
|
||||
button.show-more:focus {
|
||||
outline: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-device-child-devices-card": HaDeviceChildDevicesCard;
|
||||
}
|
||||
}
|
||||
@@ -93,27 +93,6 @@ export class HaDeviceCard extends LitElement {
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this.device.parent_device_id
|
||||
? html`
|
||||
<div class="extra-info">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.part_of"
|
||||
)}
|
||||
<span class="hub"
|
||||
><a
|
||||
href="/config/devices/device/${
|
||||
this.device.parent_device_id
|
||||
}"
|
||||
>${this._computeDeviceNameDisplay(
|
||||
this.device.parent_device_id
|
||||
)}</a
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this.device.via_device_id
|
||||
? html`
|
||||
|
||||
@@ -107,7 +107,6 @@ import { createSearchParam } from "../../../common/url/search-params";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import "../../logbook/ha-logbook";
|
||||
import "./device-detail/ha-device-child-devices-card";
|
||||
import "./device-detail/ha-device-entities-card";
|
||||
import "./device-detail/ha-device-info-card";
|
||||
import "./device-detail/ha-device-linked-devices-card";
|
||||
@@ -901,10 +900,6 @@ export class HaConfigDevicePage extends LitElement {
|
||||
: ""
|
||||
}
|
||||
</ha-device-info-card>
|
||||
<ha-device-child-devices-card
|
||||
.hass=${this.hass}
|
||||
.deviceId=${this.deviceId}
|
||||
></ha-device-child-devices-card>
|
||||
<ha-device-linked-devices-card
|
||||
.hass=${this.hass}
|
||||
.deviceId=${this.deviceId}
|
||||
|
||||
@@ -459,15 +459,6 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
? new Map(labelReg.map((label) => [label.label_id, label]))
|
||||
: undefined;
|
||||
|
||||
// Ids of devices that have at least one child device, so a parent can be
|
||||
// grouped together with its children.
|
||||
const deviceIdsWithChildren = new Set<string>();
|
||||
for (const dev of Object.values(devices)) {
|
||||
if (dev.parent_device_id) {
|
||||
deviceIdsWithChildren.add(dev.parent_device_id);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedOutputDevices = outputDevices.map((device) => {
|
||||
const deviceEntries = sortConfigEntries(
|
||||
device.config_entries
|
||||
@@ -481,15 +472,6 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
.map((lbl) => labelLookup!.get(lbl))
|
||||
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
|
||||
|
||||
const parentDevice = device.parent_device_id
|
||||
? this.hass.devices[device.parent_device_id]
|
||||
: undefined;
|
||||
// The device that identifies this device's family: its parent for a
|
||||
// child device, itself for a device that has children.
|
||||
const familyParentDevice =
|
||||
parentDevice ??
|
||||
(deviceIdsWithChildren.has(device.id) ? device : undefined);
|
||||
|
||||
const { areaName } = computeDeviceAreaLabel(
|
||||
device,
|
||||
this.hass.areas,
|
||||
@@ -545,29 +527,6 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
"ui.panel.config.devices.data_table.no_integration"
|
||||
),
|
||||
domains: deviceEntries.map((entry) => entry.domain),
|
||||
parent_device_name: parentDevice
|
||||
? computeDeviceNameDisplay(
|
||||
parentDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states,
|
||||
deviceEntityLookup[parentDevice.id]
|
||||
)
|
||||
: "",
|
||||
// Grouping key that keeps a device with its family: children group
|
||||
// under their parent's name, a parent groups under its own name, and
|
||||
// standalone devices stay ungrouped. The name is always computed from
|
||||
// the family's parent device with the same arguments, so a parent and
|
||||
// its children can never end up in different groups. Like the area and
|
||||
// floor columns, this groups on the display name rather than the id,
|
||||
// because the data table renders the raw group value as its header.
|
||||
device_family_name: familyParentDevice
|
||||
? computeDeviceNameDisplay(
|
||||
familyParentDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states,
|
||||
deviceEntityLookup[familyParentDevice.id]
|
||||
)
|
||||
: undefined,
|
||||
firmware_version: device.sw_version || undefined,
|
||||
battery_entity: [
|
||||
this._batteryEntity(device.id, deviceEntityLookup),
|
||||
@@ -628,16 +587,6 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
flex: 2,
|
||||
minWidth: "150px",
|
||||
extraTemplate: (device) => html`
|
||||
${
|
||||
device.parent_device_name
|
||||
? html`<div style="color: var(--secondary-text-color);">
|
||||
${localize(
|
||||
"ui.panel.config.devices.data_table.part_of_device",
|
||||
{ name: device.parent_device_name }
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
device.label_entries.length
|
||||
? html`
|
||||
@@ -658,19 +607,6 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
groupable: true,
|
||||
minWidth: "120px",
|
||||
},
|
||||
device_family_name: {
|
||||
title: localize("ui.panel.config.devices.data_table.parent_device"),
|
||||
// Keyed on the family name so grouping/sorting keeps a parent together
|
||||
// with its children (grouping uses the column key directly). The cell
|
||||
// only shows the parent name for child devices. Filterable stays on
|
||||
// even when hidden, so searching a parent's name surfaces its children.
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
defaultHidden: true,
|
||||
minWidth: "120px",
|
||||
template: (device) => device.parent_device_name || "",
|
||||
},
|
||||
manufacturer: {
|
||||
title: localize("ui.panel.config.devices.data_table.manufacturer"),
|
||||
sortable: true,
|
||||
|
||||
@@ -11,8 +11,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../../../common/entity/compute_area_name";
|
||||
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-icon-button";
|
||||
@@ -25,11 +23,9 @@ import type {
|
||||
EnergyPreferencesValidation,
|
||||
EnergyValidationIssue,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
saveEnergyPreferences,
|
||||
} from "../../../../data/energy";
|
||||
import { saveEnergyPreferences } from "../../../../data/energy";
|
||||
import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
import { getStatisticLabel } from "../../../../data/recorder";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
@@ -108,7 +104,18 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
${this._renderName(device)}
|
||||
<span class="content"
|
||||
>${
|
||||
device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this.statsMetadata?.[
|
||||
device.stat_consumption
|
||||
]
|
||||
)
|
||||
}</span
|
||||
>
|
||||
${this._renderIssueIndicator(
|
||||
this.validationResult?.device_consumption_water[
|
||||
index
|
||||
@@ -148,32 +155,6 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderName(device: DeviceConsumptionEnergyPreference) {
|
||||
const name = computeEnergyLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this.statsMetadata?.[device.stat_consumption],
|
||||
device.name
|
||||
);
|
||||
const areaId = getEntityAreaId(
|
||||
device.stat_consumption,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
);
|
||||
const area = areaId ? this.hass.areas[areaId] : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
return html`
|
||||
<div class="content">
|
||||
<span class="label">${name}</span>
|
||||
${
|
||||
areaName
|
||||
? html`<span class="label secondary">${areaName}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderIssueIndicator(
|
||||
issues: EnergyValidationIssue[] | undefined,
|
||||
index: number
|
||||
@@ -299,22 +280,6 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
haStyle,
|
||||
energyCardStyles,
|
||||
css`
|
||||
.row {
|
||||
height: 58px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.label.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.handle {
|
||||
cursor: move; /* fallback if grab cursor is unsupported */
|
||||
cursor: grab;
|
||||
|
||||
@@ -11,8 +11,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../../../common/entity/compute_area_name";
|
||||
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-icon-button";
|
||||
@@ -25,11 +23,9 @@ import type {
|
||||
EnergyPreferencesValidation,
|
||||
EnergyValidationIssue,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
saveEnergyPreferences,
|
||||
} from "../../../../data/energy";
|
||||
import { saveEnergyPreferences } from "../../../../data/energy";
|
||||
import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
import { getStatisticLabel } from "../../../../data/recorder";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
@@ -108,7 +104,18 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
${this._renderName(device)}
|
||||
<span class="content"
|
||||
>${
|
||||
device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this.statsMetadata?.[
|
||||
device.stat_consumption
|
||||
]
|
||||
)
|
||||
}</span
|
||||
>
|
||||
${this._renderIssueIndicator(
|
||||
this.validationResult?.device_consumption[
|
||||
index
|
||||
@@ -148,32 +155,6 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderName(device: DeviceConsumptionEnergyPreference) {
|
||||
const name = computeEnergyLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this.statsMetadata?.[device.stat_consumption],
|
||||
device.name
|
||||
);
|
||||
const areaId = getEntityAreaId(
|
||||
device.stat_consumption,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
);
|
||||
const area = areaId ? this.hass.areas[areaId] : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
return html`
|
||||
<div class="content">
|
||||
<span class="label">${name}</span>
|
||||
${
|
||||
areaName
|
||||
? html`<span class="label secondary">${areaName}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderIssueIndicator(
|
||||
issues: EnergyValidationIssue[] | undefined,
|
||||
index: number
|
||||
@@ -295,22 +276,6 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
haStyle,
|
||||
energyCardStyles,
|
||||
css`
|
||||
.row {
|
||||
height: 58px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.label.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.handle {
|
||||
cursor: move; /* fallback if grab cursor is unsupported */
|
||||
cursor: grab;
|
||||
|
||||
@@ -11,11 +11,9 @@ import "../../../../components/input/ha-input";
|
||||
import "./ha-energy-upstream-device-picker";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
|
||||
import { energyStatisticHelpUrl } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
energyStatisticHelpUrl,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
} from "../../../../data/recorder";
|
||||
@@ -176,7 +174,7 @@ export class DialogEnergyDeviceSettingsWater
|
||||
.value=${this._device?.name || ""}
|
||||
.placeholder=${
|
||||
this._device
|
||||
? computeEnergyLabel(
|
||||
? getStatisticLabel(
|
||||
this.hass,
|
||||
this._device.stat_consumption,
|
||||
this._params?.statsMetadata?.[this._device.stat_consumption]
|
||||
|
||||
@@ -11,11 +11,9 @@ import "../../../../components/input/ha-input";
|
||||
import "./ha-energy-upstream-device-picker";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
|
||||
import { energyStatisticHelpUrl } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
energyStatisticHelpUrl,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
} from "../../../../data/recorder";
|
||||
@@ -172,7 +170,7 @@ export class DialogEnergyDeviceSettings
|
||||
.value=${this._device?.name || ""}
|
||||
.placeholder=${
|
||||
this._device
|
||||
? computeEnergyLabel(
|
||||
? getStatisticLabel(
|
||||
this.hass,
|
||||
this._device.stat_consumption,
|
||||
this._params?.statsMetadata?.[this._device.stat_consumption]
|
||||
|
||||
@@ -7,6 +7,7 @@ import memoizeOne from "memoize-one";
|
||||
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
|
||||
import { computeStateName } from "../../../../common/entity/compute_state_name";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeRTL } from "../../../../common/util/compute_rtl";
|
||||
import "../../../../components/entity/state-badge";
|
||||
import "../../../../components/ha-combo-box-item";
|
||||
import "../../../../components/ha-generic-picker";
|
||||
@@ -14,7 +15,6 @@ import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-
|
||||
import type { PickerValueRenderer } from "../../../../components/ha-picker-field";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
|
||||
import { computeEnergyLabel } from "../../../../data/energy";
|
||||
import { domainToName } from "../../../../data/integration";
|
||||
import {
|
||||
getStatisticLabel,
|
||||
@@ -73,18 +73,20 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
return {
|
||||
id: statisticId,
|
||||
// Match the label shown in the device list and the graphs.
|
||||
primary: computeEnergyLabel(
|
||||
this.hass,
|
||||
statisticId,
|
||||
this.statsMetadata?.[statisticId],
|
||||
name
|
||||
),
|
||||
secondary: areaName,
|
||||
primary: name || entityName || deviceName || statisticId,
|
||||
secondary,
|
||||
stateObj,
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
|
||||
@@ -10,15 +10,12 @@ import {
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-tree-indicator";
|
||||
import {
|
||||
disableConfigEntry,
|
||||
type ConfigEntry,
|
||||
@@ -51,13 +48,6 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
|
||||
|
||||
// Rendered indented under its parent device.
|
||||
@property({ type: Boolean, reflect: true, attribute: "is-child" })
|
||||
public isChild = false;
|
||||
|
||||
// The last child of its parent, so the tree connector draws its end.
|
||||
@property({ attribute: false }) public isLastChild = false;
|
||||
|
||||
protected render() {
|
||||
const device = this.device;
|
||||
|
||||
@@ -65,11 +55,6 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
|
||||
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
|
||||
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
const supportingText = [
|
||||
device.model || device.sw_version || device.manufacturer,
|
||||
area ? area.name : undefined,
|
||||
@@ -80,25 +65,6 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
@click=${this._handleNavigateToDevice}
|
||||
class=${classMap({ disabled: Boolean(device.disabled_by) })}
|
||||
>
|
||||
${
|
||||
this.isChild
|
||||
? html`<ha-tree-indicator
|
||||
style=${styleMap({
|
||||
position: "absolute",
|
||||
// Span the full row height so consecutive children form one
|
||||
// continuous line; the elbow sits at the vertical centre.
|
||||
top: "0",
|
||||
// Align the connector under the parent device icon; the leading
|
||||
// space (and thus the icon column) is smaller in narrow mode.
|
||||
left: rtl ? undefined : this.narrow ? "4px" : "44px",
|
||||
right: rtl ? (this.narrow ? "4px" : "44px") : undefined,
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
.end=${this.isLastChild}
|
||||
slot="start"
|
||||
></ha-tree-indicator>`
|
||||
: nothing
|
||||
}
|
||||
<ha-svg-icon
|
||||
.path=${
|
||||
device.entry_type === "service"
|
||||
@@ -385,22 +351,12 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
--md-ripple-hover-color: transparent;
|
||||
--md-ripple-pressed-color: transparent;
|
||||
}
|
||||
:host([is-child]) ha-md-list-item {
|
||||
--md-list-item-leading-space: 88px;
|
||||
}
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
:host([narrow]) ha-md-list-item {
|
||||
--md-list-item-leading-space: 16px;
|
||||
}
|
||||
:host([narrow][is-child]) ha-md-list-item {
|
||||
--md-list-item-leading-space: 48px;
|
||||
}
|
||||
ha-tree-indicator {
|
||||
width: 48px;
|
||||
height: 100%;
|
||||
}
|
||||
.vertical-divider {
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
} from "../../../data/config_entries";
|
||||
import type { DiagnosticInfo } from "../../../data/diagnostics";
|
||||
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics";
|
||||
import { groupDevicesByParent } from "../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import {
|
||||
@@ -480,16 +479,14 @@ export class HaConfigEntryRow extends LitElement {
|
||||
</ha-md-list-item>
|
||||
${
|
||||
this._devicesExpanded
|
||||
? groupDevicesByParent(ownDevices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
? ownDevices.map(
|
||||
(device) =>
|
||||
html`<ha-config-entry-device-row
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.entry=${item}
|
||||
.device=${device}
|
||||
.entities=${entities}
|
||||
.isChild=${isChild}
|
||||
.isLastChild=${isLastChild}
|
||||
></ha-config-entry-device-row>`
|
||||
)
|
||||
: nothing
|
||||
@@ -512,16 +509,14 @@ export class HaConfigEntryRow extends LitElement {
|
||||
`
|
||||
)}`
|
||||
: html`
|
||||
${groupDevicesByParent(ownDevices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
${ownDevices.map(
|
||||
(device) =>
|
||||
html`<ha-config-entry-device-row
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.entry=${item}
|
||||
.device=${device}
|
||||
.entities=${entities}
|
||||
.isChild=${isChild}
|
||||
.isLastChild=${isLastChild}
|
||||
></ha-config-entry-device-row>`
|
||||
)}
|
||||
`
|
||||
|
||||
@@ -15,7 +15,6 @@ import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import type { ConfigEntry } from "../../../data/config_entries";
|
||||
import { deleteSubEntry, updateSubEntry } from "../../../data/config_entries";
|
||||
import { groupDevicesByParent } from "../../../data/device/device_registry";
|
||||
import type { DiagnosticInfo } from "../../../data/diagnostics";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
@@ -192,16 +191,14 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
${
|
||||
this._expanded
|
||||
? html`
|
||||
${groupDevicesByParent(devices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
${devices.map(
|
||||
(device) =>
|
||||
html`<ha-config-entry-device-row
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.entry=${this.entry}
|
||||
.device=${device}
|
||||
.entities=${this.entities}
|
||||
.isChild=${isChild}
|
||||
.isLastChild=${isLastChild}
|
||||
></ha-config-entry-device-row>`
|
||||
)}
|
||||
${services.map(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
mdiChartBoxOutline,
|
||||
mdiDotsVertical,
|
||||
mdiDownload,
|
||||
mdiFilterRemove,
|
||||
mdiImagePlus,
|
||||
mdiTuneVariant,
|
||||
} from "@mdi/js";
|
||||
import { differenceInHours } from "date-fns";
|
||||
import type {
|
||||
@@ -10,10 +11,11 @@ import type {
|
||||
UnsubscribeFunc,
|
||||
} from "home-assistant-js-websocket/dist/types";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { storage } from "../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
@@ -29,14 +31,25 @@ import {
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "../../components/chart/ha-chart-base";
|
||||
import "../../components/chart/state-history-charts";
|
||||
import type { StateHistoryCharts } from "../../components/chart/state-history-charts";
|
||||
import "../../components/date-picker/ha-date-range-picker";
|
||||
import "../../components/date-picker/ha-date-range-nav";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
|
||||
import "../../components/ha-dropdown-item";
|
||||
import "../../components/ha-empty-state";
|
||||
import "../../components/ha-filter-pane-chip";
|
||||
import "../../components/ha-filter-pane";
|
||||
import "../../components/ha-icon-button";
|
||||
import {
|
||||
applySourceFilters,
|
||||
countSourceFilters,
|
||||
countTargets,
|
||||
} from "../../components/ha-sources-picker";
|
||||
import type { SourceFilters } from "../../components/ha-sources-picker";
|
||||
import "../../components/ha-spinner";
|
||||
import "../../components/ha-target-picker";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import type { EntitySources } from "../../data/entity/entity_sources";
|
||||
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
|
||||
import type { HistoryResult } from "../../data/history";
|
||||
import {
|
||||
computeHistory,
|
||||
@@ -52,6 +65,8 @@ import type { HomeAssistant } from "../../types";
|
||||
import { addEntitiesToLovelaceView } from "../lovelace/editor/add-entities-to-view";
|
||||
import { csvSafeString, csvDownload } from "../../util/csv";
|
||||
|
||||
const EMPTY_STATES: HomeAssistant["states"] = {};
|
||||
|
||||
@customElement("ha-panel-history")
|
||||
class HaPanelHistory extends LitElement {
|
||||
@property({ attribute: false }) hass!: HomeAssistant;
|
||||
@@ -78,6 +93,12 @@ class HaPanelHistory extends LitElement {
|
||||
|
||||
@state() private _isLoading = false;
|
||||
|
||||
@state() private _filters: SourceFilters = {};
|
||||
|
||||
@state() private _showSources = false;
|
||||
|
||||
@state() private _entitySources?: EntitySources;
|
||||
|
||||
@state() private _stateHistory?: HistoryResult;
|
||||
|
||||
private _mungedStateHistory?: HistoryResult;
|
||||
@@ -119,7 +140,18 @@ class HaPanelHistory extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const entitiesSelected = this._getEntityIds().length > 0;
|
||||
const targetCount = countTargets(this._targetPickerValue);
|
||||
const sourceCount = targetCount + countSourceFilters(this._filters);
|
||||
// History only shows something once a target is picked, so narrowing it
|
||||
// down further does not make it any less empty.
|
||||
const hasTargets = targetCount > 0;
|
||||
const loading =
|
||||
this._isLoading || (hasTargets && !this._mungedStateHistory);
|
||||
const hasResults =
|
||||
!!this._mungedStateHistory &&
|
||||
(this._mungedStateHistory.line.length > 0 ||
|
||||
this._mungedStateHistory.timeline.length > 0);
|
||||
|
||||
return html`
|
||||
<ha-top-app-bar-fixed
|
||||
.narrow=${this.narrow}
|
||||
@@ -128,13 +160,6 @@ class HaPanelHistory extends LitElement {
|
||||
<h1 class="page-title" slot="title">
|
||||
${this.hass.localize("panel.history")}
|
||||
</h1>
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
@click=${this._removeAll}
|
||||
.disabled=${this._isLoading || !entitiesSelected}
|
||||
.path=${mdiFilterRemove}
|
||||
.label=${this.hass.localize("ui.panel.history.remove_all")}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
@@ -153,51 +178,109 @@ class HaPanelHistory extends LitElement {
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
|
||||
<div class="flex content ha-scrollbar">
|
||||
<div class="filters">
|
||||
<ha-date-range-picker
|
||||
?disabled=${this._isLoading}
|
||||
.startDate=${this._startDate}
|
||||
.endDate=${this._endDate}
|
||||
extended-presets
|
||||
time-picker
|
||||
@value-changed=${this._dateRangeChanged}
|
||||
></ha-date-range-picker>
|
||||
<ha-target-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this._targetPickerValue}
|
||||
.disabled=${this._isLoading}
|
||||
add-on-top
|
||||
@value-changed=${this._targetsChanged}
|
||||
compact
|
||||
></ha-target-picker>
|
||||
</div>
|
||||
${
|
||||
this._isLoading
|
||||
? html`<div class="progress-wrapper">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>`
|
||||
: !entitiesSelected
|
||||
? html`<div class="start-search">
|
||||
${this.hass.localize("ui.panel.history.start_search")}
|
||||
</div>`
|
||||
: html`
|
||||
<state-history-charts
|
||||
<div class="content">
|
||||
<div class="main">
|
||||
${
|
||||
this._showSources
|
||||
? html`<ha-filter-pane
|
||||
.narrow=${this.narrow}
|
||||
.label=${this.hass.localize("ui.panel.history.sources")}
|
||||
.path=${mdiTuneVariant}
|
||||
.count=${sourceCount}
|
||||
.resultCount=${this._getEntityIds().length}
|
||||
.disabled=${this._isLoading}
|
||||
@close-filter-pane=${this._closeSources}
|
||||
@clear-filter=${this._clearSources}
|
||||
>
|
||||
<ha-sources-picker
|
||||
.hass=${this.hass}
|
||||
.historyData=${this._mungedStateHistory}
|
||||
.startTime=${this._startDate}
|
||||
.endTime=${this._endDate}
|
||||
.value=${this._targetPickerValue}
|
||||
.filters=${this._filters}
|
||||
.narrow=${this.narrow}
|
||||
sync-charts
|
||||
>
|
||||
</state-history-charts>
|
||||
`
|
||||
}
|
||||
.disabled=${this._isLoading}
|
||||
.description=${this.hass.localize(
|
||||
"ui.panel.history.no_targets"
|
||||
)}
|
||||
@value-changed=${this._targetsChanged}
|
||||
@source-filters-changed=${this._filtersChanged}
|
||||
></ha-sources-picker>
|
||||
</ha-filter-pane>`
|
||||
: nothing
|
||||
}
|
||||
<div class="content-column">
|
||||
<div class="toolbar">
|
||||
${
|
||||
this._showSources && !this.narrow
|
||||
? nothing
|
||||
: html`<ha-filter-pane-chip
|
||||
.label=${this.hass.localize("ui.panel.history.sources")}
|
||||
.path=${mdiTuneVariant}
|
||||
.count=${sourceCount}
|
||||
.active=${sourceCount > 0}
|
||||
.disabled=${this._isLoading}
|
||||
@click=${this._toggleSources}
|
||||
></ha-filter-pane-chip>`
|
||||
}
|
||||
<ha-date-range-nav
|
||||
.disabled=${this._isLoading}
|
||||
.startDate=${this._startDate}
|
||||
.endDate=${this._endDate}
|
||||
extended-presets
|
||||
time-picker
|
||||
@value-changed=${this._dateRangeChanged}
|
||||
></ha-date-range-nav>
|
||||
</div>
|
||||
<div class="results ha-scrollbar">
|
||||
${
|
||||
loading
|
||||
? html`<div class="progress-wrapper">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>`
|
||||
: !hasTargets || !hasResults
|
||||
? this._renderEmptyState(hasTargets)
|
||||
: html`
|
||||
<state-history-charts
|
||||
.hass=${this.hass}
|
||||
.historyData=${this._mungedStateHistory}
|
||||
.startTime=${this._startDate}
|
||||
.endTime=${this._endDate}
|
||||
.narrow=${this.narrow}
|
||||
inside-labels
|
||||
sync-charts
|
||||
>
|
||||
</state-history-charts>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ha-top-app-bar-fixed>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderEmptyState(hasTargets: boolean) {
|
||||
return html`
|
||||
<ha-empty-state
|
||||
.icon=${mdiChartBoxOutline}
|
||||
.heading=${this.hass.localize(
|
||||
hasTargets
|
||||
? "ui.panel.history.no_results_title"
|
||||
: "ui.panel.history.start_search_title"
|
||||
)}
|
||||
.description=${this.hass.localize(
|
||||
hasTargets
|
||||
? "ui.panel.history.no_results"
|
||||
: "ui.panel.history.start_search"
|
||||
)}
|
||||
>
|
||||
<ha-button appearance="plain" @click=${this._openSources}>
|
||||
${this.hass.localize("ui.panel.history.add_targets")}
|
||||
</ha-button>
|
||||
</ha-empty-state>
|
||||
`;
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
@@ -242,6 +325,10 @@ class HaPanelHistory extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
// Needed to map entities to their integration for the integration filter.
|
||||
fetchEntitySourcesWithCache(this.hass).then((sources) => {
|
||||
this._entitySources = sources;
|
||||
});
|
||||
const searchParams = extractSearchParamsObject();
|
||||
if (searchParams.back === "1" && history.length > 1) {
|
||||
this._showBack = true;
|
||||
@@ -256,6 +343,8 @@ class HaPanelHistory extends LitElement {
|
||||
changedProps.has("_startDate") ||
|
||||
changedProps.has("_endDate") ||
|
||||
changedProps.has("_targetPickerValue") ||
|
||||
changedProps.has("_filters") ||
|
||||
changedProps.has("_entitySources") ||
|
||||
(!this._stateHistory &&
|
||||
(changedProps.has("_deviceEntityLookup") ||
|
||||
changedProps.has("_areaEntityLookup") ||
|
||||
@@ -266,7 +355,26 @@ class HaPanelHistory extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _removeAll() {
|
||||
private _toggleSources() {
|
||||
this._showSources = !this._showSources;
|
||||
}
|
||||
|
||||
private _openSources() {
|
||||
this._showSources = true;
|
||||
}
|
||||
|
||||
private _closeSources() {
|
||||
this._showSources = false;
|
||||
}
|
||||
|
||||
private _filtersChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
|
||||
) {
|
||||
this._filters = ev.detail.value;
|
||||
}
|
||||
|
||||
private _clearSources() {
|
||||
this._filters = {};
|
||||
this._targetPickerValue = {};
|
||||
this._storedTargetPickerValue = this._targetPickerValue;
|
||||
this._updatePath();
|
||||
@@ -379,20 +487,39 @@ class HaPanelHistory extends LitElement {
|
||||
private _getEntityIds(): string[] {
|
||||
return this.__getEntityIds(
|
||||
this._targetPickerValue,
|
||||
this._filters,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas
|
||||
this.hass.areas,
|
||||
// Only the device class filter reads the states, so they stay out of the
|
||||
// memoization key while it is not used.
|
||||
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
|
||||
this._entitySources
|
||||
);
|
||||
}
|
||||
|
||||
private __getEntityIds = memoizeOne(
|
||||
(
|
||||
targetPickerValue: HassServiceTarget,
|
||||
filters: SourceFilters,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"]
|
||||
areas: HomeAssistant["areas"],
|
||||
states: HomeAssistant["states"],
|
||||
entitySources: EntitySources | undefined
|
||||
): string[] =>
|
||||
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
|
||||
applySourceFilters(
|
||||
resolveEntityIDs(
|
||||
this.hass,
|
||||
targetPickerValue,
|
||||
entities,
|
||||
devices,
|
||||
areas
|
||||
),
|
||||
filters,
|
||||
states,
|
||||
entitySources
|
||||
)
|
||||
);
|
||||
|
||||
private _dateRangeChanged(ev) {
|
||||
@@ -573,7 +700,15 @@ class HaPanelHistory extends LitElement {
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
:host {
|
||||
/* The picker of the target picker is wider than the pane. */
|
||||
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
|
||||
--ha-generic-picker-max-width: 400px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(
|
||||
100vh - var(--header-height, 0px) - var(
|
||||
--safe-area-inset-top,
|
||||
@@ -581,13 +716,57 @@ class HaPanelHistory extends LitElement {
|
||||
) - var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
padding: 0 16px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host([virtualize]) {
|
||||
height: 100%;
|
||||
--ha-generic-picker-max-width: 400px;
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Sits in the content column, so it shifts along with the pane. */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--primary-background-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
direction: var(--direction);
|
||||
/* Keep the controls at their size and scroll them if they do not fit. */
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.toolbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.results {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden auto;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
/* Line the charts up with the toolbar when there are no axis labels. */
|
||||
:host([narrow]) .results {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
@@ -597,42 +776,6 @@ class HaPanelHistory extends LitElement {
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
ha-date-range-picker {
|
||||
margin-right: 16px;
|
||||
margin-inline-end: 16px;
|
||||
margin-inline-start: initial;
|
||||
max-width: 100%;
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
ha-target-picker {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1025px) {
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
ha-date-range-picker {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.start-search {
|
||||
padding-top: 16px;
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ class HaLogbookRenderer extends LitElement {
|
||||
|
||||
@state() private _showRelative = false;
|
||||
|
||||
// Index of the row at the top of the list, which the floating date header
|
||||
// takes its day from.
|
||||
@state() private _firstVisibleIndex = 0;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (
|
||||
(!this.hasUpdated && this.virtualize) ||
|
||||
@@ -79,6 +83,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
changedProps.has("entries") ||
|
||||
changedProps.has("traceContexts") ||
|
||||
changedProps.has("_showRelative" as never) ||
|
||||
changedProps.has("_firstVisibleIndex" as never) ||
|
||||
languageChanged
|
||||
);
|
||||
}
|
||||
@@ -92,12 +97,26 @@ class HaLogbookRenderer extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// The virtualizer positions its rows, so an inline date header cannot
|
||||
// stick. Instead one header floats above the list and follows the day of
|
||||
// the row that is at the top.
|
||||
const floatingEntry = this.virtualize
|
||||
? this.entries[this._firstVisibleIndex]
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="container ha-scrollbar"
|
||||
@scroll=${this._saveScrollPos}
|
||||
@logbook-toggle-time=${this._handleToggleTime}
|
||||
>
|
||||
${
|
||||
floatingEntry
|
||||
? html`<h4 class="date floating-date">
|
||||
${this._formatDateHeader(new Date(floatingEntry.when * 1000))}
|
||||
</h4>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.virtualize
|
||||
? html`<lit-virtualizer
|
||||
@@ -183,6 +202,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private _visibilityChanged(e: VisibilityChangedEvent) {
|
||||
this._firstVisibleIndex = Math.max(0, e.first);
|
||||
fireEvent(this, "hass-logbook-live", {
|
||||
enable: e.first === 0,
|
||||
});
|
||||
@@ -209,12 +229,25 @@ class HaLogbookRenderer extends LitElement {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
/* Floats above the virtualized list and lines up with the inline date
|
||||
headers, so they scroll underneath it. */
|
||||
.floating-date {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline: 0;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
padding-bottom: var(--ha-space-2);
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
.no-entries {
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
max-height: var(--logbook-max-height);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ const idsChanged = (oldIds?: string[], newIds?: string[]) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @slot empty - Shown instead of the default text when there is no activity,
|
||||
* for surfaces that can offer a way out (e.g. changing the filters).
|
||||
*/
|
||||
@customElement("ha-logbook")
|
||||
export class HaLogbook extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -126,9 +130,11 @@ export class HaLogbook extends LitElement {
|
||||
}
|
||||
|
||||
if (this._logbookEntries.length === 0) {
|
||||
return html`<div class="no-entries">
|
||||
${this.hass.localize("ui.components.logbook.entries_not_found")}
|
||||
</div>`;
|
||||
return html`<slot name="empty">
|
||||
<div class="no-entries">
|
||||
${this.hass.localize("ui.components.logbook.entries_not_found")}
|
||||
</div>
|
||||
</slot>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
|
||||
@@ -3,14 +3,17 @@ import {
|
||||
mdiDownload,
|
||||
mdiFilterRemove,
|
||||
mdiRefresh,
|
||||
mdiTextBoxOutline,
|
||||
mdiTuneVariant,
|
||||
} from "@mdi/js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fromUnixTime } from "date-fns";
|
||||
import { storage } from "../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
import {
|
||||
@@ -23,14 +26,25 @@ import {
|
||||
removeSearchParam,
|
||||
} from "../../common/url/search-params";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/date-picker/ha-date-range-picker";
|
||||
import "../../components/date-picker/ha-date-range-nav";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
|
||||
import "../../components/ha-dropdown-item";
|
||||
import "../../components/ha-empty-state";
|
||||
import "../../components/ha-filter-pane-chip";
|
||||
import "../../components/ha-filter-pane";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-target-picker";
|
||||
import {
|
||||
applySourceFilters,
|
||||
countSourceFilters,
|
||||
countTargets,
|
||||
} from "../../components/ha-sources-picker";
|
||||
import type { SourceFilters } from "../../components/ha-sources-picker";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { EntitySources } from "../../data/entity/entity_sources";
|
||||
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
|
||||
import { filterLogbookCompatibleEntities } from "../../data/logbook";
|
||||
import { resolveEntityIDs } from "../../data/selector";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
@@ -57,6 +71,12 @@ export class HaPanelLogbook extends LitElement {
|
||||
@state()
|
||||
private _showBack?: boolean;
|
||||
|
||||
@state() private _filters: SourceFilters = {};
|
||||
|
||||
@state() private _showSources = false;
|
||||
|
||||
@state() private _entitySources?: EntitySources;
|
||||
|
||||
@state() private _targetPickerValue: HassServiceTarget = {};
|
||||
|
||||
// Remembers the last user-picked selection as a fallback for visits without
|
||||
@@ -75,19 +95,16 @@ export class HaPanelLogbook extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const entityIds = this._getEntityIds();
|
||||
const sourceCount =
|
||||
countTargets(this._targetPickerValue) + countSourceFilters(this._filters);
|
||||
|
||||
return html`
|
||||
<ha-top-app-bar-fixed
|
||||
.narrow=${this.narrow}
|
||||
.backButton=${!!this._showBack}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.logbook")}</div>
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
@click=${this._resetLogbook}
|
||||
.disabled=${this._isDefaultState()}
|
||||
.path=${mdiFilterRemove}
|
||||
.label=${this.hass.localize("ui.common.reset")}
|
||||
></ha-icon-button>
|
||||
|
||||
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
|
||||
<ha-icon-button
|
||||
@@ -105,40 +122,126 @@ export class HaPanelLogbook extends LitElement {
|
||||
${this.hass.localize("ui.panel.logbook.download_data")}
|
||||
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
|
||||
<ha-dropdown-item value="reset" .disabled=${this._isDefaultState()}>
|
||||
${this.hass.localize("ui.common.reset")}
|
||||
<ha-svg-icon slot="icon" .path=${mdiFilterRemove}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
|
||||
<div class="content">
|
||||
<div class="filters">
|
||||
<ha-date-range-picker
|
||||
.startDate=${this._time.range[0]}
|
||||
.endDate=${this._time.range[1]}
|
||||
@value-changed=${this._dateRangeChanged}
|
||||
time-picker
|
||||
></ha-date-range-picker>
|
||||
<div class="main">
|
||||
${
|
||||
this._showSources
|
||||
? html`<ha-filter-pane
|
||||
.narrow=${this.narrow}
|
||||
.label=${this.hass.localize("ui.panel.logbook.sources")}
|
||||
.path=${mdiTuneVariant}
|
||||
.count=${sourceCount}
|
||||
.resultCount=${entityIds?.length}
|
||||
@close-filter-pane=${this._closeSources}
|
||||
@clear-filter=${this._clearSources}
|
||||
>
|
||||
<ha-sources-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this._targetPickerValue}
|
||||
.filters=${this._filters}
|
||||
.entityFilter=${this._filterFunc}
|
||||
.narrow=${this.narrow}
|
||||
.description=${this.hass.localize(
|
||||
"ui.panel.logbook.no_targets"
|
||||
)}
|
||||
@value-changed=${this._targetsChanged}
|
||||
@source-filters-changed=${this._filtersChanged}
|
||||
></ha-sources-picker>
|
||||
</ha-filter-pane>`
|
||||
: nothing
|
||||
}
|
||||
<div class="content-column">
|
||||
<div class="toolbar">
|
||||
${
|
||||
this._showSources && !this.narrow
|
||||
? nothing
|
||||
: html`<ha-filter-pane-chip
|
||||
.label=${this.hass.localize("ui.panel.logbook.sources")}
|
||||
.path=${mdiTuneVariant}
|
||||
.count=${sourceCount}
|
||||
.active=${sourceCount > 0}
|
||||
@click=${this._toggleSources}
|
||||
></ha-filter-pane-chip>`
|
||||
}
|
||||
<ha-date-range-nav
|
||||
.startDate=${this._time.range[0]}
|
||||
.endDate=${this._time.range[1]}
|
||||
@value-changed=${this._dateRangeChanged}
|
||||
time-picker
|
||||
></ha-date-range-nav>
|
||||
</div>
|
||||
|
||||
<ha-target-picker
|
||||
.hass=${this.hass}
|
||||
.entityFilter=${this._filterFunc}
|
||||
.value=${this._targetPickerValue}
|
||||
add-on-top
|
||||
@value-changed=${this._targetsChanged}
|
||||
compact
|
||||
></ha-target-picker>
|
||||
<ha-logbook
|
||||
.hass=${this.hass}
|
||||
.time=${this._time}
|
||||
.entityIds=${entityIds}
|
||||
.narrow=${this.narrow}
|
||||
show-cause
|
||||
virtualize
|
||||
>
|
||||
${
|
||||
sourceCount > 0
|
||||
? html`<ha-empty-state
|
||||
slot="empty"
|
||||
.icon=${mdiTextBoxOutline}
|
||||
.heading=${this.hass.localize(
|
||||
"ui.panel.logbook.no_results_title"
|
||||
)}
|
||||
.description=${this.hass.localize(
|
||||
"ui.panel.logbook.no_results"
|
||||
)}
|
||||
>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this._openSources}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.logbook.change_sources"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-empty-state>`
|
||||
: nothing
|
||||
}
|
||||
</ha-logbook>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ha-logbook
|
||||
.hass=${this.hass}
|
||||
.time=${this._time}
|
||||
.entityIds=${this._getEntityIds()}
|
||||
.narrow=${this.narrow}
|
||||
show-cause
|
||||
virtualize
|
||||
></ha-logbook>
|
||||
</div>
|
||||
</ha-top-app-bar-fixed>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleSources() {
|
||||
this._showSources = !this._showSources;
|
||||
}
|
||||
|
||||
private _openSources() {
|
||||
this._showSources = true;
|
||||
}
|
||||
|
||||
private _closeSources() {
|
||||
this._showSources = false;
|
||||
}
|
||||
|
||||
private _filtersChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
|
||||
) {
|
||||
this._filters = ev.detail.value;
|
||||
}
|
||||
|
||||
private _clearSources() {
|
||||
this._filters = {};
|
||||
this._targetPickerValue = {};
|
||||
this._storedTargetPickerValue = this._targetPickerValue;
|
||||
this._updatePath();
|
||||
}
|
||||
|
||||
private _filterFunc: HaEntityPickerEntityFilterFunc = (entity) =>
|
||||
filterLogbookCompatibleEntities(entity);
|
||||
|
||||
@@ -155,6 +258,10 @@ export class HaPanelLogbook extends LitElement {
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.hass.loadBackendTranslation("title");
|
||||
// Needed to map entities to their integration for the integration filter.
|
||||
fetchEntitySourcesWithCache(this.hass).then((sources) => {
|
||||
this._entitySources = sources;
|
||||
});
|
||||
|
||||
const searchParams = extractSearchParamsObject();
|
||||
if (searchParams.back === "1" && history.length > 1) {
|
||||
@@ -179,17 +286,30 @@ export class HaPanelLogbook extends LitElement {
|
||||
this._applyURLParams();
|
||||
};
|
||||
|
||||
/**
|
||||
* The entities to show activity for, or undefined for all of them. Filters
|
||||
* without a target narrow down every entity the logbook can show.
|
||||
*/
|
||||
private _getEntityIds(): string[] | undefined {
|
||||
const entities = this.__getEntityIds(
|
||||
const targetEntities = this.__getEntityIds(
|
||||
this._targetPickerValue,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas
|
||||
);
|
||||
if (entities.length === 0) {
|
||||
return undefined;
|
||||
|
||||
if (!countSourceFilters(this._filters)) {
|
||||
return targetEntities.length ? targetEntities : undefined;
|
||||
}
|
||||
return entities;
|
||||
|
||||
return this.__filterEntityIds(
|
||||
targetEntities.length
|
||||
? targetEntities
|
||||
: this.__logbookEntityIds(this.hass.states),
|
||||
this._filters,
|
||||
this.hass.states,
|
||||
this._entitySources
|
||||
);
|
||||
}
|
||||
|
||||
private __getEntityIds = memoizeOne(
|
||||
@@ -202,6 +322,15 @@ export class HaPanelLogbook extends LitElement {
|
||||
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
|
||||
);
|
||||
|
||||
private __logbookEntityIds = memoizeOne(
|
||||
(states: HomeAssistant["states"]): string[] =>
|
||||
Object.values(states)
|
||||
.filter((stateObj) => filterLogbookCompatibleEntities(stateObj))
|
||||
.map((stateObj) => stateObj.entity_id)
|
||||
);
|
||||
|
||||
private __filterEntityIds = memoizeOne(applySourceFilters);
|
||||
|
||||
private _applyURLParams() {
|
||||
const queryParams = decodeHistoryLogbookQueryParams(
|
||||
extractSearchParamsObject()
|
||||
@@ -284,6 +413,7 @@ export class HaPanelLogbook extends LitElement {
|
||||
this._time = defaultState.time;
|
||||
this._targetPickerValue = defaultState.targetPickerValue;
|
||||
this._storedTargetPickerValue = undefined;
|
||||
this._filters = {};
|
||||
navigate("/logbook", { replace: true });
|
||||
}
|
||||
|
||||
@@ -300,6 +430,9 @@ export class HaPanelLogbook extends LitElement {
|
||||
case "refresh":
|
||||
this._refreshLogbook();
|
||||
break;
|
||||
case "reset":
|
||||
this._resetLogbook();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +496,8 @@ export class HaPanelLogbook extends LitElement {
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
/* The picker of the target picker is wider than the pane. */
|
||||
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
|
||||
--ha-generic-picker-max-width: 400px;
|
||||
}
|
||||
|
||||
@@ -375,59 +510,52 @@ export class HaPanelLogbook extends LitElement {
|
||||
0px
|
||||
) - var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
overflow-x: hidden;
|
||||
padding: 0 0 16px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Sits in the content column, so it shifts along with the pane. */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--primary-background-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
direction: var(--direction);
|
||||
/* Keep the controls at their size and scroll them if they do not fit. */
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.toolbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
ha-logbook {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
ha-date-range-picker {
|
||||
margin-right: 16px;
|
||||
margin-inline-end: 16px;
|
||||
margin-inline-start: initial;
|
||||
max-width: 100%;
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
@media all and (max-width: 870px) {
|
||||
ha-date-range-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
:host([narrow]) ha-date-range-picker {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
margin-inline-start: initial;
|
||||
direction: var(--direction);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
|
||||
:host([narrow]) .filters {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
ha-target-picker {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type {
|
||||
LovelaceCardFeatureConfig,
|
||||
LovelaceCardFeaturePosition,
|
||||
} from "../types";
|
||||
|
||||
export interface CardFeatureLayout {
|
||||
inline: LovelaceCardFeatureConfig[];
|
||||
below: LovelaceCardFeatureConfig[];
|
||||
/** Columns filled by the below features, 0 when there are none */
|
||||
columns: number;
|
||||
}
|
||||
|
||||
const INLINE_COLUMNS = 2;
|
||||
|
||||
export const computeCardFeatureLayout = (
|
||||
features: LovelaceCardFeatureConfig[] | undefined,
|
||||
position: LovelaceCardFeaturePosition
|
||||
): CardFeatureLayout => {
|
||||
if (position !== "inline") {
|
||||
return { inline: [], below: features ?? [], columns: 1 };
|
||||
}
|
||||
const inline = features?.slice(0, 1) ?? [];
|
||||
const below = features?.slice(1) ?? [];
|
||||
return { inline, below, columns: Math.min(below.length, INLINE_COLUMNS) };
|
||||
};
|
||||
|
||||
export const computeCardFeatureRows = (
|
||||
features: LovelaceCardFeatureConfig[] | undefined,
|
||||
position: LovelaceCardFeaturePosition
|
||||
): number => {
|
||||
const { below, columns } = computeCardFeatureLayout(features, position);
|
||||
return Math.ceil(below.length / Math.max(columns, 1));
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./hui-card-feature";
|
||||
import type {
|
||||
@@ -33,32 +32,22 @@ export class HuiCardFeatures extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public position?: LovelaceCardFeaturePosition;
|
||||
|
||||
@property({ type: Number, reflect: true })
|
||||
public columns = 1;
|
||||
|
||||
protected render() {
|
||||
if (!this.features) {
|
||||
return nothing;
|
||||
}
|
||||
const lastIndex = this.features.length - 1;
|
||||
const columns = Math.max(this.columns, 1);
|
||||
return html`
|
||||
${this.features.map((feature, index) => {
|
||||
const column = index % columns;
|
||||
return html`
|
||||
${this.features.map(
|
||||
(feature) => html`
|
||||
<hui-card-feature
|
||||
class=${classMap({
|
||||
divided: column > 0,
|
||||
wide: column === 0 && index === lastIndex,
|
||||
})}
|
||||
.hass=${this.hass}
|
||||
.context=${this.context}
|
||||
.color=${this.color}
|
||||
.feature=${feature}
|
||||
.position=${this.position}
|
||||
></hui-card-feature>
|
||||
`;
|
||||
})}
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -66,38 +55,20 @@ export class HuiCardFeatures extends LitElement {
|
||||
:host {
|
||||
--feature-color: var(--state-icon-color);
|
||||
--feature-height: 42px;
|
||||
--feature-columns: 1;
|
||||
--feature-border-radius: var(
|
||||
--ha-card-features-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
--feature-button-spacing: 12px;
|
||||
--feature-column-gap: var(
|
||||
--ha-card-feature-column-gap,
|
||||
var(--ha-card-feature-gap, 12px)
|
||||
);
|
||||
--feature-divider-inset: var(--ha-card-feature-divider-inset, 0px);
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--ha-card-feature-gap, 12px) var(--feature-column-gap);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-card-feature-gap, 12px);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
align-content: space-evenly;
|
||||
}
|
||||
:host([columns="2"]) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
/* pull the divider out of the column and into the middle of the gutter */
|
||||
.divided {
|
||||
box-sizing: border-box;
|
||||
margin-inline-start: calc(-1 * var(--feature-divider-inset));
|
||||
padding-inline-start: var(--feature-divider-inset);
|
||||
border-inline-start: var(--ha-card-feature-divider, none);
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import {
|
||||
consumeEntityState,
|
||||
@@ -202,7 +202,14 @@ class HuiMediaPlayerPlaybackCardFeature
|
||||
});
|
||||
}
|
||||
|
||||
static styles = cardFeatureStyles;
|
||||
static styles = [
|
||||
cardFeatureStyles,
|
||||
css`
|
||||
ha-control-button-group {
|
||||
overflow: hidden;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -8,13 +8,13 @@ import type {
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
computeEnergyDeviceLabels,
|
||||
getSuggestedPeriod,
|
||||
getSummedData,
|
||||
} from "../../../../data/energy";
|
||||
import type { Statistics } from "../../../../data/recorder";
|
||||
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
|
||||
import {
|
||||
calculateStatisticSumGrowth,
|
||||
getStatisticLabel,
|
||||
isExternalStatistic,
|
||||
} from "../../../../data/recorder";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
@@ -69,13 +69,13 @@ interface ProcessContext {
|
||||
end: Date;
|
||||
compareStart?: Date;
|
||||
untrackedOrder: number;
|
||||
deviceLabels: Record<string, string>;
|
||||
}
|
||||
|
||||
function processDataSet(
|
||||
ctx: ProcessContext,
|
||||
computedStyle: CSSStyleDeclaration,
|
||||
statistics: Statistics,
|
||||
statisticsMetaData: Record<string, StatisticsMetaData>,
|
||||
devices: DeviceConsumptionEnergyPreference[],
|
||||
sorted_devices: string[],
|
||||
childMap: Record<string, string[]>,
|
||||
@@ -167,7 +167,12 @@ function processDataSet(
|
||||
}
|
||||
|
||||
const name =
|
||||
ctx.deviceLabels[source.stat_consumption] +
|
||||
(source.name ||
|
||||
getStatisticLabel(
|
||||
ctx.hass,
|
||||
source.stat_consumption,
|
||||
statisticsMetaData[source.stat_consumption]
|
||||
)) +
|
||||
(source.stat_consumption in childMap
|
||||
? ` (${ctx.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked")})`
|
||||
: "");
|
||||
@@ -346,8 +351,6 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
const data = energyData.stats;
|
||||
const compareData = energyData.statsCompare;
|
||||
|
||||
const devices = energyData.prefs.device_consumption;
|
||||
|
||||
const ctx: ProcessContext = {
|
||||
hass,
|
||||
config,
|
||||
@@ -355,13 +358,10 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
end,
|
||||
compareStart,
|
||||
untrackedOrder,
|
||||
deviceLabels: computeEnergyDeviceLabels(
|
||||
hass,
|
||||
devices,
|
||||
energyData.statsMetadata
|
||||
),
|
||||
};
|
||||
|
||||
const devices = energyData.prefs.device_consumption;
|
||||
|
||||
const childMap: Record<string, string[]> = {};
|
||||
devices.forEach((d) => {
|
||||
if (d.included_in_stat) {
|
||||
@@ -425,6 +425,7 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
ctx,
|
||||
computedStyles,
|
||||
compareData,
|
||||
energyData.statsMetadata,
|
||||
energyData.prefs.device_consumption,
|
||||
sorted_devices,
|
||||
childMap,
|
||||
@@ -467,6 +468,7 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
ctx,
|
||||
computedStyles,
|
||||
data,
|
||||
energyData.statsMetadata,
|
||||
energyData.prefs.device_consumption,
|
||||
sorted_devices,
|
||||
childMap,
|
||||
|
||||
@@ -16,7 +16,6 @@ import "../../../../components/chart/ha-chart-tooltip-marker";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
computeEnergyDeviceLabels,
|
||||
getEnergyDataCollection,
|
||||
getSummedData,
|
||||
validateEnergyCollectionKey,
|
||||
@@ -92,8 +91,6 @@ export class HuiEnergyDevicesGraphCard
|
||||
|
||||
private _compoundStats: string[] = [];
|
||||
|
||||
private _deviceLabels: Record<string, string> = {};
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
@@ -298,8 +295,9 @@ export class HuiEnergyDevicesGraphCard
|
||||
? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})`
|
||||
: "";
|
||||
return (
|
||||
// The untracked slice is not a statistic, so it has no label.
|
||||
(this._deviceLabels[statisticId] ||
|
||||
(this._data?.prefs.device_consumption.find(
|
||||
(d) => d.stat_consumption === statisticId
|
||||
)?.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statisticId,
|
||||
@@ -379,12 +377,6 @@ export class HuiEnergyDevicesGraphCard
|
||||
.map((d) => d.included_in_stat)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
this._deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
energyData.prefs.device_consumption,
|
||||
energyData.statsMetadata
|
||||
);
|
||||
|
||||
const devices = energyData.prefs.device_consumption;
|
||||
const devicesTotals: Record<string, number> = {};
|
||||
devices.forEach((device) => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import "../../../../components/ha-svg-icon";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
computeEnergyDeviceLabels,
|
||||
energySourcesByType,
|
||||
getEnergyDataCollection,
|
||||
getSummedData,
|
||||
@@ -273,14 +272,8 @@ class HuiEnergySankeyCard
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
prefs.device_consumption,
|
||||
this._data.statsMetadata
|
||||
);
|
||||
|
||||
const deviceLabel = (statConsumption: string) =>
|
||||
deviceLabels[statConsumption] ||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statConsumption,
|
||||
|
||||
@@ -7,7 +7,6 @@ import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyDeviceLabels,
|
||||
formatPowerShort,
|
||||
getEnergyDataCollection,
|
||||
getPowerFromState,
|
||||
@@ -279,13 +278,6 @@ class HuiPowerSankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
prefs.device_consumption,
|
||||
this._data.statsMetadata,
|
||||
"stat_rate"
|
||||
);
|
||||
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
@@ -302,7 +294,7 @@ class HuiPowerSankeyCard
|
||||
initialUntracked: homeNode.value,
|
||||
getId: (device) => device.stat_rate,
|
||||
getValue: (id) => this._getCurrentPower(id),
|
||||
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
@@ -36,10 +36,6 @@ 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";
|
||||
@@ -155,12 +151,14 @@ 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 featureRows = this._config ? this._featureRows(this._config) : 0;
|
||||
const featuresCount = this._config?.features?.length || 0;
|
||||
return (
|
||||
1 +
|
||||
(displayType === "compact" ? (this._config?.vertical ? 1 : 0) : 2) +
|
||||
featureRows
|
||||
(featuresPosition === "inline" ? 0 : featuresCount)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,12 +170,13 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
? this._featurePosition(this._config)
|
||||
: "bottom";
|
||||
const featuresCount = this._config?.features?.length || 0;
|
||||
if (this._config && featuresCount) {
|
||||
if (featuresCount) {
|
||||
if (featurePosition === "inline") {
|
||||
min_columns = 12;
|
||||
columns = 12;
|
||||
} else {
|
||||
rows += featuresCount;
|
||||
}
|
||||
rows += this._featureRows(this._config);
|
||||
}
|
||||
|
||||
const displayType = this._config?.display_type || "picture";
|
||||
@@ -556,13 +555,15 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
return config.features_position || "bottom";
|
||||
});
|
||||
|
||||
private _featureLayout = memoizeOne((config: AreaCardConfig) =>
|
||||
computeCardFeatureLayout(config.features, this._featurePosition(config))
|
||||
);
|
||||
private _displayedFeatures = memoizeOne((config: AreaCardConfig) => {
|
||||
const features = config.features || [];
|
||||
const featurePosition = this._featurePosition(config);
|
||||
|
||||
private _featureRows = memoizeOne((config: AreaCardConfig) =>
|
||||
computeCardFeatureRows(config.features, this._featurePosition(config))
|
||||
);
|
||||
if (featurePosition === "inline") {
|
||||
return features.slice(0, 1);
|
||||
}
|
||||
return features;
|
||||
});
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (changedProps.has("_config") || this._ratio === null) {
|
||||
@@ -600,7 +601,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
const secondary = this._computeSensorsDisplay();
|
||||
|
||||
const featurePosition = this._featurePosition(this._config);
|
||||
const features = this._featureLayout(this._config);
|
||||
const features = this._displayedFeatures(this._config);
|
||||
|
||||
const displayType = this._config.display_type || "picture";
|
||||
|
||||
@@ -619,11 +620,8 @@ 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 =
|
||||
displayType === "compact" &&
|
||||
this.layout === "grid" &&
|
||||
this._config.grid_options?.rows !== "auto";
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)}>
|
||||
@@ -712,30 +710,15 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
.secondary=${secondary}
|
||||
></ha-tile-info>
|
||||
${
|
||||
features.inline.length > 0
|
||||
? html`
|
||||
<hui-card-features
|
||||
slot="features-inline"
|
||||
.hass=${this.hass}
|
||||
.context=${this._featureContext}
|
||||
.color=${this._config.color}
|
||||
.features=${features.inline}
|
||||
.position=${featurePosition}
|
||||
></hui-card-features>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
features.below.length > 0
|
||||
features.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"}
|
||||
.features=${features}
|
||||
.position=${featurePosition}
|
||||
></hui-card-features>
|
||||
`
|
||||
: nothing
|
||||
|
||||
@@ -22,10 +22,6 @@ 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";
|
||||
@@ -111,8 +107,14 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
const featureRows = this._config ? this._featureRows(this._config) : 0;
|
||||
return 1 + (this._config?.vertical ? 1 : 0) + featureRows;
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
public getGridOptions(): LovelaceGridOptions {
|
||||
@@ -121,11 +123,12 @@ 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 (this._config && featuresCount) {
|
||||
if (featuresCount) {
|
||||
if (featurePosition === "inline") {
|
||||
min_columns = 12;
|
||||
} else {
|
||||
rows += featuresCount;
|
||||
}
|
||||
rows += this._featureRows(this._config);
|
||||
}
|
||||
|
||||
if (this._config?.vertical) {
|
||||
@@ -231,13 +234,15 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
return config.features_position || "bottom";
|
||||
});
|
||||
|
||||
private _featureLayout = memoizeOne((config: TileCardConfig) =>
|
||||
computeCardFeatureLayout(config.features, this._featurePosition(config))
|
||||
);
|
||||
private _displayedFeatures = memoizeOne((config: TileCardConfig) => {
|
||||
const features = config.features || [];
|
||||
const featurePosition = this._featurePosition(config);
|
||||
|
||||
private _featureRows = memoizeOne((config: TileCardConfig) =>
|
||||
computeCardFeatureRows(config.features, this._featurePosition(config))
|
||||
);
|
||||
if (featurePosition === "inline") {
|
||||
return features.slice(0, 1);
|
||||
}
|
||||
return features;
|
||||
});
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this.hass) {
|
||||
@@ -281,7 +286,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
: undefined;
|
||||
|
||||
const featurePosition = this._featurePosition(this._config);
|
||||
const features = this._featureLayout(this._config);
|
||||
const features = this._displayedFeatures(this._config);
|
||||
|
||||
const hasImage = Boolean(imageUrl);
|
||||
|
||||
@@ -336,28 +341,14 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
</ha-tile-info>
|
||||
${
|
||||
features.inline.length > 0
|
||||
? html`
|
||||
<hui-card-features
|
||||
slot="features-inline"
|
||||
.hass=${this.hass}
|
||||
.context=${this._featureContext}
|
||||
.color=${this._config.color}
|
||||
.features=${features.inline}
|
||||
></hui-card-features>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
features.below.length > 0
|
||||
features.length > 0
|
||||
? html`
|
||||
<hui-card-features
|
||||
slot="features"
|
||||
.columns=${features.columns}
|
||||
.hass=${this.hass}
|
||||
.context=${this._featureContext}
|
||||
.color=${this._config.color}
|
||||
.features=${features.below}
|
||||
.features=${features}
|
||||
></hui-card-features>
|
||||
`
|
||||
: nothing
|
||||
|
||||
@@ -6,7 +6,6 @@ import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyDeviceLabels,
|
||||
formatFlowRateShort,
|
||||
getEnergyDataCollection,
|
||||
getFlowRateFromState,
|
||||
@@ -242,13 +241,6 @@ class HuiWaterFlowSankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
prefs.device_consumption_water,
|
||||
this._data.statsMetadata,
|
||||
"stat_rate"
|
||||
);
|
||||
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
@@ -265,7 +257,7 @@ class HuiWaterFlowSankeyCard
|
||||
initialUntracked: effectiveTotalInflow,
|
||||
getId: (device) => device.stat_rate,
|
||||
getValue: (id) => this._getCurrentFlowRate(id),
|
||||
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
@@ -7,7 +7,6 @@ import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyDeviceLabels,
|
||||
getEnergyDataCollection,
|
||||
validateEnergyCollectionKey,
|
||||
} from "../../../../data/energy";
|
||||
@@ -216,14 +215,8 @@ class HuiWaterSankeyCard
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
prefs.device_consumption_water,
|
||||
this._data!.statsMetadata
|
||||
);
|
||||
|
||||
const deviceLabel = (statConsumption: string) =>
|
||||
deviceLabels[statConsumption] ||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statConsumption,
|
||||
|
||||
+16
-16
@@ -790,11 +790,6 @@
|
||||
}
|
||||
},
|
||||
"target-picker": {
|
||||
"expand": "Expand",
|
||||
"expand_floor_id": "Split this floor into separate areas",
|
||||
"expand_area_id": "Split this area into separate devices and entities",
|
||||
"expand_device_id": "Split this device into separate entities",
|
||||
"expand_label_id": "Split this label into separate areas, devices and entities",
|
||||
"add_target": "Add target",
|
||||
"remove": "Remove",
|
||||
"remove_floor_id": "Remove floor",
|
||||
@@ -807,7 +802,6 @@
|
||||
"device_not_found": "Device not found",
|
||||
"entity_not_found": "Entity not found",
|
||||
"label_not_found": "Label not found",
|
||||
"replaced_device": "Replaced device",
|
||||
"device_replaced": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
|
||||
"replace_device": "Replace",
|
||||
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
|
||||
@@ -841,6 +835,9 @@
|
||||
},
|
||||
"style": "Time format style"
|
||||
},
|
||||
"filter-device-classes": {
|
||||
"caption": "Device class"
|
||||
},
|
||||
"subpage-data-table": {
|
||||
"filters": "Filters",
|
||||
"show_results": "Show {number} results",
|
||||
@@ -6814,10 +6811,6 @@
|
||||
"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."
|
||||
@@ -6899,8 +6892,6 @@
|
||||
"manufacturer": "Manufacturer",
|
||||
"model": "Model",
|
||||
"integration": "Integration",
|
||||
"parent_device": "Parent device",
|
||||
"part_of_device": "Part of {name}",
|
||||
"firmware_version": "Firmware",
|
||||
"battery": "Battery",
|
||||
"disabled_by": "Disabled",
|
||||
@@ -7193,7 +7184,6 @@
|
||||
"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}",
|
||||
@@ -10472,7 +10462,7 @@
|
||||
"bottom": "Bottom",
|
||||
"bottom_description": "Displays all features stacked",
|
||||
"inline": "Inline",
|
||||
"inline_description": "Displays features in two columns, starting next to the name"
|
||||
"inline_description": "Displays only the first feature"
|
||||
},
|
||||
"features_position_helper_vertical": "Always displayed at the bottom if the content layout is vertical",
|
||||
"content_layout": "Content layout",
|
||||
@@ -11707,9 +11697,14 @@
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"start_search": "Select areas, devices, entities or labels above",
|
||||
"sources": "Sources",
|
||||
"add_targets": "Add targets",
|
||||
"no_targets": "No targets selected",
|
||||
"start_search_title": "Nothing to show yet",
|
||||
"start_search": "Select areas, devices, entities or labels to see their history",
|
||||
"no_results_title": "No results found",
|
||||
"no_results": "No history for the current selection. Try changing the targets, filters, or time range.",
|
||||
"add_all": "Add all entities",
|
||||
"remove_all": "Remove all selections",
|
||||
"download_data": "Download data",
|
||||
"download_data_error": "Unable to download data",
|
||||
"add_card": "Add current view as card",
|
||||
@@ -11717,6 +11712,11 @@
|
||||
"error_no_data": "You need to select some data sources first."
|
||||
},
|
||||
"logbook": {
|
||||
"sources": "[%key:ui::panel::history::sources%]",
|
||||
"change_sources": "Change sources",
|
||||
"no_targets": "Showing all activity. Add targets to narrow it down.",
|
||||
"no_results_title": "[%key:ui::panel::history::no_results_title%]",
|
||||
"no_results": "No activity for the current selection. Try changing the targets, filters, or time range.",
|
||||
"download_data": "[%key:ui::panel::history::download_data%]",
|
||||
"download_data_error": "[%key:ui::panel::history::download_data_error%]",
|
||||
"error_no_data": "No activity for the selected target in the selected time range."
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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,19 +13,13 @@ 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: {
|
||||
@@ -950,151 +944,3 @@ 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" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+9
-32
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* Deterministic `HomeAssistant` stub covering exactly what the chart data
|
||||
* transforms read: states, registries, locale, config, localize, and entity
|
||||
* state/name formatting. Everything is stable across runs.
|
||||
* transforms read: states, entities, locale, config, localize, and entity
|
||||
* state 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,
|
||||
@@ -44,43 +43,21 @@ export const createMockEntityState = (
|
||||
context: { id: "fixture", parent_id: null, user_id: null },
|
||||
});
|
||||
|
||||
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 {
|
||||
export const createMockHass = (states: HassEntities = {}): HomeAssistant =>
|
||||
({
|
||||
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,
|
||||
formatEntityName: ((stateObj, name, options) =>
|
||||
computeEntityNameDisplay(
|
||||
stateObj,
|
||||
name,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
options
|
||||
)) satisfies HomeAssistant["formatEntityName"],
|
||||
} as unknown as HomeAssistant;
|
||||
};
|
||||
}) as unknown as HomeAssistant;
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user