Compare commits

...
Author SHA1 Message Date
Bruno Pantaleão 9ab95b701f Relay navigation out of the standalone more-info page to the app
Links out of the more-info content (device page, entity editor, related
items) are in-page navigations, which would turn the standalone page
into the framed frontend inside the app's screen. `navigate()` gains a
small interceptor hook; the standalone page registers one while an
external app is present and sends `more_info/navigate` with the path
instead, so the app can show the destination in its main frontend and
dismiss the screen. The interceptor runs after the unsaved-changes
prompt, so leaving is confirmed before the app is asked.

Without an app the page falls back to the framed frontend as before.
2026-09-17 17:06:31 +02:00
Bruno Pantaleão d91fe942d7 Add a standalone more-info page for companion apps to show natively
The frontend serves `/more-info?more-info-entity-id=<id>` as a frameless
page: the more-info dialog's content without sidebar, panel or dialog
frame. Companion apps load it in a screen of their own to give entity
details a native feel.

- `ha-adaptive-dialog` gains a `standalone` mode that renders its slots
  as a full-viewport page instead of a dialog or bottom sheet.
- `ha-more-info-dialog` gains a `standalone` property. Closing sends
  `more_info/close` on the external bus instead of hiding a dialog, so
  the app owning the screen can dismiss it. It reports no
  `more_info/opened` or `more_info/closed` in that mode.
- `ha-more-info-page` reads the entity and view from the same query
  parameters as the more-info deep link, answers the app's external bus
  commands (the main layout is not rendered on this route), removes the
  launch screen and sends `frontend/loaded`.
- The root skips the deep-link dialog restore on the standalone route.

