mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-18 04:27:45 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb479a1ccb | ||
|
|
8ebe2111b0 | ||
|
|
49a7c1aadd | ||
|
|
53be309e34 | ||
|
|
a3aa5d38dc | ||
|
|
ba9e696e96 | ||
|
|
3fc41198c7 |
@@ -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>
|
||||
`
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type {
|
||||
CustomSeriesOption,
|
||||
CustomSeriesRenderItem,
|
||||
@@ -22,6 +23,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 +43,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;
|
||||
|
||||
@@ -60,6 +73,20 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@state() private _yWidth = 0;
|
||||
|
||||
// Inside labels are truncated to the plot, so their width follows the chart.
|
||||
@state() private _width = 0;
|
||||
|
||||
// The host is an inline element, which a resize observer skips, so the chart
|
||||
// itself is the one being observed.
|
||||
@query("ha-chart-base") private _chartBase?: HTMLElement;
|
||||
|
||||
private _resizeController = new ResizeController<void>(this, {
|
||||
target: null,
|
||||
callback: (entries) => {
|
||||
this._width = entries[0]?.contentRect.width ?? 0;
|
||||
},
|
||||
});
|
||||
|
||||
private _chartTime: Date = new Date();
|
||||
|
||||
protected render() {
|
||||
@@ -67,7 +94,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}
|
||||
@@ -163,6 +190,12 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
)}<br />${formattedDuration}`;
|
||||
};
|
||||
|
||||
protected firstUpdated() {
|
||||
if (this._chartBase) {
|
||||
this._resizeController.observe(this._chartBase);
|
||||
}
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
this.isConnected &&
|
||||
@@ -182,8 +215,10 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("insideLabels") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth")
|
||||
changedProps.has("_yWidth") ||
|
||||
changedProps.has("_width")
|
||||
) {
|
||||
this._createOptions();
|
||||
}
|
||||
@@ -193,14 +228,24 @@ 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;
|
||||
// Before the first resize observation the width is unknown, and a zero
|
||||
// width would hide the labels instead of truncating them.
|
||||
const insideLabelWidth = this._width
|
||||
? Math.max(0, this._width - plotPadding - labelMargin)
|
||||
: undefined;
|
||||
this._chartOptions = {
|
||||
xAxis: {
|
||||
type: "time",
|
||||
@@ -224,37 +269,54 @@ 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",
|
||||
width: insideLabelWidth,
|
||||
overflow: "truncate",
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -254,119 +245,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 +361,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 +536,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 +575,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,
|
||||
@@ -1438,13 +1150,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);
|
||||
@@ -1460,7 +1165,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -203,28 +203,9 @@ export const showRepairsFlowDialog = (
|
||||
return "";
|
||||
},
|
||||
|
||||
renderCreateEntryDescription(hass, step) {
|
||||
const description = hass.localize(
|
||||
`component.${issue.domain}.issues.${
|
||||
issue.translation_key || issue.issue_id
|
||||
}.fix_flow.create_entry.${step.description || "default"}`,
|
||||
step.description_placeholders
|
||||
);
|
||||
|
||||
renderCreateEntryDescription(hass, _step) {
|
||||
return html`
|
||||
${
|
||||
description
|
||||
? html`
|
||||
<ha-markdown
|
||||
allow-svg
|
||||
breaks
|
||||
.content=${description}
|
||||
></ha-markdown>
|
||||
`
|
||||
: html`<p>
|
||||
${hass.localize("ui.dialogs.repair_flow.success.description")}
|
||||
</p>`
|
||||
}
|
||||
<p>${hass.localize("ui.dialogs.repair_flow.success.description")}</p>
|
||||
`;
|
||||
},
|
||||
|
||||
|
||||
@@ -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,21 @@ class HaPanelHistory extends LitElement {
|
||||
|
||||
@state() private _isLoading = false;
|
||||
|
||||
@state() private _filters: SourceFilters = {};
|
||||
|
||||
// Restored on the next visit, like the target selection the filters narrow
|
||||
// down.
|
||||
@storage({
|
||||
key: "historySourceFilters",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _storedFilters?: SourceFilters;
|
||||
|
||||
@state() private _showSources = false;
|
||||
|
||||
@state() private _entitySources?: EntitySources;
|
||||
|
||||
@state() private _stateHistory?: HistoryResult;
|
||||
|
||||
private _mungedStateHistory?: HistoryResult;
|
||||
@@ -119,7 +149,22 @@ 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;
|
||||
// Keyed on the entities the selection resolves to, not on the targets
|
||||
// themselves: a target whose entities are all filtered out fetches nothing
|
||||
// and would otherwise load forever.
|
||||
const loading =
|
||||
this._isLoading ||
|
||||
(this._getEntityIds().length > 0 && !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 +173,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 +191,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);
|
||||
|
||||
@@ -226,12 +322,16 @@ class HaPanelHistory extends LitElement {
|
||||
const queryParams = decodeHistoryLogbookQueryParams(
|
||||
extractSearchParamsObject()
|
||||
);
|
||||
const initialValue =
|
||||
historyLogbookTargetFromQueryParams(queryParams) ??
|
||||
this._storedTargetPickerValue;
|
||||
const urlTarget = historyLogbookTargetFromQueryParams(queryParams);
|
||||
const initialValue = urlTarget ?? this._storedTargetPickerValue;
|
||||
if (initialValue) {
|
||||
this._targetPickerValue = initialValue;
|
||||
}
|
||||
// A target in the URL describes the whole selection. Restoring the stored
|
||||
// filters on top of it could narrow it down to nothing.
|
||||
if (!urlTarget && this._storedFilters) {
|
||||
this._filters = this._storedFilters;
|
||||
}
|
||||
if (queryParams.start_date) {
|
||||
this._startDate = queryParams.start_date;
|
||||
}
|
||||
@@ -242,6 +342,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 +360,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 +372,28 @@ 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;
|
||||
this._storedFilters = this._filters;
|
||||
}
|
||||
|
||||
private _clearSources() {
|
||||
this._filters = {};
|
||||
this._storedFilters = this._filters;
|
||||
this._targetPickerValue = {};
|
||||
this._storedTargetPickerValue = this._targetPickerValue;
|
||||
this._updatePath();
|
||||
@@ -312,7 +439,11 @@ class HaPanelHistory extends LitElement {
|
||||
const entityIds = this._getEntityIds();
|
||||
|
||||
if (entityIds.length === 0) {
|
||||
// The running subscription still holds the previous entities, so it would
|
||||
// keep pushing the ones the selection no longer covers.
|
||||
this._unsubscribeHistory();
|
||||
this._stateHistory = undefined;
|
||||
this._isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -379,20 +510,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 +723,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 +739,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 +799,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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VisibilityChangedEvent } from "@lit-labs/virtualizer";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, eventOptions, property } from "lit/decorators";
|
||||
import { customElement, eventOptions, property, state } from "lit/decorators";
|
||||
import { formatDate } from "../../common/datetime/format_date";
|
||||
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
|
||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||
@@ -60,6 +60,10 @@ class HaLogbookRenderer extends LitElement {
|
||||
// @ts-ignore
|
||||
@restoreScroll(".container") private _savedScrollPos?: number;
|
||||
|
||||
// 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) ||
|
||||
@@ -80,9 +84,11 @@ class HaLogbookRenderer extends LitElement {
|
||||
|
||||
return (
|
||||
changedProps.has("entries") ||
|
||||
changedProps.has("traceContexts") ||
|
||||
changedProps.has("noDetail") ||
|
||||
changedProps.has("userIdToName") ||
|
||||
changedProps.has("systemUserIds") ||
|
||||
changedProps.has("_firstVisibleIndex" as never) ||
|
||||
languageChanged
|
||||
);
|
||||
}
|
||||
@@ -96,12 +102,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-entry-selected=${this._handleEntrySelected}
|
||||
>
|
||||
${
|
||||
floatingEntry
|
||||
? html`<h4 class="date floating-date">
|
||||
${this._formatDateHeader(new Date(floatingEntry.when * 1000))}
|
||||
</h4>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.virtualize
|
||||
? html`<lit-virtualizer
|
||||
@@ -200,6 +220,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,
|
||||
});
|
||||
@@ -226,12 +247,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`
|
||||
@@ -243,9 +249,9 @@ export class HaLogbook extends LitElement {
|
||||
if (this._unsubLogbook) {
|
||||
this._unsubLogbook.then((unsub) => unsub());
|
||||
this._unsubLogbook = undefined;
|
||||
this._logbookEntries = loading ? undefined : [];
|
||||
this._pendingStreamMessages = [];
|
||||
}
|
||||
this._logbookEntries = loading ? undefined : [];
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
|
||||
@@ -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";
|
||||
@@ -39,6 +53,8 @@ import "./ha-logbook";
|
||||
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { csvDownload, csvSafeString } from "../../util/csv";
|
||||
|
||||
const EMPTY_STATES: HomeAssistant["states"] = {};
|
||||
|
||||
interface LogbookState {
|
||||
time: { range: [Date, Date] };
|
||||
targetPickerValue: HassServiceTarget;
|
||||
@@ -57,6 +73,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
|
||||
@@ -69,25 +91,31 @@ export class HaPanelLogbook extends LitElement {
|
||||
})
|
||||
private _storedTargetPickerValue?: HassServiceTarget;
|
||||
|
||||
// Restored on the next visit, like the target selection the filters narrow
|
||||
// down.
|
||||
@storage({
|
||||
key: "logbookSourceFilters",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _storedFilters?: SourceFilters;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
this._time = this._defaultState.time;
|
||||
}
|
||||
|
||||
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 +133,128 @@ 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;
|
||||
this._storedFilters = this._filters;
|
||||
}
|
||||
|
||||
private _clearSources() {
|
||||
this._filters = {};
|
||||
this._storedFilters = this._filters;
|
||||
this._targetPickerValue = {};
|
||||
this._storedTargetPickerValue = this._targetPickerValue;
|
||||
this._updatePath();
|
||||
}
|
||||
|
||||
private _filterFunc: HaEntityPickerEntityFilterFunc = (entity) =>
|
||||
filterLogbookCompatibleEntities(entity);
|
||||
|
||||
@@ -155,6 +271,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 +299,32 @@ 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,
|
||||
// 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(
|
||||
@@ -202,6 +337,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()
|
||||
@@ -213,6 +357,12 @@ export class HaPanelLogbook extends LitElement {
|
||||
this._targetPickerValue = this._storedTargetPickerValue;
|
||||
}
|
||||
|
||||
// A target in the URL describes the whole selection. Restoring the stored
|
||||
// filters on top of it could narrow it down to nothing.
|
||||
if (!this.hasUpdated && !targetPickerValue && this._storedFilters) {
|
||||
this._filters = this._storedFilters;
|
||||
}
|
||||
|
||||
if (queryParams.start_date || queryParams.end_date) {
|
||||
const startDate = queryParams.start_date ?? this._time.range[0];
|
||||
const endDate = queryParams.end_date ?? this._time.range[1];
|
||||
@@ -273,9 +423,12 @@ export class HaPanelLogbook extends LitElement {
|
||||
}
|
||||
|
||||
private _isDefaultState(): boolean {
|
||||
return deepEqual(
|
||||
{ time: this._time, targetPickerValue: this._targetPickerValue },
|
||||
this._defaultState
|
||||
return (
|
||||
!countSourceFilters(this._filters) &&
|
||||
deepEqual(
|
||||
{ time: this._time, targetPickerValue: this._targetPickerValue },
|
||||
this._defaultState
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,6 +437,8 @@ export class HaPanelLogbook extends LitElement {
|
||||
this._time = defaultState.time;
|
||||
this._targetPickerValue = defaultState.targetPickerValue;
|
||||
this._storedTargetPickerValue = undefined;
|
||||
this._filters = {};
|
||||
this._storedFilters = undefined;
|
||||
navigate("/logbook", { replace: true });
|
||||
}
|
||||
|
||||
@@ -300,6 +455,9 @@ export class HaPanelLogbook extends LitElement {
|
||||
case "refresh":
|
||||
this._refreshLogbook();
|
||||
break;
|
||||
case "reset":
|
||||
this._resetLogbook();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +521,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 +535,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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -11720,9 +11717,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",
|
||||
@@ -11730,6 +11732,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."
|
||||
|
||||
Reference in New Issue
Block a user