mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-31 11:26:12 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8c0015df6 | |||
| 37a0a5c4d7 | |||
| 764ec29512 | |||
| 1691890ad3 | |||
| e8382cdedc | |||
| f58ff2983b | |||
| af2bf4940c | |||
| c2adaaa7aa | |||
| 43077cfc8a | |||
| c8459968fb | |||
| 0cb586b15b | |||
| b1ccb6355d | |||
| 5f2086765a |
@@ -62,7 +62,7 @@ Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so on
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal. `yarn dev` and `yarn dev:serve` share one managed process slot because both write the app output.
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. `yarn dev`, `yarn dev:serve`, `yarn dev:demo`, and `yarn dev:gallery` also support `--fetch-translations`; this runs translation fetching, including first-time GitHub device authentication, under the workflow lock before starting the watcher. It works in foreground and background modes. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal. `yarn dev` and `yarn dev:serve` share one managed process slot because both write the app output.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
// --status Report whether the suite's dev server is running.
|
||||
// --stop Stop a running background dev server.
|
||||
// --logs [--follow] Print (or follow) the background dev server log.
|
||||
// --fetch-translations
|
||||
// Fetch nightly translations before starting (app,
|
||||
// app-serve, demo, and gallery only).
|
||||
//
|
||||
// Extra args (for example -p or -c on app-serve) are forwarded to the underlying
|
||||
// script. Suites use one of two liveness models:
|
||||
@@ -73,6 +76,7 @@ const SUITES = new Map([
|
||||
"demo",
|
||||
{
|
||||
alias: "dev:demo",
|
||||
fetchTranslations: true,
|
||||
liveness: "health",
|
||||
port: 8090,
|
||||
spawn: { cmd: gulpBin, args: ["develop-demo"] },
|
||||
@@ -82,6 +86,7 @@ const SUITES = new Map([
|
||||
"gallery",
|
||||
{
|
||||
alias: "dev:gallery",
|
||||
fetchTranslations: true,
|
||||
liveness: "health",
|
||||
port: 8100,
|
||||
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
|
||||
@@ -91,6 +96,7 @@ const SUITES = new Map([
|
||||
"app",
|
||||
{
|
||||
alias: "dev",
|
||||
fetchTranslations: true,
|
||||
liveness: "process",
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
||||
@@ -102,6 +108,7 @@ const SUITES = new Map([
|
||||
alias: "dev:serve",
|
||||
liveness: "process",
|
||||
acceptsArgs: true,
|
||||
fetchTranslations: true,
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: developAndServeScript, args: [] },
|
||||
},
|
||||
@@ -144,12 +151,14 @@ const usage = () => {
|
||||
const suites = [...SUITES.keys()].join("|");
|
||||
process.stderr.write(
|
||||
`Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` +
|
||||
`[--background | --status | --stop | --logs [--follow]]\n`
|
||||
`[--background | --status | --stop | --logs [--follow]] ` +
|
||||
`[--fetch-translations]\n`
|
||||
);
|
||||
};
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const args = {
|
||||
fetchTranslations: false,
|
||||
mode: "foreground",
|
||||
follow: false,
|
||||
modes: [],
|
||||
@@ -165,6 +174,9 @@ const parseArgs = (argv) => {
|
||||
case "--follow":
|
||||
args.follow = true;
|
||||
break;
|
||||
case "--fetch-translations":
|
||||
args.fetchTranslations = true;
|
||||
break;
|
||||
default:
|
||||
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
|
||||
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
|
||||
@@ -178,6 +190,28 @@ const parseArgs = (argv) => {
|
||||
return args;
|
||||
};
|
||||
|
||||
const translationPrebuildEnv = (token) => {
|
||||
const env = workflowLockEnv(token);
|
||||
delete env.SKIP_FETCH_NIGHTLY_TRANSLATIONS;
|
||||
return env;
|
||||
};
|
||||
|
||||
const suiteEnv = (token, fetchTranslations = false) => ({
|
||||
...workflowLockEnv(token),
|
||||
...(fetchTranslations && { SKIP_FETCH_NIGHTLY_TRANSLATIONS: "1" }),
|
||||
});
|
||||
|
||||
const runPrebuild = (token, fetchTranslations = false) =>
|
||||
fetchTranslations
|
||||
? spawnForeground({
|
||||
cmd: gulpBin,
|
||||
args: ["setup-and-fetch-nightly-translations"],
|
||||
cwd: repoRoot,
|
||||
env: translationPrebuildEnv(token),
|
||||
processGroup: true,
|
||||
})
|
||||
: Promise.resolve(0);
|
||||
|
||||
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
|
||||
const acquireSuite = (suite) => {
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
@@ -405,7 +439,7 @@ const isHttpServing = async (port, timeoutMs = 1000) => {
|
||||
return probeHost(0);
|
||||
};
|
||||
|
||||
const runForegroundHealth = async (suite, cfg) => {
|
||||
const runForegroundHealth = async (suite, cfg, fetchTranslations = false) => {
|
||||
const { port } = cfg;
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
@@ -427,11 +461,15 @@ const runForegroundHealth = async (suite, cfg) => {
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const prebuildCode = await runPrebuild(lock.token, fetchTranslations);
|
||||
if (prebuildCode !== 0) {
|
||||
return prebuildCode;
|
||||
}
|
||||
return await spawnForeground({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: cfg.spawn.args,
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
env: suiteEnv(lock.token, fetchTranslations),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateSuite(suite, lock.token, child, port),
|
||||
});
|
||||
@@ -440,7 +478,7 @@ const runForegroundHealth = async (suite, cfg) => {
|
||||
}
|
||||
};
|
||||
|
||||
const runBackgroundHealth = async (suite, cfg) => {
|
||||
const runBackgroundHealth = async (suite, cfg, fetchTranslations = false) => {
|
||||
const { port } = cfg;
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
@@ -463,12 +501,17 @@ const runBackgroundHealth = async (suite, cfg) => {
|
||||
}
|
||||
let child;
|
||||
try {
|
||||
const prebuildCode = await runPrebuild(lock.token, fetchTranslations);
|
||||
if (prebuildCode !== 0) {
|
||||
releaseSuite(lock.token);
|
||||
return prebuildCode;
|
||||
}
|
||||
const logFile = logFileFor(suite);
|
||||
child = await spawnDetachedToLog({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: cfg.spawn.args,
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
env: suiteEnv(lock.token, fetchTranslations),
|
||||
logFile,
|
||||
});
|
||||
updateSuite(suite, lock.token, child, port);
|
||||
@@ -520,17 +563,26 @@ const spawnArgs = (cfg, passthrough) => [
|
||||
...(cfg.acceptsArgs ? passthrough : []),
|
||||
];
|
||||
|
||||
const runForegroundProcess = async (suite, cfg, passthrough) => {
|
||||
const runForegroundProcess = async (
|
||||
suite,
|
||||
cfg,
|
||||
passthrough,
|
||||
fetchTranslations = false
|
||||
) => {
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
return lock.code;
|
||||
}
|
||||
try {
|
||||
const prebuildCode = await runPrebuild(lock.token, fetchTranslations);
|
||||
if (prebuildCode !== 0) {
|
||||
return prebuildCode;
|
||||
}
|
||||
return await spawnForeground({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: spawnArgs(cfg, passthrough),
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
env: suiteEnv(lock.token, fetchTranslations),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateSuite(suite, lock.token, child),
|
||||
});
|
||||
@@ -539,7 +591,12 @@ const runForegroundProcess = async (suite, cfg, passthrough) => {
|
||||
}
|
||||
};
|
||||
|
||||
const runBackgroundProcess = async (suite, cfg, passthrough) => {
|
||||
const runBackgroundProcess = async (
|
||||
suite,
|
||||
cfg,
|
||||
passthrough,
|
||||
fetchTranslations = false
|
||||
) => {
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
return lock.code;
|
||||
@@ -547,12 +604,17 @@ const runBackgroundProcess = async (suite, cfg, passthrough) => {
|
||||
|
||||
let child;
|
||||
try {
|
||||
const prebuildCode = await runPrebuild(lock.token, fetchTranslations);
|
||||
if (prebuildCode !== 0) {
|
||||
releaseSuite(lock.token);
|
||||
return prebuildCode;
|
||||
}
|
||||
const logFile = logFileFor(suite);
|
||||
child = await spawnDetachedToLog({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: spawnArgs(cfg, passthrough),
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
env: suiteEnv(lock.token, fetchTranslations),
|
||||
logFile,
|
||||
});
|
||||
|
||||
@@ -630,6 +692,17 @@ const main = async () => {
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (
|
||||
args.fetchTranslations &&
|
||||
(!["foreground", "background"].includes(args.mode) ||
|
||||
!cfg.fetchTranslations)
|
||||
) {
|
||||
process.stderr.write(
|
||||
"--fetch-translations is only supported when starting app, app-serve, demo, or gallery.\n"
|
||||
);
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.passthrough.length && !cfg.acceptsArgs) {
|
||||
process.stderr.write(
|
||||
`Ignoring unexpected arguments: ${args.passthrough.join(" ")}\n`
|
||||
@@ -665,14 +738,26 @@ const main = async () => {
|
||||
const handlers =
|
||||
cfg.liveness === "health"
|
||||
? {
|
||||
foreground: () => runForegroundHealth(args.suite, cfg),
|
||||
background: () => runBackgroundHealth(args.suite, cfg),
|
||||
foreground: () =>
|
||||
runForegroundHealth(args.suite, cfg, args.fetchTranslations),
|
||||
background: () =>
|
||||
runBackgroundHealth(args.suite, cfg, args.fetchTranslations),
|
||||
}
|
||||
: {
|
||||
foreground: () =>
|
||||
runForegroundProcess(args.suite, cfg, args.passthrough),
|
||||
runForegroundProcess(
|
||||
args.suite,
|
||||
cfg,
|
||||
args.passthrough,
|
||||
args.fetchTranslations
|
||||
),
|
||||
background: () =>
|
||||
runBackgroundProcess(args.suite, cfg, args.passthrough),
|
||||
runBackgroundProcess(
|
||||
args.suite,
|
||||
cfg,
|
||||
args.passthrough,
|
||||
args.fetchTranslations
|
||||
),
|
||||
};
|
||||
return handlers[mode]();
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface LovelaceViewElement extends HTMLElement {
|
||||
badges?: HuiBadge[];
|
||||
sections?: HuiSection[];
|
||||
isStrategy: boolean;
|
||||
initialRenderComplete?: Promise<void>;
|
||||
setConfig(config: LovelaceViewConfig): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../components/animation/ha-fade-in";
|
||||
import "../components/ha-top-app-bar-fixed";
|
||||
import "../components/ha-spinner";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -36,7 +37,9 @@ class HassLoadingScreen extends LitElement {
|
||||
private _renderContent(): TemplateResult {
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-spinner></ha-spinner>
|
||||
<ha-fade-in .delay=${500}>
|
||||
<ha-spinner></ha-spinner>
|
||||
</ha-fade-in>
|
||||
${
|
||||
this.message
|
||||
? html`<div id="loading-text">${this.message}</div>`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ContextProvider, createContext } from "@lit/context";
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
|
||||
@@ -15,6 +17,61 @@ export const panelIsReady = async (element: HTMLElement) => {
|
||||
fireEvent(element, "hass-panel-ready");
|
||||
};
|
||||
|
||||
export type RegisterChildPanelReady = (ready: Promise<void>) => void;
|
||||
|
||||
export const childPanelReadyContext =
|
||||
createContext<RegisterChildPanelReady>("child-panel-ready");
|
||||
|
||||
export class ChildPanelReady implements ReactiveController {
|
||||
private _promises: Promise<void>[] = [];
|
||||
|
||||
private _host: ReactiveControllerHost & HTMLElement;
|
||||
|
||||
private _resolveReady?: () => void;
|
||||
|
||||
private _completing = false;
|
||||
|
||||
public ready = new Promise<void>((resolve) => {
|
||||
this._resolveReady = resolve;
|
||||
});
|
||||
|
||||
public constructor(host: ReactiveControllerHost & HTMLElement) {
|
||||
this._host = host;
|
||||
host.addController(this);
|
||||
new ContextProvider(host, {
|
||||
context: childPanelReadyContext,
|
||||
initialValue: (ready) => this._promises.push(ready),
|
||||
});
|
||||
}
|
||||
|
||||
public hostUpdated() {
|
||||
if (this._completing) {
|
||||
return;
|
||||
}
|
||||
this._completing = true;
|
||||
this._host.removeController(this);
|
||||
void this._complete();
|
||||
}
|
||||
|
||||
private async _complete() {
|
||||
// Children created/updated during this render register after the host's
|
||||
// update commits. Wait for that before snapshotting readiness promises.
|
||||
await this._host.updateComplete;
|
||||
|
||||
await this._waitForPromises();
|
||||
|
||||
this._resolveReady?.();
|
||||
await panelIsReady(this._host);
|
||||
}
|
||||
|
||||
private _waitForPromises(): Promise<void> {
|
||||
const count = this._promises.length;
|
||||
return Promise.allSettled(this._promises).then(() =>
|
||||
this._promises.length > count ? this._waitForPromises() : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class PanelReady {
|
||||
public ready?: Promise<void>;
|
||||
|
||||
|
||||
@@ -19,29 +19,62 @@ import { HassRouterPage } from "./hass-router-page";
|
||||
|
||||
const CACHE_URL_PATHS = ["lovelace", "home", "config"];
|
||||
const PANEL_READY_TIMEOUT = 2000;
|
||||
const DASHBOARD_READY_TIMEOUT = 5000;
|
||||
const COMPONENTS = {
|
||||
app: () => import("../panels/app/ha-panel-app"),
|
||||
energy: () => import("../panels/energy/ha-panel-energy"),
|
||||
calendar: () => import("../panels/calendar/ha-panel-calendar"),
|
||||
config: () => import("../panels/config/ha-panel-config"),
|
||||
custom: () => import("../panels/custom/ha-panel-custom"),
|
||||
lovelace: () => import("../panels/lovelace/ha-panel-lovelace"),
|
||||
history: () => import("../panels/history/ha-panel-history"),
|
||||
iframe: () => import("../panels/iframe/ha-panel-iframe"),
|
||||
logbook: () => import("../panels/logbook/ha-panel-logbook"),
|
||||
map: () => import("../panels/map/ha-panel-map"),
|
||||
my: () => import("../panels/my/ha-panel-my"),
|
||||
profile: () => import("../panels/profile/ha-panel-profile"),
|
||||
todo: () => import("../panels/todo/ha-panel-todo"),
|
||||
"media-browser": () =>
|
||||
import("../panels/media-browser/ha-panel-media-browser"),
|
||||
light: () => import("../panels/light/ha-panel-light"),
|
||||
security: () => import("../panels/security/ha-panel-security"),
|
||||
climate: () => import("../panels/climate/ha-panel-climate"),
|
||||
maintenance: () => import("../panels/maintenance/ha-panel-maintenance"),
|
||||
home: () => import("../panels/home/ha-panel-home"),
|
||||
notfound: () => import("../panels/notfound/ha-panel-notfound"),
|
||||
};
|
||||
app: { load: () => import("../panels/app/ha-panel-app") },
|
||||
energy: {
|
||||
load: () => import("../panels/energy/ha-panel-energy"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
calendar: { load: () => import("../panels/calendar/ha-panel-calendar") },
|
||||
config: { load: () => import("../panels/config/ha-panel-config") },
|
||||
custom: { load: () => import("../panels/custom/ha-panel-custom") },
|
||||
lovelace: {
|
||||
load: () => import("../panels/lovelace/ha-panel-lovelace"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
history: { load: () => import("../panels/history/ha-panel-history") },
|
||||
iframe: { load: () => import("../panels/iframe/ha-panel-iframe") },
|
||||
logbook: { load: () => import("../panels/logbook/ha-panel-logbook") },
|
||||
map: { load: () => import("../panels/map/ha-panel-map") },
|
||||
my: { load: () => import("../panels/my/ha-panel-my") },
|
||||
profile: { load: () => import("../panels/profile/ha-panel-profile") },
|
||||
todo: { load: () => import("../panels/todo/ha-panel-todo") },
|
||||
"media-browser": {
|
||||
load: () => import("../panels/media-browser/ha-panel-media-browser"),
|
||||
},
|
||||
light: {
|
||||
load: () => import("../panels/light/ha-panel-light"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
security: {
|
||||
load: () => import("../panels/security/ha-panel-security"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
climate: {
|
||||
load: () => import("../panels/climate/ha-panel-climate"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
maintenance: {
|
||||
load: () => import("../panels/maintenance/ha-panel-maintenance"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
home: {
|
||||
load: () => import("../panels/home/ha-panel-home"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
notfound: { load: () => import("../panels/notfound/ha-panel-notfound") },
|
||||
} satisfies Record<
|
||||
string,
|
||||
Pick<RouteOptions, "load" | "waitForReady"> & { readyTimeout?: number }
|
||||
>;
|
||||
|
||||
@customElement("partial-panel-resolver")
|
||||
class PartialPanelResolver extends HassRouterPage {
|
||||
@@ -126,13 +159,12 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
private _getRoutes(panels: Panels): RouterOptions {
|
||||
const routes: RouterOptions["routes"] = {};
|
||||
Object.values(panels).forEach((panel) => {
|
||||
const component = COMPONENTS[panel.component_name];
|
||||
const data: RouteOptions = {
|
||||
tag: `ha-panel-${panel.component_name}`,
|
||||
cache: CACHE_URL_PATHS.includes(panel.url_path),
|
||||
...(component ?? {}),
|
||||
};
|
||||
if (panel.component_name in COMPONENTS) {
|
||||
data.load = COMPONENTS[panel.component_name];
|
||||
}
|
||||
routes[panel.url_path] = data;
|
||||
});
|
||||
|
||||
@@ -221,9 +253,12 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
)
|
||||
) {
|
||||
await this.rebuild();
|
||||
await promiseTimeout(PANEL_READY_TIMEOUT, this.pageRendered).catch(
|
||||
() => undefined
|
||||
);
|
||||
const component =
|
||||
COMPONENTS[this.hass.panels[this._currentPage].component_name];
|
||||
await promiseTimeout(
|
||||
component?.readyTimeout ?? PANEL_READY_TIMEOUT,
|
||||
this.pageRendered
|
||||
).catch(() => undefined);
|
||||
// Only fire frontend/loaded when this call actually removed the launch
|
||||
// screen, so later panel updates do not fire it again. Native apps remove
|
||||
// it instantly because their own splash screen is still visible.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -31,6 +32,8 @@ class PanelClimate extends LitElement {
|
||||
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -127,6 +130,7 @@ class PanelClimate extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -36,6 +36,7 @@ import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
|
||||
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
|
||||
import { showShortcutsDialog } from "../../../dialogs/shortcuts/show-shortcuts-dialog";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import { ChildPanelReady } from "../../../layouts/panel-ready";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -157,6 +158,11 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
total: 0,
|
||||
};
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
new ChildPanelReady(this);
|
||||
}
|
||||
|
||||
private _pages = memoizeOne(
|
||||
(
|
||||
cloudStatus,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { consume } from "@lit/context";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { filterNavigationPages } from "../../../common/config/filter_navigation_pages";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-icon-next";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
import { getConfigEntries } from "../../../data/config_entries";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import {
|
||||
childPanelReadyContext,
|
||||
type RegisterChildPanelReady,
|
||||
} from "../../../layouts/panel-ready";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../components/ha-config-navigation-list";
|
||||
|
||||
@@ -18,21 +24,53 @@ class HaConfigNavigation extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public pages!: PageNavigation[];
|
||||
|
||||
@state() private _hasBluetoothConfigEntries = false;
|
||||
@state() private _visiblePages?: PageNavigation[];
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
getConfigEntries(this.hass, {
|
||||
domain: "bluetooth",
|
||||
}).then((bluetoothEntries) => {
|
||||
this._hasBluetoothConfigEntries = bluetoothEntries.length > 0;
|
||||
});
|
||||
private _hasBluetoothConfigEntries = false;
|
||||
|
||||
private _bluetoothEntriesLoaded?: Promise<void>;
|
||||
|
||||
private _childReadyRegistered = false;
|
||||
|
||||
private _filterNavigationPages = memoizeOne(
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
pages: PageNavigation[],
|
||||
hasBluetoothConfigEntries: boolean
|
||||
) => filterNavigationPages(hass, pages, { hasBluetoothConfigEntries })
|
||||
);
|
||||
|
||||
@consume({ context: childPanelReadyContext, subscribe: true })
|
||||
private _registerChildPanelReady?: RegisterChildPanelReady;
|
||||
|
||||
protected override updated(changedProps: Map<PropertyKey, unknown>) {
|
||||
super.updated(changedProps);
|
||||
|
||||
const pagesOrHassChanged =
|
||||
changedProps.has("pages") || changedProps.has("hass");
|
||||
|
||||
if (pagesOrHassChanged) {
|
||||
const ready = this._resolveVisiblePages();
|
||||
if (!this._childReadyRegistered && this._registerChildPanelReady) {
|
||||
this._registerChildPanelReady(ready);
|
||||
this._childReadyRegistered = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!this._childReadyRegistered &&
|
||||
this._registerChildPanelReady &&
|
||||
this.pages &&
|
||||
this.hass
|
||||
) {
|
||||
this._registerChildPanelReady(this._resolveVisiblePages());
|
||||
this._childReadyRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const pages = filterNavigationPages(this.hass, this.pages, {
|
||||
hasBluetoothConfigEntries: this._hasBluetoothConfigEntries,
|
||||
}).map((page) => ({
|
||||
const pages = (this._visiblePages ?? []).map((page) => ({
|
||||
...page,
|
||||
name:
|
||||
page.name ||
|
||||
@@ -75,6 +113,42 @@ class HaConfigNavigation extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _loadBluetoothEntries(): Promise<void> {
|
||||
if (!this._bluetoothEntriesLoaded) {
|
||||
this._bluetoothEntriesLoaded = getConfigEntries(this.hass, {
|
||||
domain: "bluetooth",
|
||||
})
|
||||
.then((entries) => {
|
||||
this._hasBluetoothConfigEntries = entries.length > 0;
|
||||
})
|
||||
.catch(() => {
|
||||
this._hasBluetoothConfigEntries = false;
|
||||
});
|
||||
}
|
||||
return this._bluetoothEntriesLoaded;
|
||||
}
|
||||
|
||||
private async _resolveVisiblePages(): Promise<void> {
|
||||
if (this.pages.some((page) => page.component === "bluetooth")) {
|
||||
await this._loadBluetoothEntries();
|
||||
}
|
||||
|
||||
const visiblePages = this._filterNavigationPages(
|
||||
this.hass,
|
||||
this.pages,
|
||||
this._hasBluetoothConfigEntries
|
||||
);
|
||||
const currentVisiblePages = this._visiblePages;
|
||||
if (
|
||||
!currentVisiblePages ||
|
||||
visiblePages.length !== currentVisiblePages.length ||
|
||||
visiblePages.some((page, index) => page !== currentVisiblePages[index])
|
||||
) {
|
||||
this._visiblePages = visiblePages;
|
||||
}
|
||||
await this.updateComplete;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
/* Accessibility */
|
||||
.visually-hidden {
|
||||
|
||||
@@ -89,6 +89,7 @@ class HaPanelConfig extends HassRouterPage {
|
||||
dashboard: {
|
||||
tag: "ha-config-dashboard",
|
||||
load: () => import("./dashboard/ha-config-dashboard"),
|
||||
waitForReady: true,
|
||||
},
|
||||
entities: {
|
||||
tag: "ha-config-entities",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -31,6 +32,8 @@ class PanelLight extends LitElement {
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -127,6 +130,7 @@ class PanelLight extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/animation/ha-fade-in";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-spinner";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
@@ -38,7 +39,9 @@ export class HuiStartingCard extends LitElement implements LovelaceCard {
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-spinner></ha-spinner>
|
||||
<ha-fade-in .delay=${500}>
|
||||
<ha-spinner></ha-spinner>
|
||||
</ha-fade-in>
|
||||
${this.hass.localize("ui.panel.lovelace.cards.starting.description")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -76,6 +76,7 @@ import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-di
|
||||
import { showQuickBar } from "../../dialogs/quick-bar/show-dialog-quick-bar";
|
||||
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
@@ -164,6 +165,8 @@ class HUIRoot extends LitElement {
|
||||
|
||||
private _restoreScroll = false;
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
private _undoRedoController = new UndoRedoController<UndoStackItem>(this, {
|
||||
apply: (config) => this._applyUndoRedo(config),
|
||||
currentConfig: () => ({
|
||||
@@ -777,7 +780,7 @@ class HUIRoot extends LitElement {
|
||||
huiView.narrow = this.narrow;
|
||||
}
|
||||
|
||||
let newSelectView;
|
||||
let newSelectView: HUIRoot["_curView"];
|
||||
|
||||
let viewPath: string | undefined = this.route!.path.split("/")[1];
|
||||
viewPath = viewPath ? decodeURI(viewPath) : undefined;
|
||||
@@ -1258,7 +1261,7 @@ class HUIRoot extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
let view;
|
||||
let view: HUIView;
|
||||
const viewConfig = this.config.views[viewIndex];
|
||||
|
||||
if (!viewConfig) {
|
||||
@@ -1269,12 +1272,16 @@ class HUIRoot extends LitElement {
|
||||
if (this._viewCache[viewIndex]) {
|
||||
view = this._viewCache[viewIndex];
|
||||
} else {
|
||||
if (!this._childPanelReady) {
|
||||
this._childPanelReady = new ChildPanelReady(this);
|
||||
this.requestUpdate();
|
||||
}
|
||||
view = document.createElement("hui-view");
|
||||
view.index = viewIndex;
|
||||
this._viewCache[viewIndex] = view;
|
||||
}
|
||||
|
||||
view.lovelace = this.lovelace;
|
||||
view.lovelace = this.lovelace!;
|
||||
view.hass = this.hass;
|
||||
view.narrow = this.narrow;
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
|
||||
private _mqlListenerRef?: () => void;
|
||||
|
||||
private _resolveInitialRender?: () => void;
|
||||
|
||||
public initialRenderComplete = new Promise<void>((resolve) => {
|
||||
this._resolveInitialRender = resolve;
|
||||
});
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._initMqls();
|
||||
@@ -166,7 +172,13 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
root.removeChild(root.lastChild);
|
||||
}
|
||||
|
||||
columns.forEach((column) => root.appendChild(column));
|
||||
columns.forEach((column) => {
|
||||
root.appendChild(column);
|
||||
});
|
||||
if (this.cards.length === 0 || columns.some((column) => column.lastChild)) {
|
||||
this._resolveInitialRender?.();
|
||||
this._resolveInitialRender = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async _createColumns() {
|
||||
@@ -234,6 +246,10 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
index,
|
||||
this.lovelace!.editMode
|
||||
);
|
||||
if (columnElements.some((column) => column.isConnected)) {
|
||||
this._resolveInitialRender?.();
|
||||
this._resolveInitialRender = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove empty columns
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import deepClone from "deep-clone-simple";
|
||||
import { consume } from "@lit/context";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -19,6 +20,10 @@ import type {
|
||||
} from "../../../data/lovelace/config/view";
|
||||
import { isStrategyView } from "../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
childPanelReadyContext,
|
||||
type RegisterChildPanelReady,
|
||||
} from "../../../layouts/panel-ready";
|
||||
import "../badges/hui-badge";
|
||||
import type { HuiBadge } from "../badges/hui-badge";
|
||||
import "../cards/hui-card";
|
||||
@@ -95,6 +100,15 @@ export class HUIView extends ReactiveElement {
|
||||
|
||||
private _config?: LovelaceViewConfig;
|
||||
|
||||
private _resolveInitialRender?: () => void;
|
||||
|
||||
private _initialRenderComplete = new Promise<void>((resolve) => {
|
||||
this._resolveInitialRender = resolve;
|
||||
});
|
||||
|
||||
@consume({ context: childPanelReadyContext })
|
||||
private _registerChildPanelReady?: RegisterChildPanelReady;
|
||||
|
||||
@storage({
|
||||
key: "dashboardCardClipboard",
|
||||
state: false,
|
||||
@@ -152,6 +166,11 @@ export class HUIView extends ReactiveElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._registerChildPanelReady?.(this._initialRenderComplete);
|
||||
}
|
||||
|
||||
public willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProperties);
|
||||
|
||||
@@ -324,6 +343,13 @@ export class HUIView extends ReactiveElement {
|
||||
const viewConfig = await this._generateConfig(rawConfig);
|
||||
|
||||
this._setConfig(viewConfig, isStrategy);
|
||||
await customElements.whenDefined(this._layoutElement!.localName);
|
||||
if (this._layoutElement instanceof ReactiveElement) {
|
||||
await this._layoutElement.updateComplete;
|
||||
}
|
||||
await this._layoutElement!.initialRenderComplete;
|
||||
this._resolveInitialRender?.();
|
||||
this._resolveInitialRender = undefined;
|
||||
}
|
||||
|
||||
private _createLayoutElement(config: LovelaceViewConfig): void {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -31,6 +32,8 @@ class PanelMaintenance extends LitElement {
|
||||
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -127,6 +130,7 @@ class PanelMaintenance extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -31,6 +32,8 @@ class PanelSecurity extends LitElement {
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -127,6 +130,7 @@ class PanelSecurity extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -166,6 +166,40 @@ defineRouteSmokeTests(appRouteSmokeGroups);
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Lovelace dashboard", () => {
|
||||
test("keeps the launch screen until generated content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToPanel(page, "/?scenario=delayed-generated-dashboard#/climate");
|
||||
|
||||
const launchScreen = page.locator("#ha-launch-screen");
|
||||
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(page.locator("hui-view")).not.toBeAttached();
|
||||
|
||||
await page.evaluate(() => window.resolveGeneratedDashboard?.());
|
||||
|
||||
await expect(page.locator("hui-view")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
});
|
||||
|
||||
test("keeps the launch screen until initial content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToPanel(page, "/?scenario=delayed-lovelace#/lovelace");
|
||||
|
||||
const launchScreen = page.locator("#ha-launch-screen");
|
||||
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(page.locator("hui-card")).not.toBeAttached();
|
||||
|
||||
await page.evaluate(() => window.resolveLovelaceConfig?.());
|
||||
|
||||
await expect(page.locator("hui-card").first()).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
});
|
||||
|
||||
test("renders cards", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
// At least one card should appear
|
||||
|
||||
@@ -92,6 +92,8 @@ declare global {
|
||||
interface Window {
|
||||
__assistRun?: unknown;
|
||||
__mockHass: MockHomeAssistant;
|
||||
resolveGeneratedDashboard?: () => void;
|
||||
resolveLovelaceConfig?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ExtEntityRegistryEntry } from "../../../../../src/data/entity/entity_registry";
|
||||
import type { AssistPipeline } from "../../../../../src/data/assist_pipeline";
|
||||
import type { LovelaceRawConfig } from "../../../../../src/data/lovelace/config/types";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
|
||||
@@ -124,6 +125,50 @@ const quickSearchAssistScenario: Scenario = async (hass) => {
|
||||
});
|
||||
};
|
||||
|
||||
const addLaunchScreen = () => {
|
||||
const launchScreen = document.createElement("div");
|
||||
launchScreen.id = "ha-launch-screen";
|
||||
document.body.prepend(launchScreen);
|
||||
};
|
||||
|
||||
const delayedLovelaceScenario: Scenario = (hass) => {
|
||||
addLaunchScreen();
|
||||
|
||||
const config: LovelaceRawConfig = {
|
||||
views: [
|
||||
{
|
||||
title: "Home",
|
||||
cards: [{ type: "markdown", content: "Dashboard ready" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
let resolveConfig: ((config: LovelaceRawConfig) => void) | undefined;
|
||||
const configPromise = new Promise<LovelaceRawConfig>((resolve) => {
|
||||
resolveConfig = resolve;
|
||||
});
|
||||
|
||||
window.resolveLovelaceConfig = () => resolveConfig?.(config);
|
||||
hass.mockWS("lovelace/config", () => configPromise);
|
||||
};
|
||||
|
||||
const delayedGeneratedDashboardScenario: Scenario = (hass) => {
|
||||
addLaunchScreen();
|
||||
|
||||
const loadFragmentTranslation = hass.loadFragmentTranslation;
|
||||
let resolveTranslation: (() => void) | undefined;
|
||||
const translationReady = new Promise<void>((resolve) => {
|
||||
resolveTranslation = resolve;
|
||||
});
|
||||
|
||||
hass.loadFragmentTranslation = async (fragment) => {
|
||||
if (fragment === "lovelace") {
|
||||
await translationReady;
|
||||
}
|
||||
return loadFragmentTranslation(fragment);
|
||||
};
|
||||
window.resolveGeneratedDashboard = resolveTranslation;
|
||||
};
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const scenarios: Record<string, Scenario> = {
|
||||
@@ -131,6 +176,8 @@ export const scenarios: Record<string, Scenario> = {
|
||||
"non-admin": nonAdminScenario,
|
||||
"dark-theme": darkThemeScenario,
|
||||
"custom-theme": customThemeScenario,
|
||||
"delayed-generated-dashboard": delayedGeneratedDashboardScenario,
|
||||
"light-more-info": lightMoreInfoScenario,
|
||||
"quick-search-assist": quickSearchAssistScenario,
|
||||
"delayed-lovelace": delayedLovelaceScenario,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user