Compare commits

...
Author SHA1 Message Date
Bruno Pantaleão 6bf03569bf Let the companion app draw the more-info header from the frontend's description
The companion app draws the standalone more-info screen's top bar
itself, but must not guess what belongs there: which buttons show
depends on the domain, the user's rights, the view and the entity's
favorites. The frontend stays the single source of truth.

- `more_info/open` carries `title` and `subtitle`: the entity name and
  the area and device breadcrumb the dialog's header would show
  (`computeMoreInfoHeader()`), so the bar is right from the first frame.
- When the app reports `hasNativeMoreInfoHeader`, the standalone page
  leaves the frontend header out, so nothing is shown twice.
- The page sends `more_info/header` whenever the header it would render
  changes: title, breadcrumb, whether the leading button closes or goes
  back, the icon buttons and the overflow menu items, each with a
  translated label and an MDI icon name. The app renders exactly that
  and sends `more_info/action` with the item's id; the dialog answers it
  as it would its own button (`performHeaderAction`).

`computeNativeMoreInfoHeader` builds the description under the same
conditions and in the same order as the dialog's template; the header
text and favorites state the template needs are hoisted into helpers so
both read the same values. The only header content not described is a
child view's own header element (the vacuum area picker), which has no
native counterpart yet.
2026-09-17 17:06:31 +02:00
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
15 changed files with 1249 additions and 103 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(
@@ -0,0 +1,221 @@
import type { HassEntity } from "home-assistant-js-websocket";
import {
computeEntityNameList,
type EntityNameItem,
} from "../../common/entity/compute_entity_name_display";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import type {
MoreInfoHeaderAction,
MoreInfoHeaderMenuItem,
MoreInfoNativeHeader,
} from "../../external_app/external_messaging";
import type { HomeAssistant } from "../../types";
import type { MoreInfoView } from "./more-info-view";
/** The names the more-info header shows above the entity's own, most general first. */
export const MORE_INFO_BREADCRUMB_NAME: EntityNameItem[] = [
{ type: "area" },
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
];
export interface MoreInfoHeader {
title: string;
subtitle?: string;
}
/**
* The title and breadcrumb the more-info dialog would show for an entity, for
* a host that draws the header itself, like a companion app's native screen.
* An entity without a state falls back to its id, as the dialog does.
*/
export const computeMoreInfoHeader = (
hass: HomeAssistant,
entityId: string
): MoreInfoHeader => {
const stateObj = hass.states[entityId] as HassEntity | undefined;
if (!stateObj) {
return { title: entityId };
}
const names = computeEntityNameList(
stateObj,
MORE_INFO_BREADCRUMB_NAME,
hass.entities,
hass.devices,
hass.areas,
hass.floors
).filter((name): name is string => Boolean(name));
const title = names.pop() || entityId;
if (!names.length) {
return { title };
}
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
return { title, subtitle: names.join(isRTL ? " ◂ " : " ▸ ") };
};
/** What the more-info dialog's header would offer, without how it renders it. */
export interface NativeMoreInfoHeaderContext {
localize: LocalizeFunc;
entityId: string;
domain: string;
title: string;
subtitle?: string;
/** The header shows a back button instead of close: a view or a related entity is open. */
canGoBack: boolean;
/** The entity's own view; the history, settings and menu buttons belong to it. */
isDefaultView: boolean;
view: MoreInfoView;
/** A child view drawing its own header actions; none of them are offered natively. */
hasChildViewHeader: boolean;
showHistory: boolean;
isAdmin: boolean;
showAddTo: boolean;
favorites?: {
editMode: boolean;
editModeLabel: string;
resetLabel: string;
copyLabel: string;
canReset: boolean;
canCopy: boolean;
};
/** Set when the entity belongs to a device; `type` is the device's entry type. */
device?: { type: string };
showEdit: boolean;
}
/**
* The header of the standalone more-info page as `more_info/header` describes
* it to the app: the same buttons and menu items the dialog renders, in the
* same order and under the same conditions, named by the ids the dialog
* answers in `performHeaderAction`.
*/
export const computeNativeMoreInfoHeader = (
ctx: NativeMoreInfoHeaderContext
): MoreInfoNativeHeader => {
const { localize } = ctx;
const actions: MoreInfoHeaderAction[] = [];
const menu: MoreInfoHeaderMenuItem[] = [];
const addToLabel = localize("ui.dialogs.more_info_control.add_to.item");
if (ctx.isDefaultView) {
if (ctx.showHistory) {
actions.push({
id: "history",
label: localize("ui.dialogs.more_info_control.history"),
icon: "mdi:chart-box-outline",
});
}
if (ctx.isAdmin) {
actions.push({
id: "settings",
label: localize("ui.dialogs.more_info_control.settings"),
icon: "mdi:cog-outline",
});
if (ctx.showAddTo) {
menu.push({
id: "add_to",
label: addToLabel,
icon: "mdi:plus-box-multiple-outline",
divider_after: true,
});
}
if (ctx.favorites) {
const { favorites } = ctx;
menu.push(
{
id: "toggle_edit",
label: favorites.editMode
? localize("ui.dialogs.more_info_control.exit_edit_mode")
: favorites.editModeLabel,
icon: favorites.editMode ? "mdi:pencil-off" : "mdi:pencil",
},
{
id: "reset_favorites",
label: favorites.resetLabel,
icon: "mdi:backup-restore",
disabled: !favorites.canReset,
},
{
id: "copy_favorites",
label: favorites.copyLabel,
icon: "mdi:content-duplicate",
disabled: !favorites.canCopy,
divider_after: true,
}
);
}
if (ctx.device) {
menu.push({
id: "device",
label: localize(
"ui.dialogs.more_info_control.device_or_service_info",
{
type: localize(
`ui.dialogs.more_info_control.device_type.${ctx.device.type}` as LocalizeKeys
),
}
),
icon:
ctx.device.type === "service"
? "mdi:transit-connection-variant"
: "mdi:devices",
});
}
if (ctx.showEdit) {
menu.push({
id: "edit",
label:
localize(
`ui.dialogs.more_info_control.edit_domain.${ctx.domain}` as LocalizeKeys
) || localize("ui.dialogs.more_info_control.edit"),
icon: "mdi:pencil-outline",
});
}
menu.push(
{
id: "related",
label: localize("ui.dialogs.more_info_control.related"),
icon: "mdi:link-variant",
},
{
id: "details",
label: localize("ui.dialogs.more_info_control.details"),
icon: "mdi:information-outline",
}
);
} else if (ctx.showAddTo) {
actions.push({
id: "add_to",
label: addToLabel,
icon: "mdi:plus-box-multiple-outline",
});
}
} else if (ctx.view === "details" && !ctx.hasChildViewHeader) {
actions.push({
id: "toggle_yaml",
label: localize("ui.dialogs.more_info_control.toggle_yaml_mode"),
icon: "mdi:code-braces",
});
}
return {
entity_id: ctx.entityId,
title: ctx.title,
subtitle: ctx.subtitle,
navigation: ctx.canGoBack ? "back" : "close",
navigation_label: ctx.canGoBack
? localize("ui.dialogs.more_info_control.back_to_info")
: localize("ui.common.close"),
menu_label: localize("ui.common.menu"),
actions,
menu,
};
};
+295 -98
View File
@@ -33,7 +33,6 @@ import { computeDomain } from "../../common/entity/compute_domain";
import {
computeEntityEntryNameList,
computeEntityNameList,
type EntityNameItem,
} from "../../common/entity/compute_entity_name_display";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import {
@@ -84,6 +83,11 @@ import {
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
type MoreInfoView,
} from "./const";
import {
computeNativeMoreInfoHeader,
MORE_INFO_BREADCRUMB_NAME,
} from "./compute-more-info-header";
import type { MoreInfoNativeHeader } from "../../external_app/external_messaging";
import "./controls/more-info-default";
import type { FavoritesDialogContext } from "./favorites";
import { getFavoritesDialogHandler } from "./favorites";
@@ -125,13 +129,6 @@ declare global {
const DEFAULT_VIEW: MoreInfoView = "info";
const BREADCRUMB_NAME: EntityNameItem[] = [
{ type: "area" },
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
];
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends DirtyStateProviderMixin<
EntitySettingsState | Helper | Record<string, string[]> | null,
@@ -141,6 +138,20 @@ 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;
/**
* Leave out the header in standalone mode: the app draws the title and the
* close button in its own screen chrome, from the `more_info/open` payload.
*/
@property({ type: Boolean, attribute: "hide-header" }) public hideHeader =
false;
@state() private _fill = false;
@state() private _open = false;
@@ -153,6 +164,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;
@@ -175,6 +191,9 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _isEscapeEnabled = true;
/** The header as last described to the app; see `_sendNativeHeader`. */
private _sentNativeHeader?: string;
protected scrollFadeThreshold = 24;
protected get scrollableElement(): HTMLElement | null {
@@ -219,6 +238,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 +398,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 +426,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() {
@@ -439,7 +471,37 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _handleMenuAction(ev: HaDropdownSelectEvent) {
const action = ev.detail?.item?.value;
this._performMenuAction(ev.detail?.item?.value);
}
/**
* Answers a tap on the app's native header (`more_info/action`) with what the
* dialog's own button or menu item would do; ids are those `more_info/header`
* named. See `computeNativeMoreInfoHeader`.
*/
public performHeaderAction(id: string) {
switch (id) {
case "close":
this.closeDialog();
break;
case "back":
this._goBack();
break;
case "history":
this._goToHistory();
break;
case "settings":
this._goToSettings();
break;
case "toggle_yaml":
this._toggleDetailsYamlMode();
break;
default:
this._performMenuAction(id);
}
}
private _performMenuAction(action: string | undefined) {
switch (action) {
case "device":
this._goToDevice();
@@ -547,6 +609,150 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this._setView("related");
}
private get _isDefaultView(): boolean {
return this._currView === DEFAULT_VIEW && !this._childView;
}
/** Nothing to go back to: the header closes instead. */
private get _showsCloseIcon(): boolean {
return (
this._isDefaultView &&
this._parentEntityIds.length === 0 &&
!this._childView
);
}
/**
* The header's title and the names above it. A view named by the current
* view keeps the entity's own name in the breadcrumb; the entity's view
* takes it as the title.
*/
private _computeHeaderText(
entityId: string,
stateObj: HassEntity | undefined
): { breadcrumb: string[]; title: string } {
const breadcrumb = (
stateObj
? computeEntityNameList(
stateObj,
MORE_INFO_BREADCRUMB_NAME,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: this._entry
? computeEntityEntryNameList(
this._entry,
MORE_INFO_BREADCRUMB_NAME,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: [entityId]
).filter((v): v is string => Boolean(v));
const viewTitle =
this._currView === "details"
? this.hass.localize("ui.dialogs.more_info_control.details")
: this._currView === "related"
? this.hass.localize("ui.dialogs.more_info_control.related")
: this._currView === "add_to"
? this.hass.localize("ui.dialogs.more_info_control.add_to.item")
: this._childView?.viewTitle;
const defaultTitle = breadcrumb[breadcrumb.length - 1] || entityId;
if (!viewTitle) {
breadcrumb.pop();
}
return { breadcrumb, title: viewTitle || defaultTitle };
}
private _computeFavoritesState(stateObj: HassEntity | undefined) {
const favoritesContext =
this._entry && stateObj
? {
host: this,
hass: this.hass,
entry: this._entry,
stateObj,
}
: undefined;
const favoritesHandler = favoritesContext
? getFavoritesDialogHandler(favoritesContext.stateObj)
: undefined;
return {
favoritesLabels: favoritesHandler?.getLabels(this.hass),
supportsFavorites: Boolean(favoritesHandler && favoritesContext),
resetFavoritesDisabled:
favoritesContext && favoritesHandler
? !favoritesHandler.hasCustomFavorites(favoritesContext.entry)
: false,
copyFavoritesDisabled:
favoritesContext && favoritesHandler?.canCopy
? !favoritesHandler.canCopy(favoritesContext.entry)
: false,
};
}
/**
* The header the page leaves out, described for the app that draws it
* (`hasNativeMoreInfoHeader`), from the same state and conditions as the
* header `render` would show.
*/
private _computeNativeHeader(): MoreInfoNativeHeader | undefined {
if (!this.standalone || !this.hideHeader || !this._entityId) {
return undefined;
}
const entityId = this._entityId;
const stateObj = this.hass.states[entityId] as HassEntity | undefined;
const domain = computeDomain(entityId);
const deviceId = this._getDeviceId();
const { breadcrumb, title } = this._computeHeaderText(entityId, stateObj);
const {
favoritesLabels,
supportsFavorites,
resetFavoritesDisabled,
copyFavoritesDisabled,
} = this._computeFavoritesState(stateObj);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return computeNativeMoreInfoHeader({
localize: this.hass.localize,
entityId,
domain,
title,
subtitle: breadcrumb.length
? breadcrumb.join(isRTL ? " ◂ " : " ▸ ")
: undefined,
canGoBack: !this._showsCloseIcon,
isDefaultView: this._isDefaultView,
view: this._currView,
hasChildViewHeader: !!this._childView?.viewHeaderTag,
showHistory: this._shouldShowHistory(domain),
isAdmin: !__DEMO__ && !!this.hass.user?.is_admin,
showAddTo: !__DEMO__ && this._shouldShowAddEntityTo(),
favorites:
supportsFavorites && favoritesLabels
? {
editMode: this._infoEditMode,
editModeLabel: favoritesLabels.editMode,
resetLabel: favoritesLabels.reset,
copyLabel: favoritesLabels.copy,
canReset: !resetFavoritesDisabled,
canCopy: !copyFavoritesDisabled,
}
: undefined,
device: deviceId
? { type: this.hass.devices[deviceId]?.entry_type || "device" }
: undefined,
showEdit: this._shouldShowEditIcon(domain, stateObj),
});
}
protected render() {
if (!this._entityId) {
return nothing;
@@ -562,81 +768,51 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const deviceType =
(deviceId && this.hass.devices[deviceId].entry_type) || "device";
const isDefaultView = this._currView === DEFAULT_VIEW && !this._childView;
const showCloseIcon =
isDefaultView && this._parentEntityIds.length === 0 && !this._childView;
const isDefaultView = this._isDefaultView;
const showCloseIcon = this._showsCloseIcon;
const breadcrumb = (
stateObj
? computeEntityNameList(
stateObj,
BREADCRUMB_NAME,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: this._entry
? computeEntityEntryNameList(
this._entry,
BREADCRUMB_NAME,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: [entityId]
).filter((v): v is string => Boolean(v));
const addToMenuItem = this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
);
const viewTitle =
this._currView === "details"
? this.hass.localize("ui.dialogs.more_info_control.details")
: this._currView === "related"
? this.hass.localize("ui.dialogs.more_info_control.related")
: this._currView === "add_to"
? addToMenuItem
: this._childView?.viewTitle;
const defaultTitle = breadcrumb[breadcrumb.length - 1] || entityId;
if (!viewTitle) {
breadcrumb.pop();
}
const title = viewTitle || defaultTitle;
const { breadcrumb, title } = this._computeHeaderText(entityId, stateObj);
const favoritesContext =
this._entry && stateObj
? {
host: this,
hass: this.hass,
entry: this._entry,
stateObj,
}
: undefined;
const favoritesHandler = favoritesContext
? getFavoritesDialogHandler(favoritesContext.stateObj)
: undefined;
const favoritesLabels = favoritesHandler?.getLabels(this.hass);
const supportsFavorites = Boolean(favoritesHandler && favoritesContext);
const resetFavoritesDisabled =
favoritesContext && favoritesHandler
? !favoritesHandler.hasCustomFavorites(favoritesContext.entry)
: false;
const copyFavoritesDisabled =
favoritesContext && favoritesHandler?.canCopy
? !favoritesHandler.canCopy(favoritesContext.entry)
: false;
const {
favoritesLabels,
supportsFavorites,
resetFavoritesDisabled,
copyFavoritesDisabled,
} = this._computeFavoritesState(stateObj);
const isRTL = computeRTL(
this.hass.language,
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 +828,8 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
return html`
<ha-adaptive-dialog
.open=${this._open}
.standalone=${this.standalone}
.withoutHeader=${this.standalone && this.hideHeader}
.width=${this._fill ? "full" : this.large ? "large" : "medium"}
@closed=${this._dialogClosed}
@opened=${this._handleOpened}
@@ -663,26 +841,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
@@ -1022,13 +1181,38 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
changedProps.get("_entityId") as string | null | undefined
);
}
this._sendNativeHeader();
}
/**
* Tells the app what its header should show now. Every update describes the
* header anew (view changes, edit mode, a related entity), so only a changed
* description goes over the bus.
*/
private _sendNativeHeader() {
const external = this.hass.auth.external;
if (!external) {
return;
}
const header = this._computeNativeHeader();
if (!header) {
return;
}
const serialized = JSON.stringify(header);
if (serialized === this._sentNativeHeader) {
return;
}
this._sentNativeHeader = serialized;
external.fireMessage({ type: "more_info/header", payload: header });
}
private _reportShownEntityToExternalApp(
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 +1228,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;
});
+170
View File
@@ -0,0 +1,170 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
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);
this.addEventListener("more-info-header-action", this._headerAction);
}
public disconnectedCallback() {
super.disconnectedCallback();
mainWindow.removeEventListener("location-changed", this._readUrl);
mainWindow.removeEventListener("popstate", this._readUrl);
unregisterNavigationInterceptor(this._relayNavigation);
this.removeEventListener("more-info-header-action", this._headerAction);
}
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
.hideHeader=${!!this.hass.auth.external?.config.hasNativeMoreInfoHeader}
.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;
};
/** The app's native header was tapped; the dialog answers as if its own button was. */
private _headerAction = (
ev: HASSDomEvent<HASSDomEvents["more-info-header-action"]>
) => {
this._dialog?.performHeaderAction(ev.detail.id);
};
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;
}
interface HTMLElementEventMap {
"more-info-header-action": HASSDomEvent<
HASSDomEvents["more-info-header-action"]
>;
}
}
+15 -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!;
@@ -99,6 +107,8 @@ export const handleExternalMessage = (
barCodeListeners.forEach((listener) => listener(msg));
} else if (msg.command === "kiosk_mode/set") {
fireEvent(window, "hass-kiosk-mode", { enable: msg.payload.enable });
} else if (msg.command === "more_info/action") {
fireEvent(hassMainEl, "more-info-header-action", { id: msg.payload.id });
} else {
return false;
}
@@ -118,6 +128,8 @@ declare global {
"improv-discovered-device": ImprovDiscoveredDevice;
"improv-device-setup-done": undefined;
"matter-commission-finish": MatterCommissionFinish;
/** The app's `more_info/action`, for the standalone more-info page. */
"more-info-header-action": { id: string };
}
interface GlobalEventHandlersEventMap {
+103 -1
View File
@@ -207,6 +207,91 @@ 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;
/** What the dialog's header would show, for the app's own header. */
title: string;
/** The area and device breadcrumb above the title, when there is one. */
subtitle?: 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;
};
}
/** An icon button in the more-info header, or an item of its overflow menu. */
export interface MoreInfoHeaderAction {
/** Sent back in `more_info/action` when the user picks it. */
id: string;
/** Translated, for the button's accessibility label or the menu item. */
label: string;
/** MDI icon name, like `mdi:chart-box-outline`. */
icon: string;
}
export interface MoreInfoHeaderMenuItem extends MoreInfoHeaderAction {
disabled?: boolean;
/** A separator follows this item. */
divider_after?: boolean;
}
export interface MoreInfoNativeHeader {
entity_id: string;
title: string;
/** The area and device breadcrumb above the title, when there is one. */
subtitle?: string;
/** What the leading button does: dismiss the screen, or return to the previous view. */
navigation: "close" | "back";
/** Translated accessibility label of the leading button. */
navigation_label: string;
/** Translated accessibility label of the overflow menu button. */
menu_label: string;
/** Icon buttons, in order, before the overflow menu. */
actions: MoreInfoHeaderAction[];
/** Items of the overflow menu; empty means no menu button. */
menu: MoreInfoHeaderMenuItem[];
}
/**
* Sent by the standalone more-info page whenever the header it leaves out
* (`hasNativeMoreInfoHeader`) would change: on open, when the view changes,
* when the dialog follows a related entity. The app draws exactly this and
* answers a tap with the `more_info/action` command, so what the header offers
* is decided by the frontend alone.
*/
interface EMOutgoingMessageMoreInfoHeader extends EMMessage {
type: "more_info/header";
payload: MoreInfoNativeHeader;
}
interface EMOutgoingMessageFocusElement extends EMMessage {
type: "focus_element";
payload: {
@@ -239,8 +324,12 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageHaptic
| EMOutgoingMessageImportThreadCredentials
| EMOutgoingMessageMatterCommission
| EMOutgoingMessageMoreInfoOpen
| EMOutgoingMessageMoreInfoOpened
| EMOutgoingMessageMoreInfoClosed
| EMOutgoingMessageMoreInfoClose
| EMOutgoingMessageMoreInfoNavigate
| EMOutgoingMessageMoreInfoHeader
| EMOutgoingMessageSidebarShow
| EMOutgoingMessageTagWrite
| EMOutgoingMessageThemeUpdate
@@ -356,6 +445,16 @@ export interface EMIncomingMessageKioskModeSet {
};
}
/** The user picked an item of the native more-info header; `id` is from `more_info/header`. */
export interface EMIncomingMessageMoreInfoAction {
id: number;
type: "command";
command: "more_info/action";
payload: {
id: string;
};
}
export interface MatterCommissionFinish {
name: string | null;
success: boolean;
@@ -380,7 +479,8 @@ export type EMIncomingMessageCommands =
| EMIncomingMessageImprovDeviceDiscovered
| EMIncomingMessageImprovDeviceSetupDone
| EMIncomingMessageMatterCommissionFinish
| EMIncomingMessageKioskModeSet;
| EMIncomingMessageKioskModeSet
| EMIncomingMessageMoreInfoAction;
type EMIncomingMessage =
EMMessageResultSuccess | EMMessageResultError | EMIncomingMessageCommands;
@@ -401,6 +501,8 @@ 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
hasNativeMoreInfoHeader?: boolean; // The native more-info screen draws the header from more_info/header (title, buttons, menu) and sends more_info/action, so the standalone page leaves its own out
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;
+20
View File
@@ -8,6 +8,7 @@ import {
removeMoreInfoUrl,
} from "../common/url/more-info-query-params";
import { showDialog } from "../dialogs/make-dialog-manager";
import { computeMoreInfoHeader } from "../dialogs/more-info/compute-more-info-header";
import type { MoreInfoDialogParams } from "../dialogs/more-info/ha-more-info-dialog";
import type { Constructor } from "../types";
import type { HassBaseEl } from "./hass-base-mixin";
@@ -41,6 +42,25 @@ 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) {
// The app draws the header itself, so it gets what the dialog would show.
external.fireMessage({
type: "more_info/open",
payload: {
entity_id: ev.detail.entityId,
...computeMoreInfoHeader(this.hass!, 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);
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
mockArea,
mockDevice,
mockEntity,
mockStateObj,
} from "../../common/entity/context/context-mock";
import { computeMoreInfoHeader } from "../../../src/dialogs/more-info/compute-more-info-header";
import type { HomeAssistant } from "../../../src/types";
const hassWith = (overrides: Partial<HomeAssistant>): HomeAssistant =>
({
states: {},
entities: {},
devices: {},
areas: {},
floors: {},
language: "en",
translationMetadata: { translations: { he: { isRTL: true } } },
...overrides,
}) as unknown as HomeAssistant;
const kitchenLight = () =>
hassWith({
states: {
"light.kitchen": mockStateObj({
entity_id: "light.kitchen",
attributes: { friendly_name: "Kitchen Light" },
}),
},
entities: {
"light.kitchen": mockEntity({
entity_id: "light.kitchen",
name: "Light",
device_id: "dev1",
area_id: "kitchen",
}),
},
devices: {
dev1: mockDevice({
id: "dev1",
name: "Smart Device",
area_id: "kitchen",
}),
},
areas: {
kitchen: mockArea({ area_id: "kitchen", name: "Kitchen" }),
},
});
describe("computeMoreInfoHeader", () => {
it("names the entity and puts its area and device in the subtitle", () => {
expect(computeMoreInfoHeader(kitchenLight(), "light.kitchen")).toEqual({
title: "Light",
subtitle: "Kitchen ▸ Smart Device",
});
});
it("reverses the breadcrumb arrow for a right-to-left language", () => {
const hass = kitchenLight();
hass.language = "he";
expect(computeMoreInfoHeader(hass, "light.kitchen").subtitle).toBe(
"Kitchen ◂ Smart Device"
);
});
it("leaves the subtitle out for an entity with nothing above it", () => {
const hass = hassWith({
states: {
"sensor.outside": mockStateObj({
entity_id: "sensor.outside",
attributes: { friendly_name: "Outside" },
}),
},
});
expect(computeMoreInfoHeader(hass, "sensor.outside")).toEqual({
title: "Outside",
});
});
// Like the dialog, an entity the frontend has no state for shows its id.
it("falls back to the entity id without a state", () => {
expect(computeMoreInfoHeader(hassWith({}), "light.gone")).toEqual({
title: "light.gone",
});
});
});
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import {
computeNativeMoreInfoHeader,
type NativeMoreInfoHeaderContext,
} from "../../../src/dialogs/more-info/compute-more-info-header";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
// Labels are only carried through, so the key itself stands in for the text.
const localize = ((key: string, values?: Record<string, unknown>) =>
values ? `${key}(${Object.values(values).join(",")})` : key) as LocalizeFunc;
const kitchenLight = (
overrides: Partial<NativeMoreInfoHeaderContext> = {}
): NativeMoreInfoHeaderContext => ({
localize,
entityId: "light.kitchen",
domain: "light",
title: "Light",
subtitle: "Kitchen ▸ Smart Device",
canGoBack: false,
isDefaultView: true,
view: "info",
hasChildViewHeader: false,
showHistory: true,
isAdmin: true,
showAddTo: true,
device: { type: "device" },
showEdit: false,
...overrides,
});
const ids = (items: { id: string }[]) => items.map((item) => item.id);
describe("computeNativeMoreInfoHeader", () => {
it("offers an admin the dialog's buttons and menu in the dialog's order", () => {
const header = computeNativeMoreInfoHeader(kitchenLight());
expect(header.navigation).toBe("close");
expect(header.navigation_label).toBe("ui.common.close");
expect(header.menu_label).toBe("ui.common.menu");
expect(ids(header.actions)).toEqual(["history", "settings"]);
expect(ids(header.menu)).toEqual([
"add_to",
"device",
"related",
"details",
]);
expect(header.menu[0].divider_after).toBe(true);
expect(header.menu[1]).toMatchObject({
label:
"ui.dialogs.more_info_control.device_or_service_info(ui.dialogs.more_info_control.device_type.device)",
icon: "mdi:devices",
});
});
it("keeps the favorites items together with their disabled state and edit mode", () => {
const header = computeNativeMoreInfoHeader(
kitchenLight({
favorites: {
editMode: true,
editModeLabel: "Edit favorites",
resetLabel: "Reset favorites",
copyLabel: "Copy favorites",
canReset: true,
canCopy: false,
},
})
);
expect(ids(header.menu)).toEqual([
"add_to",
"toggle_edit",
"reset_favorites",
"copy_favorites",
"device",
"related",
"details",
]);
expect(header.menu[1]).toMatchObject({
label: "ui.dialogs.more_info_control.exit_edit_mode",
icon: "mdi:pencil-off",
});
expect(header.menu[2].disabled).toBe(false);
expect(header.menu[3]).toMatchObject({
disabled: true,
divider_after: true,
});
});
it("gives a non-admin only history and, when the app supports it, add to", () => {
const header = computeNativeMoreInfoHeader(
kitchenLight({ isAdmin: false })
);
expect(ids(header.actions)).toEqual(["history", "add_to"]);
expect(header.menu).toEqual([]);
});
it("shows a back button and no actions on a secondary view", () => {
const header = computeNativeMoreInfoHeader(
kitchenLight({ canGoBack: true, isDefaultView: false, view: "history" })
);
expect(header.navigation).toBe("back");
expect(header.navigation_label).toBe(
"ui.dialogs.more_info_control.back_to_info"
);
expect(header.actions).toEqual([]);
expect(header.menu).toEqual([]);
});
it("offers the YAML toggle on the details view", () => {
const header = computeNativeMoreInfoHeader(
kitchenLight({ canGoBack: true, isDefaultView: false, view: "details" })
);
expect(ids(header.actions)).toEqual(["toggle_yaml"]);
});
it("uses the domain's own edit label when it has one", () => {
const header = computeNativeMoreInfoHeader(
kitchenLight({ domain: "script", showEdit: true, device: undefined })
);
expect(header.menu.find((item) => item.id === "edit")).toMatchObject({
label: "ui.dialogs.more_info_control.edit_domain.script",
icon: "mdi:pencil-outline",
});
});
});