mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-08 06:06:03 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab95b701f | ||
|
|
d91fe942d7 |
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user