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
5 changed files with 766 additions and 125 deletions
+2 -2
View File
@@ -170,7 +170,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "4.1.11",
"@vitest/coverage-v8": "4.1.10",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.28.8",
@@ -215,7 +215,7 @@
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.11",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
+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);
});
});
+61 -61
View File
@@ -6452,12 +6452,12 @@ __metadata:
languageName: node
linkType: hard
"@vitest/coverage-v8@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/coverage-v8@npm:4.1.11"
"@vitest/coverage-v8@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/coverage-v8@npm:4.1.10"
dependencies:
"@bcoe/v8-coverage": "npm:^1.0.2"
"@vitest/utils": "npm:4.1.11"
"@vitest/utils": "npm:4.1.10"
ast-v8-to-istanbul: "npm:^1.0.0"
istanbul-lib-coverage: "npm:^3.2.2"
istanbul-lib-report: "npm:^3.0.1"
@@ -6467,34 +6467,34 @@ __metadata:
std-env: "npm:^4.0.0-rc.1"
tinyrainbow: "npm:^3.1.0"
peerDependencies:
"@vitest/browser": 4.1.11
vitest: 4.1.11
"@vitest/browser": 4.1.10
vitest: 4.1.10
peerDependenciesMeta:
"@vitest/browser":
optional: true
checksum: 10/b6171ec592e0017c3b10954a9400b10af0becf944a0533af01b002a4cc35b6e562a353b4837451a44b73c5561de358c23a2c135d4e1e79bc9041ebb241a68440
checksum: 10/e593f5205a65d10f200e68a99e720d7a9f5e9be65d8160e4f0b6b5f1a7a87f0453c03e79fd1c022dbd7fb26a22657ca4d9410ae27b36da90d41d7ca04f681ab1
languageName: node
linkType: hard
"@vitest/expect@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/expect@npm:4.1.11"
"@vitest/expect@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/expect@npm:4.1.10"
dependencies:
"@standard-schema/spec": "npm:^1.1.0"
"@types/chai": "npm:^5.2.2"
"@vitest/spy": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
"@vitest/spy": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
chai: "npm:^6.2.2"
tinyrainbow: "npm:^3.1.0"
checksum: 10/9bfcfe5ad926ab58beea1c700dc057f17422f14516506f8fc12c9881ed3e81d4c2faadb768042c8497fdee7007e201c1bd3e7d2e91157dbb47fb5c07c4c02aaa
checksum: 10/487fcad404a68968a54ae5fb9d099f12170cd793420a04b34a5606516317090c50a8303ab687c70166ee181864e3e138941d4a96d0405434dcd37696b3105350
languageName: node
linkType: hard
"@vitest/mocker@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/mocker@npm:4.1.11"
"@vitest/mocker@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/mocker@npm:4.1.10"
dependencies:
"@vitest/spy": "npm:4.1.11"
"@vitest/spy": "npm:4.1.10"
estree-walker: "npm:^3.0.3"
magic-string: "npm:^0.30.21"
peerDependencies:
@@ -6505,56 +6505,56 @@ __metadata:
optional: true
vite:
optional: true
checksum: 10/00b6e1266d8403194b49313e3a9a1af0dff2c773f4b2df11f4955fa0f244fd4b59484cd23381dfef8af30298e2651c1aaa42b439fdbc871bb4bb911de38a9509
checksum: 10/ae9645d1bcdad3ab7de7182feb4f1c9148a5ff97cef19581eec9257112aace94889eee9a1ad12e40ce59453ac05f52453b5fdb49ff76a31af8ccdbaaa4471ef3
languageName: node
linkType: hard
"@vitest/pretty-format@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/pretty-format@npm:4.1.11"
"@vitest/pretty-format@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/pretty-format@npm:4.1.10"
dependencies:
tinyrainbow: "npm:^3.1.0"
checksum: 10/2dfc2f20dbe1c4dbea33ec42e85a8b5648aa6585521bea47573406f5cefb81cd3b86b71f981c4e3d69946e77252cb42a700416c1dc656bb0478cb2932c953cdc
checksum: 10/e4f6907143ab0e40dda29d70b17027586c92921d622091321f10512e660b3995dcee7aa56e17b750b72560f295e25f96035372348415f18ebfd39b66a55b4704
languageName: node
linkType: hard
"@vitest/runner@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/runner@npm:4.1.11"
"@vitest/runner@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/runner@npm:4.1.10"
dependencies:
"@vitest/utils": "npm:4.1.11"
"@vitest/utils": "npm:4.1.10"
pathe: "npm:^2.0.3"
checksum: 10/5247df824fa28b458ba0102592dfec50707982193b62076db941fbe5d7c88fb7067e68a33c194db0092bbe35459cfbeaed33b3c667e5f02192c18baeb4f56239
checksum: 10/2c962cb13af0880990036808a35679b7ac6657c8f542490234c2faa6ffd2ab080ac6bf21b487c64d84aa635cfb37b49eb679098c2003a100dfc6c4d5e87bf055
languageName: node
linkType: hard
"@vitest/snapshot@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/snapshot@npm:4.1.11"
"@vitest/snapshot@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/snapshot@npm:4.1.10"
dependencies:
"@vitest/pretty-format": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
magic-string: "npm:^0.30.21"
pathe: "npm:^2.0.3"
checksum: 10/5d096373fb4b102f65ff884844a18c2d2e7d88caf68a64a842a245573311b2d531ca5a94ebf7e4fe39324e71a78670f74c949d4ec2cad3764c3f4c272b84d982
checksum: 10/7940d83ffd2fbebf9a04ea31e196b7e8bf981093ec739950959fe8dd29caa33c80823780fb4b1063d9459c44a0a8d8b2748c00dfb6941becd7404e6d687eea01
languageName: node
linkType: hard
"@vitest/spy@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/spy@npm:4.1.11"
checksum: 10/d49a7ed7501080e5f817d61250a169a46fcc7901887e4985a1e08705ce79aea8d1edcffd74f4dc6669ea1bc3d717a39354ce89c67188d81a63dc439d42f195f6
"@vitest/spy@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/spy@npm:4.1.10"
checksum: 10/7c1b79a95474338e0659f0f2e43be4df1ef7939ff5b37b044954e0287582947803bd417508f44a7f244809672309d9b3dd67660b704ec3fe7f323cc958ae47a3
languageName: node
linkType: hard
"@vitest/utils@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/utils@npm:4.1.11"
"@vitest/utils@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/utils@npm:4.1.10"
dependencies:
"@vitest/pretty-format": "npm:4.1.11"
"@vitest/pretty-format": "npm:4.1.10"
convert-source-map: "npm:^2.0.0"
tinyrainbow: "npm:^3.1.0"
checksum: 10/f05381e12d0926db7b01bfaae9a577fa664d36b96c23df185e4b0f6dad3a9fb59ac00931613da53b4511ee6ab473a14ac500c72c5ec5e9b3c3042875051f20c4
checksum: 10/95484aad55c7b00bbcd4963e27cbb86fe207620a6093973d68da9d0a06bad37c388d84c9ab43d5f35d88e46c8f376a5592d9c54c025c958361160f4802bb25ee
languageName: node
linkType: hard
@@ -10047,7 +10047,7 @@ __metadata:
"@types/tar": "npm:7.0.87"
"@typescript/native": "npm:[email protected]"
"@vibrant/color": "npm:4.0.4"
"@vitest/coverage-v8": "npm:4.1.11"
"@vitest/coverage-v8": "npm:4.1.10"
"@vvo/tzdb": "npm:6.198.0"
"@webcomponents/scoped-custom-element-registry": "npm:0.0.10"
"@webcomponents/webcomponentsjs": "npm:2.8.0"
@@ -10134,7 +10134,7 @@ __metadata:
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.67.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.11"
vitest: "npm:4.1.10"
webpack-stats-plugin: "npm:1.1.3"
webpackbar: "npm:7.0.0"
weekstart: "npm:2.0.0"
@@ -15470,17 +15470,17 @@ __metadata:
languageName: node
linkType: hard
"vitest@npm:4.1.11":
version: 4.1.11
resolution: "vitest@npm:4.1.11"
"vitest@npm:4.1.10":
version: 4.1.10
resolution: "vitest@npm:4.1.10"
dependencies:
"@vitest/expect": "npm:4.1.11"
"@vitest/mocker": "npm:4.1.11"
"@vitest/pretty-format": "npm:4.1.11"
"@vitest/runner": "npm:4.1.11"
"@vitest/snapshot": "npm:4.1.11"
"@vitest/spy": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
"@vitest/expect": "npm:4.1.10"
"@vitest/mocker": "npm:4.1.10"
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/runner": "npm:4.1.10"
"@vitest/snapshot": "npm:4.1.10"
"@vitest/spy": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
es-module-lexer: "npm:^2.0.0"
expect-type: "npm:^1.3.0"
magic-string: "npm:^0.30.21"
@@ -15498,12 +15498,12 @@ __metadata:
"@edge-runtime/vm": "*"
"@opentelemetry/api": ^1.9.0
"@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
"@vitest/browser-playwright": 4.1.11
"@vitest/browser-preview": 4.1.11
"@vitest/browser-webdriverio": 4.1.11
"@vitest/coverage-istanbul": 4.1.11
"@vitest/coverage-v8": 4.1.11
"@vitest/ui": 4.1.11
"@vitest/browser-playwright": 4.1.10
"@vitest/browser-preview": 4.1.10
"@vitest/browser-webdriverio": 4.1.10
"@vitest/coverage-istanbul": 4.1.10
"@vitest/coverage-v8": 4.1.10
"@vitest/ui": 4.1.10
happy-dom: "*"
jsdom: "*"
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -15534,7 +15534,7 @@ __metadata:
optional: false
bin:
vitest: ./vitest.mjs
checksum: 10/054f1e25d90d911693b0b93c5b85a7c3105775aa3e39c3279c7d3e7af719e2d94070a898d6d74292445366c1215bb91d07063989ac0965973f0c5ae22ad3b06b
checksum: 10/020843460fe696c23be2a363634dde4daf54625f1c443c24066ba3f87c478b0ccfdd5124343ba30eb092f54902ffea09f4bed0af4a16a9ee805e494ee2dce34e
languageName: node
linkType: hard