mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-21 06:08:25 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecc1a7de24 | ||
|
|
84369dee0d | ||
|
|
ebab30171b | ||
|
|
28b6b8069a |
+178
-62
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user