Compare commits

...
Author SHA1 Message Date
ecc1a7de24 Fix type errors in ha-selector-object dialog flow test
Co-authored-by: MindFreeze <[email protected]>
2026-08-21 05:31:17 +00:00
84369dee0d 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.

Co-authored-by: MindFreeze <[email protected]>
2026-08-21 05:31:17 +00:00
ebab30171b Fix editing nested multiple object selectors
Co-authored-by: MindFreeze <[email protected]>
2026-08-21 05:31:17 +00:00
copilot-swe-agent[bot]andGitHub 28b6b8069a Initial plan 2026-08-21 05:26:17 +00:00
3 changed files with 703 additions and 62 deletions
+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>
@@ -0,0 +1,155 @@
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) => {
const dialogParams = event.detail.dialogParams as FormDialogParams;
params = dialogParams;
if (result === null) {
dialogParams.cancel!();
} else {
dialogParams.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);
});
});