mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-31 11:26:12 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 128c5bddeb |
@@ -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]`. `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.
|
||||
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.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
// --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:
|
||||
@@ -76,7 +73,6 @@ const SUITES = new Map([
|
||||
"demo",
|
||||
{
|
||||
alias: "dev:demo",
|
||||
fetchTranslations: true,
|
||||
liveness: "health",
|
||||
port: 8090,
|
||||
spawn: { cmd: gulpBin, args: ["develop-demo"] },
|
||||
@@ -86,7 +82,6 @@ const SUITES = new Map([
|
||||
"gallery",
|
||||
{
|
||||
alias: "dev:gallery",
|
||||
fetchTranslations: true,
|
||||
liveness: "health",
|
||||
port: 8100,
|
||||
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
|
||||
@@ -96,7 +91,6 @@ const SUITES = new Map([
|
||||
"app",
|
||||
{
|
||||
alias: "dev",
|
||||
fetchTranslations: true,
|
||||
liveness: "process",
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
||||
@@ -108,7 +102,6 @@ const SUITES = new Map([
|
||||
alias: "dev:serve",
|
||||
liveness: "process",
|
||||
acceptsArgs: true,
|
||||
fetchTranslations: true,
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: developAndServeScript, args: [] },
|
||||
},
|
||||
@@ -151,14 +144,12 @@ const usage = () => {
|
||||
const suites = [...SUITES.keys()].join("|");
|
||||
process.stderr.write(
|
||||
`Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` +
|
||||
`[--background | --status | --stop | --logs [--follow]] ` +
|
||||
`[--fetch-translations]\n`
|
||||
`[--background | --status | --stop | --logs [--follow]]\n`
|
||||
);
|
||||
};
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const args = {
|
||||
fetchTranslations: false,
|
||||
mode: "foreground",
|
||||
follow: false,
|
||||
modes: [],
|
||||
@@ -174,9 +165,6 @@ 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);
|
||||
@@ -190,28 +178,6 @@ 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()}`;
|
||||
@@ -439,7 +405,7 @@ const isHttpServing = async (port, timeoutMs = 1000) => {
|
||||
return probeHost(0);
|
||||
};
|
||||
|
||||
const runForegroundHealth = async (suite, cfg, fetchTranslations = false) => {
|
||||
const runForegroundHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
@@ -461,15 +427,11 @@ const runForegroundHealth = async (suite, cfg, fetchTranslations = false) => {
|
||||
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: suiteEnv(lock.token, fetchTranslations),
|
||||
env: workflowLockEnv(lock.token),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateSuite(suite, lock.token, child, port),
|
||||
});
|
||||
@@ -478,7 +440,7 @@ const runForegroundHealth = async (suite, cfg, fetchTranslations = false) => {
|
||||
}
|
||||
};
|
||||
|
||||
const runBackgroundHealth = async (suite, cfg, fetchTranslations = false) => {
|
||||
const runBackgroundHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
@@ -501,17 +463,12 @@ const runBackgroundHealth = async (suite, cfg, fetchTranslations = false) => {
|
||||
}
|
||||
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: suiteEnv(lock.token, fetchTranslations),
|
||||
env: workflowLockEnv(lock.token),
|
||||
logFile,
|
||||
});
|
||||
updateSuite(suite, lock.token, child, port);
|
||||
@@ -563,26 +520,17 @@ const spawnArgs = (cfg, passthrough) => [
|
||||
...(cfg.acceptsArgs ? passthrough : []),
|
||||
];
|
||||
|
||||
const runForegroundProcess = async (
|
||||
suite,
|
||||
cfg,
|
||||
passthrough,
|
||||
fetchTranslations = false
|
||||
) => {
|
||||
const runForegroundProcess = async (suite, cfg, passthrough) => {
|
||||
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: suiteEnv(lock.token, fetchTranslations),
|
||||
env: workflowLockEnv(lock.token),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateSuite(suite, lock.token, child),
|
||||
});
|
||||
@@ -591,12 +539,7 @@ const runForegroundProcess = async (
|
||||
}
|
||||
};
|
||||
|
||||
const runBackgroundProcess = async (
|
||||
suite,
|
||||
cfg,
|
||||
passthrough,
|
||||
fetchTranslations = false
|
||||
) => {
|
||||
const runBackgroundProcess = async (suite, cfg, passthrough) => {
|
||||
const lock = acquireSuiteForStart(suite);
|
||||
if (!lock.token) {
|
||||
return lock.code;
|
||||
@@ -604,17 +547,12 @@ const runBackgroundProcess = async (
|
||||
|
||||
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: suiteEnv(lock.token, fetchTranslations),
|
||||
env: workflowLockEnv(lock.token),
|
||||
logFile,
|
||||
});
|
||||
|
||||
@@ -692,17 +630,6 @@ 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`
|
||||
@@ -738,26 +665,14 @@ const main = async () => {
|
||||
const handlers =
|
||||
cfg.liveness === "health"
|
||||
? {
|
||||
foreground: () =>
|
||||
runForegroundHealth(args.suite, cfg, args.fetchTranslations),
|
||||
background: () =>
|
||||
runBackgroundHealth(args.suite, cfg, args.fetchTranslations),
|
||||
foreground: () => runForegroundHealth(args.suite, cfg),
|
||||
background: () => runBackgroundHealth(args.suite, cfg),
|
||||
}
|
||||
: {
|
||||
foreground: () =>
|
||||
runForegroundProcess(
|
||||
args.suite,
|
||||
cfg,
|
||||
args.passthrough,
|
||||
args.fetchTranslations
|
||||
),
|
||||
runForegroundProcess(args.suite, cfg, args.passthrough),
|
||||
background: () =>
|
||||
runBackgroundProcess(
|
||||
args.suite,
|
||||
cfg,
|
||||
args.passthrough,
|
||||
args.fetchTranslations
|
||||
),
|
||||
runBackgroundProcess(args.suite, cfg, args.passthrough),
|
||||
};
|
||||
return handlers[mode]();
|
||||
};
|
||||
|
||||
@@ -20,8 +20,7 @@ type DialogType =
|
||||
| "basic-subtitle-below"
|
||||
| "basic-subtitle-above"
|
||||
| "form"
|
||||
| "actions"
|
||||
| "stacked-footer";
|
||||
| "actions";
|
||||
|
||||
@customElement("demo-components-ha-dialog")
|
||||
export class DemoHaDialog extends LitElement {
|
||||
@@ -52,9 +51,6 @@ export class DemoHaDialog extends LitElement {
|
||||
<ha-button @click=${this._handleOpenDialog("actions")}
|
||||
>Dialog with actions</ha-button
|
||||
>
|
||||
<ha-button @click=${this._handleOpenDialog("stacked-footer")}
|
||||
>Dialog with stacked footer</ha-button
|
||||
>
|
||||
</div>
|
||||
|
||||
<ha-dialog
|
||||
@@ -120,42 +116,6 @@ export class DemoHaDialog extends LitElement {
|
||||
<div>Dialog content</div>
|
||||
</ha-dialog>
|
||||
|
||||
<ha-dialog
|
||||
.open=${this._openDialog === "stacked-footer"}
|
||||
header-title="Delete event"
|
||||
width="small"
|
||||
@closed=${this._handleClosed}
|
||||
>
|
||||
<div>
|
||||
Do you want to delete only this event, or this and all future
|
||||
occurrences of the event?
|
||||
</div>
|
||||
|
||||
<ha-dialog-footer slot="footer" stacked>
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this._handleClosed}
|
||||
>
|
||||
Cancel
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
variant="danger"
|
||||
@click=${this._handleClosed}
|
||||
>
|
||||
Delete only this event
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
variant="danger"
|
||||
@click=${this._handleClosed}
|
||||
>
|
||||
Delete all future events
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
|
||||
<h2>Design</h2>
|
||||
|
||||
<h3>Width</h3>
|
||||
@@ -286,15 +246,6 @@ export class DemoHaDialog extends LitElement {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
Actions sit side by side on a single line. When the labels are too
|
||||
long to fit, add the <code>stacked</code> attribute to
|
||||
<code>ha-dialog-footer</code>: it stacks them as full-width buttons,
|
||||
with the primary action(s) above the secondary action(s). Without it,
|
||||
the buttons are squeezed until every word wraps onto its own line. See
|
||||
the <em>Dialog with stacked footer</em> demo above.
|
||||
</p>
|
||||
|
||||
<h2>Implementation</h2>
|
||||
|
||||
<h3>Example Usage</h3>
|
||||
|
||||
@@ -23,7 +23,7 @@ export class DemoHaProgressButton extends LitElement {
|
||||
<ha-progress-button @click=${this._clickedFail}>
|
||||
Fail
|
||||
</ha-progress-button>
|
||||
<ha-progress-button size="small" @click=${this._clickedSuccess}>
|
||||
<ha-progress-button size="s" @click=${this._clickedSuccess}>
|
||||
small
|
||||
</ha-progress-button>
|
||||
<ha-progress-button
|
||||
|
||||
@@ -18,6 +18,8 @@ export class HaProgressButton extends LitElement {
|
||||
|
||||
@property() appearance: Appearance = "accent";
|
||||
|
||||
@property() size: "xs" | "s" | "m" | "l" | "xl" = "m";
|
||||
|
||||
@property({ attribute: false }) public iconPath?: string;
|
||||
|
||||
@property() variant: "brand" | "danger" | "neutral" | "warning" | "success" =
|
||||
@@ -32,6 +34,7 @@ export class HaProgressButton extends LitElement {
|
||||
return html`
|
||||
<ha-button
|
||||
.appearance=${appearance}
|
||||
.size=${this.size}
|
||||
.disabled=${this.disabled}
|
||||
.loading=${this.progress}
|
||||
.variant=${
|
||||
@@ -118,6 +121,12 @@ export class HaProgressButton extends LitElement {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The icon lives in this shadow root, so callers cannot size it themselves. */
|
||||
ha-button[size="xs"] ha-svg-icon[slot="start"],
|
||||
ha-button[size="s"] ha-svg-icon[slot="start"] {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
|
||||
ha-button.result::part(start),
|
||||
ha-button.result::part(end),
|
||||
ha-button.result::part(label),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* Home Assistant dialog footer component
|
||||
@@ -14,10 +14,6 @@ import { customElement, property } from "lit/decorators";
|
||||
* @slot primaryAction - Primary action button(s), aligned to the end.
|
||||
* @slot secondaryAction - Secondary action button(s), placed before the primary action.
|
||||
*
|
||||
* @attr {boolean} stacked - Stacks the actions as full-width buttons, with the primary
|
||||
* action(s) above the secondary action(s). Use it when the labels are too long to sit side
|
||||
* by side, otherwise they are squeezed until every word wraps onto its own line.
|
||||
*
|
||||
* @remarks
|
||||
* **Button Styling Guidance:**
|
||||
* - `primaryAction` slot: Use `variant="accent"`
|
||||
@@ -25,24 +21,11 @@ import { customElement, property } from "lit/decorators";
|
||||
*/
|
||||
@customElement("ha-dialog-footer")
|
||||
export class HaDialogFooter extends LitElement {
|
||||
@property({ type: Boolean, reflect: true }) public stacked = false;
|
||||
|
||||
protected render() {
|
||||
// Swap the slots rather than reversing with CSS, so that focus and reading
|
||||
// order keep matching the visual order.
|
||||
return html`
|
||||
<footer>
|
||||
${
|
||||
this.stacked
|
||||
? html`
|
||||
<slot name="primaryAction"></slot>
|
||||
<slot name="secondaryAction"></slot>
|
||||
`
|
||||
: html`
|
||||
<slot name="secondaryAction"></slot>
|
||||
<slot name="primaryAction"></slot>
|
||||
`
|
||||
}
|
||||
<slot name="secondaryAction"></slot>
|
||||
<slot name="primaryAction"></slot>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
@@ -57,10 +40,6 @@ export class HaDialogFooter extends LitElement {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
:host([stacked]) footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ContextProvider, createContext } from "@lit/context";
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
|
||||
@@ -17,61 +15,6 @@ 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>;
|
||||
|
||||
|
||||
@@ -49,10 +49,7 @@ class ConfirmEventDialogBox extends LitElement {
|
||||
<div>
|
||||
<p>${this._params.text}</p>
|
||||
</div>
|
||||
<ha-dialog-footer
|
||||
slot="footer"
|
||||
?stacked=${!!this._params.confirmFutureText}
|
||||
>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this._dismiss}
|
||||
|
||||
@@ -36,7 +36,6 @@ 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";
|
||||
@@ -158,11 +157,6 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
total: 0,
|
||||
};
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
new ChildPanelReady(this);
|
||||
}
|
||||
|
||||
private _pages = memoizeOne(
|
||||
(
|
||||
cloudStatus,
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues, 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";
|
||||
|
||||
@@ -24,53 +18,21 @@ class HaConfigNavigation extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public pages!: PageNavigation[];
|
||||
|
||||
@state() private _visiblePages?: PageNavigation[];
|
||||
@state() private _hasBluetoothConfigEntries = false;
|
||||
|
||||
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 firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
getConfigEntries(this.hass, {
|
||||
domain: "bluetooth",
|
||||
}).then((bluetoothEntries) => {
|
||||
this._hasBluetoothConfigEntries = bluetoothEntries.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const pages = (this._visiblePages ?? []).map((page) => ({
|
||||
const pages = filterNavigationPages(this.hass, this.pages, {
|
||||
hasBluetoothConfigEntries: this._hasBluetoothConfigEntries,
|
||||
}).map((page) => ({
|
||||
...page,
|
||||
name:
|
||||
page.name ||
|
||||
@@ -113,42 +75,6 @@ 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,7 +89,6 @@ class HaPanelConfig extends HassRouterPage {
|
||||
dashboard: {
|
||||
tag: "ha-config-dashboard",
|
||||
load: () => import("./dashboard/ha-config-dashboard"),
|
||||
waitForReady: true,
|
||||
},
|
||||
entities: {
|
||||
tag: "ha-config-entities",
|
||||
|
||||
@@ -19,6 +19,8 @@ import { animationStyles } from "../../../../../resources/theme/animations.globa
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/buttons/ha-progress-button";
|
||||
import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
|
||||
|
||||
import "../../../../../components/ha-icon-next";
|
||||
import "../../../../../components/ha-md-list";
|
||||
@@ -67,6 +69,8 @@ class ZHAConfigDashboard extends LitElement {
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _generatingBackup = false;
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (!this.hass) {
|
||||
@@ -355,17 +359,18 @@ class ZHAConfigDashboard extends LitElement {
|
||||
"ui.panel.config.zha.configuration_page.download_backup_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-button
|
||||
<ha-progress-button
|
||||
appearance="plain"
|
||||
slot="end"
|
||||
size="s"
|
||||
.iconPath=${mdiDownload}
|
||||
.progress=${this._generatingBackup}
|
||||
@click=${this._createAndDownloadBackup}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.download_backup_action"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-progress-button>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
@@ -408,30 +413,29 @@ class ZHAConfigDashboard extends LitElement {
|
||||
this._configuration = await fetchZHAConfiguration(this.hass!);
|
||||
}
|
||||
|
||||
private async _createAndDownloadBackup(): Promise<void> {
|
||||
private async _createAndDownloadBackup(ev: Event): Promise<void> {
|
||||
const button = ev.currentTarget as HaProgressButton;
|
||||
let backup_and_metadata: ZHANetworkBackupAndMetadata;
|
||||
|
||||
// Reading the backup from the coordinator can take 5-30 seconds.
|
||||
this._generatingBackup = true;
|
||||
|
||||
try {
|
||||
backup_and_metadata = await createZHANetworkBackup(this.hass!);
|
||||
} catch (err: any) {
|
||||
button.actionError();
|
||||
showAlertDialog(this, {
|
||||
title: "Failed to create backup",
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.backup_failed"
|
||||
),
|
||||
text: err.message,
|
||||
warning: true,
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
this._generatingBackup = false;
|
||||
}
|
||||
|
||||
if (!backup_and_metadata.is_complete) {
|
||||
await showAlertDialog(this, {
|
||||
title: "Backup is incomplete",
|
||||
text: "A backup has been created but it is incomplete and cannot be restored. This is a coordinator firmware limitation.",
|
||||
});
|
||||
}
|
||||
|
||||
const backupJSON: string =
|
||||
"data:text/plain;charset=utf-8," +
|
||||
encodeURIComponent(JSON.stringify(backup_and_metadata.backup, null, 4));
|
||||
const backupTime: Date = new Date(
|
||||
Date.parse(backup_and_metadata.backup.backup_time)
|
||||
);
|
||||
@@ -441,7 +445,23 @@ class ZHAConfigDashboard extends LitElement {
|
||||
basename = `Incomplete ${basename}`;
|
||||
}
|
||||
|
||||
fileDownload(backupJSON, `${basename}.json`);
|
||||
const blob = new Blob(
|
||||
[JSON.stringify(backup_and_metadata.backup, null, 4)],
|
||||
{ type: "application/json" }
|
||||
);
|
||||
fileDownload(URL.createObjectURL(blob), `${basename}.json`);
|
||||
button.actionSuccess();
|
||||
|
||||
if (!backup_and_metadata.is_complete) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.backup_incomplete_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.backup_incomplete_text"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _openOptionFlow() {
|
||||
@@ -517,10 +537,6 @@ class ZHAConfigDashboard extends LitElement {
|
||||
--md-item-overflow: visible;
|
||||
}
|
||||
|
||||
ha-button[size="s"] ha-svg-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
|
||||
.network-status div.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -7557,6 +7557,9 @@
|
||||
"download_backup": "Download backup",
|
||||
"download_backup_description": "Save your Zigbee network configuration to a file",
|
||||
"download_backup_action": "Download",
|
||||
"backup_failed": "Failed to create backup",
|
||||
"backup_incomplete_title": "Backup is incomplete",
|
||||
"backup_incomplete_text": "A backup has been created but it is incomplete and cannot be restored. This is a limitation of your adapter's firmware.",
|
||||
"migrate_radio": "Migrate adapter",
|
||||
"migrate_radio_description": "Move your Zigbee network to a different adapter",
|
||||
"migrate_radio_action": "Migrate",
|
||||
|
||||
Reference in New Issue
Block a user