mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-26 06:50:52 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f3436c61f | ||
|
|
5ea505ec41 | ||
|
|
7302b7e709 | ||
|
|
716c4e464a | ||
|
|
78a64d4ab2 | ||
|
|
ff5df1bfc8 | ||
|
|
03c2ed1f46 | ||
|
|
e964058614 | ||
|
|
da7e8e7a24 | ||
|
|
66d985fb27 | ||
|
|
98395405f3 | ||
|
|
465f4a8227 | ||
|
|
87763fb59d | ||
|
|
f4bb4e242d | ||
|
|
eb91411cfe | ||
|
|
1767632811 | ||
|
|
7c5dd1841b | ||
|
|
d8effd44b2 | ||
|
|
182563d943 | ||
|
|
e5f1cb0b1f | ||
|
|
b31da04913 | ||
|
|
d35c398bfd | ||
|
|
71f9285e70 |
@@ -118,7 +118,8 @@ For user-facing changes, establish the existing design context as part of fronte
|
||||
- Before reviewing a pull request, read its existing comments, reviews, and threads, including their status, resolver, and Copilot resolution reason when available.
|
||||
- Prioritise substantive human feedback, especially from authors marked `MEMBER`, and validate agent-generated feedback against the code and repository guidance.
|
||||
- Do not duplicate unresolved findings as new inline comments; reference any that still need action in the review summary. Treat resolved feedback as closed only when the resolution reason or surrounding discussion supports that outcome; otherwise validate it against the current code before suppressing it. Respect **Won't fix** and **Incorrect** reasons.
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests that `ha-frontend-testing` calls for first.
|
||||
- Do not ask for new tests on visual components. If the visuals clearly changed and the PR has no screenshots or videos, suggest adding them instead.
|
||||
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
|
||||
@@ -51,10 +51,10 @@ Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so on
|
||||
|
||||
## When To Add Tests
|
||||
|
||||
- Write tests for code that computes something: data processing, utility functions, config validation, and what happens when the user interacts with a component.
|
||||
- Do not write tests that check what a component looks like: its text, CSS classes, styles, or slots. Do not write tests that check the default value of an option.
|
||||
- A component that only takes data from contexts and helpers and puts it in a template does not need a test.
|
||||
- If you are not sure a test is useful, describe the test and what it would catch, and let the user decide.
|
||||
- Write tests for code that computes something: data processing, utilities, config validation, and strategies.
|
||||
- Do not write rendering tests. This includes views, panels, and components whose text, styles, slots, or option defaults are checked, or that only put context and helper data into a template.
|
||||
- Do not try to cover every scenario, especially for behaviour that changes often.
|
||||
- If you are not sure a test is useful, describe it and what it would catch, and let the user decide.
|
||||
- Tests never talk to a real Home Assistant. Replace `callWS`, `callApi`, and the connection with fakes.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
@@ -32,8 +32,11 @@ import { internationalizationContext } from "../../data/context";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import { haStyleScrollbar } from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import "../animation/ha-fade-in";
|
||||
import "../ha-checkbox";
|
||||
import type { HaCheckbox } from "../ha-checkbox";
|
||||
import "../skeleton/ha-skeleton-icon";
|
||||
import "../skeleton/ha-skeleton-text";
|
||||
import "../ha-svg-icon";
|
||||
import "../input/ha-input-search";
|
||||
import { filterData, sortData } from "./sort-filter";
|
||||
@@ -111,6 +114,25 @@ export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
|
||||
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
|
||||
const AUTO_FOCUS_ALLOWED_ACTIVE_TAGS = ["BODY", "HTML", "HOME-ASSISTANT"];
|
||||
|
||||
// Default row height, used to fill the viewport with skeleton rows.
|
||||
const ROW_HEIGHT = 52;
|
||||
|
||||
const cellClasses = (column: DataTableColumnData) => ({
|
||||
"mdc-data-table__cell--flex": column.type === "flex",
|
||||
"mdc-data-table__cell--numeric": column.type === "numeric",
|
||||
"mdc-data-table__cell--icon": column.type === "icon",
|
||||
"mdc-data-table__cell--icon-button": column.type === "icon-button",
|
||||
"mdc-data-table__cell--overflow-menu": column.type === "overflow-menu",
|
||||
"mdc-data-table__cell--overflow": column.type === "overflow",
|
||||
forceLTR: Boolean(column.forceLTR),
|
||||
});
|
||||
|
||||
const cellStyles = (column: DataTableColumnData) => ({
|
||||
minWidth: column.minWidth,
|
||||
maxWidth: column.maxWidth,
|
||||
flex: column.flex || 1,
|
||||
});
|
||||
|
||||
@customElement("ha-data-table")
|
||||
export class HaDataTable extends LitElement {
|
||||
@state()
|
||||
@@ -123,6 +145,8 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@property({ type: Array }) public data: DataTableRowData[] = [];
|
||||
|
||||
@property({ type: Boolean }) public loading = false;
|
||||
|
||||
@property({ type: Boolean }) public selectable = false;
|
||||
|
||||
@property({ type: Boolean }) public clickable = false;
|
||||
@@ -165,6 +189,9 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@state() private _filteredData?: DataTableRowData[];
|
||||
|
||||
// Row count of the data that _filteredData was computed from
|
||||
@state() private _filteredDataSourceLength = 0;
|
||||
|
||||
@state() private _headerHeight = 0;
|
||||
|
||||
@query("slot[name='header']") private _header!: HTMLSlotElement;
|
||||
@@ -515,18 +542,79 @@ export class HaDataTable extends LitElement {
|
||||
</div>
|
||||
${
|
||||
!this._filteredData?.length
|
||||
? html`
|
||||
<div class="mdc-data-table__content">
|
||||
<div class="mdc-data-table__row" role="row">
|
||||
<div
|
||||
class="mdc-data-table__cell grows center"
|
||||
role="cell"
|
||||
>
|
||||
${
|
||||
!this._filteredData
|
||||
? this._i18n?.localize?.("ui.common.loading") ||
|
||||
? this.loading ||
|
||||
!this._filteredData ||
|
||||
(this.data.length && !this._filteredDataSourceLength)
|
||||
? html`
|
||||
<div class="mdc-data-table__content" role="row">
|
||||
<ha-fade-in .duration=${300} easing="ease-in">
|
||||
<div role="cell">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label=${
|
||||
this._i18n?.localize?.("ui.common.loading") ||
|
||||
"Loading"
|
||||
: this.data.length
|
||||
}
|
||||
>
|
||||
${Array.from(
|
||||
{
|
||||
length: this.autoHeight
|
||||
? 1
|
||||
: Math.ceil(window.innerHeight / ROW_HEIGHT),
|
||||
},
|
||||
() => html`
|
||||
<div class="mdc-data-table__row">
|
||||
${
|
||||
this.selectable
|
||||
? html`<div
|
||||
class="mdc-data-table__cell mdc-data-table__cell--checkbox"
|
||||
></div>`
|
||||
: nothing
|
||||
}
|
||||
${Object.entries(columns).map(
|
||||
([key, column]) =>
|
||||
(this.narrow &&
|
||||
!column.main &&
|
||||
!column.showNarrow) ||
|
||||
!this._isColumnVisible(key, column)
|
||||
? nothing
|
||||
: html`
|
||||
<div
|
||||
class="mdc-data-table__cell ${classMap(
|
||||
cellClasses(column)
|
||||
)}"
|
||||
style=${styleMap(cellStyles(column))}
|
||||
>
|
||||
${
|
||||
column.type === "icon"
|
||||
? html`<ha-skeleton-icon></ha-skeleton-icon>`
|
||||
: column.type ===
|
||||
"icon-button" ||
|
||||
column.type ===
|
||||
"overflow-menu"
|
||||
? nothing
|
||||
: html`<ha-skeleton-text></ha-skeleton-text>`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ha-fade-in>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="mdc-data-table__content">
|
||||
<div class="mdc-data-table__row" role="row">
|
||||
<div
|
||||
class="mdc-data-table__cell grows center"
|
||||
role="cell"
|
||||
>
|
||||
${
|
||||
this.data.length
|
||||
? this._i18n?.localize?.(
|
||||
"ui.components.data-table.no_match_filter"
|
||||
) || "No rows matching current filters"
|
||||
@@ -535,11 +623,11 @@ export class HaDataTable extends LitElement {
|
||||
"ui.components.data-table.no-data"
|
||||
) ||
|
||||
"No data"
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
`
|
||||
: html`
|
||||
<lit-virtualizer
|
||||
scroller
|
||||
@@ -633,22 +721,8 @@ export class HaDataTable extends LitElement {
|
||||
@mouseover=${this._setTitle}
|
||||
@focus=${this._setTitle}
|
||||
role=${column.main ? "rowheader" : "cell"}
|
||||
class="mdc-data-table__cell ${classMap({
|
||||
"mdc-data-table__cell--flex": column.type === "flex",
|
||||
"mdc-data-table__cell--numeric": column.type === "numeric",
|
||||
"mdc-data-table__cell--icon": column.type === "icon",
|
||||
"mdc-data-table__cell--icon-button":
|
||||
column.type === "icon-button",
|
||||
"mdc-data-table__cell--overflow-menu":
|
||||
column.type === "overflow-menu",
|
||||
"mdc-data-table__cell--overflow": column.type === "overflow",
|
||||
forceLTR: Boolean(column.forceLTR),
|
||||
})}"
|
||||
style=${styleMap({
|
||||
minWidth: column.minWidth,
|
||||
maxWidth: column.maxWidth,
|
||||
flex: column.flex || 1,
|
||||
})}
|
||||
class="mdc-data-table__cell ${classMap(cellClasses(column))}"
|
||||
style=${styleMap(cellStyles(column))}
|
||||
>
|
||||
${
|
||||
column.template
|
||||
@@ -721,10 +795,11 @@ export class HaDataTable extends LitElement {
|
||||
!this._lastUpdate ||
|
||||
(timeBetweenUpdate > 500 && timeBetweenRequest < 500);
|
||||
|
||||
let filteredData = this.data;
|
||||
const sourceData = this.data;
|
||||
let filteredData = sourceData;
|
||||
if (this._filter) {
|
||||
filteredData = await this._memFilterData(
|
||||
this.data,
|
||||
sourceData,
|
||||
this._sortColumns,
|
||||
this._filter.trim()
|
||||
);
|
||||
@@ -760,8 +835,13 @@ export class HaDataTable extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (startTime < this._lastUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastUpdate = startTime;
|
||||
this._filteredData = data;
|
||||
this._filteredDataSourceLength = sourceData.length;
|
||||
}
|
||||
|
||||
private _groupData = memoizeOne(
|
||||
@@ -1317,7 +1397,8 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__cell--icon:first-child ha-svg-icon,
|
||||
.mdc-data-table__cell--icon:first-child ha-state-icon,
|
||||
.mdc-data-table__cell--icon:first-child ha-domain-icon,
|
||||
.mdc-data-table__cell--icon:first-child ha-service-icon {
|
||||
.mdc-data-table__cell--icon:first-child ha-service-icon,
|
||||
.mdc-data-table__cell--icon:first-child ha-skeleton-icon {
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
|
||||
@@ -166,8 +166,8 @@ export class HaIcon extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const iconPromise = fetch(`/static/mdi/${chunk}.json`).then((response) =>
|
||||
response.json()
|
||||
const iconPromise = fetch(`${__STATIC_PATH__}mdi/${chunk}.json`).then(
|
||||
(response) => response.json()
|
||||
);
|
||||
chunks[chunk] = iconPromise;
|
||||
this._setPath(iconPromise, iconName, requestedIcon);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
narrowViewportContext,
|
||||
uiContext,
|
||||
@@ -32,6 +33,10 @@ class HaMenuButton extends LitElement {
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
private _ui?: ContextType<typeof uiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config?: ContextType<typeof configContext>;
|
||||
|
||||
@state() private _hasNotifications = false;
|
||||
|
||||
@state() private _show = false;
|
||||
@@ -82,7 +87,8 @@ class HaMenuButton extends LitElement {
|
||||
if (
|
||||
!changedProps.has("_narrow") &&
|
||||
!changedProps.has("_ui") &&
|
||||
!changedProps.has("_connection")
|
||||
!changedProps.has("_connection") &&
|
||||
!changedProps.has("_config")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -94,6 +100,7 @@ class HaMenuButton extends LitElement {
|
||||
|
||||
const showButton =
|
||||
this._ui?.kioskMode === false &&
|
||||
this._config?.auth.external?.config.hasSidebar !== true &&
|
||||
(this._narrow || this._ui.dockedSidebar === "always_hidden");
|
||||
|
||||
this._show = showButton || this._alwaysVisible;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaSkeleton } from "./ha-skeleton";
|
||||
|
||||
/**
|
||||
* Placeholder for an icon. Follows `ha-svg-icon`'s `--mdc-icon-size`
|
||||
* (24px by default), including inherited size overrides.
|
||||
*/
|
||||
@customElement("ha-skeleton-icon")
|
||||
export class HaSkeletonIcon extends HaSkeleton {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
super.styles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
flex: none;
|
||||
width: var(--mdc-icon-size, 24px);
|
||||
height: var(--mdc-icon-size, 24px);
|
||||
min-height: 0;
|
||||
--ha-skeleton-border-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-skeleton-icon": HaSkeletonIcon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaSkeleton } from "./ha-skeleton";
|
||||
|
||||
/**
|
||||
* Placeholder for a line of text, matching the tile secondary text skeleton.
|
||||
* Its height follows the surrounding font size. Set `width` in CSS to fit the
|
||||
* expected text.
|
||||
*
|
||||
* @cssprop --ha-skeleton-text-width - The width of the placeholder. Defaults to `140px`.
|
||||
*/
|
||||
@customElement("ha-skeleton-text")
|
||||
export class HaSkeletonText extends HaSkeleton {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
super.styles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
width: var(--ha-skeleton-text-width, 140px);
|
||||
max-width: 100%;
|
||||
height: 1em;
|
||||
min-height: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-skeleton-text": HaSkeletonText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import WaSkeleton from "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* Placeholder shown while content loads. Sized by its container unless a
|
||||
* more specific variant such as `ha-skeleton-text` or `ha-skeleton-icon` is used.
|
||||
*
|
||||
* @cssprop --ha-skeleton-color - The fill color. Defaults to `var(--ha-color-fill-neutral-normal-resting)`.
|
||||
* @cssprop --ha-skeleton-sheen-color - The sheen color when `effect="sheen"`. Defaults to `var(--ha-color-fill-neutral-loud-resting)`.
|
||||
* @cssprop --ha-skeleton-border-radius - The corner radius. Defaults to `var(--ha-border-radius-sm)`.
|
||||
*/
|
||||
@customElement("ha-skeleton")
|
||||
export class HaSkeleton extends WaSkeleton {
|
||||
override effect: WaSkeleton["effect"] = "pulse";
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
WaSkeleton.styles,
|
||||
css`
|
||||
:host {
|
||||
--color: var(
|
||||
--ha-skeleton-color,
|
||||
var(--ha-color-fill-neutral-normal-resting)
|
||||
);
|
||||
--sheen-color: var(
|
||||
--ha-skeleton-sheen-color,
|
||||
var(--ha-color-fill-neutral-loud-resting)
|
||||
);
|
||||
--wa-border-radius-pill: var(
|
||||
--ha-skeleton-border-radius,
|
||||
var(--ha-border-radius-sm)
|
||||
);
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
:host {
|
||||
--color: GrayText;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:host([effect="pulse"]) .indicator,
|
||||
:host([effect="sheen"]) .indicator {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-skeleton": HaSkeleton;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import "../skeleton/ha-skeleton-text";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@@ -49,7 +49,7 @@ export class HaTileInfo extends LitElement {
|
||||
${
|
||||
this.secondaryLoading
|
||||
? html`<div class="secondary">
|
||||
<wa-skeleton class="placeholder" effect="pulse"></wa-skeleton>
|
||||
<ha-skeleton-text></ha-skeleton-text>
|
||||
</div>`
|
||||
: html`<slot name="secondary" class="secondary">
|
||||
<span>${this.secondary}</span>
|
||||
@@ -150,14 +150,6 @@ export class HaTileInfo extends LitElement {
|
||||
letter-spacing: var(--tile-info-secondary-letter-spacing);
|
||||
color: var(--tile-info-secondary-color);
|
||||
}
|
||||
.placeholder {
|
||||
width: 140px;
|
||||
max-width: 100%;
|
||||
height: var(--tile-info-secondary-font-size);
|
||||
--wa-border-radius-pill: var(--ha-border-radius-sm);
|
||||
--color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import "../../../../components/skeleton/ha-skeleton";
|
||||
import "../../../../components/skeleton/ha-skeleton-text";
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -112,14 +113,14 @@ export class HaMoreInfoUpdateBackup extends LitElement {
|
||||
${
|
||||
!createBackupTexts
|
||||
? html`<ha-fade-in slot="headline" .delay=${500}
|
||||
><wa-skeleton effect="sheen"></wa-skeleton
|
||||
><ha-skeleton-text></ha-skeleton-text
|
||||
></ha-fade-in>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._createBackupLoading
|
||||
? html`<ha-fade-in class="skeleton-end" slot="end" .delay=${500}
|
||||
><wa-skeleton effect="sheen"></wa-skeleton
|
||||
><ha-skeleton></ha-skeleton
|
||||
></ha-fade-in>`
|
||||
: html`<ha-switch
|
||||
slot="end"
|
||||
@@ -307,6 +308,7 @@ export class HaMoreInfoUpdateBackup extends LitElement {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
--ha-skeleton-border-radius: var(--ha-border-radius-pill);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ 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 { handleNativeBackButtonPressed } from "./external_back_button";
|
||||
import type {
|
||||
EMIncomingMessageBarCodeScanAborted,
|
||||
EMIncomingMessageBarCodeScanResult,
|
||||
@@ -100,16 +99,6 @@ 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 === "back_button/pressed") {
|
||||
if (!handleNativeBackButtonPressed()) {
|
||||
bus.fireMessage({
|
||||
id: msg.id,
|
||||
type: "result",
|
||||
success: false,
|
||||
error: { code: "not_allowed", message: "no back button shown" },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { ExternalMessaging } from "./external_messaging";
|
||||
|
||||
/*
|
||||
Apps that draw their own toolbar tell us so with `hasNativeBackButton`. We then
|
||||
hide our own back arrow and instead report whether the current top bar offers a
|
||||
back action, so the app can show or hide its native button. Tapping that button
|
||||
sends `back_button/pressed` back to us, so the navigation stays ours.
|
||||
*/
|
||||
|
||||
interface Registration {
|
||||
bus: ExternalMessaging;
|
||||
back: () => void;
|
||||
}
|
||||
|
||||
// Top bars that currently offer a back action. The last one to register owns
|
||||
// the app's back button, so a page mounted on top of another one wins.
|
||||
const registrations: Registration[] = [];
|
||||
|
||||
// The bus we last told to show the button, so we only report changes.
|
||||
let shownOn: ExternalMessaging | undefined;
|
||||
|
||||
const sync = (): void => {
|
||||
const active = registrations[registrations.length - 1];
|
||||
|
||||
if (active) {
|
||||
if (shownOn !== active.bus) {
|
||||
active.bus.fireMessage({ type: "back_button/show" });
|
||||
shownOn = active.bus;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (shownOn) {
|
||||
shownOn.fireMessage({ type: "back_button/hide" });
|
||||
shownOn = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the back action of the top bar that currently owns the app's back
|
||||
* button. Returns false when no top bar claims one, so the app can be told
|
||||
* that its button was out of date.
|
||||
*/
|
||||
export const handleNativeBackButtonPressed = (): boolean => {
|
||||
const active = registrations[registrations.length - 1];
|
||||
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
active.back();
|
||||
return true;
|
||||
};
|
||||
|
||||
interface NativeBackButtonHost extends ReactiveControllerHost {
|
||||
hass?: HomeAssistant;
|
||||
}
|
||||
|
||||
interface NativeBackButtonOptions {
|
||||
/** Whether the top bar wants to offer a back action right now. */
|
||||
visible: () => boolean;
|
||||
/** Navigates back. Called for both our own arrow and the app's button. */
|
||||
back: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the back button of a top bar over to the external app when it renders
|
||||
* one itself. Hosts must not render their own arrow while `native` is true.
|
||||
*/
|
||||
export class NativeBackButtonController implements ReactiveController {
|
||||
private _registration?: Registration;
|
||||
|
||||
constructor(
|
||||
private _host: NativeBackButtonHost,
|
||||
private _options: NativeBackButtonOptions
|
||||
) {
|
||||
_host.addController(this);
|
||||
}
|
||||
|
||||
/** True while the app renders the back button instead of us. */
|
||||
public get native(): boolean {
|
||||
return this._bus !== undefined;
|
||||
}
|
||||
|
||||
private get _bus(): ExternalMessaging | undefined {
|
||||
// Demo and gallery hosts get by with a partial hass, so tread carefully.
|
||||
const external = this._host.hass?.auth?.external;
|
||||
return external?.config.hasNativeBackButton ? external : undefined;
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._sync();
|
||||
}
|
||||
|
||||
public hostUpdated(): void {
|
||||
this._sync();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._unregister();
|
||||
}
|
||||
|
||||
private _sync(): void {
|
||||
const bus = this._bus;
|
||||
|
||||
if (!bus || !this._options.visible()) {
|
||||
this._unregister();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._registration) {
|
||||
this._registration.bus = bus;
|
||||
this._registration.back = this._options.back;
|
||||
} else {
|
||||
this._registration = { bus, back: this._options.back };
|
||||
registrations.push(this._registration);
|
||||
}
|
||||
|
||||
sync();
|
||||
}
|
||||
|
||||
private _unregister(): void {
|
||||
if (!this._registration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = registrations.indexOf(this._registration);
|
||||
if (index !== -1) {
|
||||
registrations.splice(index, 1);
|
||||
}
|
||||
this._registration = undefined;
|
||||
|
||||
sync();
|
||||
}
|
||||
}
|
||||
@@ -218,14 +218,6 @@ interface EMOutgoingMessageReloadAndClearCache extends EMMessage {
|
||||
type: "frontend/reload_and_clear_cache";
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageBackButtonShow extends EMMessage {
|
||||
type: "back_button/show"; // The top bar offers a back action; only sent with hasNativeBackButton
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageBackButtonHide extends EMMessage {
|
||||
type: "back_button/hide";
|
||||
}
|
||||
|
||||
// These types are handled internally by the Android app via postMessage.
|
||||
// They are not sent by the frontend and should not be used directly.
|
||||
// They are intentionally listed here to prevent anyone from using them unintentionally.
|
||||
@@ -236,8 +228,6 @@ type EMOutgoingMessageWithoutAnswer =
|
||||
| EMMessageResultSuccess
|
||||
| EMOutgoingMessageAppConfiguration
|
||||
| EMOutgoingMessageAssistShow
|
||||
| EMOutgoingMessageBackButtonHide
|
||||
| EMOutgoingMessageBackButtonShow
|
||||
| EMOutgoingMessageBarCodeClose
|
||||
| EMOutgoingMessageBarCodeNotify
|
||||
| EMOutgoingMessageBarCodeScan
|
||||
@@ -357,12 +347,6 @@ export interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage {
|
||||
command: "improv/device_setup_done";
|
||||
}
|
||||
|
||||
export interface EMIncomingMessageBackButtonPressed {
|
||||
id: number;
|
||||
type: "command";
|
||||
command: "back_button/pressed";
|
||||
}
|
||||
|
||||
export interface EMIncomingMessageKioskModeSet {
|
||||
id: number;
|
||||
type: "command";
|
||||
@@ -385,7 +369,6 @@ export interface EMIncomingMessageMatterCommissionFinish extends EMMessage {
|
||||
}
|
||||
|
||||
export type EMIncomingMessageCommands =
|
||||
| EMIncomingMessageBackButtonPressed
|
||||
| EMIncomingMessageRestart
|
||||
| EMIncomingMessageNavigate
|
||||
| EMIncomingMessageShowNotifications
|
||||
@@ -420,7 +403,6 @@ export interface ExternalConfig {
|
||||
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
|
||||
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
|
||||
hasNativeBackButton?: boolean; // App draws the back button of the top bar itself, driven by back_button/show and back_button/hide
|
||||
}
|
||||
|
||||
export interface ExternalEntityAddToAction {
|
||||
|
||||
@@ -2,22 +2,6 @@ import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { goBack } from "../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
|
||||
/**
|
||||
* Navigate back the way the toolbar back arrow does, without a click to go by.
|
||||
* Used by the external app when it renders the back button itself.
|
||||
*/
|
||||
export const navigateBack = (
|
||||
backPath?: string,
|
||||
backCallback?: () => void
|
||||
): void => {
|
||||
if (backCallback) {
|
||||
backCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
goBack(sanitizeNavigationPath(backPath));
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared behavior of the toolbar back arrow. The arrow is a link to the
|
||||
* declared parent page so it can be opened in a new tab, but a plain click
|
||||
@@ -28,12 +12,19 @@ export const handleBackClick = (
|
||||
backPath?: string,
|
||||
backCallback?: () => void
|
||||
): void => {
|
||||
const path = sanitizeNavigationPath(backPath);
|
||||
|
||||
// Ctrl, cmd and shift click open the parent in a new tab or window: let
|
||||
// the anchor handle those. A plain click is handled here instead, and
|
||||
// isNavigationClick calls preventDefault so the anchor stays inert.
|
||||
if (sanitizeNavigationPath(backPath) && !isNavigationClick(ev)) {
|
||||
if (path && !isNavigationClick(ev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigateBack(backPath, backCallback);
|
||||
if (backCallback) {
|
||||
backCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
goBack(path);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, eventOptions, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
|
||||
import { getHistoryState } from "../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import { NativeBackButtonController } from "../external_app/external_back_button";
|
||||
import { handleBackClick, navigateBack } from "./back-navigation";
|
||||
import { handleBackClick } from "./back-navigation";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
@@ -32,18 +31,6 @@ class HassSubpage extends LitElement {
|
||||
// @ts-ignore
|
||||
@restoreScroll(".content") private _savedScrollPos?: number;
|
||||
|
||||
private _nativeBackButton = new NativeBackButtonController(this, {
|
||||
visible: () => this._showsBackButton,
|
||||
back: () => navigateBack(this.backPath, this.backCallback),
|
||||
});
|
||||
|
||||
private get _showsBackButton(): boolean {
|
||||
return (
|
||||
!this.mainPage &&
|
||||
!(!sanitizeNavigationPath(this.backPath) && getHistoryState()?.root)
|
||||
);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const backPath = sanitizeNavigationPath(this.backPath);
|
||||
|
||||
@@ -51,16 +38,14 @@ class HassSubpage extends LitElement {
|
||||
<div class="toolbar ${classMap({ narrow: this.narrow })}">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
!this._showsBackButton
|
||||
this.mainPage || (!backPath && getHistoryState()?.root)
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: this._nativeBackButton.native
|
||||
? nothing
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
}
|
||||
|
||||
<div class="main-title">
|
||||
|
||||
@@ -154,6 +154,12 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
*/
|
||||
@property({ type: Boolean }) public empty = false;
|
||||
|
||||
/**
|
||||
* Show a loading state instead of the empty message until data is ready.
|
||||
* @type {Boolean}
|
||||
*/
|
||||
@property({ type: Boolean }) public loading = false;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
/**
|
||||
@@ -492,7 +498,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.empty
|
||||
this.empty && !this.loading
|
||||
? html`<div class="center">
|
||||
<slot name="empty">${this.noDataText}</slot>
|
||||
</div>`
|
||||
@@ -514,6 +520,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
.narrow=${this.narrow}
|
||||
.columns=${this.columns}
|
||||
.data=${this.data}
|
||||
.loading=${this.loading}
|
||||
.noDataText=${this.noDataText}
|
||||
.filter=${this.filter}
|
||||
.selectable=${this._selectMode}
|
||||
|
||||
@@ -17,8 +17,7 @@ import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { getHistoryState, navigate } from "../common/navigate";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import { NativeBackButtonController } from "../external_app/external_back_button";
|
||||
import { handleBackClick, navigateBack } from "./back-navigation";
|
||||
import { handleBackClick } from "./back-navigation";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import "../components/ha-svg-icon";
|
||||
@@ -95,18 +94,6 @@ export class HassTabsSubpage extends LitElement {
|
||||
// @ts-ignore
|
||||
@restoreScroll(".content") private _savedScrollPos?: number;
|
||||
|
||||
private _nativeBackButton = new NativeBackButtonController(this, {
|
||||
visible: () => this._showsBackButton,
|
||||
back: () => navigateBack(this.backPath, this.backCallback),
|
||||
});
|
||||
|
||||
private get _showsBackButton(): boolean {
|
||||
return (
|
||||
!this.mainPage &&
|
||||
!(!sanitizeNavigationPath(this.backPath) && getHistoryState()?.root)
|
||||
);
|
||||
}
|
||||
|
||||
private _getTabs = memoizeOne(
|
||||
(
|
||||
tabs: PageNavigation[],
|
||||
@@ -187,16 +174,14 @@ export class HassTabsSubpage extends LitElement {
|
||||
<slot name="toolbar">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
!this._showsBackButton
|
||||
this.mainPage || (!backPath && getHistoryState()?.root)
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: this._nativeBackButton.native
|
||||
? nothing
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
}
|
||||
${
|
||||
this._narrow || !this.showTabs
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import "../components/skeleton/ha-skeleton-text";
|
||||
|
||||
/** Placeholder shown in place of a text while onboarding translations load. */
|
||||
export const renderSkeleton = (variant: string): TemplateResult =>
|
||||
html`<wa-skeleton effect="sheen" class="skeleton ${variant}"></wa-skeleton>`;
|
||||
html`<ha-skeleton-text class="skeleton ${variant}"></ha-skeleton-text>`;
|
||||
|
||||
export const skeletonStyles = css`
|
||||
.skeleton {
|
||||
height: 1em;
|
||||
vertical-align: middle;
|
||||
--color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
.skeleton.title {
|
||||
width: 200px;
|
||||
--ha-skeleton-text-width: 200px;
|
||||
}
|
||||
.skeleton.line {
|
||||
width: 100%;
|
||||
--ha-skeleton-text-width: 100%;
|
||||
}
|
||||
.skeleton.headline {
|
||||
width: 40%;
|
||||
--ha-skeleton-text-width: 40%;
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
.skeleton.chip {
|
||||
width: 80px;
|
||||
--ha-skeleton-text-width: 80px;
|
||||
}
|
||||
.skeleton.button {
|
||||
width: 120px;
|
||||
--ha-skeleton-text-width: 120px;
|
||||
}
|
||||
.skeleton.label {
|
||||
width: 80px;
|
||||
--ha-skeleton-text-width: 80px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -37,6 +37,8 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
|
||||
@state() public _applicationCredentials: ApplicationCredential[] = [];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@@ -154,6 +156,7 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
back-path="/config"
|
||||
.tabs=${configSections.devices}
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.loading=${this._loading}
|
||||
.data=${this._getApplicationCredentials(
|
||||
this._applicationCredentials,
|
||||
this.hass.localize
|
||||
@@ -278,7 +281,13 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
}
|
||||
|
||||
private async _fetchApplicationCredentials() {
|
||||
this._applicationCredentials = await fetchApplicationCredentials(this.hass);
|
||||
try {
|
||||
this._applicationCredentials = await fetchApplicationCredentials(
|
||||
this.hass
|
||||
);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _addApplicationCredential() {
|
||||
|
||||
@@ -204,7 +204,7 @@ class DialogAreaAddTo extends LitElement {
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
--dialog-content-padding: 0 0 var(--ha-space-6);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -222,7 +222,7 @@ export class DialogDeviceAddTo extends LitElement {
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
--dialog-content-padding: 0 0 var(--ha-space-6);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -168,10 +168,6 @@ export class HaDeviceLinkedDevicesCard extends LitElement {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
color: var(--secondary-text-color);
|
||||
padding-bottom: var(--ha-space-2);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { computeFloorName } from "../../../common/entity/compute_floor_name";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
@@ -42,6 +43,8 @@ import type {
|
||||
import "../../../components/data-table/ha-data-table-labels";
|
||||
import "../../../components/entity/ha-battery-icon";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/skeleton/ha-skeleton-icon";
|
||||
import "../../../components/skeleton/ha-skeleton-text";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
@@ -115,13 +118,15 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ attribute: false }) public entries!: ConfigEntry[];
|
||||
@property({ attribute: false }) public entries?: ConfigEntry[];
|
||||
|
||||
@property({ attribute: false }) public entriesFailed = false;
|
||||
|
||||
@state() private _subEntries?: SubEntry[];
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
entities: EntityRegistryEntry[] = [];
|
||||
entities?: EntityRegistryEntry[];
|
||||
|
||||
@property({ attribute: false }) public manifests!: IntegrationManifest[];
|
||||
|
||||
@@ -238,13 +243,13 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
this._filters = this._storageFilters;
|
||||
this._setFiltersFromUrl();
|
||||
}
|
||||
if (changedProps.has("_selected")) {
|
||||
if (changedProps.has("_selected") || changedProps.has("entries")) {
|
||||
this._selectedCanDelete = this._selected.filter((d) => {
|
||||
const device = this.hass.devices[d];
|
||||
const entries = device.config_entries;
|
||||
return entries.some(
|
||||
(entryId) =>
|
||||
this.entries.find((e) => e.entry_id === entryId)
|
||||
this.entries?.find((e) => e.entry_id === entryId)
|
||||
?.supports_remove_device
|
||||
);
|
||||
});
|
||||
@@ -307,6 +312,10 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _reloadConfigEntries() {
|
||||
fireEvent(this, "reload-config-entries");
|
||||
}
|
||||
|
||||
private _clearFilter() {
|
||||
this._filters = {};
|
||||
if (!this._fromUrl) {
|
||||
@@ -317,8 +326,9 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
private _devicesAndFilterDomains = memoizeOne(
|
||||
(
|
||||
devices: HomeAssistant["devices"],
|
||||
entries: ConfigEntry[],
|
||||
entities: EntityRegistryEntry[],
|
||||
entries: ConfigEntry[] | undefined,
|
||||
entriesFailed: boolean,
|
||||
entities: EntityRegistryEntry[] = [],
|
||||
areas: HomeAssistant["areas"],
|
||||
manifests: IntegrationManifest[],
|
||||
filters: DataTableFilters,
|
||||
@@ -346,7 +356,8 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
}
|
||||
|
||||
const entryLookup: Record<string, ConfigEntry> = {};
|
||||
for (const entry of entries) {
|
||||
|
||||
for (const entry of entries ?? []) {
|
||||
entryLookup[entry.entry_id] = entry;
|
||||
}
|
||||
|
||||
@@ -371,7 +382,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
)
|
||||
);
|
||||
|
||||
const configEntries = entries.filter(
|
||||
const configEntries = (entries ?? []).filter(
|
||||
(entry) =>
|
||||
entry.entry_id &&
|
||||
(filter.value as string[]).includes(entry.entry_id)
|
||||
@@ -412,7 +423,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
Array.isArray(filter.value) &&
|
||||
filter.value.length
|
||||
) {
|
||||
const entryIds = entries
|
||||
const entryIds = (entries ?? [])
|
||||
.filter((entry) =>
|
||||
(filter.value as string[]).includes(entry.domain)
|
||||
)
|
||||
@@ -531,16 +542,21 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
`<${localize("ui.panel.config.devices.data_table.unknown")}>`,
|
||||
area: areaName,
|
||||
floor: floorName,
|
||||
integration: deviceEntries.length
|
||||
? deviceEntries
|
||||
.map(
|
||||
(entry) =>
|
||||
localize(`component.${entry.domain}.title`) || entry.domain
|
||||
)
|
||||
.join(", ")
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.devices.data_table.no_integration"
|
||||
),
|
||||
integration: !entries
|
||||
? localize("ui.common.loading")
|
||||
: entriesFailed
|
||||
? `<${localize("ui.panel.config.devices.data_table.unknown")}>`
|
||||
: deviceEntries.length
|
||||
? deviceEntries
|
||||
.map(
|
||||
(entry) =>
|
||||
localize(`component.${entry.domain}.title`) ||
|
||||
entry.domain
|
||||
)
|
||||
.join(", ")
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.devices.data_table.no_integration"
|
||||
),
|
||||
domains: deviceEntries.map((entry) => entry.domain),
|
||||
parent_device_name: parentDevice
|
||||
? computeDeviceNameDisplay(
|
||||
@@ -599,21 +615,23 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
moveable: false,
|
||||
showNarrow: true,
|
||||
template: (device) =>
|
||||
device.domains.length
|
||||
? html`<img
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: device.domains[0],
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`
|
||||
: "",
|
||||
!this.entries
|
||||
? html`<ha-skeleton-icon></ha-skeleton-icon>`
|
||||
: device.domains.length
|
||||
? html`<img
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: device.domains[0],
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`
|
||||
: "",
|
||||
},
|
||||
name: {
|
||||
title: localize("ui.panel.config.devices.data_table.device"),
|
||||
@@ -642,7 +660,9 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
.labels=${device.label_entries}
|
||||
></ha-data-table-labels>
|
||||
`
|
||||
: nothing
|
||||
: device.labels.length && !this._labels
|
||||
? html`<ha-skeleton-text></ha-skeleton-text>`
|
||||
: nothing
|
||||
}
|
||||
`,
|
||||
},
|
||||
@@ -654,6 +674,10 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
minWidth: "120px",
|
||||
template: (device) =>
|
||||
!this.entries
|
||||
? html`<ha-skeleton-text></ha-skeleton-text>`
|
||||
: device.integration,
|
||||
},
|
||||
device_family_name: {
|
||||
title: localize("ui.panel.config.devices.data_table.parent_device"),
|
||||
@@ -697,6 +721,9 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
minWidth: "101px",
|
||||
valueColumn: "battery_level",
|
||||
template: (device) => {
|
||||
if (!this.entities) {
|
||||
return html`<ha-skeleton-text></ha-skeleton-text>`;
|
||||
}
|
||||
const batteryEntityPair = device.battery_entity;
|
||||
const battery =
|
||||
batteryEntityPair && batteryEntityPair[0]
|
||||
@@ -831,6 +858,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
const { devicesOutput } = this._devicesAndFilterDomains(
|
||||
this.hass.devices,
|
||||
this.entries,
|
||||
this.entriesFailed,
|
||||
this.entities,
|
||||
this.hass.areas,
|
||||
this.manifests,
|
||||
@@ -895,6 +923,22 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.devices.add_device")}
|
||||
</ha-button>
|
||||
${
|
||||
this.entriesFailed
|
||||
? html`<ha-alert slot="top-header" alert-type="error">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.config_entries_load_failed"
|
||||
)}
|
||||
<ha-button
|
||||
slot="action"
|
||||
appearance="plain"
|
||||
@click=${this._reloadConfigEntries}
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.devices.retry")}
|
||||
</ha-button>
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
Array.isArray(this._filters.config_entry?.value) &&
|
||||
this._filters.config_entry?.value.length
|
||||
@@ -903,10 +947,13 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
"ui.panel.config.devices.filtering_by_config_entry"
|
||||
)}
|
||||
${
|
||||
this.entries?.find(
|
||||
(entry) =>
|
||||
entry.entry_id === this._filters.config_entry!.value![0]
|
||||
)?.title || this._filters.config_entry.value[0]
|
||||
!this.entries
|
||||
? html`<ha-skeleton-text></ha-skeleton-text>`
|
||||
: this.entries.find(
|
||||
(entry) =>
|
||||
entry.entry_id ===
|
||||
this._filters.config_entry!.value![0]
|
||||
)?.title || this._filters.config_entry.value[0]
|
||||
}${
|
||||
this._filters.config_entry.value.length === 1 &&
|
||||
Array.isArray(this._filters.sub_entry?.value) &&
|
||||
@@ -1118,6 +1165,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
this._devicesAndFilterDomains(
|
||||
this.hass.devices,
|
||||
this.entries,
|
||||
this.entriesFailed,
|
||||
this.entities,
|
||||
this.hass.areas,
|
||||
this.manifests,
|
||||
@@ -1357,6 +1405,13 @@ ${rejected
|
||||
ha-assist-chip {
|
||||
--ha-assist-chip-container-shape: 10px;
|
||||
}
|
||||
ha-alert[slot="top-header"] {
|
||||
display: block;
|
||||
margin: var(--ha-space-2) var(--ha-space-4);
|
||||
}
|
||||
ha-alert ha-skeleton-text {
|
||||
--ha-skeleton-text-width: 100px;
|
||||
}
|
||||
ha-dropdown::part(menu),
|
||||
ha-dropdown::part(submenu) {
|
||||
--auto-size-available-width: calc(50vw - var(--ha-space-4));
|
||||
|
||||
@@ -31,13 +31,21 @@ class HaConfigDevices extends HassRouterPage {
|
||||
},
|
||||
};
|
||||
|
||||
@state() private _configEntries: ConfigEntry[] = [];
|
||||
@state() private _configEntries?: ConfigEntry[];
|
||||
|
||||
@state() private _configEntriesFailed = false;
|
||||
|
||||
@state() private _manifests: IntegrationManifest[] = [];
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._loadData();
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this.addEventListener("reload-config-entries", () =>
|
||||
this._loadConfigEntries()
|
||||
);
|
||||
this._loadData();
|
||||
}
|
||||
}
|
||||
|
||||
protected updatePageEl(pageEl) {
|
||||
@@ -45,9 +53,12 @@ class HaConfigDevices extends HassRouterPage {
|
||||
|
||||
if (this._currentPage === "device") {
|
||||
pageEl.deviceId = this.routeTail.path.substr(1);
|
||||
pageEl.entries = this._configEntries ?? [];
|
||||
} else {
|
||||
pageEl.entries = this._configEntries;
|
||||
pageEl.entriesFailed = this._configEntriesFailed;
|
||||
}
|
||||
|
||||
pageEl.entries = this._configEntries;
|
||||
pageEl.manifests = this._manifests;
|
||||
pageEl.narrow = this.narrow;
|
||||
pageEl.isWide = this.isWide;
|
||||
@@ -55,12 +66,36 @@ class HaConfigDevices extends HassRouterPage {
|
||||
}
|
||||
|
||||
private async _loadData() {
|
||||
this._configEntries = await getConfigEntries(this.hass);
|
||||
this._manifests = await fetchIntegrationManifests(this.hass);
|
||||
await Promise.all([
|
||||
this._loadConfigEntries(),
|
||||
fetchIntegrationManifests(this.hass)
|
||||
.then((manifests) => {
|
||||
this._manifests = manifests;
|
||||
})
|
||||
.catch(() => {
|
||||
// The pages remain usable without integration manifests.
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
this._configEntriesFailed = false;
|
||||
this._configEntries = undefined;
|
||||
|
||||
try {
|
||||
this._configEntries = await getConfigEntries(this.hass);
|
||||
} catch {
|
||||
this._configEntriesFailed = true;
|
||||
this._configEntries = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"reload-config-entries": undefined;
|
||||
}
|
||||
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-devices": HaConfigDevices;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ class HaConfigHardwareAll extends LitElement {
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
private _columns = memoizeOne(
|
||||
(localize: LocalizeFunc): DataTableColumnContainer<HardwareDeviceRow> => ({
|
||||
name: {
|
||||
@@ -98,6 +100,7 @@ class HaConfigHardwareAll extends LitElement {
|
||||
.tabs=${hardwareTabs(this.hass)}
|
||||
clickable
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.loading=${this._loading}
|
||||
.data=${this._hardware ? this._data(this._hardware) : []}
|
||||
.noDataText=${
|
||||
this._error ||
|
||||
@@ -113,6 +116,8 @@ class HaConfigHardwareAll extends LitElement {
|
||||
this._hardware = await fetchHassioHardwareInfo(this.hass);
|
||||
} catch (err: any) {
|
||||
this._error = extractApiErrorMessage(err);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdown";
|
||||
import "../../../../../components/ha-dropdown-item";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-list-item";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import { getSignedPath } from "../../../../../data/auth";
|
||||
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../../../data/diagnostics";
|
||||
import type { OTBRInfo, OTBRInfoDict } from "../../../../../data/otbr";
|
||||
|
||||
@@ -45,6 +45,8 @@ export class ZHAGroupsDashboard extends LitElement {
|
||||
|
||||
@state() private _groups: ZHAGroup[] = [];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
private _firstUpdatedCalled = false;
|
||||
|
||||
public connectedCallback(): void {
|
||||
@@ -114,6 +116,7 @@ export class ZHAGroupsDashboard extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.loading=${this._loading}
|
||||
.data=${this._formattedGroups(this._groups)}
|
||||
@row-click=${this._handleRowClicked}
|
||||
clickable
|
||||
@@ -128,7 +131,11 @@ export class ZHAGroupsDashboard extends LitElement {
|
||||
}
|
||||
|
||||
private async _fetchGroups() {
|
||||
this._groups = (await fetchGroups(this.hass!)).sort(sortZHAGroups);
|
||||
try {
|
||||
this._groups = (await fetchGroups(this.hass!)).sort(sortZHAGroups);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
|
||||
|
||||
@@ -30,6 +30,8 @@ class ZWaveJSProvisioned extends LitElement {
|
||||
|
||||
@state() private _provisioningEntries: ZwaveJSProvisioningEntry[] = [];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@state() private _nodeIdToDevice: Record<number, DeviceRegistryEntry> = {};
|
||||
|
||||
protected render() {
|
||||
@@ -50,6 +52,7 @@ class ZWaveJSProvisioned extends LitElement {
|
||||
this.configEntryId
|
||||
}"
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.loading=${this._loading}
|
||||
.data=${this._getData(this._provisioningEntries, this._nodeIdToDevice)}
|
||||
>
|
||||
</hass-tabs-subpage-data-table>
|
||||
@@ -176,10 +179,14 @@ class ZWaveJSProvisioned extends LitElement {
|
||||
}
|
||||
|
||||
private async _fetchProvisioningEntries() {
|
||||
this._provisioningEntries = await fetchZwaveProvisioningEntries(
|
||||
this.hass!,
|
||||
this.configEntryId
|
||||
);
|
||||
try {
|
||||
this._provisioningEntries = await fetchZwaveProvisioningEntries(
|
||||
this.hass!,
|
||||
this.configEntryId
|
||||
);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _unprovision = async (ev) => {
|
||||
|
||||
@@ -108,6 +108,8 @@ export class HaConfigLabels extends LitElement {
|
||||
|
||||
@state() private _labels: LabelRegistryEntry[] = [];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
@@ -258,6 +260,7 @@ export class HaConfigLabels extends LitElement {
|
||||
.tabs=${configSections.areas}
|
||||
.columns=${this._columns(this.hass.localize, this.narrow)}
|
||||
.data=${this._data(this._labels)}
|
||||
.loading=${this._loading}
|
||||
.noDataText=${this.hass.localize("ui.panel.config.labels.no_labels")}
|
||||
has-fab
|
||||
.initialSorting=${this._activeSorting}
|
||||
@@ -321,7 +324,11 @@ export class HaConfigLabels extends LitElement {
|
||||
}
|
||||
|
||||
private async _fetchLabels() {
|
||||
this._labels = await fetchLabelRegistry(this.hass.connection);
|
||||
try {
|
||||
this._labels = await fetchLabelRegistry(this.hass.connection);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _addLabel() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import "../../../components/skeleton/ha-skeleton-text";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiContentCopy } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
@@ -178,6 +178,7 @@ class DialogSystemLogDetail extends LitElement {
|
||||
<span slot="headerTitle">${title}</span>
|
||||
<ha-icon-button
|
||||
id="copy"
|
||||
autofocus
|
||||
@click=${this._copyLog}
|
||||
slot="headerActionItems"
|
||||
.label=${this._i18n.localize("ui.panel.config.logs.copy")}
|
||||
@@ -187,8 +188,10 @@ class DialogSystemLogDetail extends LitElement {
|
||||
integration &&
|
||||
this._manifest === undefined &&
|
||||
reportTarget !== "frontend"
|
||||
? html`<ha-alert alert-type="info">
|
||||
<wa-skeleton effect="sheen"></wa-skeleton>
|
||||
? html`<ha-alert
|
||||
alert-type=${this.isCustomIntegration ? "warning" : "info"}
|
||||
>
|
||||
<ha-skeleton-text></ha-skeleton-text>
|
||||
</ha-alert>`
|
||||
: html`<ha-alert
|
||||
alert-type=${this.isCustomIntegration ? "warning" : "info"}
|
||||
@@ -237,7 +240,7 @@ class DialogSystemLogDetail extends LitElement {
|
||||
}
|
||||
</ha-alert>`
|
||||
}
|
||||
<div class="contents" tabindex="-1" autofocus>
|
||||
<div class="contents">
|
||||
<p>
|
||||
${this._i18n.localize("ui.panel.config.logs.detail.logger")}:
|
||||
${item.name}<br />
|
||||
@@ -420,10 +423,24 @@ class DialogSystemLogDetail extends LitElement {
|
||||
ha-alert p + p {
|
||||
margin-block-start: var(--ha-space-2);
|
||||
}
|
||||
wa-skeleton {
|
||||
height: 1em;
|
||||
--color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
ha-skeleton-text {
|
||||
--ha-skeleton-text-width: 320px;
|
||||
}
|
||||
@supports (color: color-mix(in srgb, black, transparent)) {
|
||||
ha-alert[alert-type="info"] ha-skeleton-text {
|
||||
--ha-skeleton-color: color-mix(
|
||||
in srgb,
|
||||
var(--info-color) 24%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
ha-alert[alert-type="warning"] ha-skeleton-text {
|
||||
--ha-skeleton-color: color-mix(
|
||||
in srgb,
|
||||
var(--warning-color) 24%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
.contents {
|
||||
outline: none;
|
||||
|
||||
@@ -101,6 +101,8 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
|
||||
@state() private _dashboards: LovelaceDashboard[] = [];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
@@ -409,6 +411,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
this._dashboards,
|
||||
this.hass.localize
|
||||
)}
|
||||
.loading=${this._loading}
|
||||
.data=${this._getItems(
|
||||
this._dashboards,
|
||||
defaultPanel,
|
||||
@@ -476,7 +479,11 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
}
|
||||
|
||||
private async _getDashboards() {
|
||||
this._dashboards = await fetchDashboards(this.hass);
|
||||
try {
|
||||
this._dashboards = await fetchDashboards(this.hass);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleRowClicked(ev: CustomEvent) {
|
||||
|
||||
@@ -58,6 +58,8 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _tags: Tag[] = [];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
private get _canWriteTags() {
|
||||
return this.hass.auth.external?.config.canWriteTag;
|
||||
}
|
||||
@@ -195,6 +197,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.tags}
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.loading=${this._loading}
|
||||
.data=${this._data(this._tags)}
|
||||
.noDataText=${this.hass.localize("ui.panel.config.tag.no_tags")}
|
||||
.filter=${this._filter}
|
||||
@@ -266,7 +269,11 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private async _fetchTags() {
|
||||
this._tags = await fetchTags(this.hass);
|
||||
try {
|
||||
this._tags = await fetchTags(this.hass);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _openWrite(tag: Tag) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { clearStatistics, getStatisticLabel } from "../../../../data/recorder";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
|
||||
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import type { DialogStatisticsFixParams } from "./show-dialog-statistics-fix";
|
||||
|
||||
@customElement("dialog-statistics-fix")
|
||||
|
||||
@@ -13,7 +13,14 @@ import {
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, type CSSResultGroup, html, LitElement, nothing } from "lit";
|
||||
import {
|
||||
css,
|
||||
type CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
nothing,
|
||||
type PropertyValues,
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type {
|
||||
@@ -67,7 +74,7 @@ import { getAreaTableColumn } from "../../common/data-table-columns";
|
||||
import { KeyboardShortcutMixin } from "../../../../mixins/keyboard-shortcut-mixin";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistantRegistries } from "../../../../types";
|
||||
import { showConfirmationDialog } from "../../../lovelace/custom-card-helpers";
|
||||
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { fixStatisticsIssue } from "./fix-statistics";
|
||||
import { showStatisticsAdjustSumDialog } from "./show-dialog-statistics-adjust-sum";
|
||||
|
||||
@@ -108,6 +115,8 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
@state() private _data: StatisticData[] = [] as StatisticsMetaData[];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@state() private filter = "";
|
||||
|
||||
@state() private _selected: string[] = [];
|
||||
@@ -146,8 +155,12 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
@query("ha-input-search") private _searchInput!: HaInputSearch;
|
||||
|
||||
protected firstUpdated() {
|
||||
this._validateStatistics();
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._validateStatistics();
|
||||
}
|
||||
}
|
||||
|
||||
private _displayData = memoizeOne(
|
||||
@@ -554,6 +567,7 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
<ha-data-table
|
||||
.narrow=${this.narrow}
|
||||
.loading=${this._loading}
|
||||
.columns=${columns}
|
||||
.data=${this._displayData(
|
||||
this._data,
|
||||
@@ -722,38 +736,42 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
|
||||
private async _validateStatistics() {
|
||||
const [statisticIds, issues] = await Promise.all([
|
||||
getStatisticIds(this._api),
|
||||
validateStatistics(this._api),
|
||||
]);
|
||||
try {
|
||||
const [statisticIds, issues] = await Promise.all([
|
||||
getStatisticIds(this._api),
|
||||
validateStatistics(this._api),
|
||||
]);
|
||||
|
||||
updateStatisticsIssues(this._api);
|
||||
updateStatisticsIssues(this._api);
|
||||
|
||||
const statsIds = new Set();
|
||||
const statsIds = new Set();
|
||||
|
||||
this._data = statisticIds.map((statistic) => {
|
||||
statsIds.add(statistic.statistic_id);
|
||||
return {
|
||||
...statistic,
|
||||
state: this._states[statistic.statistic_id],
|
||||
issues: issues[statistic.statistic_id],
|
||||
};
|
||||
});
|
||||
this._data = statisticIds.map((statistic) => {
|
||||
statsIds.add(statistic.statistic_id);
|
||||
return {
|
||||
...statistic,
|
||||
state: this._states[statistic.statistic_id],
|
||||
issues: issues[statistic.statistic_id],
|
||||
};
|
||||
});
|
||||
|
||||
Object.keys(issues).forEach((statisticId) => {
|
||||
if (!statsIds.has(statisticId)) {
|
||||
this._data.push({
|
||||
statistic_id: statisticId,
|
||||
statistics_unit_of_measurement: "",
|
||||
source: "",
|
||||
state: this._states[statisticId],
|
||||
issues: issues[statisticId],
|
||||
mean_type: StatisticMeanType.NONE,
|
||||
has_sum: false,
|
||||
unit_class: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
Object.keys(issues).forEach((statisticId) => {
|
||||
if (!statsIds.has(statisticId)) {
|
||||
this._data.push({
|
||||
statistic_id: statisticId,
|
||||
statistics_unit_of_measurement: "",
|
||||
source: "",
|
||||
state: this._states[statisticId],
|
||||
issues: issues[statisticId],
|
||||
mean_type: StatisticMeanType.NONE,
|
||||
has_sum: false,
|
||||
unit_class: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _clearSelected = async () => {
|
||||
|
||||
@@ -40,6 +40,8 @@ export class HaConfigUsers extends LitElement {
|
||||
|
||||
@state() private _users: User[] = [];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@storage({ key: "users-table-sort", state: false, subscribe: false })
|
||||
private _activeSorting?: SortingChangedEvent;
|
||||
|
||||
@@ -179,6 +181,7 @@ export class HaConfigUsers extends LitElement {
|
||||
back-path="/config"
|
||||
.tabs=${configSections.persons}
|
||||
.columns=${this._columns(this.narrow, this.hass.localize)}
|
||||
.loading=${this._loading}
|
||||
.data=${this._userData(this._users, this.hass.localize)}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
.hiddenColumns=${this._activeHiddenColumns}
|
||||
@@ -212,7 +215,11 @@ export class HaConfigUsers extends LitElement {
|
||||
);
|
||||
|
||||
private async _fetchUsers() {
|
||||
this._users = await fetchUsers(this.hass);
|
||||
try {
|
||||
this._users = await fetchUsers(this.hass);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
this._users.forEach((user) => {
|
||||
if (user.is_owner) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import "../../components/ha-dropdown-item";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-icon-button-arrow-prev";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import { NativeBackButtonController } from "../../external_app/external_back_button";
|
||||
import "../../components/media-player/ha-media-manage-button";
|
||||
import "../../components/media-player/ha-media-player-browse";
|
||||
import type {
|
||||
@@ -90,16 +89,11 @@ class PanelMediaBrowser extends LitElement {
|
||||
|
||||
@query("ha-bar-media-player") private _player!: BarMediaPlayer;
|
||||
|
||||
private _nativeBackButton = new NativeBackButtonController(this, {
|
||||
visible: () => this._navigateIds.length > 1,
|
||||
back: () => this._goBack(),
|
||||
});
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-top-app-bar-fixed .narrow=${this.narrow}>
|
||||
${
|
||||
this._navigateIds.length > 1 && !this._nativeBackButton.native
|
||||
this._navigateIds.length > 1
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
slot="navigationIcon"
|
||||
|
||||
@@ -340,6 +340,9 @@ class HaRefreshTokens extends LitElement {
|
||||
ha-list-item-base {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
}
|
||||
[slot="supporting-text"] {
|
||||
white-space: normal;
|
||||
}
|
||||
ha-icon-button {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export const loadVirtualizer = async () => {
|
||||
await import("@lit-labs/virtualizer");
|
||||
// The default flow layout is otherwise only fetched once there are items.
|
||||
await Promise.all([
|
||||
import("@lit-labs/virtualizer"),
|
||||
import("@lit-labs/virtualizer/layouts/flow.js"),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -6948,6 +6948,8 @@
|
||||
"caption": "Devices",
|
||||
"description": "Manage configured devices",
|
||||
"filtering_by_config_entry": "[%key:ui::panel::config::entities::picker::filtering_by_config_entry%]",
|
||||
"config_entries_load_failed": "Unable to load integrations for your devices.",
|
||||
"retry": "[%key:ui::panel::app::retry%]",
|
||||
"device_info": "{type} info",
|
||||
"edit_settings": "Edit settings",
|
||||
"restore_entity_ids": "Recreate entity IDs",
|
||||
|
||||
@@ -264,7 +264,8 @@ export async function openOnboarding(page: Page, baseURL: string) {
|
||||
|
||||
export async function createOwner(page: Page) {
|
||||
await page
|
||||
.locator("onboarding-welcome ha-button.start")
|
||||
.locator("onboarding-welcome")
|
||||
.getByRole("button", { name: "Create my smart home", exact: true })
|
||||
.click({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
const inputs = page.locator("onboarding-create-user ha-input >> input");
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
handleExternalMessage,
|
||||
addExternalBarCodeListener,
|
||||
} from "../../src/external_app/external_app_entrypoint";
|
||||
import { handleNativeBackButtonPressed } from "../../src/external_app/external_back_button";
|
||||
import { showAutomationEditor } from "../../src/data/automation";
|
||||
import type {
|
||||
EMIncomingMessageRestart,
|
||||
@@ -20,7 +19,6 @@ import type {
|
||||
EMIncomingMessageImprovDeviceSetupDone,
|
||||
EMIncomingMessageBarCodeScanResult,
|
||||
EMIncomingMessageBarCodeScanAborted,
|
||||
EMIncomingMessageBackButtonPressed,
|
||||
} from "../../src/external_app/external_messaging";
|
||||
|
||||
vi.mock("../../src/common/dom/fire_event", () => ({
|
||||
@@ -32,9 +30,6 @@ vi.mock("../../src/common/navigate", () => ({
|
||||
vi.mock("../../src/data/automation", () => ({
|
||||
showAutomationEditor: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../src/external_app/external_back_button", () => ({
|
||||
handleNativeBackButtonPressed: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("handleExternalMessage", () => {
|
||||
let hassMainEl: any;
|
||||
@@ -290,38 +285,4 @@ describe("handleExternalMessage", () => {
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
it("handles back_button/pressed command", () => {
|
||||
vi.mocked(handleNativeBackButtonPressed).mockReturnValue(true);
|
||||
const msg: EMIncomingMessageBackButtonPressed = {
|
||||
type: "command",
|
||||
command: "back_button/pressed",
|
||||
id: 13,
|
||||
};
|
||||
const result = handleExternalMessage(hassMainEl, msg);
|
||||
expect(handleNativeBackButtonPressed).toHaveBeenCalledOnce();
|
||||
expect(fireMessage).toHaveBeenCalledWith({
|
||||
id: 13,
|
||||
type: "result",
|
||||
success: true,
|
||||
result: null,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("reports back_button/pressed without a back button as an error", () => {
|
||||
vi.mocked(handleNativeBackButtonPressed).mockReturnValue(false);
|
||||
const msg: EMIncomingMessageBackButtonPressed = {
|
||||
type: "command",
|
||||
command: "back_button/pressed",
|
||||
id: 14,
|
||||
};
|
||||
const result = handleExternalMessage(hassMainEl, msg);
|
||||
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
|
||||
id: 14,
|
||||
type: "result",
|
||||
success: false,
|
||||
error: { code: "not_allowed", message: "no back button shown" },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleNativeBackButtonPressed } from "../../src/external_app/external_back_button";
|
||||
import type { ExternalMessaging } from "../../src/external_app/external_messaging";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
// The real back button pulls in the localize context, which is not provided here.
|
||||
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
|
||||
vi.mock("../../src/components/ha-menu-button", () => ({}));
|
||||
vi.mock("../../src/common/navigate", () => ({
|
||||
getHistoryState: () => undefined,
|
||||
goBack: vi.fn(),
|
||||
}));
|
||||
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
|
||||
customElements.define("ha-menu-button", class extends LitElement {});
|
||||
await import("../../src/layouts/hass-subpage");
|
||||
|
||||
const { goBack } = await import("../../src/common/navigate");
|
||||
|
||||
let fireMessage: ReturnType<typeof vi.fn>;
|
||||
let nativeBus: ExternalMessaging;
|
||||
|
||||
// The app has a single external bus, so every page shares one.
|
||||
const makeHass = (hasNativeBackButton: boolean): HomeAssistant =>
|
||||
({
|
||||
auth: {
|
||||
external: hasNativeBackButton
|
||||
? nativeBus
|
||||
: ({
|
||||
config: {},
|
||||
fireMessage,
|
||||
} as unknown as ExternalMessaging),
|
||||
},
|
||||
}) as HomeAssistant;
|
||||
|
||||
let host: HTMLDivElement | undefined;
|
||||
|
||||
const mount = async (hass: HomeAssistant, backPath = "/config") => {
|
||||
const element = document.createElement("hass-subpage");
|
||||
Object.assign(element, { hass, backPath });
|
||||
host!.append(element);
|
||||
await (element as LitElement).updateComplete;
|
||||
return element;
|
||||
};
|
||||
|
||||
const arrowOf = (element: Element) =>
|
||||
element.shadowRoot!.querySelector("ha-icon-button-arrow-prev");
|
||||
|
||||
beforeEach(() => {
|
||||
fireMessage = vi.fn();
|
||||
nativeBus = {
|
||||
config: { hasNativeBackButton: true },
|
||||
fireMessage,
|
||||
} as unknown as ExternalMessaging;
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("native back button", () => {
|
||||
it("keeps rendering the arrow when the app has no native back button", async () => {
|
||||
const element = await mount(makeHass(false));
|
||||
|
||||
expect(arrowOf(element)).not.toBeNull();
|
||||
expect(fireMessage).not.toHaveBeenCalled();
|
||||
expect(handleNativeBackButtonPressed()).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the arrow and reports the back button to the app", async () => {
|
||||
const element = await mount(makeHass(true));
|
||||
|
||||
expect(arrowOf(element)).toBeNull();
|
||||
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
|
||||
type: "back_button/show",
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the app back button once the page is gone", async () => {
|
||||
const element = await mount(makeHass(true));
|
||||
fireMessage.mockClear();
|
||||
|
||||
element.remove();
|
||||
|
||||
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
|
||||
type: "back_button/hide",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not report a back button on a main page", async () => {
|
||||
const hass = makeHass(true);
|
||||
const element = document.createElement("hass-subpage");
|
||||
Object.assign(element, { hass, mainPage: true });
|
||||
host!.append(element);
|
||||
await (element as LitElement).updateComplete;
|
||||
|
||||
expect(fireMessage).not.toHaveBeenCalled();
|
||||
expect(handleNativeBackButtonPressed()).toBe(false);
|
||||
});
|
||||
|
||||
it("navigates back when the app reports a press", async () => {
|
||||
await mount(makeHass(true), "/config/areas");
|
||||
|
||||
expect(handleNativeBackButtonPressed()).toBe(true);
|
||||
expect(goBack).toHaveBeenCalledWith("/config/areas");
|
||||
});
|
||||
|
||||
it("uses the back callback of the page when it has one", async () => {
|
||||
const backCallback = vi.fn();
|
||||
const element = await mount(makeHass(true));
|
||||
Object.assign(element, { backCallback });
|
||||
await (element as LitElement).updateComplete;
|
||||
|
||||
expect(handleNativeBackButtonPressed()).toBe(true);
|
||||
expect(backCallback).toHaveBeenCalledOnce();
|
||||
expect(goBack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets the page mounted last own the back button", async () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
const firstPage = await mount(makeHass(true));
|
||||
Object.assign(firstPage, { backCallback: first });
|
||||
await (firstPage as LitElement).updateComplete;
|
||||
|
||||
const secondPage = await mount(makeHass(true));
|
||||
Object.assign(secondPage, { backCallback: second });
|
||||
await (secondPage as LitElement).updateComplete;
|
||||
|
||||
handleNativeBackButtonPressed();
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
|
||||
// Back on the first page, it owns the button again.
|
||||
secondPage.remove();
|
||||
handleNativeBackButtonPressed();
|
||||
expect(first).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("only reports the back button once while pages come and go", async () => {
|
||||
const firstPage = await mount(makeHass(true));
|
||||
const secondPage = await mount(makeHass(true));
|
||||
firstPage.remove();
|
||||
|
||||
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
|
||||
type: "back_button/show",
|
||||
});
|
||||
|
||||
secondPage.remove();
|
||||
|
||||
expect(fireMessage).toHaveBeenLastCalledWith({ type: "back_button/hide" });
|
||||
});
|
||||
});
|
||||
@@ -11443,13 +11443,13 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:^4.1.0":
|
||||
version: 4.3.1
|
||||
resolution: "js-yaml@npm:4.3.1"
|
||||
version: 4.3.2
|
||||
resolution: "js-yaml@npm:4.3.2"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.js
|
||||
checksum: 10/2ce71b5d632abbd77da80447bf860e8a0264e54bffe94840984887d58b023761495b523727547904517a6107a1ef189854b361e0fc44995ee13a84f222d7bd42
|
||||
checksum: 10/05c44b9c73e4901d92703b155e76518df64bf01ac62e4c036b47de4b391e19b72e32656e8954d51b436307f08cc9d0c0d4ec617d061cf2f65fffee9f3114bee7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user