mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-01 14:49:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cc701f778 |
+111
-5
@@ -42,6 +42,7 @@ export const updateHistoryState = (patch: Record<string, unknown>) => {
|
||||
*/
|
||||
export const replaceCurrentUrl = (url: string) => {
|
||||
mainWindow.history.replaceState(mainWindow.history.state, "", url);
|
||||
rememberCurrentEntry();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -100,6 +101,9 @@ export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
|
||||
unsavedChangesGuards.delete(guard);
|
||||
};
|
||||
|
||||
const dirtyGuards = (): UnsavedChangesGuard[] =>
|
||||
[...unsavedChangesGuards].filter((guard) => guard.isDirty());
|
||||
|
||||
let pendingUnsavedPrompt: Promise<boolean> | undefined;
|
||||
|
||||
/**
|
||||
@@ -109,6 +113,43 @@ let pendingUnsavedPrompt: Promise<boolean> | undefined;
|
||||
*/
|
||||
let committedNavigations = 0;
|
||||
|
||||
interface HistoryEntry {
|
||||
path: string;
|
||||
/** Path of the entry behind this one, stamped by `performNavigation`. */
|
||||
from?: string;
|
||||
}
|
||||
|
||||
const readEntry = (): HistoryEntry => ({
|
||||
path: currentPath(),
|
||||
from: mainWindow.history.state?.from,
|
||||
});
|
||||
|
||||
/**
|
||||
* How far a pop moved from `entry`, signed, or undefined when it cannot be told:
|
||||
* the entry behind us is the one our `from` names, the one ahead is the one
|
||||
* whose `from` names us. A stack with the same path on both sides matches both,
|
||||
* and back is then by far the likelier press.
|
||||
*/
|
||||
const popStep = (entry: HistoryEntry): number | undefined => {
|
||||
if (currentPath() === entry.from) {
|
||||
return -1;
|
||||
}
|
||||
return mainWindow.history.state?.from === entry.path ? 1 : undefined;
|
||||
};
|
||||
|
||||
let currentEntry: HistoryEntry = readEntry();
|
||||
|
||||
const rememberCurrentEntry = () => {
|
||||
currentEntry = readEntry();
|
||||
};
|
||||
|
||||
/** Where the pops held while a prompt is open left us. */
|
||||
let heldEntry: HistoryEntry | undefined;
|
||||
let heldSteps = 0;
|
||||
|
||||
/** The entry `goBack()` asked to leave; the pop it triggers is not prompted. */
|
||||
let popRequestedFromPath: string | undefined;
|
||||
|
||||
/**
|
||||
* Asks each dirty guard whether navigation may proceed. Returns true when
|
||||
* nothing is dirty or every prompt was confirmed. Concurrent navigations
|
||||
@@ -117,16 +158,14 @@ let committedNavigations = 0;
|
||||
* its save action) cannot deadlock on its own promise.
|
||||
*/
|
||||
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
|
||||
const dirtyGuards = [...unsavedChangesGuards].filter((guard) =>
|
||||
guard.isDirty()
|
||||
);
|
||||
if (!dirtyGuards.length) {
|
||||
const guards = dirtyGuards();
|
||||
if (!guards.length) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (!pendingUnsavedPrompt) {
|
||||
pendingUnsavedPrompt = (async () => {
|
||||
try {
|
||||
for (const guard of dirtyGuards) {
|
||||
for (const guard of guards) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!(await guard.prompt())) {
|
||||
return false;
|
||||
@@ -185,6 +224,7 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
);
|
||||
}
|
||||
|
||||
rememberCurrentEntry();
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
@@ -235,9 +275,75 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
// Read after closing dialogs: their history entries are popped by then, so
|
||||
// this is the state of the page entry.
|
||||
if (canGoBack()) {
|
||||
popRequestedFromPath = currentEntry.path;
|
||||
mainWindow.history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
await performNavigation(fallbackPath || "/", { replace: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles a history pop before the router acts on it. A pop cannot be cancelled,
|
||||
* so one away from a page with unsaved changes holds the route and prompts;
|
||||
* `resume` runs once the user agrees to leave. The prompt must add no history
|
||||
* entry of its own, or the entries the pop left would be truncated.
|
||||
*/
|
||||
export const handleHistoryPop = (resume: () => void): void => {
|
||||
if (heldEntry) {
|
||||
const heldStep = popStep(heldEntry);
|
||||
if (heldStep !== undefined) {
|
||||
heldSteps += heldStep;
|
||||
heldEntry = readEntry();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPath() === currentEntry.path) {
|
||||
// A dialog's history entry was popped, not a page.
|
||||
rememberCurrentEntry();
|
||||
resume();
|
||||
return;
|
||||
}
|
||||
|
||||
const step = popStep(currentEntry);
|
||||
if (
|
||||
popRequestedFromPath === currentEntry.path ||
|
||||
// Not a pop we could undo, so do not hold it.
|
||||
step === undefined ||
|
||||
!dirtyGuards().length
|
||||
) {
|
||||
popRequestedFromPath = undefined;
|
||||
rememberCurrentEntry();
|
||||
committedNavigations += 1;
|
||||
resume();
|
||||
return;
|
||||
}
|
||||
|
||||
heldSteps = step;
|
||||
heldEntry = readEntry();
|
||||
const navigationsAtPop = committedNavigations;
|
||||
ensureUnsavedChangesConfirmed().then(
|
||||
(confirmed) => {
|
||||
const steps = heldSteps;
|
||||
heldEntry = undefined;
|
||||
if (committedNavigations !== navigationsAtPop) {
|
||||
// A navigation landed while the prompt was open.
|
||||
return;
|
||||
}
|
||||
if (!confirmed) {
|
||||
// go(0) would reload the document, and at zero we are already back.
|
||||
if (steps) {
|
||||
mainWindow.history.go(-steps);
|
||||
}
|
||||
return;
|
||||
}
|
||||
rememberCurrentEntry();
|
||||
committedNavigations += 1;
|
||||
resume();
|
||||
},
|
||||
() => {
|
||||
heldEntry = undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { TemplateResult } from "lit";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
|
||||
interface BaseDialogBoxParams {
|
||||
/** Whether back closes the dialog. Off while a navigation is in flight. */
|
||||
addHistory?: boolean;
|
||||
confirmText?: string;
|
||||
text?: string | TemplateResult;
|
||||
title?: string;
|
||||
@@ -61,6 +63,7 @@ const showDialogHelper = (
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-box",
|
||||
dialogImport: loadGenericDialog,
|
||||
addHistory: dialogParams.addHistory,
|
||||
dialogParams: {
|
||||
...dialogParams,
|
||||
...extra,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { storage } from "../common/decorators/storage";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { navigate } from "../common/navigate";
|
||||
import { handleHistoryPop, navigate } from "../common/navigate";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { decodeMoreInfoUrl } from "../common/url/more-info-query-params";
|
||||
import { extractSearchParamsObject } from "../common/url/search-params";
|
||||
@@ -180,8 +180,10 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
|
||||
window.addEventListener("location-changed", () => updateRoute());
|
||||
|
||||
// Handle history changes
|
||||
window.addEventListener("popstate", () => updateRoute());
|
||||
// A pop away from a page with unsaved changes is held until the user agrees.
|
||||
window.addEventListener("popstate", () =>
|
||||
handleHistoryPop(() => updateRoute())
|
||||
);
|
||||
|
||||
// Restore a more-info dialog deep-linked in the current URL, if any.
|
||||
window.addEventListener("location-changed", () =>
|
||||
|
||||
@@ -7,6 +7,8 @@ export const loadAutomationSaveDialog = () =>
|
||||
import("./dialog-automation-save");
|
||||
|
||||
interface BaseRenameDialogParams {
|
||||
/** Whether back closes the dialog. Off while a navigation is in flight. */
|
||||
addHistory?: boolean;
|
||||
entityRegistryUpdate?: EntityRegistryUpdate;
|
||||
entityRegistryEntry?: EntityRegistryEntry;
|
||||
onClose: () => void;
|
||||
@@ -52,5 +54,6 @@ export const showAutomationSaveDialog = (
|
||||
dialogTag: "ha-dialog-automation-save",
|
||||
dialogImport: loadAutomationSaveDialog,
|
||||
dialogParams,
|
||||
addHistory: dialogParams.addHistory,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -869,13 +869,14 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
this.errors = undefined;
|
||||
}
|
||||
|
||||
protected async confirmUnsavedChanged(): Promise<boolean> {
|
||||
protected async confirmUnsavedChanged(addHistory = true): Promise<boolean> {
|
||||
if (!this.isDirtyState) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
showAutomationSaveDialog(this, {
|
||||
addHistory,
|
||||
config: this.config!,
|
||||
domain: "automation",
|
||||
updateConfig: async (config, entityRegistryUpdate) => {
|
||||
|
||||
@@ -261,15 +261,16 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
};
|
||||
|
||||
protected async promptDiscardChanges() {
|
||||
return this.confirmUnsavedChanged();
|
||||
return this.confirmUnsavedChanged(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks whether unsaved changes should be discarded.
|
||||
* Subclasses must override this to show a confirmation dialog.
|
||||
* @param addHistory false while a navigation is already in flight.
|
||||
* @returns true to proceed (discard/save changes), false to cancel.
|
||||
*/
|
||||
protected confirmUnsavedChanged(): Promise<boolean> {
|
||||
protected confirmUnsavedChanged(_addHistory = true): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -1125,11 +1125,12 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
goBack("/config/scene/dashboard");
|
||||
}
|
||||
|
||||
private async _confirmUnsavedChanged(): Promise<boolean> {
|
||||
private async _confirmUnsavedChanged(addHistory = true): Promise<boolean> {
|
||||
if (!this.isDirtyState) {
|
||||
return true;
|
||||
}
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
addHistory,
|
||||
title: this.hass!.localize(
|
||||
"ui.panel.config.scene.editor.unsaved_confirm_title"
|
||||
),
|
||||
@@ -1408,7 +1409,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
}
|
||||
|
||||
protected async promptDiscardChanges() {
|
||||
return this._confirmUnsavedChanged();
|
||||
return this._confirmUnsavedChanged(false);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -777,13 +777,14 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
this.errors = undefined;
|
||||
}
|
||||
|
||||
protected async confirmUnsavedChanged(): Promise<boolean> {
|
||||
protected async confirmUnsavedChanged(addHistory = true): Promise<boolean> {
|
||||
if (!this.isDirtyState) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
showAutomationSaveDialog(this, {
|
||||
addHistory,
|
||||
config: this.config!,
|
||||
domain: "script",
|
||||
updateConfig: async (config, entityRegistryUpdate) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
showConfirmationDialog,
|
||||
} from "../../dialogs/generic/show-dialog-box";
|
||||
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
|
||||
import { PreventUnsavedMixin } from "../../mixins/prevent-unsaved-mixin";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { Lovelace } from "./types";
|
||||
@@ -34,7 +35,7 @@ const strategyStruct = type({
|
||||
|
||||
@customElement("hui-editor")
|
||||
class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
|
||||
LitElement
|
||||
PreventUnsavedMixin(LitElement)
|
||||
) {
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@@ -171,17 +172,17 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
|
||||
this._config = ev.detail.isValid ? ev.detail.value : undefined;
|
||||
this._yamlError = ev.detail.errorMsg;
|
||||
this._updateDirtyState(this.yamlEditor.yaml);
|
||||
if (this.isDirtyState && !window.onbeforeunload) {
|
||||
window.onbeforeunload = () => true;
|
||||
} else if (!this.isDirtyState && window.onbeforeunload) {
|
||||
window.onbeforeunload = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _closeEditor() {
|
||||
/**
|
||||
* Also closes the editor: it is a panel state rather than a route, so leaving
|
||||
* by a navigation never reaches `_closeEditor` and the panel can be cached.
|
||||
*/
|
||||
private async _confirmDiscard(addHistory: boolean): Promise<boolean> {
|
||||
if (
|
||||
this.isDirtyState &&
|
||||
!(await showConfirmationDialog(this, {
|
||||
addHistory,
|
||||
text: this.hass.localize(
|
||||
"ui.panel.lovelace.editor.raw_editor.confirm_unsaved_changes"
|
||||
),
|
||||
@@ -189,13 +190,20 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
|
||||
confirmText: this.hass!.localize("ui.common.leave"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
window.onbeforeunload = null;
|
||||
if (this.closeEditor) {
|
||||
this.closeEditor();
|
||||
}
|
||||
this._markDirtyStateClean();
|
||||
this.closeEditor?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async promptDiscardChanges(): Promise<boolean> {
|
||||
return this._confirmDiscard(false);
|
||||
}
|
||||
|
||||
private async _closeEditor() {
|
||||
await this._confirmDiscard(true);
|
||||
}
|
||||
|
||||
private async _resetConfig() {
|
||||
@@ -210,7 +218,7 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.onbeforeunload = null;
|
||||
this._markDirtyStateClean();
|
||||
if (this.closeEditor) {
|
||||
this.closeEditor();
|
||||
}
|
||||
@@ -290,7 +298,6 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
|
||||
),
|
||||
});
|
||||
}
|
||||
window.onbeforeunload = null;
|
||||
this._markDirtyStateClean();
|
||||
this._saving = false;
|
||||
}
|
||||
|
||||
+249
-35
@@ -7,6 +7,7 @@ import type {
|
||||
import {
|
||||
canGoBack,
|
||||
goBack,
|
||||
handleHistoryPop,
|
||||
navigate,
|
||||
registerUnsavedChangesGuard,
|
||||
unregisterUnsavedChangesGuard,
|
||||
@@ -22,6 +23,41 @@ const setEntry = (path: string, state: unknown = null) => {
|
||||
window.history.replaceState(state, "", path);
|
||||
};
|
||||
|
||||
const registeredGuards: UnsavedChangesGuard[] = [];
|
||||
|
||||
// Registering through this keeps the cleanup able to reach the module-level
|
||||
// registry, which outlives the test that filled it.
|
||||
const trackGuard = <T extends UnsavedChangesGuard>(guard: T): T => {
|
||||
registerUnsavedChangesGuard(guard);
|
||||
registeredGuards.push(guard);
|
||||
return guard;
|
||||
};
|
||||
|
||||
const registerGuard = (isDirty: boolean, promptResult = true) =>
|
||||
trackGuard({
|
||||
isDirty: vi.fn(() => isDirty),
|
||||
prompt: vi.fn(async () => promptResult),
|
||||
});
|
||||
|
||||
// A guard whose prompt stays open until the test answers it.
|
||||
const trackDeferredGuard = (isDirty: () => boolean = () => true) => {
|
||||
let answer!: (value: boolean) => void;
|
||||
const guard = trackGuard({
|
||||
isDirty,
|
||||
prompt: vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
answer = resolve;
|
||||
})
|
||||
),
|
||||
});
|
||||
return { guard, answerPrompt: (value: boolean) => answer(value) };
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
|
||||
});
|
||||
|
||||
describe("navigate", () => {
|
||||
beforeEach(() => {
|
||||
setEntry("/config");
|
||||
@@ -64,45 +100,10 @@ describe("navigate", () => {
|
||||
});
|
||||
|
||||
describe("unsaved changes guard", () => {
|
||||
const registeredGuards: UnsavedChangesGuard[] = [];
|
||||
|
||||
// Registering through this keeps afterEach able to clean up the module-level
|
||||
// registry, which outlives the test that filled it.
|
||||
const trackGuard = <T extends UnsavedChangesGuard>(guard: T): T => {
|
||||
registerUnsavedChangesGuard(guard);
|
||||
registeredGuards.push(guard);
|
||||
return guard;
|
||||
};
|
||||
|
||||
const registerGuard = (isDirty: boolean, promptResult = true) =>
|
||||
trackGuard({
|
||||
isDirty: vi.fn(() => isDirty),
|
||||
prompt: vi.fn(async () => promptResult),
|
||||
});
|
||||
|
||||
// A guard whose prompt stays open until the test answers it.
|
||||
const trackDeferredGuard = (isDirty: () => boolean) => {
|
||||
let answer!: (value: boolean) => void;
|
||||
const guard = trackGuard({
|
||||
isDirty,
|
||||
prompt: vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
answer = resolve;
|
||||
})
|
||||
),
|
||||
});
|
||||
return { guard, answerPrompt: (value: boolean) => answer(value) };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setEntry("/config");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
|
||||
});
|
||||
|
||||
it("navigates without prompting when no guard is dirty", async () => {
|
||||
const guard = registerGuard(false);
|
||||
|
||||
@@ -238,3 +239,216 @@ describe("goBack", () => {
|
||||
expect(window.location.pathname).toEqual("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleHistoryPop", () => {
|
||||
// What the browser leaves behind before the router hears about a pop.
|
||||
const pop = (path: string, from = "/config") => {
|
||||
window.history.replaceState({ from }, "", path);
|
||||
const resume = vi.fn();
|
||||
handleHistoryPop(resume);
|
||||
return resume;
|
||||
};
|
||||
|
||||
// The editor is pushed from the dashboard, so the entry behind it is BACK and
|
||||
// any entry ahead of it records the editor as its own `from`.
|
||||
const EDITOR = "/config/automation/edit/1";
|
||||
const BACK = "/config/automation/dashboard";
|
||||
const popForward = (path: string) => pop(path, EDITOR);
|
||||
|
||||
// Lets the answered prompt's continuation run, so "did not act" is assertable.
|
||||
const flush = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve);
|
||||
});
|
||||
|
||||
let go: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
go = vi.spyOn(window.history, "go").mockImplementation(() => undefined);
|
||||
vi.spyOn(window.history, "back").mockImplementation(() => undefined);
|
||||
// Seed the tracked entry the way the app does.
|
||||
setEntry("/config/automation/dashboard");
|
||||
await navigate("/config/automation/edit/1");
|
||||
});
|
||||
|
||||
it("routes a pop when nothing is dirty", () => {
|
||||
const guard = registerGuard(false);
|
||||
|
||||
expect(pop(BACK)).toHaveBeenCalledOnce();
|
||||
expect(guard.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("holds the route and prompts on a pop away from a dirty page", async () => {
|
||||
const guard = registerGuard(true, false);
|
||||
|
||||
const resume = pop(BACK);
|
||||
|
||||
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
|
||||
await flush();
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not prompt for a pop that stays on the same path", () => {
|
||||
const guard = registerGuard(true, false);
|
||||
|
||||
// A dialog closing pops its own entry without changing the URL.
|
||||
expect(pop("/config/automation/edit/1")).toHaveBeenCalledOnce();
|
||||
expect(guard.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("guards a forward traversal and declines it backwards", async () => {
|
||||
const guard = registerGuard(true, false);
|
||||
|
||||
const resume = popForward("/history");
|
||||
|
||||
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
// Undoing a forward means going back, not forward again.
|
||||
await vi.waitFor(() => expect(go).toHaveBeenCalledWith(-1));
|
||||
});
|
||||
|
||||
it("reads a pop as back when both neighbours share a path", async () => {
|
||||
const guard = registerGuard(true, false);
|
||||
|
||||
// editor -> dashboard -> editor: the dashboard is both the entry behind us
|
||||
// and, by path, one that could be ahead. Treating it as forward would leave
|
||||
// a real back press unguarded.
|
||||
const resume = pop(BACK, EDITOR);
|
||||
|
||||
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(go).toHaveBeenCalledWith(1));
|
||||
});
|
||||
|
||||
it("does not reload when the held pops cancel out", async () => {
|
||||
const { guard, answerPrompt } = trackDeferredGuard();
|
||||
|
||||
pop(BACK);
|
||||
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
|
||||
// Forward again while the prompt is up, back to where we started.
|
||||
pop(EDITOR, BACK);
|
||||
|
||||
answerPrompt(false);
|
||||
await flush();
|
||||
|
||||
// go(0) would reload the document and lose the edits it just protected.
|
||||
expect(go).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not guard a jump over several entries", async () => {
|
||||
const guard = registerGuard(true, false);
|
||||
|
||||
// A long press on back skips past the entry the editor came from.
|
||||
expect(pop("/config")).toHaveBeenCalledOnce();
|
||||
|
||||
await flush();
|
||||
expect(guard.prompt).not.toHaveBeenCalled();
|
||||
expect(go).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes once when the prompt is confirmed", async () => {
|
||||
registerGuard(true, true);
|
||||
|
||||
const resume = pop(BACK);
|
||||
|
||||
await vi.waitFor(() => expect(resume).toHaveBeenCalledOnce());
|
||||
expect(go).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns to the page when the prompt is declined", async () => {
|
||||
registerGuard(true, false);
|
||||
|
||||
const resume = pop(BACK);
|
||||
|
||||
await vi.waitFor(() => expect(go).toHaveBeenCalledWith(1));
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("undoes every pop taken while the prompt was open", async () => {
|
||||
const { guard, answerPrompt } = trackDeferredGuard();
|
||||
|
||||
pop(BACK);
|
||||
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
|
||||
// Impatient second back press while the prompt is still up.
|
||||
const resume = pop("/config");
|
||||
|
||||
answerPrompt(false);
|
||||
|
||||
await vi.waitFor(() => expect(go).toHaveBeenCalledWith(2));
|
||||
expect(guard.prompt).toHaveBeenCalledOnce();
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not count a held pop it cannot account for", async () => {
|
||||
const { guard, answerPrompt } = trackDeferredGuard();
|
||||
|
||||
pop(BACK);
|
||||
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
|
||||
// Cannot be undone, so it must not inflate what declining restores.
|
||||
pop("/history");
|
||||
|
||||
answerPrompt(false);
|
||||
|
||||
await vi.waitFor(() => expect(go).toHaveBeenCalledWith(1));
|
||||
});
|
||||
|
||||
it("tracks the entry it loaded on, so a pop after a reload is guarded", async () => {
|
||||
// A reload keeps the entry's state but re-evaluates the module, so the
|
||||
// entry has to be read at init rather than left with no `from`.
|
||||
vi.resetModules();
|
||||
setEntry(EDITOR, { from: BACK });
|
||||
const fresh = await import("../../src/common/navigate");
|
||||
const guard = { isDirty: () => true, prompt: vi.fn(async () => false) };
|
||||
fresh.registerUnsavedChangesGuard(guard);
|
||||
|
||||
window.history.replaceState({ from: "/config" }, "", BACK);
|
||||
const resume = vi.fn();
|
||||
fresh.handleHistoryPop(resume);
|
||||
|
||||
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
fresh.unregisterUnsavedChangesGuard(guard);
|
||||
});
|
||||
|
||||
it("does not prompt for the pop goBack asked for", async () => {
|
||||
const guard = registerGuard(true, false);
|
||||
|
||||
await goBack();
|
||||
|
||||
expect(pop(BACK)).toHaveBeenCalledOnce();
|
||||
expect(guard.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a pop once a navigation has moved the app on", async () => {
|
||||
let dirty = true;
|
||||
const { guard, answerPrompt } = trackDeferredGuard(() => dirty);
|
||||
|
||||
const resume = pop(BACK);
|
||||
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
|
||||
// The prompt's save action can clean the page and navigate on its own.
|
||||
dirty = false;
|
||||
await navigate("/config/areas");
|
||||
|
||||
answerPrompt(true);
|
||||
await flush();
|
||||
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
expect(window.location.pathname).toEqual("/config/areas");
|
||||
});
|
||||
|
||||
it("shares one prompt with a navigation, and the pop wins", async () => {
|
||||
const { guard, answerPrompt } = trackDeferredGuard();
|
||||
|
||||
// The back press opens the prompt, so it is the one that gets answered.
|
||||
const resume = pop(BACK);
|
||||
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
|
||||
const navigation = navigate("/config/areas");
|
||||
|
||||
answerPrompt(true);
|
||||
|
||||
expect(await navigation).toBe(false);
|
||||
expect(resume).toHaveBeenCalledOnce();
|
||||
expect(window.location.pathname).toEqual("/config/automation/dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ShowDialogParams } from "../../../src/dialogs/make-dialog-manager";
|
||||
import { showConfirmationDialog } from "../../../src/dialogs/generic/show-dialog-box";
|
||||
|
||||
// The unsaved-changes guard needs its prompt to add no history entry, or the
|
||||
// entries a pop left are truncated before declining can restore them. Losing
|
||||
// this pass-through breaks that silently.
|
||||
const firedDetail = (params: Parameters<typeof showConfirmationDialog>[1]) => {
|
||||
const element = document.createElement("div");
|
||||
let detail: ShowDialogParams<unknown> | undefined;
|
||||
element.addEventListener("show-dialog", (ev) => {
|
||||
detail = (ev as CustomEvent<ShowDialogParams<unknown>>).detail;
|
||||
});
|
||||
showConfirmationDialog(element, params);
|
||||
return detail;
|
||||
};
|
||||
|
||||
describe("showConfirmationDialog", () => {
|
||||
it("passes addHistory through to the dialog manager", () => {
|
||||
expect(firedDetail({ text: "x", addHistory: false })?.addHistory).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves addHistory unset when the caller does not ask", () => {
|
||||
// The manager then defaults it to true, so back closes the dialog.
|
||||
expect(firedDetail({ text: "x" })?.addHistory).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { navigate } from "../../src/common/navigate";
|
||||
import { handleHistoryPop, navigate } from "../../src/common/navigate";
|
||||
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
|
||||
import { PreventUnsavedMixin } from "../../src/mixins/prevent-unsaved-mixin";
|
||||
|
||||
@@ -126,6 +126,26 @@ describe("PreventUnsavedMixin", () => {
|
||||
expect(window.location.pathname).toEqual("/config/scene/dashboard");
|
||||
});
|
||||
|
||||
it("prompts on a history pop away from the page", async () => {
|
||||
// Reach the editor while still clean, so the entry records the page behind it.
|
||||
setEntry("/config/automation/dashboard");
|
||||
const element = await mountClean();
|
||||
await navigate("/config/automation/edit/1234");
|
||||
|
||||
element.promptResponse = false;
|
||||
element.setValue({ name: "Bedroom" });
|
||||
await element.updateComplete;
|
||||
|
||||
// Fake the browser popping back to the page behind the editor.
|
||||
window.history.replaceState(null, "", "/config/automation/dashboard");
|
||||
const resume = vi.fn();
|
||||
|
||||
handleHistoryPop(resume);
|
||||
|
||||
await vi.waitFor(() => expect(element.promptCalls).toBe(1));
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("arms beforeunload only while dirty", async () => {
|
||||
const element = await mountDirty();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user