mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-14 17:02:50 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed9333dc2 | ||
|
|
2706bd89a1 | ||
|
|
bd434eeb0d | ||
|
|
16f01e1081 | ||
|
|
050251db23 | ||
|
|
13128609de | ||
|
|
0f70813ada | ||
|
|
5762da9d22 | ||
|
|
33f709cd21 | ||
|
|
1f77e4cd59 |
No files matched your search
@@ -1,19 +0,0 @@
|
||||
import type { ReactiveElement } from "lit";
|
||||
import { ContextSubscriptionController } from "../../../src/common/decorators/consume";
|
||||
import { uiContext } from "../../../src/data/context";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// A demo that provides its own hass hides the gallery theme from its children,
|
||||
// so pass the gallery light or dark mode on to it.
|
||||
export const followGalleryTheme = (
|
||||
host: ReactiveElement,
|
||||
hass: MockHomeAssistant
|
||||
) =>
|
||||
new ContextSubscriptionController(host, uiContext, (ui) => {
|
||||
if (ui) {
|
||||
hass.updateHass({
|
||||
themes: ui.themes,
|
||||
selectedTheme: ui.selectedTheme,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
} from "../../../../src/data/history";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { followGalleryTheme } from "../../data/follow-gallery-theme";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
@@ -167,7 +166,6 @@ export class DemoStateHistoryCharts extends LitElement {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
followGalleryTheme(this, hass);
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { ChartSelectMode } from "../../../../src/components/chart/ha-chart-base";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../../src/components/chart/statistics-chart";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-card";
|
||||
import type {
|
||||
Statistics,
|
||||
@@ -13,9 +11,7 @@ import type {
|
||||
} from "../../../../src/data/recorder";
|
||||
import { StatisticMeanType } from "../../../../src/data/recorder";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { followGalleryTheme } from "../../data/follow-gallery-theme";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
@@ -112,64 +108,6 @@ interface Example {
|
||||
height?: string;
|
||||
}
|
||||
|
||||
interface TimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface RangeExample {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
statistics: Statistics;
|
||||
statTypes: StatisticType[];
|
||||
chartType: "line" | "bar";
|
||||
unit: string;
|
||||
selectMode: ChartSelectMode;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const RANGE_EXAMPLES: RangeExample[] = [
|
||||
{
|
||||
id: "point",
|
||||
title: "Point selection",
|
||||
description:
|
||||
"With select-mode set to point, a click selects the hour that contains it, and everything outside fades. Dragging still zooms.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
selectMode: "point",
|
||||
hint: "Click on a bar to select its hour.",
|
||||
},
|
||||
{
|
||||
id: "range-bar",
|
||||
title: "Range selection on bars",
|
||||
description:
|
||||
"With select-mode set to range, dragging selects a time range instead of zooming, so several bars can be selected at once. A click still selects one bar.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
selectMode: "range",
|
||||
hint: "Click or drag on the chart to select one or more bars.",
|
||||
},
|
||||
{
|
||||
id: "range",
|
||||
title: "Range selection on lines",
|
||||
description: "The same on a line chart.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
|
||||
statTypes: ["mean", "min", "max"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
selectMode: "range",
|
||||
hint: "Click or drag on the chart to select a period.",
|
||||
},
|
||||
];
|
||||
|
||||
const formatRange = (range: TimeRange) =>
|
||||
`${new Date(range.start).toLocaleString()} to ${new Date(range.end).toLocaleString()}`;
|
||||
|
||||
const EXAMPLES: Example[] = [
|
||||
{
|
||||
title: "Sum as bars",
|
||||
@@ -221,20 +159,17 @@ const EXAMPLES: Example[] = [
|
||||
export class DemoStatisticsChart extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() private _selections: Record<string, TimeRange | undefined> = {};
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
followGalleryTheme(this, hass);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return html`${EXAMPLES.map(
|
||||
return EXAMPLES.map(
|
||||
(example) => html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
@@ -256,66 +191,7 @@ export class DemoStatisticsChart extends LitElement {
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
)}
|
||||
${RANGE_EXAMPLES.map((example) => this._renderRangeExample(example))}`;
|
||||
}
|
||||
|
||||
private _renderRangeExample(example: RangeExample) {
|
||||
const selection = this._selections[example.id];
|
||||
return html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
<p>${example.description}</p>
|
||||
<statistics-chart
|
||||
.statisticsData=${example.statistics}
|
||||
.metadata=${METADATA}
|
||||
.names=${NAMES}
|
||||
.statTypes=${example.statTypes}
|
||||
.chartType=${example.chartType}
|
||||
period="hour"
|
||||
.unit=${example.unit}
|
||||
.startTime=${START}
|
||||
.endTime=${END}
|
||||
.highlight=${selection}
|
||||
.clickForMoreInfo=${false}
|
||||
data-example=${example.id}
|
||||
.selectMode=${example.selectMode}
|
||||
hide-legend
|
||||
@chart-select=${this._rangeSelected}
|
||||
></statistics-chart>
|
||||
<div class="selection">
|
||||
${
|
||||
selection
|
||||
? html`
|
||||
<span>${formatRange(selection)}</span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
data-example=${example.id}
|
||||
@click=${this._clearSelection}
|
||||
>
|
||||
Clear
|
||||
</ha-button>
|
||||
`
|
||||
: html`<span class="hint">${example.hint}</span>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _rangeSelected(ev: HASSDomEvent<HASSDomEvents["chart-select"]>) {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.example!;
|
||||
this._selections = {
|
||||
...this._selections,
|
||||
[id]: ev.detail,
|
||||
};
|
||||
}
|
||||
|
||||
private _clearSelection(ev: Event) {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.example!;
|
||||
this._selections = { ...this._selections, [id]: undefined };
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -326,20 +202,6 @@ export class DemoStatisticsChart extends LitElement {
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
.selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-2);
|
||||
min-height: 48px;
|
||||
margin-top: var(--ha-space-2);
|
||||
padding: 0 var(--ha-space-2) 0 var(--ha-space-4);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--secondary-background-color);
|
||||
}
|
||||
.hint {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -79,7 +79,7 @@
|
||||
"@swc/helpers": "0.5.24",
|
||||
"@tsparticles/engine": "4.5.3",
|
||||
"@tsparticles/preset-links": "4.5.3",
|
||||
"@versatiles/style": "9.0.0",
|
||||
"@versatiles/style": "9.1.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.204.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -109,7 +109,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"maplibre-gl": "6.17.0",
|
||||
"marked": "18.1.3",
|
||||
"marked": "18.2.0",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
"node-vibrant": "4.0.4",
|
||||
@@ -153,7 +153,7 @@
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.65.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.3.1",
|
||||
"@rspack/core": "2.3.2",
|
||||
"@rspack/dev-server": "2.2.2",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "7.0.1",
|
||||
@@ -214,8 +214,8 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.72.1",
|
||||
"vite": "8.3.7",
|
||||
"typescript-eslint": "8.72.2",
|
||||
"vite": "8.3.8",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.1.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -224,6 +224,6 @@
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"volta": {
|
||||
"node": "24.22.2"
|
||||
"node": "24.22.3"
|
||||
}
|
||||
}
|
||||
Generated
+235
-248
File diff suppressed because it is too large.
Load diff
@@ -89,10 +89,19 @@ export class LegendFitController {
|
||||
|
||||
private _fit() {
|
||||
const list = this._element;
|
||||
const rowWidth = list?.getBoundingClientRect().width;
|
||||
if (!list || !rowWidth) {
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
const style = getComputedStyle(list);
|
||||
const rowWidth = parseFloat(style.width);
|
||||
// Bounding rects include transforms, such as a dialog scaling in, which
|
||||
// never resize the list, so measure in layout pixels
|
||||
const scale = list.getBoundingClientRect().width / rowWidth;
|
||||
if (!rowWidth || !scale) {
|
||||
return;
|
||||
}
|
||||
const layoutWidth = (element: Element) =>
|
||||
element.getBoundingClientRect().width / scale;
|
||||
const children = Array.from(list.children);
|
||||
const more = children.find((child) =>
|
||||
child.classList.contains("legend-more")
|
||||
@@ -107,9 +116,7 @@ export class LegendFitController {
|
||||
}
|
||||
this._more = more;
|
||||
}
|
||||
const widths = children
|
||||
.filter((child) => child !== more)
|
||||
.map((child) => child.getBoundingClientRect().width);
|
||||
const widths = children.filter((child) => child !== more).map(layoutWidth);
|
||||
// Values resize the items on every state update. Fitting each item at its
|
||||
// widest since the last resize keeps the more button from flickering.
|
||||
if (rowWidth !== this._rowWidth || widths.length !== this._widths.length) {
|
||||
@@ -122,9 +129,9 @@ export class LegendFitController {
|
||||
}
|
||||
const fit = fitInRows(
|
||||
this._widths,
|
||||
more?.getBoundingClientRect().width ?? 0,
|
||||
more ? layoutWidth(more) : 0,
|
||||
rowWidth,
|
||||
parseFloat(getComputedStyle(list).columnGap) || 0,
|
||||
parseFloat(style.columnGap) || 0,
|
||||
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
|
||||
);
|
||||
if (fit !== this.fit) {
|
||||
|
||||
@@ -19,6 +19,7 @@ interface MinMaxFrame {
|
||||
maxPoint: Point;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
lastX: number;
|
||||
gapPoint: Point | undefined;
|
||||
}
|
||||
|
||||
@@ -68,6 +69,7 @@ function newFrame(
|
||||
maxPoint: point,
|
||||
maxX: x,
|
||||
maxY: y,
|
||||
lastX: x,
|
||||
gapPoint,
|
||||
};
|
||||
}
|
||||
@@ -174,6 +176,7 @@ export function downSampleLineData<
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, newFrame(point, x, y, undefined));
|
||||
} else if (frame.hasValue) {
|
||||
frame.lastX = x;
|
||||
// Match the original strict-less / strict-greater comparisons so the
|
||||
// first occurrence wins on ties.
|
||||
if (y < frame.minY) {
|
||||
@@ -189,6 +192,7 @@ export function downSampleLineData<
|
||||
} else {
|
||||
// the frame held nothing but a marker so far
|
||||
frame.hasValue = true;
|
||||
frame.lastX = x;
|
||||
frame.minPoint = point;
|
||||
frame.minX = x;
|
||||
frame.minY = y;
|
||||
@@ -213,11 +217,8 @@ export function downSampleLineData<
|
||||
if (frame.gapPoint !== undefined) {
|
||||
// A marker followed by a value in its own frame is a gap that closed
|
||||
// within one frame, which is about one device pixel: too narrow to show.
|
||||
// The kept points are exactly min and max, so comparing against the
|
||||
// later of the two catches that without any work on the ingest path. A
|
||||
// marker-only frame compares against its own x and always passes.
|
||||
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
|
||||
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
|
||||
// That value may be neither min nor max, so check the frame's last x.
|
||||
if (Number(getPointData(frame.gapPoint)[0]) >= frame.lastX) {
|
||||
result.push(frame.gapPoint as T);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
mdiRestart,
|
||||
} from "@mdi/js";
|
||||
import { differenceInMinutes } from "date-fns";
|
||||
import type {
|
||||
DataZoomComponentOption,
|
||||
MarkAreaComponentOption,
|
||||
} from "echarts/components";
|
||||
import type { DataZoomComponentOption } from "echarts/components";
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type {
|
||||
ECElementEvent,
|
||||
@@ -93,29 +90,14 @@ export const DEFAULT_CHART_WIDTH = 500;
|
||||
// is short enough for a whole screenful; on a desktop that would cover the page.
|
||||
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
|
||||
const VISIBILITY_ROOT_MARGIN = "300px";
|
||||
const CLICK_DRAG_TOLERANCE = 4;
|
||||
// The selection is shown by the mask, not by the brush
|
||||
const BRUSH_STYLE = {
|
||||
color: "rgba(0, 0, 0, 0)",
|
||||
borderColor: "rgba(0, 0, 0, 0)",
|
||||
};
|
||||
|
||||
export type ZoomSliderMode = "auto" | "always" | "never";
|
||||
|
||||
export type ChartSelectMode = "none" | "point" | "range";
|
||||
const DEFERRED_PROPS = [
|
||||
"options",
|
||||
"data",
|
||||
"_hiddenDatasets",
|
||||
"_isZoomed",
|
||||
"highlight",
|
||||
] as const;
|
||||
const MASK_OPACITY = 0.7;
|
||||
|
||||
export interface ChartTimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
type RawSeriesOption = Exclude<
|
||||
NonNullable<ECOption["series"]>,
|
||||
@@ -194,18 +176,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
@property({ attribute: "grow-for-zoom-slider", type: Boolean })
|
||||
public growForZoomSlider = false;
|
||||
|
||||
// "point" selects on click, "range" also selects on drag instead of zooming
|
||||
@property({ attribute: "select-mode" })
|
||||
public selectMode: ChartSelectMode = "none";
|
||||
|
||||
// Everything outside this time range fades
|
||||
@property({ attribute: false }) public highlight?: ChartTimeRange;
|
||||
|
||||
// Rounds a selection to the periods of the data, or drops it
|
||||
@property({ attribute: false }) public snapSelection?: (
|
||||
range: ChartTimeRange
|
||||
) => ChartTimeRange | undefined;
|
||||
|
||||
// extraComponents is not reactive and should not trigger updates
|
||||
public extraComponents?: any[];
|
||||
|
||||
@@ -231,14 +201,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
@state() private _isZoomed = false;
|
||||
|
||||
private _pointerDownX?: number;
|
||||
|
||||
private _selectingRange?: ChartTimeRange;
|
||||
|
||||
private _maskColor = "";
|
||||
|
||||
private _maskShown = false;
|
||||
|
||||
// Separate from _isZoomed, whose touch handling reruns graph layouts
|
||||
@state() private _isGraphRoamed = false;
|
||||
|
||||
@@ -492,9 +454,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
if (changedProps.has("selectMode")) {
|
||||
this._updateSelectMode();
|
||||
}
|
||||
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
|
||||
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
|
||||
return;
|
||||
@@ -540,8 +499,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
) {
|
||||
this._disposeSonification();
|
||||
}
|
||||
} else if (changedProps.has("highlight")) {
|
||||
this._updateMask();
|
||||
}
|
||||
if (changedProps.has("options")) {
|
||||
chartOptions = { ...chartOptions, ...this._createOptions() };
|
||||
@@ -931,9 +888,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
const style = getComputedStyle(this);
|
||||
echarts.registerTheme("custom", this._createTheme(style));
|
||||
this._maskColor =
|
||||
style.getPropertyValue("--card-background-color").trim() ||
|
||||
style.getPropertyValue("--primary-background-color").trim();
|
||||
|
||||
this.chart = echarts.init(this._chartContainer!, "custom");
|
||||
this._dragToZoom = false;
|
||||
@@ -1016,9 +970,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
this._legendItemCount = this._getLegendItems()?.length;
|
||||
this._updateSankeyRoam();
|
||||
this._updateDragToZoom();
|
||||
if (this.selectMode !== "none") {
|
||||
this._updateSelectMode();
|
||||
}
|
||||
if (zoomStart !== 0 || zoomEnd !== 100) {
|
||||
this.zoom(zoomStart, zoomEnd, true);
|
||||
}
|
||||
@@ -1070,10 +1021,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
// A chart can switch type (e.g. pie and bar) without being rebuilt
|
||||
private _updateDragToZoom() {
|
||||
const active =
|
||||
!this._touchInput &&
|
||||
this._hasZoomableXAxis() &&
|
||||
this.selectMode !== "range";
|
||||
const active = !this._touchInput && this._hasZoomableXAxis();
|
||||
if (!this.chart || active === this._dragToZoom) {
|
||||
return;
|
||||
}
|
||||
@@ -1085,127 +1033,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _snapRange(range: ChartTimeRange) {
|
||||
return this.snapSelection ? this.snapSelection(range) : range;
|
||||
}
|
||||
|
||||
private _getMaskArea(): MarkAreaComponentOption | undefined {
|
||||
const range = this._selectingRange ?? this.highlight;
|
||||
if (!range) {
|
||||
return undefined;
|
||||
}
|
||||
this._maskShown = true;
|
||||
return {
|
||||
silent: true,
|
||||
z: 10,
|
||||
itemStyle: {
|
||||
color: this._maskColor,
|
||||
opacity: MASK_OPACITY,
|
||||
},
|
||||
data: [
|
||||
[{ xAxis: -Infinity }, { xAxis: range.start }],
|
||||
[{ xAxis: range.end }, { xAxis: Infinity }],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Only the first series carries the mask, merged in without resending data
|
||||
private _updateMask() {
|
||||
this.chart?.setOption({
|
||||
series: [{ markArea: this._getMaskArea() ?? { data: [] } }],
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSelectPointerDown = (e: ECElementEvent) => {
|
||||
this._pointerDownX = e.offsetX;
|
||||
};
|
||||
|
||||
private _handleSelectClick = (e: ECElementEvent) => {
|
||||
const point = [e.offsetX, e.offsetY];
|
||||
// A drag that ends on the plot is not a click
|
||||
const dragged =
|
||||
this._pointerDownX !== undefined &&
|
||||
Math.abs(e.offsetX - this._pointerDownX) > CLICK_DRAG_TOLERANCE;
|
||||
if (dragged || !this.chart?.containPixel("grid", point)) {
|
||||
return;
|
||||
}
|
||||
const [x] = this.chart.convertFromPixel("grid", point) as number[];
|
||||
const selection = this._snapRange({ start: x, end: x });
|
||||
if (selection) {
|
||||
fireEvent(this, "chart-select", selection);
|
||||
}
|
||||
};
|
||||
|
||||
private _handleBrush = (e: any) => {
|
||||
const range = e.areas?.[0]?.coordRange as number[] | undefined;
|
||||
if (range) {
|
||||
this._selectingRange = this._snapRange({
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
});
|
||||
this._updateMask();
|
||||
}
|
||||
};
|
||||
|
||||
private _handleBrushEnd = (e: any) => {
|
||||
const range = e.areas?.[0]?.coordRange as number[] | undefined;
|
||||
this.chart?.dispatchAction({ type: "brush", areas: [] });
|
||||
this._selectingRange = undefined;
|
||||
this._updateMask();
|
||||
const selection =
|
||||
range && this._snapRange({ start: range[0], end: range[1] });
|
||||
if (selection) {
|
||||
fireEvent(this, "chart-select", selection);
|
||||
}
|
||||
};
|
||||
|
||||
private _updateSelectMode() {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
const zr = this.chart.getZr();
|
||||
zr.off("mousedown", this._handleSelectPointerDown);
|
||||
zr.off("click", this._handleSelectClick);
|
||||
this.chart.off("brush", this._handleBrush);
|
||||
this.chart.off("brushEnd", this._handleBrushEnd);
|
||||
if (this.selectMode !== "none") {
|
||||
zr.on("mousedown", this._handleSelectPointerDown);
|
||||
zr.on("click", this._handleSelectClick);
|
||||
}
|
||||
const selectRange = this.selectMode === "range";
|
||||
if (selectRange) {
|
||||
this.chart.on("brush", this._handleBrush);
|
||||
this.chart.on("brushEnd", this._handleBrushEnd);
|
||||
this.chart.setOption({
|
||||
brush: {
|
||||
xAxisIndex: 0,
|
||||
// Only measures the range, the mask fades what is outside
|
||||
seriesIndex: [],
|
||||
transformable: false,
|
||||
brushStyle: BRUSH_STYLE,
|
||||
},
|
||||
});
|
||||
}
|
||||
const brushCursor = {
|
||||
type: "takeGlobalCursor",
|
||||
key: "brush",
|
||||
brushOption: {
|
||||
brushType: selectRange ? "lineX" : false,
|
||||
brushMode: "single",
|
||||
brushStyle: BRUSH_STYLE,
|
||||
},
|
||||
};
|
||||
// Brush and drag to zoom share the global cursor, the last one taken wins
|
||||
if (selectRange) {
|
||||
this._updateDragToZoom();
|
||||
this.chart.dispatchAction(brushCursor);
|
||||
} else {
|
||||
this.chart.dispatchAction(brushCursor);
|
||||
this._dragToZoom = false;
|
||||
this._updateDragToZoom();
|
||||
}
|
||||
}
|
||||
|
||||
private _hasZoomableXAxis() {
|
||||
const xAxis = ensureArray(this.options?.xAxis)?.[0] as
|
||||
XAXisOption | undefined;
|
||||
@@ -1648,8 +1475,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
private _getSeries() {
|
||||
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
|
||||
XAXisOption | undefined;
|
||||
const maskArea = this._getMaskArea();
|
||||
const series = ensureArray(this.data).map((s, index) => {
|
||||
const series = ensureArray(this.data).map((s) => {
|
||||
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
|
||||
? undefined
|
||||
: s.data;
|
||||
@@ -1687,16 +1513,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
} as HaECSeriesItem;
|
||||
}
|
||||
}
|
||||
// ECharts merges series by id and would keep a mask left out here
|
||||
if (this._maskShown && (s.type === "line" || s.type === "bar")) {
|
||||
result = {
|
||||
...result,
|
||||
markArea:
|
||||
index === 0 && maskArea
|
||||
? maskArea
|
||||
: ((s as LineSeriesOption).markArea ?? { data: [] }),
|
||||
} as HaECSeriesItem;
|
||||
}
|
||||
return processSeriesTooltipFormatter(result);
|
||||
});
|
||||
return series as ECOption["series"];
|
||||
@@ -1946,7 +1762,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
// it, so a quick drag out would lose the release and its selection
|
||||
if (
|
||||
ev.type === "pointerdown" &&
|
||||
(this._dragToZoom || this.selectMode === "range") &&
|
||||
this._dragToZoom &&
|
||||
this._isZoomPress(ev)
|
||||
) {
|
||||
this.chart
|
||||
@@ -2521,7 +2337,6 @@ declare global {
|
||||
"dataset-hidden": { id: string };
|
||||
"dataset-unhidden": { id: string };
|
||||
"chart-click": ECElementEvent;
|
||||
"chart-select": ChartTimeRange;
|
||||
"legend-label-click": { id: string };
|
||||
"chart-zoom": {
|
||||
start: number;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
@@ -49,13 +49,7 @@ import type {
|
||||
HomeAssistantUI,
|
||||
} from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
import type {
|
||||
ChartSelectMode,
|
||||
ChartTimeRange,
|
||||
CustomLegendOption,
|
||||
HaChartBase,
|
||||
ZoomSliderMode,
|
||||
} from "./ha-chart-base";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
@@ -134,16 +128,6 @@ export class StatisticsChart extends LitElement {
|
||||
@property({ attribute: "expand-legend", type: Boolean })
|
||||
public expandLegend?: boolean;
|
||||
|
||||
@property({ attribute: "select-mode" })
|
||||
public selectMode: ChartSelectMode = "none";
|
||||
|
||||
@property({ attribute: "zoom-slider" })
|
||||
public zoomSlider: ZoomSliderMode = "auto";
|
||||
|
||||
@property({ attribute: false }) public highlight?: ChartTimeRange;
|
||||
|
||||
@query("ha-chart-base") private _chartBase?: HaChartBase;
|
||||
|
||||
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||
|
||||
@state() private _legendData: CustomLegendOption["data"];
|
||||
@@ -260,12 +244,8 @@ export class StatisticsChart extends LitElement {
|
||||
return html`
|
||||
<ha-chart-base
|
||||
.data=${this._chartData}
|
||||
.highlight=${this.highlight}
|
||||
.snapSelection=${this._snapSelection}
|
||||
.options=${this._chartOptions}
|
||||
.height=${this.height}
|
||||
.selectMode=${this.selectMode}
|
||||
.zoomSlider=${this.zoomSlider}
|
||||
style=${styleMap({ height: this.height })}
|
||||
@dataset-hidden=${this._datasetHidden}
|
||||
@dataset-unhidden=${this._datasetUnhidden}
|
||||
@@ -279,34 +259,6 @@ export class StatisticsChart extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
public resetZoom() {
|
||||
this._chartBase?.zoom(0, 100);
|
||||
}
|
||||
|
||||
// A click selects the period that contains it, a drag every period it
|
||||
// touches
|
||||
private _snapSelection = (
|
||||
range: ChartTimeRange
|
||||
): ChartTimeRange | undefined => {
|
||||
let start: number | undefined;
|
||||
let end: number | undefined;
|
||||
Object.values(this.statisticsData ?? {}).forEach((values) =>
|
||||
values.forEach((value) => {
|
||||
const touched =
|
||||
range.start === range.end
|
||||
? value.start <= range.start && range.start < value.end
|
||||
: value.start < range.end && value.end > range.start;
|
||||
if (touched) {
|
||||
start = Math.min(start ?? value.start, value.start);
|
||||
end = Math.max(end ?? value.end, value.end);
|
||||
}
|
||||
})
|
||||
);
|
||||
return start === undefined || end === undefined
|
||||
? undefined
|
||||
: { start, end };
|
||||
};
|
||||
|
||||
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
|
||||
this._hiddenStats.add(ev.detail.id);
|
||||
this.requestUpdate("_hiddenStats");
|
||||
|
||||
@@ -112,7 +112,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
initialValue: {
|
||||
options: [],
|
||||
showIndices: false,
|
||||
select: () => undefined,
|
||||
select: () => true,
|
||||
fixDuplicateIds: async () => undefined,
|
||||
updateTriggerId: () => false,
|
||||
},
|
||||
|
||||
@@ -60,3 +60,18 @@ export const validateHassioSession = async (
|
||||
): Promise<void> => {
|
||||
await hass.callWS({ type: "supervisor/ingress/validate_session", session });
|
||||
};
|
||||
|
||||
// Extends the session, or replaces it with a new one for the app if it has
|
||||
// expired. Returns the session to keep using.
|
||||
export const refreshHassioSession = async (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
slug: string,
|
||||
session: string
|
||||
): Promise<string> => {
|
||||
try {
|
||||
await validateHassioSession(hass, session);
|
||||
return session;
|
||||
} catch (_err: any) {
|
||||
return createHassioSession(hass, slug);
|
||||
}
|
||||
};
|
||||
@@ -16,7 +16,7 @@ import type { IngressAppInfo } from "../../data/hassio/ingress";
|
||||
import {
|
||||
createHassioSession,
|
||||
fetchIngressAppInfo,
|
||||
validateHassioSession,
|
||||
refreshHassioSession,
|
||||
} from "../../data/hassio/ingress";
|
||||
import {
|
||||
showAlertDialog,
|
||||
@@ -60,6 +60,10 @@ class HaPanelApp extends LitElement {
|
||||
|
||||
private _sessionKeepAlive?: number;
|
||||
|
||||
private _session?: { slug: string; session: string };
|
||||
|
||||
private _sessionRefresh?: Promise<void>;
|
||||
|
||||
private _fetchDataTimeout?: number;
|
||||
|
||||
private _autoRetryUntil?: number;
|
||||
@@ -92,17 +96,22 @@ class HaPanelApp extends LitElement {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("message", this._handleIframeMessage);
|
||||
window.addEventListener("resize", this._handleResize);
|
||||
document.addEventListener("visibilitychange", this._handleVisibility);
|
||||
document.addEventListener("resume", this._handleVisibility);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("message", this._handleIframeMessage);
|
||||
window.removeEventListener("resize", this._handleResize);
|
||||
document.removeEventListener("visibilitychange", this._handleVisibility);
|
||||
document.removeEventListener("resume", this._handleVisibility);
|
||||
|
||||
if (this._sessionKeepAlive) {
|
||||
clearInterval(this._sessionKeepAlive);
|
||||
this._sessionKeepAlive = undefined;
|
||||
}
|
||||
this._session = undefined;
|
||||
if (this._fetchDataTimeout) {
|
||||
clearTimeout(this._fetchDataTimeout);
|
||||
this._fetchDataTimeout = undefined;
|
||||
@@ -342,17 +351,51 @@ class HaPanelApp extends LitElement {
|
||||
if (this._sessionKeepAlive) {
|
||||
clearInterval(this._sessionKeepAlive);
|
||||
}
|
||||
this._sessionKeepAlive = window.setInterval(async () => {
|
||||
try {
|
||||
await validateHassioSession(this.hass, session);
|
||||
} catch (_err: any) {
|
||||
session = await createHassioSession(this.hass, addonSlug);
|
||||
}
|
||||
}, 60000);
|
||||
this._session = { slug: addonSlug, session };
|
||||
this._sessionKeepAlive = window.setInterval(this._refreshSession, 60000);
|
||||
|
||||
this._addon = addon;
|
||||
}
|
||||
|
||||
// The keep-alive timer does not run while the device sleeps, so the session
|
||||
// may have expired by the time the page is visible again. Refresh it right
|
||||
// away instead of leaving the app unreachable until the next tick.
|
||||
// "resume" only fires in Chromium after a frozen page, other browsers rely
|
||||
// on "visibilitychange".
|
||||
private _handleVisibility = () => {
|
||||
if (!document.hidden) {
|
||||
this._refreshSession();
|
||||
}
|
||||
};
|
||||
|
||||
private _refreshSession = (): Promise<void> => {
|
||||
const current = this._session;
|
||||
if (!current) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this._sessionRefresh) {
|
||||
return this._sessionRefresh;
|
||||
}
|
||||
this._sessionRefresh = refreshHassioSession(
|
||||
this.hass,
|
||||
current.slug,
|
||||
current.session
|
||||
)
|
||||
.then((session) => {
|
||||
// the panel may have switched to another app meanwhile
|
||||
if (this._session === current) {
|
||||
this._session = { slug: current.slug, session };
|
||||
}
|
||||
})
|
||||
.catch((_err: any) => {
|
||||
// Connection to Home Assistant is gone, the next refresh will retry
|
||||
})
|
||||
.finally(() => {
|
||||
this._sessionRefresh = undefined;
|
||||
});
|
||||
return this._sessionRefresh;
|
||||
};
|
||||
|
||||
private async _checkLoaded(ev: Event): Promise<void> {
|
||||
const iframe = ev.target as HTMLIFrameElement;
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import { consume } from "../../../../../common/decorators/consume";
|
||||
import { ensureArray } from "../../../../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
|
||||
import "../../../../../components/animation/ha-fade-in";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import "../../../../../components/ha-trigger-icon";
|
||||
import "../../../../../components/item/ha-list-item-option";
|
||||
@@ -17,6 +19,7 @@ import type { TriggerCondition } from "../../../../../data/automation";
|
||||
import {
|
||||
automationTriggerContext,
|
||||
hasCustomTriggerId,
|
||||
triggerOptionsResolved,
|
||||
type AutomationTriggerContext,
|
||||
type TriggerIdOption,
|
||||
} from "../../trigger/automation-trigger-id";
|
||||
@@ -53,16 +56,33 @@ export class HaTriggerCondition extends LitElement {
|
||||
const selectedIds = (ensureArray(this.condition.id) ?? []).filter(Boolean);
|
||||
const triggerIdOptions = this._triggers?.options ?? [];
|
||||
const missingIds = this._missingIds(this.condition.id, triggerIdOptions);
|
||||
const status = this._triggers?.blueprintStatus;
|
||||
const resolved = triggerOptionsResolved(this._triggers);
|
||||
|
||||
if (!triggerIdOptions.length && !selectedIds.length) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
|
||||
);
|
||||
switch (status) {
|
||||
case "loading":
|
||||
return html`<ha-fade-in .delay=${500}>
|
||||
<ha-spinner size="small"></ha-spinner>
|
||||
</ha-fade-in>`;
|
||||
case "failed":
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.blueprint_triggers_unavailable"
|
||||
);
|
||||
case "loaded":
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.blueprint_no_trigger_ids"
|
||||
);
|
||||
case undefined:
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
${
|
||||
missingIds.length
|
||||
resolved && missingIds.length
|
||||
? html`<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_triggers"
|
||||
@@ -106,7 +126,13 @@ export class HaTriggerCondition extends LitElement {
|
||||
appearance="checkbox"
|
||||
>
|
||||
<span slot="start" class="trigger-row-leading">
|
||||
<span class="trigger-index-badge">${option.index + 1}</span>
|
||||
${
|
||||
this._triggers?.showIndices
|
||||
? html`<span class="trigger-index-badge"
|
||||
>${option.index + 1}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
<ha-trigger-icon
|
||||
.trigger=${
|
||||
"trigger" in option.trigger ? option.trigger.trigger : ""
|
||||
@@ -119,7 +145,9 @@ export class HaTriggerCondition extends LitElement {
|
||||
)}
|
||||
</span>
|
||||
${
|
||||
option.duplicate || hasCustomTriggerId(option)
|
||||
option.duplicate ||
|
||||
hasCustomTriggerId(option) ||
|
||||
!this._triggers?.showIndices
|
||||
? html`<span
|
||||
slot="supporting-text"
|
||||
class="trigger-id ${option.duplicate ? "duplicate" : ""}"
|
||||
@@ -152,14 +180,22 @@ export class HaTriggerCondition extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
appearance="checkbox"
|
||||
>
|
||||
<span slot="start" class="missing-trigger-badge">
|
||||
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
|
||||
<span class="missing-trigger-id">${id}</span>
|
||||
</span>
|
||||
${
|
||||
resolved
|
||||
? html`<span slot="start" class="missing-trigger-badge">
|
||||
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
|
||||
<span class="missing-trigger-id">${id}</span>
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
|
||||
)}
|
||||
${
|
||||
resolved
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
|
||||
)
|
||||
: id
|
||||
}
|
||||
</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
@@ -197,8 +233,7 @@ export class HaTriggerCondition extends LitElement {
|
||||
? ids
|
||||
: [...ids, id]
|
||||
: ids.filter((_id) => _id !== id);
|
||||
if (this._triggers) {
|
||||
this._triggers.select(this.condition, selectedIds);
|
||||
if (this._triggers?.select(this.condition, selectedIds)) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
|
||||
@@ -146,6 +146,15 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
this._manualEditor?.resetPastedConfig();
|
||||
this._updateConfig(config);
|
||||
},
|
||||
fetchBlueprintTriggers: async ({ path, input }) => {
|
||||
const result = await substituteBlueprint(
|
||||
this.hass,
|
||||
"automation",
|
||||
path,
|
||||
input || {}
|
||||
);
|
||||
return normalizeAutomationConfig(result.substituted_config).triggers;
|
||||
},
|
||||
});
|
||||
|
||||
private _newAutomationId?: string;
|
||||
|
||||
@@ -3,11 +3,14 @@ import type { LitElement, ReactiveController } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type {
|
||||
AutomationConfig,
|
||||
BlueprintAutomationConfig,
|
||||
SidebarConfig,
|
||||
Trigger,
|
||||
TriggerCondition,
|
||||
} from "../../../../data/automation";
|
||||
import { ContextSubscriptionController } from "../../../../common/decorators/consume";
|
||||
import { debounce } from "../../../../common/util/debounce";
|
||||
import { deepEqual } from "../../../../common/util/deep-equal";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
@@ -16,12 +19,15 @@ import {
|
||||
automationTriggerContext,
|
||||
cleanupRemovedGeneratedTriggerReferences,
|
||||
cleanupUnusedGeneratedTriggerIds,
|
||||
getBlueprintTriggerIdOptions,
|
||||
getTriggerIdOptions,
|
||||
getExplicitTriggerIds,
|
||||
isGeneratedTriggerId,
|
||||
makeDuplicateTriggerIdsUnique,
|
||||
replaceLeafTrigger,
|
||||
updateTriggerCondition,
|
||||
type TriggerIdOption,
|
||||
type BlueprintTriggersStatus,
|
||||
} from "./automation-trigger-id";
|
||||
import { preserveAutomationRowKeys } from "../ha-automation-sortable-list-mixin";
|
||||
|
||||
@@ -31,6 +37,10 @@ interface AutomationTriggerControllerOptions {
|
||||
canEdit: () => boolean;
|
||||
/** Apply one complete change through the editor's undo and dirty-state handling. */
|
||||
commit: (config: AutomationConfig) => void;
|
||||
/** Resolve a blueprint automation's triggers, with its inputs substituted. */
|
||||
fetchBlueprintTriggers: (
|
||||
useBlueprint: BlueprintAutomationConfig["use_blueprint"]
|
||||
) => Promise<Trigger | Trigger[] | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +53,9 @@ interface AutomationTriggerControllerOptions {
|
||||
*
|
||||
* The transformation helpers only change configuration. This controller preserves
|
||||
* the corresponding row keys so an open sidebar continues to track the edited row.
|
||||
*
|
||||
* A blueprint automation has no triggers of its own: options come from the
|
||||
* blueprint's substituted triggers, and conditions in its inputs change themselves.
|
||||
*/
|
||||
export class AutomationTriggerController implements ReactiveController {
|
||||
// Only read in event handlers, so it does not need to rerender the host.
|
||||
@@ -54,6 +67,18 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
// changes, so rendering and selection use the same IDs across host updates.
|
||||
private _getTriggerIdOptions = memoizeOne(getTriggerIdOptions);
|
||||
|
||||
private _getBlueprintTriggerIdOptions = memoizeOne(
|
||||
getBlueprintTriggerIdOptions
|
||||
);
|
||||
|
||||
/** The blueprint use whose triggers were last requested. */
|
||||
private _blueprintRequested?: BlueprintAutomationConfig["use_blueprint"];
|
||||
|
||||
private _blueprintTriggers?: Trigger | Trigger[];
|
||||
|
||||
/** "failed" while required inputs are empty. */
|
||||
private _blueprintStatus: BlueprintTriggersStatus = "loading";
|
||||
|
||||
constructor(
|
||||
private _host: LitElement,
|
||||
private _options: AutomationTriggerControllerOptions
|
||||
@@ -78,15 +103,39 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
_host.addController(this);
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
this._fetchBlueprintTriggers.cancel();
|
||||
this._blueprintRequested = undefined;
|
||||
}
|
||||
|
||||
/** Publish options before descendants render, including after undo or redo. */
|
||||
public hostUpdate() {
|
||||
const config = this._options.getConfig();
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
const options = this._getTriggerIdOptions(config.triggers);
|
||||
if (options !== this._provider.value.options) {
|
||||
this._provider.setValue({ ...this._provider.value, options });
|
||||
let options: TriggerIdOption[];
|
||||
let blueprintStatus: BlueprintTriggersStatus | undefined;
|
||||
let showIndices = this._provider.value.showIndices;
|
||||
if ("use_blueprint" in config && config.use_blueprint) {
|
||||
this._requestBlueprintTriggers(config.use_blueprint);
|
||||
options = this._getBlueprintTriggerIdOptions(this._blueprintTriggers);
|
||||
blueprintStatus = this._blueprintStatus;
|
||||
showIndices = false;
|
||||
} else {
|
||||
options = this._getTriggerIdOptions(config.triggers);
|
||||
}
|
||||
if (
|
||||
options !== this._provider.value.options ||
|
||||
blueprintStatus !== this._provider.value.blueprintStatus ||
|
||||
showIndices !== this._provider.value.showIndices
|
||||
) {
|
||||
this._provider.setValue({
|
||||
...this._provider.value,
|
||||
options,
|
||||
blueprintStatus,
|
||||
showIndices,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,11 +187,55 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private _selectTriggerIds = (condition: TriggerCondition, ids: string[]) => {
|
||||
const config = this._options.getConfig();
|
||||
if (!config || !this._options.canEdit()) {
|
||||
/** Request the blueprint's triggers again whenever its path or inputs change. */
|
||||
private _requestBlueprintTriggers(
|
||||
useBlueprint: BlueprintAutomationConfig["use_blueprint"]
|
||||
) {
|
||||
if (deepEqual(useBlueprint, this._blueprintRequested)) {
|
||||
return;
|
||||
}
|
||||
if (useBlueprint.path !== this._blueprintRequested?.path) {
|
||||
this._blueprintTriggers = undefined;
|
||||
}
|
||||
this._blueprintStatus = "loading";
|
||||
this._blueprintRequested = useBlueprint;
|
||||
this._fetchBlueprintTriggers(useBlueprint);
|
||||
}
|
||||
|
||||
private _fetchBlueprintTriggers = debounce(
|
||||
async (useBlueprint: BlueprintAutomationConfig["use_blueprint"]) => {
|
||||
let triggers: Trigger | Trigger[] | undefined;
|
||||
try {
|
||||
triggers = await this._options.fetchBlueprintTriggers(useBlueprint);
|
||||
} catch (_err) {
|
||||
if (useBlueprint === this._blueprintRequested) {
|
||||
this._blueprintStatus = "failed";
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (useBlueprint !== this._blueprintRequested) {
|
||||
return;
|
||||
}
|
||||
this._blueprintTriggers = triggers;
|
||||
this._blueprintStatus = "loaded";
|
||||
this._host.requestUpdate();
|
||||
},
|
||||
500,
|
||||
true
|
||||
);
|
||||
|
||||
private _selectTriggerIds = (
|
||||
condition: TriggerCondition,
|
||||
ids: string[]
|
||||
): boolean => {
|
||||
const config = this._options.getConfig();
|
||||
if (config && "use_blueprint" in config) {
|
||||
return false;
|
||||
}
|
||||
if (!config || !this._options.canEdit()) {
|
||||
return true;
|
||||
}
|
||||
const triggers = assignGeneratedTriggerIds(
|
||||
config.triggers,
|
||||
this._getTriggerIdOptions(config.triggers),
|
||||
@@ -157,6 +250,7 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
// Generated IDs that no condition or action references are no longer in use.
|
||||
// Strip them so the YAML does not accumulate stale identifiers.
|
||||
this._commit(cleanupUnusedGeneratedTriggerIds(updated));
|
||||
return true;
|
||||
};
|
||||
|
||||
private _fixDuplicateTriggerIds = async () => {
|
||||
|
||||
@@ -38,13 +38,28 @@ export interface TriggerIdOption {
|
||||
duplicate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a blueprint's substituted triggers are known. While "loading" or
|
||||
* "failed", options keep the last triggers fetched, if any.
|
||||
*/
|
||||
export type BlueprintTriggersStatus = "loaded" | "loading" | "failed";
|
||||
|
||||
/** Controller-provided options and operations for editors, sidebars, and row summaries. */
|
||||
export interface AutomationTriggerContext {
|
||||
options: TriggerIdOption[];
|
||||
/**
|
||||
* Left out for an automation with its own triggers. Set for a blueprint
|
||||
* automation, whose missing IDs can only be flagged once "loaded".
|
||||
*/
|
||||
blueprintStatus?: BlueprintTriggersStatus;
|
||||
/** True while the user is editing a `triggered_by` condition; trigger rows show their index badges only in this state. */
|
||||
showIndices: boolean;
|
||||
/** Pass the original condition object so identical conditions remain distinguishable. */
|
||||
select: (condition: TriggerCondition, ids: string[]) => void;
|
||||
/**
|
||||
* Pass the original condition object so identical conditions remain distinguishable.
|
||||
* Return false when the condition is not one of the automation's conditions, as in
|
||||
* a blueprint automation, whose conditions live in its inputs.
|
||||
*/
|
||||
select: (condition: TriggerCondition, ids: string[]) => boolean;
|
||||
/** Requests confirmation before migrating duplicate IDs throughout the automation. */
|
||||
fixDuplicateIds: () => Promise<void>;
|
||||
/**
|
||||
@@ -54,6 +69,13 @@ export interface AutomationTriggerContext {
|
||||
updateTriggerId: (original: Trigger, updated: Trigger) => boolean;
|
||||
}
|
||||
|
||||
/** Whether options list every trigger, so an ID not among them is really missing. */
|
||||
export const triggerOptionsResolved = (
|
||||
context: AutomationTriggerContext | undefined
|
||||
): boolean =>
|
||||
context?.blueprintStatus === undefined ||
|
||||
context.blueprintStatus === "loaded";
|
||||
|
||||
export const automationTriggerContext =
|
||||
createContext<AutomationTriggerContext>("automationTriggers");
|
||||
|
||||
@@ -140,6 +162,27 @@ export const getTriggerIdOptions = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Build options from a blueprint's substituted triggers. The blueprint owns its
|
||||
* triggers, so only those with a stored ID can be referenced, and triggers sharing
|
||||
* an ID are one option: a condition on that ID matches any of them.
|
||||
*/
|
||||
export const getBlueprintTriggerIdOptions = (
|
||||
triggers: Trigger | Trigger[] | undefined
|
||||
): TriggerIdOption[] => {
|
||||
const options: TriggerIdOption[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
flattenTriggers(triggers).forEach((trigger, index) => {
|
||||
const id = getTriggerId(trigger);
|
||||
if (!id || seenIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
seenIds.add(id);
|
||||
options.push({ id, trigger, index, draft: false, duplicate: false });
|
||||
});
|
||||
return options;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform leaf triggers recursively, retaining list wrappers and scalar/array shape.
|
||||
* Copy only changed branches; callbacks must return replacements rather than mutate leaves.
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { EntityRegistryEntry } from "../../../../data/entity/entity_registr
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
automationTriggerContext,
|
||||
triggerOptionsResolved,
|
||||
type AutomationTriggerContext,
|
||||
} from "./automation-trigger-id";
|
||||
|
||||
@@ -41,6 +42,7 @@ export class HaAutomationTriggerReferences extends LitElement {
|
||||
const missingIds = selectedIds.filter(
|
||||
(id) => !options.some((option) => option.id === id)
|
||||
);
|
||||
const resolved = triggerOptionsResolved(this._triggers);
|
||||
|
||||
if (options.length && !selectedIds.length) {
|
||||
return html`<span class="trigger-reference no-selection">
|
||||
@@ -87,17 +89,21 @@ export class HaAutomationTriggerReferences extends LitElement {
|
||||
</span>
|
||||
`
|
||||
),
|
||||
missingIds.map(
|
||||
() => html`
|
||||
<span class="trigger-reference missing">
|
||||
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
`
|
||||
missingIds.map((id) =>
|
||||
resolved
|
||||
? html`
|
||||
<span class="trigger-reference missing">
|
||||
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
`
|
||||
: html`<span class="trigger-reference">
|
||||
<span class="trigger-reference-label">${id}</span>
|
||||
</span>`
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
DataZoomComponent,
|
||||
VisualMapComponent,
|
||||
ToolboxComponent,
|
||||
MarkAreaComponent,
|
||||
BrushComponent,
|
||||
} from "echarts/components";
|
||||
|
||||
// Features like Universal Transition and Label Layout
|
||||
@@ -50,8 +48,6 @@ import type {
|
||||
GridComponentOption,
|
||||
DataZoomComponentOption,
|
||||
VisualMapComponentOption,
|
||||
MarkAreaComponentOption,
|
||||
BrushComponentOption,
|
||||
} from "echarts/components";
|
||||
import type { ComposeOption } from "echarts/core";
|
||||
|
||||
@@ -66,8 +62,6 @@ export type ECOption = ComposeOption<
|
||||
| GridComponentOption
|
||||
| DataZoomComponentOption
|
||||
| VisualMapComponentOption
|
||||
| MarkAreaComponentOption
|
||||
| BrushComponentOption
|
||||
| SankeySeriesOption
|
||||
| GraphSeriesOption
|
||||
| SunburstSeriesOption
|
||||
@@ -97,8 +91,6 @@ echarts.use([
|
||||
UniversalTransition,
|
||||
CanvasRenderer,
|
||||
ToolboxComponent,
|
||||
MarkAreaComponent,
|
||||
BrushComponent,
|
||||
]);
|
||||
|
||||
export { LinearGradient };
|
||||
|
||||
@@ -6442,6 +6442,8 @@
|
||||
"label": "Triggered by",
|
||||
"no_triggers": "There are no triggers in this automation. Add a trigger first.",
|
||||
"no_triggers_selected": "No triggers selected.",
|
||||
"blueprint_no_trigger_ids": "This blueprint has no triggers with IDs, so there is nothing to choose.",
|
||||
"blueprint_triggers_unavailable": "Complete all of the blueprint's required inputs to see its triggers.",
|
||||
"id": "Trigger",
|
||||
"missing_trigger": "Missing trigger",
|
||||
"missing_triggers": "This condition references a trigger that no longer exists. Uncheck it to clear it.",
|
||||
|
||||
@@ -264,6 +264,23 @@ describe("downSampleLineData", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a marker followed by a value that is neither min nor max", () => {
|
||||
// The reading after the gap repeats the one before it, so it loses the
|
||||
// min/max ties. The series then holds still for hours (#55896).
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 1.2],
|
||||
[FIXED_EPOCH_MS + 1_000, 1.2],
|
||||
[FIXED_EPOCH_MS + 1_001, 1.2],
|
||||
[FIXED_EPOCH_MS + 1_001, null],
|
||||
[FIXED_EPOCH_MS + 2_000, 1.2],
|
||||
[FIXED_EPOCH_MS + 30_000, 1.2],
|
||||
[FIXED_EPOCH_MS + 31_000, 1.2],
|
||||
];
|
||||
expect(
|
||||
downSampleLineData(points, 3).some((point) => point[1] === null)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a marker sharing its x with a value after that value", () => {
|
||||
// statistics-chart-data.ts ends the line and breaks it at the same x
|
||||
const points: GappedPoint[] = [
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, assert, describe, it, vi } from "vitest";
|
||||
import { refreshHassioSession } from "../../src/data/hassio/ingress";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
interface IngressMessage {
|
||||
type: string;
|
||||
slug?: string;
|
||||
session?: string;
|
||||
}
|
||||
|
||||
describe("Refresh hassio session", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps the session when it is still valid", async () => {
|
||||
const calls: IngressMessage[] = [];
|
||||
const hass = {
|
||||
callWS: async (msg: IngressMessage) => {
|
||||
calls.push(msg);
|
||||
return {};
|
||||
},
|
||||
} as unknown as Pick<HomeAssistant, "callWS">;
|
||||
|
||||
const session = await refreshHassioSession(hass, "my_app", "old-session");
|
||||
|
||||
assert.strictEqual(session, "old-session");
|
||||
assert.deepEqual(calls, [
|
||||
{ type: "supervisor/ingress/validate_session", session: "old-session" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates a new session for the app when validation fails", async () => {
|
||||
vi.stubGlobal("document", {});
|
||||
vi.stubGlobal("location", { protocol: "http:" });
|
||||
const calls: IngressMessage[] = [];
|
||||
const hass = {
|
||||
callWS: async (msg: IngressMessage) => {
|
||||
calls.push(msg);
|
||||
if (msg.type === "supervisor/ingress/validate_session") {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
return { session: "new-session" };
|
||||
},
|
||||
} as unknown as Pick<HomeAssistant, "callWS">;
|
||||
|
||||
const session = await refreshHassioSession(hass, "my_app", "old-session");
|
||||
|
||||
assert.strictEqual(session, "new-session");
|
||||
assert.deepEqual(calls, [
|
||||
{ type: "supervisor/ingress/validate_session", session: "old-session" },
|
||||
{ type: "supervisor/ingress/session", slug: "my_app" },
|
||||
]);
|
||||
assert.strictEqual(
|
||||
global.document.cookie,
|
||||
"ingress_session=new-session;path=/api/hassio_ingress/;SameSite=Strict"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when neither validation nor creation works", async () => {
|
||||
const hass = {
|
||||
callWS: async () => {
|
||||
throw new Error("Connection lost");
|
||||
},
|
||||
} as unknown as Pick<HomeAssistant, "callWS">;
|
||||
|
||||
const result = await refreshHassioSession(
|
||||
hass,
|
||||
"my_app",
|
||||
"old-session"
|
||||
).then(
|
||||
() => "resolved",
|
||||
() => "rejected"
|
||||
);
|
||||
|
||||
assert.strictEqual(result, "rejected");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { ContextEvent } from "@lit/context";
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AutomationConfig,
|
||||
BlueprintAutomationConfig,
|
||||
Trigger,
|
||||
} from "../../../../../src/data/automation";
|
||||
import { AutomationTriggerController } from "../../../../../src/panels/config/automation/trigger/automation-trigger-controller";
|
||||
import {
|
||||
automationTriggerContext,
|
||||
type AutomationTriggerContext,
|
||||
} from "../../../../../src/panels/config/automation/trigger/automation-trigger-id";
|
||||
|
||||
class TestHost extends LitElement {}
|
||||
customElements.define("test-automation-trigger-host", TestHost);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-automation-trigger-host": TestHost;
|
||||
}
|
||||
}
|
||||
|
||||
// A stored blueprint automation: only `use_blueprint`, with input values.
|
||||
const blueprint = (path: string, input: Record<string, unknown> = {}) =>
|
||||
({ use_blueprint: { path, input } }) as unknown as BlueprintAutomationConfig;
|
||||
|
||||
const trigger = (id: string): Trigger => ({
|
||||
trigger: "event",
|
||||
event_type: id,
|
||||
id,
|
||||
});
|
||||
|
||||
/** A fetch whose responses the test resolves or rejects in any order. */
|
||||
const createFetch = () => {
|
||||
const pending: {
|
||||
resolve: (triggers: Trigger[] | undefined) => void;
|
||||
reject: (err: Error) => void;
|
||||
}[] = [];
|
||||
const fetch = vi.fn(
|
||||
() =>
|
||||
new Promise<Trigger[] | undefined>((resolve, reject) => {
|
||||
pending.push({ resolve, reject });
|
||||
})
|
||||
);
|
||||
return { fetch, pending };
|
||||
};
|
||||
|
||||
describe("AutomationTriggerController with a blueprint automation", () => {
|
||||
let host: TestHost;
|
||||
let config: AutomationConfig;
|
||||
let fetch: ReturnType<typeof createFetch>;
|
||||
let controller: AutomationTriggerController;
|
||||
|
||||
// What a condition editor inside the automation receives.
|
||||
const context = (): AutomationTriggerContext => {
|
||||
let value: AutomationTriggerContext | undefined;
|
||||
const child = document.createElement("div");
|
||||
host.appendChild(child);
|
||||
child.dispatchEvent(
|
||||
new ContextEvent(automationTriggerContext, child, (v) => {
|
||||
value = v;
|
||||
})
|
||||
);
|
||||
child.remove();
|
||||
return value!;
|
||||
};
|
||||
|
||||
const ids = () => context().options.map((option) => option.id);
|
||||
|
||||
// The host's update after the editor's config changes.
|
||||
const update = (next: AutomationConfig) => {
|
||||
config = next;
|
||||
controller.hostUpdate();
|
||||
};
|
||||
|
||||
// Let timers and answered fetches run, then the update they request.
|
||||
const settle = async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
controller.hostUpdate();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Left out of the document, so only the test runs the host's updates.
|
||||
host = document.createElement("test-automation-trigger-host");
|
||||
fetch = createFetch();
|
||||
config = blueprint("motion.yaml");
|
||||
controller = new AutomationTriggerController(host, {
|
||||
getConfig: () => config,
|
||||
canEdit: () => true,
|
||||
commit: vi.fn(),
|
||||
fetchBlueprintTriggers: fetch.fetch,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
controller.hostDisconnected();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("offers the IDs of the blueprint's substituted triggers", async () => {
|
||||
update(blueprint("motion.yaml", { sensor: "binary_sensor.hall" }));
|
||||
expect(fetch.fetch).toHaveBeenCalledWith({
|
||||
path: "motion.yaml",
|
||||
input: { sensor: "binary_sensor.hall" },
|
||||
});
|
||||
fetch.pending[0].resolve([trigger("motion_on"), trigger("motion_off")]);
|
||||
await settle();
|
||||
|
||||
expect(ids()).toEqual(["motion_on", "motion_off"]);
|
||||
});
|
||||
|
||||
it("reports the options as loading until the triggers arrive", async () => {
|
||||
update(blueprint("motion.yaml"));
|
||||
expect(context().blueprintStatus).toBe("loading");
|
||||
|
||||
fetch.pending[0].resolve([trigger("motion_on")]);
|
||||
await settle();
|
||||
|
||||
expect(context().blueprintStatus).toBe("loaded");
|
||||
});
|
||||
|
||||
it("reports a blueprint whose triggers have no IDs as loaded", async () => {
|
||||
update(blueprint("motion.yaml"));
|
||||
fetch.pending[0].resolve([{ trigger: "event", event_type: "motion" }]);
|
||||
await settle();
|
||||
|
||||
expect(ids()).toEqual([]);
|
||||
expect(context().blueprintStatus).toBe("loaded");
|
||||
});
|
||||
|
||||
it("reports a blueprint that never substituted as failed", async () => {
|
||||
update(blueprint("motion.yaml"));
|
||||
fetch.pending[0].reject(new Error("Missing input sensor"));
|
||||
await settle();
|
||||
|
||||
expect(ids()).toEqual([]);
|
||||
expect(context().blueprintStatus).toBe("failed");
|
||||
});
|
||||
|
||||
it("keeps the last triggers listed, as loading, while new inputs load", async () => {
|
||||
update(blueprint("motion.yaml", { id: "motion_on" }));
|
||||
fetch.pending[0].resolve([trigger("motion_on")]);
|
||||
await settle();
|
||||
|
||||
update(blueprint("motion.yaml", { id: "motion_detected" }));
|
||||
|
||||
expect(ids()).toEqual(["motion_on"]);
|
||||
expect(context().blueprintStatus).toBe("loading");
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
fetch.pending[1].resolve([trigger("motion_detected")]);
|
||||
await settle();
|
||||
|
||||
expect(ids()).toEqual(["motion_detected"]);
|
||||
expect(context().blueprintStatus).toBe("loaded");
|
||||
});
|
||||
|
||||
it("fetches again only when `use_blueprint` changes by value", async () => {
|
||||
update(blueprint("motion.yaml", { wait: 1 }));
|
||||
// YAML mode parses a new, equal object when another key is edited.
|
||||
update({ ...blueprint("motion.yaml", { wait: 1 }), alias: "Hall" });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(fetch.fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses only the response for the latest inputs", async () => {
|
||||
update(blueprint("motion.yaml", { wait: 1 }));
|
||||
update(blueprint("motion.yaml", { wait: 2 }));
|
||||
await vi.runAllTimersAsync();
|
||||
expect(fetch.fetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
fetch.pending[1].resolve([trigger("latest")]);
|
||||
fetch.pending[0].resolve([trigger("stale")]);
|
||||
await settle();
|
||||
|
||||
expect(ids()).toEqual(["latest"]);
|
||||
});
|
||||
|
||||
it("keeps the last triggers when the inputs fail to substitute", async () => {
|
||||
update(blueprint("motion.yaml", { wait: 1 }));
|
||||
fetch.pending[0].resolve([trigger("motion_on")]);
|
||||
await settle();
|
||||
|
||||
update(blueprint("motion.yaml", { wait: undefined }));
|
||||
await vi.runAllTimersAsync();
|
||||
fetch.pending[1].reject(new Error("Missing input wait"));
|
||||
await settle();
|
||||
|
||||
expect(ids()).toEqual(["motion_on"]);
|
||||
expect(context().blueprintStatus).toBe("failed");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
cleanupRemovedGeneratedTriggerReferences,
|
||||
cleanupUnusedGeneratedTriggerIds,
|
||||
GENERATED_TRIGGER_ID_PREFIX,
|
||||
getBlueprintTriggerIdOptions,
|
||||
getExplicitTriggerIds,
|
||||
getTriggerIdOptions,
|
||||
hasCustomTriggerId,
|
||||
@@ -25,6 +26,40 @@ const GENERATED_TRIGGER_ID_PATTERN = new RegExp(
|
||||
);
|
||||
|
||||
describe("automation trigger IDs", () => {
|
||||
it("offers a blueprint's stored trigger IDs once each, without generating any", () => {
|
||||
const motion: Trigger = {
|
||||
trigger: "state",
|
||||
entity_id: "binary_sensor.motion",
|
||||
id: "motion_on",
|
||||
};
|
||||
const triggers = [
|
||||
motion,
|
||||
{ trigger: "time_pattern", minutes: "/5" },
|
||||
{
|
||||
trigger: "state",
|
||||
entity_id: "binary_sensor.presence",
|
||||
id: "motion_on",
|
||||
},
|
||||
{ triggers: [{ trigger: "sun", event: "sunset", id: "sunset" }] },
|
||||
] as Trigger[];
|
||||
|
||||
const options = getBlueprintTriggerIdOptions(triggers);
|
||||
|
||||
expect(
|
||||
options.map(({ id, index, draft, duplicate }) => ({
|
||||
id,
|
||||
index,
|
||||
draft,
|
||||
duplicate,
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: "motion_on", index: 0, draft: false, duplicate: false },
|
||||
{ id: "sunset", index: 3, draft: false, duplicate: false },
|
||||
]);
|
||||
expect(options[0].trigger).toBe(motion);
|
||||
expect(getBlueprintTriggerIdOptions(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores null trigger entries from incomplete YAML", () => {
|
||||
const triggers = [
|
||||
null,
|
||||
|
||||
Reference in new issue
Block a user