Compare commits

..
Author SHA1 Message Date
noksideandGitHub fcca87b368 Fix editing nested multiple object selector items (#53709)
* Fix editing nested multiple object selectors

* Fix nested form dialog close and focus restoration

Cancel all pending nested form levels when the physical dialog closes, and restore focus to the opener after nested submit or cancel.

Add regression coverage for both behaviors.

* Fix selector object test types
2026-08-24 09:41:58 +03:00
7 changed files with 730 additions and 426 deletions
+17 -104
View File
@@ -27,57 +27,26 @@ const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
// lead nowhere.
const MIN_NAVIGABLE_POINTS = 2;
const itemValues = (raw: unknown): unknown[] | null => {
if (Array.isArray(raw)) {
return raw;
}
if (raw && typeof raw === "object") {
const { value } = raw as { value?: unknown };
if (Array.isArray(value)) {
return value;
}
}
return null;
};
// ECharts spells empty values and numbers as strings too, and neither names a
// category.
const NON_CATEGORY_STRINGS = new Set(["-", "NaN", "null", "undefined"]);
const isCategoryKey = (value: unknown): boolean =>
typeof value === "string" &&
!NON_CATEGORY_STRINGS.has(value) &&
Number.isNaN(Number(value));
// A chart with the value axis on x, like the energy device charts, encodes its
// items value-first: [amount, "sensor.foo"]. The extension only reads a series
// that way when every item has that shape, so mirror the same gate.
const isValueFirstSeries = (data: readonly unknown[]): boolean =>
data.length > 0 &&
data.every((raw) => {
const values = itemValues(raw);
return (
!!values && typeof values[0] === "number" && isCategoryKey(values[1])
);
});
// Mirrors the extension's own reading of a point: it takes `value` as [x, y]
// (or [y, category] in a value-first series) and drops anything whose y is not
// a real number, which rejects gap-only series. Counts no further than `limit`
// so this stays cheap on charts with many points.
// Mirrors the extension's own reading of a point: it takes `value` as [x, y] and
// drops anything whose y is not a real number. That rejects gap-only series, and
// also value-first pairs like the energy device charts' [amount, "sensor.foo"].
// Counts no further than `limit` so this stays cheap on charts with many points.
const countNumericPoints = (data: unknown, limit: number): number => {
if (!Array.isArray(data)) {
return 0;
}
const valueFirst = isValueFirstSeries(data);
let found = 0;
for (const raw of data) {
let y: unknown = raw;
const values = itemValues(raw);
if (values) {
y = valueFirst ? values[0] : values.length > 1 ? values[1] : values[0];
if (Array.isArray(raw)) {
y = raw.length > 1 ? raw[1] : raw[0];
} else if (raw && typeof raw === "object") {
y = (raw as { value?: unknown }).value;
const { value } = raw as { value?: unknown };
y = Array.isArray(value)
? value.length > 1
? value[1]
: value[0]
: value;
}
if (typeof y === "number" && !Number.isNaN(y)) {
found += 1;
@@ -126,10 +95,6 @@ interface SonifyChartOptions {
localize: LocalizeFunc;
locale: FrontendLocaleData;
config: HassConfig;
// Maps a category key or item name to what should be announced for it, so
// cards that key their data on ids (like the energy device charts) can have
// the display names read out instead. Returning undefined keeps the original.
formatLabel?: (label: string) => string | undefined;
onError: (error: string) => void;
}
@@ -198,47 +163,6 @@ const appendSonificationStyles = () => {
document.head.append(style);
};
// Rebuilds the labels the extension would announce — the category axis's data,
// or the item names on pies, which ignore whatever vestigial axes the chart
// options carry — with each one run through the card's formatter. Returns
// undefined when there is nothing to reword, so the extension's own labels
// stay untouched.
const buildValueLabels = (
categoryAxis: { type?: string; data?: unknown } | undefined,
firstSeries: { type?: string; data?: unknown } | undefined,
formatLabel?: (label: string) => string | undefined
): string[] | undefined => {
if (!formatLabel) {
return undefined;
}
const axisData =
categoryAxis?.type === "category" &&
Array.isArray(categoryAxis.data) &&
categoryAxis.data.length
? categoryAxis.data
: undefined;
const labels = axisData
? axisData.map((entry) =>
entry && typeof entry === "object"
? String((entry as { value?: unknown }).value ?? "")
: String(entry ?? "")
)
: firstSeries?.type === "pie" && Array.isArray(firstSeries.data)
? firstSeries.data.map((raw) => {
const name = (raw as { name?: unknown } | null)?.name;
if (typeof name === "string") {
return name;
}
const values = itemValues(raw);
return values && isCategoryKey(values[1]) ? String(values[1]) : "";
})
: undefined;
if (!labels?.length || labels.every((label) => !label)) {
return undefined;
}
return labels.map((label) => formatLabel(label) ?? label);
};
export const sonifyChart = async (
chart: EChartsType,
options: SonifyChartOptions
@@ -255,8 +179,8 @@ export const sonifyChart = async (
if (!chartOptions) {
return null;
}
const xAxis = ensureArray(chartOptions.xAxis)?.[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)?.[0] as YAXisOption | undefined;
const xAxis = ensureArray(chartOptions.xAxis)[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)[0] as YAXisOption | undefined;
// Chart2Music throws while validating a group with no points, which is what
// placeholder, legend-hidden and all-null series turn into, so only offer it
@@ -272,25 +196,16 @@ export const sonifyChart = async (
const seriesIndex = readable.map((s) => allSeries.indexOf(s));
// Chart2Music always reads out an axis label, and the extension picks the wrong
// axis to name when there is no category axis, so label both explicitly. On a
// horizontal chart the announced x is the category from the y axis and the
// announced y is the value from the x axis, so the sources swap.
// axis to name when there is no category axis, so label both explicitly.
const isTimeAxis = xAxis?.type === "time";
const isHorizontal = xAxis?.type === "value" && yAxis?.type === "category";
const valueLabels = buildValueLabels(
isHorizontal ? yAxis : xAxis,
readable[0],
options.formatLabel
);
const x = {
label:
(isHorizontal ? yAxis?.name : xAxis?.name) ||
xAxis?.name ||
localize(
isTimeAxis
? "ui.components.history_charts.time"
: "ui.components.history_charts.category"
),
...(valueLabels ? { valueLabels } : {}),
// Time series carry raw timestamps, which would otherwise be announced as
// epoch milliseconds.
format: isTimeAxis
@@ -298,9 +213,7 @@ export const sonifyChart = async (
: undefined,
};
const y = {
label:
(isHorizontal ? xAxis?.name : yAxis?.name) ||
localize("ui.components.history_charts.value"),
label: yAxis?.name || localize("ui.components.history_charts.value"),
};
let connection: ReturnType<typeof connect>;
-6
View File
@@ -117,11 +117,6 @@ export class HaChartBase extends LitElement {
@property({ type: String }) public height?: string;
// Lets cards that key their data on ids have display names announced
// instead when the chart is navigated with Chart2Music.
@property({ attribute: false })
public sonificationLabelFormatter?: (label: string) => string | undefined;
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@@ -588,7 +583,6 @@ export class HaChartBase extends LitElement {
localize: this.hass.localize,
locale: this.hass.locale,
config: this.hass.config,
formatLabel: this.sonificationLabelFormatter,
onError: () => {
// Charts the extension cannot describe stay silent rather than
// dropping an error on someone who only pressed Tab.
+178 -62
View File
@@ -1,29 +1,39 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import deepClone from "deep-clone-simple";
import { deepActiveElement } from "../../common/dom/deep-active-element";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-form/ha-form";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-form/ha-form";
import type { HaDialog } from "../../components/ha-dialog";
import type { HaForm } from "../../components/ha-form/ha-form";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
import type { HaForm } from "../../components/ha-form/ha-form";
interface StackEntry {
params: FormDialogParams;
initialData: FormDialogData;
data: FormDialogData;
nestedField?: string;
scrollTop: number;
focusTarget?: Element;
error?: Record<string, string>;
}
@customElement("dialog-form")
export class DialogForm
extends DirtyStateProviderMixin<FormDialogData>()(LitElement)
extends DirtyStateProviderMixin<FormDialogData[]>()(LitElement)
implements HassDialog<FormDialogData>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -32,6 +42,8 @@ export class DialogForm
@state() private _data: FormDialogData = {};
private _initialData: FormDialogData = {};
@state() private _open = false;
@state() private _closeState?: "canceled" | "submitted";
@@ -40,14 +52,19 @@ export class DialogForm
@state() private _error?: Record<string, string>;
@query("ha-form") private _form?: HaForm;
@query("ha-dialog") private _dialog?: HaDialog;
@query("ha-form:not([hidden])") private _form?: HaForm;
@queryAll("ha-form") private _forms!: NodeListOf<HaForm>;
public async showDialog(params: FormDialogParams): Promise<void> {
this._params = params;
this._data = params.data || {};
this._initialData = deepClone(this._data);
this._open = true;
this._error = undefined;
this._initDirtyTracking({ type: "deep" }, this._data);
this._resetDirtyTracking();
}
public closeDialog(): boolean {
@@ -55,54 +72,119 @@ export class DialogForm
return true;
}
private _initialDirtyState(): FormDialogData[] {
return [
...this._stack.map((entry) => entry.initialData),
this._initialData,
];
}
private _currentDirtyState(): FormDialogData[] {
return [...this._stack.map((entry) => entry.data), this._data];
}
private _resetDirtyTracking(): void {
this._initDirtyTracking({ type: "deep" }, this._initialDirtyState());
this._updateDirtyState(this._currentDirtyState());
}
private _handleNestedShowDialog = (
ev: HASSDomEvent<ShowDialogParams<unknown>>
) => {
if (ev.detail.dialogTag !== "dialog-form") {
if (
ev.detail.dialogTag !== "dialog-form" ||
ev.currentTarget !== this._form
) {
return;
}
ev.stopPropagation();
const origin = ev.composedPath()[0] as HTMLElement & { name?: string };
const nested = ev.detail.dialogParams as FormDialogParams;
if (!nested.submit || !nested.cancel) {
return;
}
ev.stopPropagation();
const focusTarget = deepActiveElement();
this._stack = [
...this._stack,
{
params: this._params!,
initialData: this._initialData,
data: this._data,
nestedField: origin?.name,
scrollTop: this._dialog?.bodyContainer.scrollTop ?? 0,
focusTarget: focusTarget ?? undefined,
error: this._error,
},
];
const nested = ev.detail.dialogParams as FormDialogParams;
this._params = nested;
this._data = nested?.data || {};
this._data = nested.data || {};
this._initialData = deepClone(this._data);
this._error = undefined;
this._initDirtyTracking({ type: "deep" }, this._data);
this._resetDirtyTracking();
};
private _popStack(): string | undefined {
private _popStack(): StackEntry | undefined {
if (!this._stack.length) {
return undefined;
}
const prev = this._stack[this._stack.length - 1];
this._stack = this._stack.slice(0, -1);
this._params = prev.params;
this._initialData = prev.initialData;
this._data = prev.data;
this._error = prev.error;
this._initDirtyTracking({ type: "deep" }, this._data);
return prev.nestedField;
this._resetDirtyTracking();
return prev;
}
private async _restoreFocusAndScroll(
scrollTop: number,
expectedParams: FormDialogParams,
focusTarget?: Element
): Promise<void> {
await this.updateComplete;
await this._form?.updateComplete;
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
if (!this._open || this._params !== expectedParams || !this._dialog) {
return;
}
if (focusTarget instanceof HTMLElement && focusTarget.isConnected) {
focusTarget.focus();
}
this._dialog.bodyContainer.scrollTop = scrollTop;
}
private _dialogClosed(): void {
if (!this._closeState) {
this._params?.cancel?.();
for (let index = this._stack.length - 1; index >= 0; index--) {
this._stack[index].params.cancel?.();
}
}
if (this._closeState !== "submitted") {
this._discardDirtyStateChanges();
}
this._closeState = undefined;
this._stack = [];
this._params = undefined;
this._initialData = {};
this._data = {};
this._open = false;
this._error = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -114,53 +196,69 @@ export class DialogForm
return;
}
this._closeState = "submitted";
const submit = this._params?.submit;
const data = this._data;
const nestedField = this._popStack();
const stackEntry = this._popStack();
submit?.(data);
if (!nestedField) {
if (!stackEntry) {
this._closeState = "submitted";
submit?.(data);
this._markDirtyStateClean();
this.closeDialog();
return;
}
const schemaField = this._params?.schema.find(
(f) => "selector" in f && f.name === nestedField
submit!(data);
void this._restoreFocusAndScroll(
stackEntry.scrollTop,
stackEntry.params,
stackEntry.focusTarget
);
const isMultiple =
schemaField &&
"selector" in schemaField &&
"object" in schemaField.selector &&
schemaField.selector.object?.multiple === true;
const current = this._data[nestedField];
const newValue = isMultiple
? [...(Array.isArray(current) ? current : []), data]
: data;
this._data = deepClone({ ...this._data, [nestedField]: newValue });
this._error = undefined;
this._updateDirtyState(this._data);
}
private _cancel(): void {
this._closeState = "canceled";
const cancel = this._params?.cancel;
const nestedField = this._popStack();
const stackEntry = this._popStack();
cancel?.();
if (!nestedField) {
if (!stackEntry) {
this._closeState = "canceled";
cancel?.();
this.closeDialog();
return;
}
cancel!();
void this._restoreFocusAndScroll(
stackEntry.scrollTop,
stackEntry.params,
stackEntry.focusTarget
);
}
private _valueChanged(ev: CustomEvent): void {
this._data = ev.detail.value;
this._error = undefined;
this._updateDirtyState(this._data);
const levelIndex = Array.from(this._forms).indexOf(
ev.currentTarget as HaForm
);
if (levelIndex === -1) {
return;
}
const data = ev.detail.value as FormDialogData;
if (levelIndex === this._stack.length) {
this._data = data;
this._error = undefined;
this._updateDirtyState(this._currentDirtyState());
return;
}
if (levelIndex < this._stack.length) {
this._stack = this._stack.map((entry, index) =>
index === levelIndex ? { ...entry, data, error: undefined } : entry
);
this._updateDirtyState(this._currentDirtyState());
}
}
protected render() {
@@ -168,35 +266,53 @@ export class DialogForm
return nothing;
}
const params = this._params;
const levels = [
...this._stack,
{
params,
initialData: this._initialData,
data: this._data,
error: this._error,
},
];
return html`
<ha-dialog
.open=${this._open}
header-title=${this._params.title}
header-title=${params.title}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-form
autofocus
.hass=${this.hass}
.computeLabel=${this._params.computeLabel}
.computeHelper=${this._params.computeHelper}
.data=${this._data}
.schema=${this._params.schema}
.error=${this._error}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
</ha-form>
${levels.map((level, index) => {
const isActive = index === levels.length - 1;
return html`
<ha-form
?hidden=${!isActive}
?autofocus=${isActive}
.hass=${this.hass}
.computeLabel=${level.params.computeLabel}
.computeHelper=${level.params.computeHelper}
.data=${level.data}
.schema=${level.params.schema}
.error=${level.error}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
</ha-form>
`;
})}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._cancel}
>
${this._params.cancelText || this.hass.localize("ui.common.cancel")}
${params.cancelText || this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._submit}>
${this._params.submitText || this.hass.localize("ui.common.save")}
${params.submitText || this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
@@ -194,7 +194,6 @@ export class HuiEnergyDevicesGraphCard
this._legendData
)}
.height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
.sonificationLabelFormatter=${this._sonificationLabel}
.extraComponents=${[PieChart]}
.expandLegend=${this._config.expand_legend}
click-label-for-more-info
@@ -294,14 +293,6 @@ export class HuiEnergyDevicesGraphCard
}
);
// The chart data is keyed on statistic ids, which is what Chart2Music would
// otherwise announce. Names that aren't statistics — the untracked slice —
// are already display text, so those stay as they are.
private _sonificationLabel = (label: string): string | undefined =>
this._deviceLabels[label] || this._data?.statsMetadata[label]
? this._getDeviceName(label)
: undefined;
private _getDeviceName(statisticId: string): string {
const suffix = this._compoundStats.includes(statisticId)
? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})`
+11 -245
View File
@@ -1,22 +1,7 @@
import type { EChartsType } from "echarts/core";
import { connect } from "echarts-extension-chart2music";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canSonifyChart,
sonifyChart,
} from "../../../src/components/chart/chart-sonification";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { FrontendLocaleData } from "../../../src/data/translation";
import type { HomeAssistant } from "../../../src/types";
import { describe, expect, it } from "vitest";
import { canSonifyChart } from "../../../src/components/chart/chart-sonification";
import type { HaECSeries } from "../../../src/resources/echarts/echarts";
vi.mock("echarts-extension-chart2music", () => ({
connect: vi.fn((_chart, options) => {
options.cc.setAttribute("aria-live", "assertive");
return { update: vi.fn(), dispose: vi.fn() };
}),
}));
const series = (
items: { type: string; id?: string; name?: string; data?: unknown[] }[]
) => items as unknown as HaECSeries;
@@ -110,9 +95,9 @@ describe("canSonifyChart", () => {
expect(canSonifyChart(series([{ type: "line" }]))).toBe(false);
});
it("accepts value-first pairs, like the energy device charts", () => {
// The energy device charts encode [amount, categoryName]. Since v0.1.1 the
// extension reads a series that way when every item has that shape.
it("rejects value-first pairs, which the extension reads as a non-numeric y", () => {
// The energy device charts encode [amount, categoryName]. Chart2Music takes
// value as [x, y], so every one of those points is dropped.
expect(
canSonifyChart(
series([
@@ -125,77 +110,18 @@ describe("canSonifyChart", () => {
},
])
)
).toBe(true);
expect(
canSonifyChart(
series([
{
type: "bar",
data: [
[12.5, "sensor.a"],
[8.25, "sensor.b"],
],
},
])
)
).toBe(true);
});
it("does not read empty markers or numeric strings as value-first pairs", () => {
// [2, "-"] is an ECharts gap inside an ordinary series and [1, "8"] is a
// numeric string y; treating either as [y, category] would fabricate
// points the chart never plotted.
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[1, "-"],
[2, "-"],
],
},
])
)
).toBe(false);
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[1, "8"],
[2, "12"],
],
},
])
)
).toBe(false);
});
it("reads a series value-first only when every item has that shape", () => {
// A lone [number, string] among ordinary pairs is an unreadable value, not
// a transposed one.
expect(
canSonifyChart(
series([
{
type: "bar",
data: [
[12.5, "sensor.a"],
["-", "sensor.b"],
],
},
])
)
).toBe(false);
});
it("rejects a chart left with a single readable point", () => {
// One device slice alone offers nothing the arrow keys could move between.
// The device pie's slices are all unreadable, leaving only its one-number
// total series — nothing the arrow keys could move between.
expect(
canSonifyChart(
series([{ type: "pie", data: [{ value: [12.5, "sensor.a"] }] }])
series([
{ type: "pie", data: [{ value: [12.5, "sensor.a"] }] },
{ type: "pie", data: [24.5] },
])
)
).toBe(false);
});
@@ -264,163 +190,3 @@ describe("canSonifyChart", () => {
).toBe(false);
});
});
describe("sonifyChart", () => {
const mockedConnect = vi.mocked(connect);
const fakeChart = (option: Record<string, unknown>) =>
({
getOption: () => option,
on: vi.fn(),
off: vi.fn(),
}) as unknown as EChartsType;
const sonify = (
option: Record<string, unknown>,
formatLabel?: (label: string) => string | undefined
) =>
sonifyChart(fakeChart(option), {
cc: document.createElement("div"),
localize: ((key: string) => key) as LocalizeFunc,
locale: { language: "en" } as FrontendLocaleData,
config: {} as HomeAssistant["config"],
formatLabel,
onError: () => undefined,
});
const connectedAxes = () => mockedConnect.mock.lastCall![1]!.axes!;
beforeEach(() => {
mockedConnect.mockClear();
});
it("swaps the axis label sources on horizontal charts", async () => {
const sonification = await sonify({
xAxis: [{ type: "value", name: "kWh" }],
yAxis: [{ type: "category", name: "Device", data: ["a", "b"] }],
series: [
{
type: "bar",
data: [
[12.5, "a"],
[7.25, "b"],
],
},
],
});
expect(sonification).not.toBeNull();
// The announced x walks the categories of the y axis, and the announced y
// is the value from the x axis.
expect(connectedAxes()).toMatchObject({
x: { label: "Device" },
y: { label: "kWh" },
});
});
it("keeps the axis label sources on vertical charts", async () => {
await sonify({
xAxis: [{ type: "category", name: "Month", data: ["Jan", "Feb"] }],
yAxis: [{ type: "value", name: "kWh" }],
series: [{ type: "bar", data: [5, 9] }],
});
expect(connectedAxes()).toMatchObject({
x: { label: "Month" },
y: { label: "kWh" },
});
// Without a formatter the extension's own labels stay untouched.
expect(connectedAxes().x.valueLabels).toBeUndefined();
});
it("announces category keys through the label formatter", async () => {
await sonify(
{
xAxis: [{ type: "value", name: "kWh" }],
yAxis: [{ type: "category", data: ["sensor.a", "sensor.b"] }],
series: [
{
type: "bar",
data: [
{ name: "sensor.a", value: [12.5, "sensor.a"] },
{ name: "sensor.b", value: [7.25, "sensor.b"] },
],
},
],
},
(label) => (label === "sensor.a" ? "Dishwasher" : "Oven")
);
expect(connectedAxes().x.valueLabels).toEqual(["Dishwasher", "Oven"]);
});
it("formats pie slice names and keeps the ones the formatter declines", async () => {
await sonify(
{
series: [
{
type: "pie",
data: [
{ name: "sensor.a", value: [12.5, "sensor.a"] },
{ name: "Untracked consumption", value: [7.25, "untracked"] },
],
},
],
},
(label) => (label === "sensor.a" ? "Dishwasher" : undefined)
);
expect(connectedAxes().x.valueLabels).toEqual([
"Dishwasher",
"Untracked consumption",
]);
});
it("labels pie slices even when the options carry hidden empty axes", async () => {
// ha-chart-base's merged options include default axes even for pies, so
// an empty category axis must not block the item-name labels.
await sonify(
{
xAxis: [{ type: "category", show: false, data: [] }],
yAxis: [{ type: "value", show: false }],
series: [
{
type: "pie",
data: [
{ name: "sensor.a", value: [12.5, "sensor.a"] },
{ name: "sensor.b", value: [7.25, "sensor.b"] },
],
},
],
},
(label) => (label === "sensor.a" ? "Dishwasher" : "Oven")
);
expect(connectedAxes().x.valueLabels).toEqual(["Dishwasher", "Oven"]);
});
it("never overrides labels on time axis charts", async () => {
await sonify(
{
xAxis: [{ type: "time" }],
yAxis: [{ type: "value", name: "°C" }],
series: [
{
type: "line",
data: [
[1700000000000, 21.5],
[1700003600000, 22.1],
],
},
],
},
() => "wrong"
);
expect(connectedAxes()).toMatchObject({
x: { label: "ui.components.history_charts.time" },
y: { label: "°C" },
});
expect(connectedAxes().x.valueLabels).toBeUndefined();
});
});
@@ -0,0 +1,154 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../../src/types";
import type { HaObjectSelector } from "../../../src/components/ha-selector/ha-selector-object";
import type { FormDialogParams } from "../../../src/dialogs/form/show-form-dialog";
import "../../../src/components/ha-selector/ha-selector-object";
vi.mock("../../../src/components/ha-input-helper-text", () => {
customElements.define("ha-input-helper-text", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-md-list", () => {
customElements.define("ha-md-list", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-md-list-item", () => {
customElements.define("ha-md-list-item", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-sortable", () => {
customElements.define("ha-sortable", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-yaml-editor", () => {
customElements.define("ha-yaml-editor", class extends HTMLElement {});
return {};
});
const hass = {
localize: (key: string) => key,
locale: "en-US",
floors: {},
areas: {},
devices: {},
states: {},
formatEntityName: () => "",
} as unknown as HomeAssistant;
const selectorConfig = {
object: {
multiple: true,
fields: {
name: { selector: { text: {} } },
},
},
};
const getInternals = (selector: HaObjectSelector) =>
selector as unknown as Record<string, unknown>;
const mountSelector = async (value: Record<string, string>[]) => {
const selector = document.createElement(
"ha-selector-object"
) as HaObjectSelector;
selector.hass = hass;
selector.selector = selectorConfig;
selector.value = value;
document.body.append(selector);
await selector.updateComplete;
return selector;
};
const resolveFormDialog = async (
selector: HaObjectSelector,
action: "_addItem" | "_editItem",
result: Record<string, string> | null,
item?: Record<string, string>,
index?: number
) => {
let params: FormDialogParams | undefined;
const dialogShown = new Promise<void>((resolve) => {
selector.addEventListener(
"show-dialog",
(event) => {
params = event.detail.dialogParams as FormDialogParams;
if (result === null) {
params.cancel!();
} else {
params.submit!(result);
}
resolve();
},
{ once: true }
);
});
const event = {
stopPropagation: vi.fn(),
currentTarget: { item, index },
};
const operation = (
getInternals(selector)[action] as (ev: typeof event) => Promise<void>
)(event);
await dialogShown;
await operation;
return params!;
};
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
describe("ha-selector-object form dialog flow", () => {
it("appends an item through the real Add flow", async () => {
const first = { name: "A" };
const selector = await mountSelector([first]);
const valueChanged = vi.fn();
selector.addEventListener("value-changed", valueChanged);
await resolveFormDialog(selector, "_addItem", { name: "B" });
expect(valueChanged).toHaveBeenCalledWith(
expect.objectContaining({
detail: { value: [first, { name: "B" }] },
})
);
});
it("replaces an item at its original index through the real Edit flow", async () => {
const first = { name: "A" };
const second = { name: "B" };
const selector = await mountSelector([first, second]);
const valueChanged = vi.fn();
selector.addEventListener("value-changed", valueChanged);
await resolveFormDialog(
selector,
"_editItem",
{ name: "B updated" },
second,
1
);
const value = valueChanged.mock.calls[0][0].detail.value;
expect(value).toEqual([first, { name: "B updated" }]);
expect(value).toHaveLength(2);
});
it("leaves the array unchanged when Add or Edit is canceled", async () => {
const first = { name: "A" };
const selector = await mountSelector([first]);
const valueChanged = vi.fn();
selector.addEventListener("value-changed", valueChanged);
await resolveFormDialog(selector, "_addItem", null);
await resolveFormDialog(selector, "_editItem", null, first, 0);
expect(valueChanged).not.toHaveBeenCalled();
});
});
+370
View File
@@ -0,0 +1,370 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { deepActiveElement } from "../../../src/common/dom/deep-active-element";
import type {
FormDialogData,
FormDialogParams,
} from "../../../src/dialogs/form/show-form-dialog";
import type { DialogForm } from "../../../src/dialogs/form/dialog-form";
import "../../../src/dialogs/form/dialog-form";
vi.mock("../../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-dialog", () => {
customElements.define(
"ha-dialog",
class extends HTMLElement {
public bodyContainer = document.createElement("div");
}
);
return {};
});
vi.mock("../../../src/components/ha-dialog-footer", () => {
customElements.define("ha-dialog-footer", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-form/ha-form", () => {
customElements.define(
"ha-form",
class extends HTMLElement {
public reportValidity = vi.fn(() => true);
}
);
return {};
});
const getInternals = (dialog: DialogForm) =>
dialog as unknown as Record<string, unknown>;
const getForms = (dialog: DialogForm): HTMLElement[] =>
Array.from(dialog.shadowRoot!.querySelectorAll("ha-form"));
const outerParams = (data: FormDialogData = {}): FormDialogParams => ({
title: "Outer",
schema: [{ name: "value", selector: { text: {} } }],
data,
submit: vi.fn(),
cancel: vi.fn(),
});
const nestedParams = (data: FormDialogData = {}): FormDialogParams => ({
title: "Nested",
schema: [{ name: "value", selector: { text: {} } }],
data,
submit: vi.fn(),
cancel: vi.fn(),
});
const hass = {
localize: (key: string) => key,
} as never;
const openDialog = async (params = outerParams()) => {
const dialog = document.createElement("dialog-form") as DialogForm;
dialog.hass = hass;
document.body.append(dialog);
await dialog.showDialog(params);
await dialog.updateComplete;
return dialog;
};
const showNestedDialog = async (
dialog: DialogForm,
form: Element,
params: FormDialogParams,
dialogTag = "dialog-form",
origin: Element = form
) => {
origin.dispatchEvent(
new CustomEvent("show-dialog", {
bubbles: true,
composed: true,
detail: { dialogTag, dialogParams: params },
})
);
await dialog.updateComplete;
};
const submit = (dialog: DialogForm) =>
(getInternals(dialog)["_submit"] as () => void)();
const cancel = (dialog: DialogForm) =>
(getInternals(dialog)["_cancel"] as () => void)();
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
describe("dialog-form mounted nested forms", () => {
it("keeps parent forms mounted while nested", async () => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
await showNestedDialog(dialog, parent, nestedParams());
const forms = getForms(dialog);
expect(forms).toHaveLength(2);
expect(forms[0].hidden).toBe(true);
expect(forms[1].hidden).toBe(false);
expect(forms[0].hasAttribute("autofocus")).toBe(false);
expect(forms[1].hasAttribute("autofocus")).toBe(true);
});
it("returns to the parent after nested submit", async () => {
const dialog = await openDialog();
const nested = nestedParams({ value: "nested" });
const parent = getForms(dialog)[0];
await showNestedDialog(dialog, parent, nested);
submit(dialog);
await dialog.updateComplete;
expect(nested.submit).toHaveBeenCalledWith({ value: "nested" });
expect(getInternals(dialog)["_open"]).toBe(true);
expect(getInternals(dialog)["_stack"]).toHaveLength(0);
expect(getForms(dialog)[0].hidden).toBe(false);
});
it.each(["submit", "cancel"] as const)(
"restores focus to the opener after nested %s",
async (action) => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
const opener = document.createElement("button");
parent.append(opener);
opener.focus();
await showNestedDialog(dialog, parent, nestedParams());
const child = getForms(dialog)[1];
const childFocusTarget = document.createElement("button");
child.append(childFocusTarget);
childFocusTarget.focus();
expect(deepActiveElement()).toBe(childFocusTarget);
if (action === "submit") {
submit(dialog);
} else {
cancel(dialog);
}
await vi.waitUntil(() => deepActiveElement() === opener);
}
);
it("keeps the parent open after a custom object selector nested save", async () => {
const dialog = await openDialog();
const nested = {
...nestedParams({ items: [] }),
schema: [
{
name: "items",
selector: {
object: {
multiple: true,
fields: { name: { selector: { text: {} } } },
},
},
},
],
} satisfies FormDialogParams;
const descendant = document.createElement("div");
getForms(dialog)[0].append(descendant);
await showNestedDialog(
dialog,
getForms(dialog)[0],
nested,
"dialog-form",
descendant
);
submit(dialog);
await dialog.updateComplete;
expect(nested.submit).toHaveBeenCalledWith({ items: [] });
expect(getInternals(dialog)["_open"]).toBe(true);
expect(getInternals(dialog)["_stack"]).toHaveLength(0);
expect(getForms(dialog)[0].hidden).toBe(false);
});
it("returns to the parent after nested cancel", async () => {
const parentData = { value: "parent" };
const dialog = await openDialog(outerParams(parentData));
const nested = nestedParams({ value: "nested" });
await showNestedDialog(dialog, getForms(dialog)[0], nested);
cancel(dialog);
await dialog.updateComplete;
expect(nested.cancel).toHaveBeenCalledOnce();
expect(getInternals(dialog)["_open"]).toBe(true);
expect((getInternals(dialog)["_data"] as FormDialogData).value).toBe(
"parent"
);
expect(getForms(dialog)[0].hidden).toBe(false);
});
it("routes hidden parent value changes to its stack entry", async () => {
const dialog = await openDialog(outerParams({ value: "original" }));
const parent = getForms(dialog)[0];
await showNestedDialog(dialog, parent, nestedParams());
parent.dispatchEvent(
new CustomEvent("value-changed", {
detail: { value: { value: "updated" } },
})
);
expect(
(getInternals(dialog)["_stack"] as Record<string, unknown>[])[0].data
).toEqual({ value: "updated" });
cancel(dialog);
expect((getInternals(dialog)["_data"] as FormDialogData).value).toBe(
"updated"
);
});
it("keeps multiple nested levels mounted and pops them in order", async () => {
const dialog = await openDialog();
await showNestedDialog(dialog, getForms(dialog)[0], nestedParams());
await showNestedDialog(dialog, getForms(dialog)[1], nestedParams());
expect(getForms(dialog)).toHaveLength(3);
expect(getForms(dialog).map((form) => form.hidden)).toEqual([
true,
true,
false,
]);
cancel(dialog);
await dialog.updateComplete;
expect(getForms(dialog).map((form) => form.hidden)).toEqual([true, false]);
cancel(dialog);
await dialog.updateComplete;
expect(getForms(dialog).map((form) => form.hidden)).toEqual([false]);
});
it("accepts active show-dialog events only", async () => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
const nested = nestedParams();
await showNestedDialog(dialog, parent, nested);
const child = getForms(dialog)[1];
await showNestedDialog(dialog, parent, nestedParams());
expect(getInternals(dialog)["_stack"]).toHaveLength(1);
await showNestedDialog(dialog, child, nestedParams(), "not-dialog-form");
expect(getInternals(dialog)["_stack"]).toHaveLength(1);
});
it("cancels all pending levels when physically closed", async () => {
const cancelOrder: string[] = [];
const root = outerParams();
const nested = nestedParams();
const grandchild = nestedParams();
root.cancel = vi.fn(() => cancelOrder.push("root"));
nested.cancel = vi.fn(() => cancelOrder.push("nested"));
grandchild.cancel = vi.fn(() => cancelOrder.push("grandchild"));
const dialog = await openDialog(root);
getForms(dialog)[0].dispatchEvent(
new CustomEvent("value-changed", {
detail: { value: { value: "dirty" } },
})
);
expect(dialog.isDirtyState).toBe(true);
await showNestedDialog(dialog, getForms(dialog)[0], nested);
await showNestedDialog(dialog, getForms(dialog)[1], grandchild);
(getInternals(dialog)["_dialogClosed"] as () => void)();
expect(cancelOrder).toEqual(["grandchild", "nested", "root"]);
expect(grandchild.cancel).toHaveBeenCalledOnce();
expect(nested.cancel).toHaveBeenCalledOnce();
expect(root.cancel).toHaveBeenCalledOnce();
expect(getInternals(dialog)["_stack"]).toHaveLength(0);
expect(getInternals(dialog)["_params"]).toBeUndefined();
expect(getInternals(dialog)["_data"]).toEqual({});
expect(getInternals(dialog)["_initialData"]).toEqual({});
expect(getInternals(dialog)["_open"]).toBe(false);
expect(dialog.isDirtyState).toBe(false);
});
it("tracks dirty state across nested levels", async () => {
const dialog = await openDialog();
expect(dialog.isDirtyState).toBe(false);
getForms(dialog)[0].dispatchEvent(
new CustomEvent("value-changed", {
detail: { value: { value: "changed" } },
})
);
expect(dialog.isDirtyState).toBe(true);
await showNestedDialog(dialog, getForms(dialog)[0], nestedParams());
cancel(dialog);
expect(dialog.isDirtyState).toBe(true);
const cleanDialog = await openDialog();
getForms(cleanDialog)[0].dispatchEvent(
new CustomEvent("value-changed", {
detail: { value: { value: "changed" } },
})
);
expect(cleanDialog.isDirtyState).toBe(true);
cancel(cleanDialog);
(getInternals(cleanDialog)["_dialogClosed"] as () => void)();
expect(cleanDialog.isDirtyState).toBe(false);
const cleanNestedDialog = await openDialog();
await showNestedDialog(
cleanNestedDialog,
getForms(cleanNestedDialog)[0],
nestedParams()
);
cancel(cleanNestedDialog);
expect(cleanNestedDialog.isDirtyState).toBe(false);
submit(cleanNestedDialog);
expect(cleanNestedDialog.isDirtyState).toBe(false);
submit(dialog);
expect(dialog.isDirtyState).toBe(false);
});
it("validates the active form before submitting", async () => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
const nested = nestedParams();
await showNestedDialog(dialog, parent, nested);
const child = getForms(dialog)[1];
const parentReportValidity = vi.fn(() => true);
const childReportValidity = vi.fn(() => false);
Object.defineProperty(parent, "reportValidity", {
value: parentReportValidity,
});
Object.defineProperty(child, "reportValidity", {
value: childReportValidity,
});
submit(dialog);
expect(parentReportValidity).not.toHaveBeenCalled();
expect(childReportValidity).toHaveBeenCalledOnce();
expect(nested.submit).not.toHaveBeenCalled();
expect(getInternals(dialog)["_open"]).toBe(true);
});
});