When the external app reports `hasNativeMoreInfo`, every request to open
the more-info dialog is sent as `more_info/open` with the entity id
instead of opening the dialog, so the app can show the entity in that
screen. The URL rewrite that strips the more-info deep-link parameters
still runs first, so a deep link hands off once and does not re-trigger
on later navigations. A request without an entity id, which would only
close an open dialog, sends nothing.
2026-09-17 17:06:31 +02:00
12 changed files with 493 additions and 29 deletions
+29
View File
@@ -104,6 +104,28 @@ export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
const dirtyGuards = (): UnsavedChangesGuard[] =>
[...unsavedChangesGuards].filter((guard) => guard.isDirty());
/**
* Lets a host that shows a single page take over navigation to other pages.
* The standalone more-info page runs inside a native screen of a companion
* app, and a link out of it belongs in the app's main frontend, not in that
* screen. Return true to claim the navigation; nothing is then navigated here.
*/
export type NavigationInterceptor = (path: string) => boolean;
const navigationInterceptors = new Set<NavigationInterceptor>();
export const registerNavigationInterceptor = (
interceptor: NavigationInterceptor
) => {
navigationInterceptors.add(interceptor);
};
export const unregisterNavigationInterceptor = (
interceptor: NavigationInterceptor
) => {
navigationInterceptors.delete(interceptor);
};
let pendingUnsavedPrompt: Promise<boolean> | undefined;
/**
@@ -246,6 +268,13 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
// pulling the user back off the page they are on now.
return false;
}
// After the unsaved-changes prompt, so leaving is confirmed before a host
// is asked to show the destination somewhere else.
for (const interceptor of navigationInterceptors) {
if (interceptor(path)) {
return false;
}
}
}
return performNavigation(path, options);
};
+15
View File
@@ -7,6 +7,17 @@ import type { SearchParamsSource } from "./query-params";
const ENTITY_ID_PARAM = "more-info-entity-id";
const VIEW_PARAM = "more-info-view";
/**
* Route that shows the more-info dialog as a frameless page, without the
* sidebar or a panel around it. Meant for external apps that embed it in a
* native screen. The entity and view are read from the same query parameters
* as the more-info deep link on any other route.
*/
export const MORE_INFO_STANDALONE_PATH = "/more-info";
export const isMoreInfoStandalonePath = (path: string): boolean =>
path.replace(/\/+$/, "") === MORE_INFO_STANDALONE_PATH;
export interface MoreInfoUrlData {
entityId?: string;
view?: MoreInfoView;
@@ -46,6 +57,10 @@ export const createMoreInfoUrl = (
return `${url.pathname}${url.search}${url.hash}`;
};
export const createStandaloneMoreInfoUrl = (
data: CreateMoreInfoUrlData
): string => createMoreInfoUrl(MORE_INFO_STANDALONE_PATH, data);
export const removeMoreInfoUrl = (base: string): string => {
const url = new URL(base, window.location.origin);
url.searchParams.delete(ENTITY_ID_PARAM);
+64
View File
@@ -25,6 +25,8 @@ export const ADAPTIVE_DIALOG_MEDIA_QUERY =
* A responsive dialog component that automatically switches between a full dialog (ha-dialog)
* and a bottom sheet (ha-bottom-sheet) based on screen size. Uses dialog mode on larger screens
* (>870px width and >500px height) and bottom sheet mode on smaller screens or mobile devices.
* With `standalone` set, it instead renders the content as a frameless page filling the
* viewport, for an external app that embeds a single dialog in a native screen.
*
* @slot header - Replace the entire header area.
* @slot headerNavigationIcon - Leading header action (e.g. close/back button).
@@ -56,6 +58,7 @@ export const ADAPTIVE_DIALOG_MEDIA_QUERY =
* @attr {string} header-subtitle - Header subtitle text. If not set, the headerSubtitle slot is used.
* @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below".
* @attr {boolean} flexcontent - Makes the content body a flex container.
* @attr {boolean} standalone - Renders the content as a frameless page filling the viewport instead of a dialog or sheet. There is no scrim and `open` is ignored; only a slotted headerNavigationIcon can close it.
* @attr {boolean} without-header - Hides the default header.
* @attr {boolean} hide-close-button - Hides the default close button.
* @attr {boolean} allow-mode-change - When set, the component can switch between dialog and bottom-sheet modes as the viewport changes.
@@ -120,6 +123,9 @@ export class HaAdaptiveDialog extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "flexcontent" })
public flexContent = false;
@property({ type: Boolean, reflect: true })
public standalone = false;
@state() public mode: DialogSheetMode = "dialog";
@state()
@@ -157,6 +163,11 @@ export class HaAdaptiveDialog extends LitElement {
return html`<span slot=${slotName}></span>`;
}
if (this.standalone) {
// A page cannot dismiss itself, so there is no default close button.
return html`<slot name="headerNavigationIcon" slot=${slotName}></slot>`;
}
return html`
<slot name="headerNavigationIcon" slot=${slotName}>
<ha-icon-button
@@ -197,6 +208,24 @@ export class HaAdaptiveDialog extends LitElement {
}
render() {
if (this.standalone) {
return html`
<div class="standalone">
${
!this.withoutHeader
? html`
<slot name="header">${this._renderHeaderContent()}</slot>
`
: nothing
}
<div class="standalone-content">
<slot></slot>
</div>
<slot name="footer"></slot>
</div>
`;
}
if (this.mode === "bottom-sheet") {
return html`
<ha-bottom-sheet
@@ -248,6 +277,41 @@ export class HaAdaptiveDialog extends LitElement {
static get styles() {
return [
css`
.standalone {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: var(--safe-area-inset-top, 0px)
var(--safe-area-inset-right, 0px) var(--safe-area-inset-bottom, 0px)
var(--safe-area-inset-left, 0px);
background: var(
--ha-dialog-surface-background,
var(--card-background-color, var(--ha-color-surface-default))
);
color: var(--primary-text-color);
}
.standalone ha-dialog-header {
flex: none;
}
.standalone-content {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: var(
--dialog-content-padding,
0 var(--ha-space-6) var(--ha-space-6) var(--ha-space-6)
);
}
:host([flexcontent]) .standalone-content {
display: flex;
flex-direction: column;
}
ha-bottom-sheet {
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
--ha-bottom-sheet-surface-background: var(
+71 -25
View File
@@ -141,6 +141,13 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@property({ type: Boolean, reflect: true }) public large = false;
/**
* Render as a frameless page filling the viewport instead of a dialog, for
* an external app that embeds it in a native screen. Closing then sends
* `more_info/close` on the external bus instead of hiding a dialog.
*/
@property({ type: Boolean, reflect: true }) public standalone = false;
@state() private _fill = false;
@state() private _open = false;
@@ -153,6 +160,11 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _entityId?: string | null;
/** The entity currently shown; the dialog may have followed a related one. */
public get entityId(): string | null | undefined {
return this._entityId;
}
@state() private _data?: Record<string, any>;
private _returnUrl?: string;
@@ -219,6 +231,10 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
public closeDialog() {
if (this.standalone) {
this._requestExternalClose();
return;
}
const dialog = this._dialogElement?.shadowRoot?.querySelector("ha-dialog");
if (dialog) {
fireEvent(dialog as HTMLElement, "dialog-set-fullscreen", false);
@@ -375,8 +391,18 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
private _goToDevice(): void {
const deviceId = this._getDeviceId();
if (!deviceId) return;
navigate(`/config/devices/device/${deviceId}`);
this.closeDialog();
this._leaveTo(`/config/devices/device/${deviceId}`);
}
/**
* Navigate to a page of the app. The standalone page is replaced by the app
* at that route, so there is no dialog to close.
*/
private _leaveTo(path: string) {
navigate(path);
if (!this.standalone) {
this.closeDialog();
}
}
private _goToEdit() {
@@ -393,8 +419,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
idToPassThroughUrl = this._entry.unique_id;
}
navigate(`/config/${domain}/edit/${idToPassThroughUrl}`);
this.closeDialog();
this._leaveTo(`/config/${domain}/edit/${idToPassThroughUrl}`);
}
private _toggleInfoEditMode() {
@@ -637,6 +662,31 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this.hass.translationMetadata.translations
);
// Closing the standalone page asks the external app to dismiss it; the
// button would do nothing without one.
const canClose = !this.standalone || !!this.hass.auth.external;
const navigationIcon = !showCloseIcon
? html`
<ha-icon-button-prev
slot="headerNavigationIcon"
@click=${this._goBack}
.label=${this.hass.localize(
"ui.dialogs.more_info_control.back_to_info"
)}
></ha-icon-button-prev>
`
: canClose
? html`
<ha-icon-button
slot="headerNavigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
`
: nothing;
const childViewContent = this._childView
? html`
<div class="child-view">
@@ -652,6 +702,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
return html`
<ha-adaptive-dialog
.open=${this._open}
.standalone=${this.standalone}
.width=${this._fill ? "full" : this.large ? "large" : "medium"}
@closed=${this._dialogClosed}
@opened=${this._handleOpened}
@@ -663,26 +714,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
flexcontent
>
${
showCloseIcon
? html`
<ha-icon-button
slot="headerNavigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
`
: html`
<ha-icon-button-prev
slot="headerNavigationIcon"
@click=${this._goBack}
.label=${this.hass.localize(
"ui.dialogs.more_info_control.back_to_info"
)}
></ha-icon-button-prev>
`
}
${navigationIcon}
<span slot="headerTitle" @click=${this._enlarge} class="title">
${
breadcrumb.length > 0
@@ -1028,7 +1060,8 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
previousEntityId: string | null | undefined
) {
const external = this.hass.auth.external;
if (!external) {
// The external app opened the standalone page itself, so it already knows.
if (!external || this.standalone) {
return;
}
if (this._entityId) {
@@ -1044,11 +1077,24 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
}
private _requestExternalClose() {
if (!this._entityId) {
return;
}
this.hass.auth.external?.fireMessage({
type: "more_info/close",
payload: { entity_id: this._entityId },
});
}
private _entryUpdated(ev: CustomEvent<ExtEntityRegistryEntry>) {
this._entry = ev.detail;
}
private _enlarge() {
if (this.standalone) {
return;
}
withViewTransition(() => {
this._fill = !this._fill;
});
+150
View File
@@ -0,0 +1,150 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { mainWindow } from "../../common/dom/get_main_window";
import {
registerNavigationInterceptor,
unregisterNavigationInterceptor,
} from "../../common/navigate";
import {
decodeMoreInfoUrl,
isMoreInfoStandalonePath,
} from "../../common/url/more-info-query-params";
import { afterNextRender } from "../../common/util/render-status";
import "../../components/ha-alert";
import type { HomeAssistant } from "../../types";
import { removeLaunchScreen } from "../../util/launch-screen";
import "./ha-more-info-dialog";
import type { MoreInfoDialog } from "./ha-more-info-dialog";
import type { MoreInfoView } from "./more-info-view";
/**
* Frameless page showing the more-info dialog for the entity named in the
* URL, without the sidebar or any panel around it. External apps load it in
* a native screen; the entity and view come from the same query parameters
* as the more-info deep link (`more-info-entity-id`, `more-info-view`).
*/
@customElement("ha-more-info-page")
export class HaMoreInfoPage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _entityId?: string;
@state() private _view?: MoreInfoView;
@query("ha-more-info-dialog") private _dialog?: MoreInfoDialog;
public connectedCallback() {
super.connectedCallback();
this._readUrl();
mainWindow.addEventListener("location-changed", this._readUrl);
mainWindow.addEventListener("popstate", this._readUrl);
registerNavigationInterceptor(this._relayNavigation);
}
public disconnectedCallback() {
super.disconnectedCallback();
mainWindow.removeEventListener("location-changed", this._readUrl);
mainWindow.removeEventListener("popstate", this._readUrl);
unregisterNavigationInterceptor(this._relayNavigation);
}
protected render() {
if (!this._entityId) {
return html`
<ha-alert alert-type="error">
${this.hass.localize("ui.dialogs.more_info_control.no_entity")}
</ha-alert>
`;
}
return html`
<ha-more-info-dialog standalone .hass=${this.hass}></ha-more-info-dialog>
`;
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
// The main frontend is not rendered on this route, so the app's commands
// (navigate to another entity, restart the connection) are answered here.
if (this.hass.auth.external) {
import("../../external_app/external_app_entrypoint").then((mod) =>
mod.attachExternalToApp(this)
);
}
// There is no panel to wait for, so remove the launch screen once the page
// has painted. Native apps cover the frontend with their own splash screen
// until frontend/loaded, so they remove it without animation.
afterNextRender(() => {
const external = this.hass.auth?.external;
if (removeLaunchScreen(!!external?.config.hasSplashscreen)) {
external?.fireMessage({ type: "frontend/loaded" });
}
});
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_entityId") || changedProps.has("_view")) {
this._showEntity();
}
}
/**
* A link out of the page (device page, entity editor, related items) is for
* the app's main frontend; this screen keeps showing its entity until the app
* dismisses it. Without an app the page is replaced by the framed frontend.
*/
private _relayNavigation = (path: string): boolean => {
const external = this.hass?.auth.external;
if (
!external ||
isMoreInfoStandalonePath(
new URL(path, mainWindow.location.origin).pathname
)
) {
return false;
}
external.fireMessage({ type: "more_info/navigate", payload: { path } });
return true;
};
private _readUrl = () => {
const { entityId, view } = decodeMoreInfoUrl(mainWindow.location.search);
this._entityId = entityId;
this._view = view;
};
private _showEntity() {
if (!this._entityId || !this._dialog) {
return;
}
// The dialog keeps the URL in sync while it switches views or follows
// related entities itself, so only a different entity is a new request.
if (this._dialog.entityId === this._entityId) {
return;
}
const { pathname, search, hash } = mainWindow.location;
this._dialog.showDialog({
entityId: this._entityId,
view: this._view,
returnUrl: `${pathname}${search}${hash}`,
});
}
static styles = css`
:host {
display: block;
}
ha-alert {
display: block;
margin: var(--ha-space-4);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-page": HaMoreInfoPage;
}
}
+11 -3
View File
@@ -10,7 +10,7 @@ import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { navigate } from "../common/navigate";
import { showAutomationEditor } from "../data/automation";
import type { HomeAssistantMain } from "../layouts/home-assistant-main";
import type { HomeAssistant } from "../types";
import type {
EMIncomingMessageBarCodeScanAborted,
EMIncomingMessageBarCodeScanResult,
@@ -26,7 +26,15 @@ const barCodeListeners = new Set<
) => boolean
>();
export const attachExternalToApp = (hassMainEl: HomeAssistantMain) => {
/**
* The element that answers the app's commands: the main frontend, or the
* standalone more-info page when the app shows it in a screen of its own.
* Events for chrome it does not have (the sidebar, notifications) bubble away
* unanswered there.
*/
export type ExternalAppHost = HTMLElement & { hass: HomeAssistant };
export const attachExternalToApp = (hassMainEl: ExternalAppHost) => {
window.addEventListener("haptic", (ev) =>
hassMainEl.hass.auth.external!.fireMessage({
type: "haptic",
@@ -52,7 +60,7 @@ export const addExternalBarCodeListener = (
};
export const handleExternalMessage = (
hassMainEl: HomeAssistantMain,
hassMainEl: ExternalAppHost,
msg: EMIncomingMessageCommands
): boolean => {
const bus = hassMainEl.hass.auth.external!;
+40
View File
@@ -207,6 +207,42 @@ interface EMOutgoingMessageMoreInfoClosed extends EMMessage {
};
}
/**
* Sent instead of opening the more-info dialog when the app reports
* `hasNativeMoreInfo`. The app then shows the entity in a native screen, for
* example by loading the standalone page (`/more-info`) in its own webview.
*/
interface EMOutgoingMessageMoreInfoOpen extends EMMessage {
type: "more_info/open";
payload: {
entity_id: string;
};
}
/**
* Sent by the standalone more-info page (`/more-info`) when the user asks to
* close it. The page has no dialog to hide, so the app owning the native
* screen dismisses it.
*/
interface EMOutgoingMessageMoreInfoClose extends EMMessage {
type: "more_info/close";
payload: {
entity_id: string;
};
}
/**
* Sent by the standalone more-info page instead of navigating to another page
* (device page, entity editor, related items). The screen shows one entity, so
* the app shows the destination in its main frontend and dismisses the screen.
*/
interface EMOutgoingMessageMoreInfoNavigate extends EMMessage {
type: "more_info/navigate";
payload: {
path: string;
};
}
interface EMOutgoingMessageFocusElement extends EMMessage {
type: "focus_element";
payload: {
@@ -239,8 +275,11 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageHaptic
| EMOutgoingMessageImportThreadCredentials
| EMOutgoingMessageMatterCommission
| EMOutgoingMessageMoreInfoOpen
| EMOutgoingMessageMoreInfoOpened
| EMOutgoingMessageMoreInfoClosed
| EMOutgoingMessageMoreInfoClose
| EMOutgoingMessageMoreInfoNavigate
| EMOutgoingMessageSidebarShow
| EMOutgoingMessageTagWrite
| EMOutgoingMessageThemeUpdate
@@ -401,6 +440,7 @@ export interface ExternalConfig {
canSetupImprov?: boolean;
appVersion?: string;
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
hasNativeMoreInfo?: boolean; // Shows more-info in a native screen: the frontend sends more_info/open instead of opening the dialog
hasAssistSettings?: boolean; // Shows the "This device" section in voice assistant settings
hasSplashscreen?: boolean; // App covers the frontend with its own loading screen until frontend/loaded, so the launch screen is removed without animation
}
+21 -1
View File
@@ -6,7 +6,10 @@ import { storage } from "../common/decorators/storage";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { handleHistoryPop, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { decodeMoreInfoUrl } from "../common/url/more-info-query-params";
import {
decodeMoreInfoUrl,
isMoreInfoStandalonePath,
} from "../common/url/more-info-query-params";
import { extractSearchParamsObject } from "../common/url/search-params";
import { afterNextRender } from "../common/util/render-status";
import { fetchHttpConfig } from "../data/http";
@@ -76,6 +79,8 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
private _visiblePromiseResolve?: () => void;
private _moreInfoPageLoaded = false;
constructor() {
super();
const path = redirectLegacyToolsPath(curPath());
@@ -88,6 +93,10 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
}
protected renderHass() {
if (isMoreInfoStandalonePath(this._route.path)) {
// Frameless more-info for external apps: no sidebar, no panel.
return html`<ha-more-info-page .hass=${this.hass}></ha-more-info-page>`;
}
return html`
<home-assistant-main
.hass=${this.hass}
@@ -98,6 +107,13 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (
!this._moreInfoPageLoaded &&
isMoreInfoStandalonePath(this._route.path)
) {
this._moreInfoPageLoaded = true;
import("../dialogs/more-info/ha-more-info-page");
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
this._databaseMigration === undefined &&
@@ -316,6 +332,10 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
if (this.render !== this.renderHass) {
return;
}
// The standalone page shows the entity itself; no dialog on top of it.
if (isMoreInfoStandalonePath(this._route.path)) {
return;
}
const searchParams = extractSearchParamsObject();
if (!searchParams["more-info-entity-id"]) {
return;
+15
View File
@@ -41,6 +41,21 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
: currentUrl;
replaceCurrentUrl(returnUrl);
// The app presents more-info natively (e.g. the standalone page in its
// own webview), so hand it the entity instead of opening the dialog.
// A null entity id would only close a dialog, and none is open.
const external = this.hass!.auth.external;
if (external?.config.hasNativeMoreInfo) {
if (ev.detail.entityId) {
external.fireMessage({
type: "more_info/open",
payload: { entity_id: ev.detail.entityId },
});
}
return;
}
const shown = await showDialog(
this,
"ha-more-info-dialog",
+1
View File
@@ -1721,6 +1721,7 @@
"toggle_yaml_mode": "Toggle YAML mode",
"back_to_info": "Back to info",
"info": "Information",
"no_entity": "No entity provided",
"related": "Related",
"attributes": "Attributes",
"history": "History",
+48
View File
@@ -9,7 +9,9 @@ import {
goBack,
handleHistoryPop,
navigate,
registerNavigationInterceptor,
registerUnsavedChangesGuard,
unregisterNavigationInterceptor,
unregisterUnsavedChangesGuard,
} from "../../src/common/navigate";
@@ -99,6 +101,52 @@ describe("navigate", () => {
});
});
describe("navigation interceptor", () => {
beforeEach(() => {
setEntry("/more-info?more-info-entity-id=light.kitchen");
});
it("hands a claimed navigation to the interceptor and leaves the page", async () => {
const interceptor = vi.fn(() => true);
registerNavigationInterceptor(interceptor);
try {
const result = await navigate("/config/devices/device/abc");
expect(result).toBe(false);
expect(interceptor).toHaveBeenCalledWith("/config/devices/device/abc");
expect(window.location.pathname).toEqual("/more-info");
} finally {
unregisterNavigationInterceptor(interceptor);
}
});
it("navigates as usual when no interceptor claims the path", async () => {
const interceptor = vi.fn(() => false);
registerNavigationInterceptor(interceptor);
try {
await navigate("/config/devices/device/abc");
expect(window.location.pathname).toEqual("/config/devices/device/abc");
} finally {
unregisterNavigationInterceptor(interceptor);
}
});
it("asks about unsaved changes before offering the navigation", async () => {
const interceptor = vi.fn(() => true);
registerNavigationInterceptor(interceptor);
const guard = registerGuard(true, false);
try {
await navigate("/config/devices/device/abc");
expect(guard.prompt).toHaveBeenCalled();
expect(interceptor).not.toHaveBeenCalled();
} finally {
unregisterNavigationInterceptor(interceptor);
}
});
});
describe("unsaved changes guard", () => {
beforeEach(() => {
setEntry("/config");
+28
View File
@@ -16,7 +16,9 @@ import {
} from "../../../src/common/url/query-params";
import {
createMoreInfoUrl,
createStandaloneMoreInfoUrl,
decodeMoreInfoUrl,
isMoreInfoStandalonePath,
removeMoreInfoUrl,
} from "../../../src/common/url/more-info-query-params";
import {
@@ -293,4 +295,30 @@ describe("more-info query params", () => {
"/lovelace/home?theme=dark&more-info-entity-id=light.kitchen&more-info-view=info#some-anchor"
);
});
it("creates a standalone page link the page can decode", () => {
const url = createStandaloneMoreInfoUrl({
entityId: "light.kitchen",
view: "history",
});
expect(url).toBe(
"/more-info?more-info-entity-id=light.kitchen&more-info-view=history"
);
expect(isMoreInfoStandalonePath(new URL(url, "http://x").pathname)).toBe(
true
);
expect(decodeMoreInfoUrl(new URL(url, "http://x").search)).toEqual({
entityId: "light.kitchen",
view: "history",
});
});
it("only treats the standalone route itself as standalone", () => {
expect(isMoreInfoStandalonePath("/more-info")).toBe(true);
expect(isMoreInfoStandalonePath("/more-info/")).toBe(true);
expect(isMoreInfoStandalonePath("/more-info-panel")).toBe(false);
expect(isMoreInfoStandalonePath("/lovelace/more-info")).toBe(false);
expect(isMoreInfoStandalonePath("/")).toBe(false);
});
});