mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-29 16:32:32 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bf03569bf | ||
|
|
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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
>;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user