mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-12 09:32:17 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb4eac09dd | ||
|
|
49ea96e091 | ||
|
|
08b33ccbc1 | ||
|
|
048e754149 | ||
|
|
3a30ea5973 | ||
|
|
f7836fd3d5 | ||
|
|
03d8c092ce | ||
|
|
b3aa3c83d5 | ||
|
|
85c7d071fe | ||
|
|
6fe34bbf7e | ||
|
|
5291a84c87 | ||
|
|
c0575fcb42 | ||
|
|
ea98a85088 | ||
|
|
a1370e331f | ||
|
|
75e862540f |
@@ -11,6 +11,9 @@ inputs:
|
||||
is-test:
|
||||
description: Set IS_TEST for the build (skips source maps and compression)
|
||||
default: "false"
|
||||
rspack-cache:
|
||||
description: rspack persistent cache mode ("readwrite", "readonly", or "" to disable)
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -21,3 +24,4 @@ runs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.github-token }}
|
||||
IS_TEST: ${{ inputs.is-test }}
|
||||
RSPACK_CACHE: ${{ inputs.rspack-cache }}
|
||||
|
||||
@@ -111,12 +111,26 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
# Read-only reuse of the rspack cache written by the nightly (see
|
||||
# nightly.yaml). rspack itself decides what is still valid (version +
|
||||
# buildDependencies + node_modules snapshot), so the GHA key just restores
|
||||
# the latest nightly cache; no fingerprint, and no save step (CI never
|
||||
# writes the shared cache).
|
||||
- name: Restore rspack cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
rspack-cache-${{ runner.os }}-
|
||||
- name: Build Application
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
rspack-cache: readonly
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -38,6 +38,11 @@ jobs:
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
# The wheel only builds the app (build-app), which does not merge
|
||||
# backend translations. Skipping the whole-project backend export (as
|
||||
# the release does) keeps this off the build's critical path; the full
|
||||
# translations artifact is produced in parallel by the job below.
|
||||
SKIP_BACKEND_TRANSLATIONS: "1"
|
||||
|
||||
- name: Bump version
|
||||
run: script/version_bump.js nightly
|
||||
@@ -53,9 +58,24 @@ jobs:
|
||||
restore-keys: |
|
||||
compress-cache-${{ runner.os }}-
|
||||
|
||||
# The nightly writes the rspack persistent cache; CI reads it read-only
|
||||
# (see ci.yaml). rspack invalidates internally (version + buildDependencies
|
||||
# + node_modules snapshot), so the cache rolls forward daily and a single
|
||||
# dependency bump keeps most of it warm instead of dropping the lineage.
|
||||
- name: Restore rspack cache
|
||||
id: rspack-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
rspack-cache-${{ runner.os }}-
|
||||
|
||||
- name: Build nightly Python wheels
|
||||
env:
|
||||
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
|
||||
RSPACK_CACHE: readwrite
|
||||
run: |
|
||||
pip install build
|
||||
yarn install
|
||||
@@ -64,7 +84,7 @@ jobs:
|
||||
rm -rf dist home_assistant_frontend.egg-info
|
||||
python3 -m build
|
||||
|
||||
# Not gated on the restore step: a transient restore failure (it is
|
||||
# Not gated on the restore steps: a transient restore failure (they are
|
||||
# continue-on-error) must not stop us persisting a freshly built cache.
|
||||
- name: Save compression cache
|
||||
if: success()
|
||||
@@ -74,8 +94,13 @@ jobs:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Archive translations
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
- name: Save rspack cache
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -84,6 +109,31 @@ jobs:
|
||||
path: dist/home_assistant_frontend*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
# The full translations (including the slow backend/core export) are only
|
||||
# needed for the uploaded artifact, not the wheel, so they are downloaded in
|
||||
# parallel here instead of blocking the build above.
|
||||
translations:
|
||||
name: Translations
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
immutable: false
|
||||
|
||||
- name: Download translations
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
- name: Archive translations
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -7,6 +7,7 @@ dist/
|
||||
/hass_frontend/
|
||||
/translations/
|
||||
/.compress-cache/
|
||||
/.rspack-cache/
|
||||
# Composite action source, not build output
|
||||
!/.github/actions/build/
|
||||
|
||||
|
||||
@@ -16,6 +16,39 @@ const SafeWebpackBar = require("./safe-webpackbar.cjs");
|
||||
const paths = require("./paths.cjs");
|
||||
const bundle = require("./bundle.cjs");
|
||||
|
||||
// Build-toolchain packages whose version changes the emitted bytes but which
|
||||
// are loader/compiler machinery, not modules in the build graph — so rspack's
|
||||
// node_modules snapshot cannot see them. Their versions are folded into the
|
||||
// persistent cache `version` so a toolchain upgrade invalidates the cache,
|
||||
// while ordinary runtime-dependency bumps (handled by the snapshot) do not.
|
||||
const TOOLCHAIN_PACKAGES = [
|
||||
"@rspack/core",
|
||||
"@babel/core",
|
||||
"@babel/preset-env",
|
||||
"babel-plugin-polyfill-corejs3",
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-transform-class-properties",
|
||||
"@babel/plugin-transform-private-methods",
|
||||
"@babel/runtime",
|
||||
"babel-loader",
|
||||
"core-js",
|
||||
"terser",
|
||||
"terser-webpack-plugin",
|
||||
"browserslist",
|
||||
"caniuse-lite",
|
||||
];
|
||||
|
||||
const toolchainHash = () =>
|
||||
require("crypto")
|
||||
.createHash("sha256")
|
||||
.update(
|
||||
TOOLCHAIN_PACKAGES.map(
|
||||
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
|
||||
).join("\n")
|
||||
)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
|
||||
class LogStartCompilePlugin {
|
||||
ignoredFirst = false;
|
||||
|
||||
@@ -376,6 +409,50 @@ const createRspackConfig = ({
|
||||
])
|
||||
),
|
||||
},
|
||||
// Persistent filesystem cache for production builds, opt-in per environment
|
||||
// via RSPACK_CACHE ("readwrite" writes it, "readonly" only reads a warm
|
||||
// cache — e.g. CI reusing the nightly-written one). Unset (releases, local,
|
||||
// tests) = no cache.
|
||||
...(isProdBuild && process.env.RSPACK_CACHE
|
||||
? {
|
||||
cache: {
|
||||
type: "persistent",
|
||||
// `name` is already unique per variant (frontend-modern/-legacy).
|
||||
name,
|
||||
// Invalidate on node and on the build toolchain versions. Runtime
|
||||
// dependencies are deliberately NOT hashed here — rspack's
|
||||
// node_modules snapshot invalidates their modules per-package, so a
|
||||
// single unrelated dep bump keeps the rest of the cache warm. Only
|
||||
// the toolchain (loader/compiler machinery, not modules in the
|
||||
// graph, so the snapshot can't see it) needs an explicit version.
|
||||
version: `node${process.versions.node.split(".")[0]}-${toolchainHash()}`,
|
||||
// Our own transform logic. (Not yarn.lock — that would drop the
|
||||
// whole cache on any dependency change; see version/snapshot above.)
|
||||
buildDependencies: [
|
||||
__filename,
|
||||
path.join(__dirname, "bundle.cjs"),
|
||||
path.join(__dirname, "minify-template-literals-loader.cjs"),
|
||||
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
|
||||
path.join(
|
||||
__dirname,
|
||||
"babel-plugins",
|
||||
"custom-polyfill-plugin.js"
|
||||
),
|
||||
path.join(
|
||||
__dirname,
|
||||
"babel-plugins",
|
||||
"inline-constants-plugin.cjs"
|
||||
),
|
||||
],
|
||||
storage: {
|
||||
type: "filesystem",
|
||||
directory: path.resolve(paths.root_dir, ".rspack-cache"),
|
||||
},
|
||||
// CI reads the nightly-written cache but must not modify it.
|
||||
readonly: process.env.RSPACK_CACHE === "readonly",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
experiments: {
|
||||
outputModule: true,
|
||||
},
|
||||
|
||||
+31
-1
@@ -17,6 +17,9 @@ const rspackConfigPath = fileURLToPath(
|
||||
new URL("./rspack.config.cjs", import.meta.url)
|
||||
);
|
||||
|
||||
// Applies everywhere, including the files exempted from the history rule below.
|
||||
const restrictedSyntax = ["LabeledStatement", "WithStatement"];
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
@@ -111,7 +114,16 @@ export default tseslint.config(
|
||||
"no-bitwise": "error",
|
||||
"no-console": "error",
|
||||
"no-restricted-globals": [2, "event"],
|
||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
...restrictedSyntax,
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.property.name=/^(push|replace)State$/]",
|
||||
message:
|
||||
"Use navigate(), updateHistoryState() or replaceCurrentUrl() from common/navigate. History entries carry the app's own bookkeeping, which a raw pushState/replaceState drops.",
|
||||
},
|
||||
],
|
||||
"wc/no-self-class": "off",
|
||||
|
||||
// import-x rules
|
||||
@@ -222,6 +234,24 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// These own history entries themselves: the navigation helpers, the dialog
|
||||
// stack, the boot paths that run before the app has any state to keep, and
|
||||
// the tests that fabricate entries to simulate a document load.
|
||||
files: [
|
||||
"src/common/navigate.ts",
|
||||
"src/dialogs/make-dialog-manager.ts",
|
||||
"src/state/url-sync-mixin.ts",
|
||||
"src/panels/config/automation/add-automation-element-dialog.ts",
|
||||
"src/entrypoints/core.ts",
|
||||
"src/onboarding/**/*.ts",
|
||||
"cast/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-syntax": ["error", ...restrictedSyntax],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/util/recorder-worklet.js"],
|
||||
languageOptions: {
|
||||
|
||||
@@ -37,6 +37,16 @@ title: Button
|
||||
<ha-button size="s"> small </ha-button>
|
||||
```
|
||||
|
||||
### Icons in the `xs` size
|
||||
|
||||
Avoid icons in `xs` buttons. At 24px the label carries the meaning on its own, and a
|
||||
16px glyph next to it adds visual noise without adding information.
|
||||
|
||||
Use an icon only when the button needs to be recognized at a glance in a dense layout,
|
||||
and only when the glyph is a common one users can identify from its silhouette alone,
|
||||
such as close, add, or settings. A detailed or unfamiliar glyph is unreadable at this
|
||||
size and should be replaced by the label alone.
|
||||
|
||||
### API
|
||||
|
||||
This component is based on the webawesome button component.
|
||||
|
||||
@@ -56,6 +56,19 @@ export class DemoHaButton extends LitElement {
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
${appearances.map(
|
||||
(appearance) => html`
|
||||
<ha-button
|
||||
.appearance=${appearance}
|
||||
.variant=${variant}
|
||||
size="xs"
|
||||
>
|
||||
${titleCase(`${variant} ${appearance}`)}
|
||||
</ha-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
${appearances.map(
|
||||
(appearance) => html`
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ReactiveElement } from "lit";
|
||||
import { getHistoryState, updateHistoryState } from "../navigate";
|
||||
import { throttle } from "../util/throttle";
|
||||
|
||||
const throttleReplaceState = throttle((value) => {
|
||||
history.replaceState({ scrollPosition: value }, "");
|
||||
updateHistoryState({ scrollPosition: value });
|
||||
}, 300);
|
||||
|
||||
export function restoreScroll(selector: string) {
|
||||
@@ -39,7 +40,8 @@ export function restoreScroll(selector: string) {
|
||||
newDescriptor = {
|
||||
get(this: ReactiveElement) {
|
||||
return (
|
||||
this[`__${String(propertyKey)}`] || history.state?.scrollPosition
|
||||
this[`__${String(propertyKey)}`] ||
|
||||
getHistoryState()?.scrollPosition
|
||||
);
|
||||
},
|
||||
set(this: ReactiveElement, value) {
|
||||
|
||||
+78
-41
@@ -1,6 +1,7 @@
|
||||
import { closeAllDialogs } from "../dialogs/make-dialog-manager";
|
||||
import { fireEvent } from "./dom/fire_event";
|
||||
import { mainWindow } from "./dom/get_main_window";
|
||||
import { currentPath } from "./url/current-path";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -11,12 +12,38 @@ declare global {
|
||||
|
||||
export interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
data?: any;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// max time to wait for dialogs to close before navigating
|
||||
const DIALOG_WAIT_TIMEOUT = 500;
|
||||
|
||||
/**
|
||||
* State of the current history entry. Always read through this, the app writes
|
||||
* to the main window and a panel running in an iframe has its own history.
|
||||
*/
|
||||
export const getHistoryState = (): any => mainWindow.history.state;
|
||||
|
||||
/**
|
||||
* Merge into the current history entry's state, keeping what is already there.
|
||||
* Entries carry the app's own bookkeeping (`from`, `root`, dialog state), so
|
||||
* they must never be replaced wholesale.
|
||||
*/
|
||||
export const updateHistoryState = (patch: Record<string, unknown>) => {
|
||||
mainWindow.history.replaceState(
|
||||
{ ...mainWindow.history.state, ...patch },
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrite the URL of the current history entry without navigating and without
|
||||
* touching its state. For query parameter cleanup.
|
||||
*/
|
||||
export const replaceCurrentUrl = (url: string) => {
|
||||
mainWindow.history.replaceState(mainWindow.history.state, "", url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stash a destination URL in the current history entry's state. If the page
|
||||
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
|
||||
@@ -24,10 +51,7 @@ const DIALOG_WAIT_TIMEOUT = 500;
|
||||
* The current URL is not changed.
|
||||
*/
|
||||
export const setRefreshUrl = (path: string) => {
|
||||
mainWindow.history.replaceState(
|
||||
{ ...mainWindow.history.state, refreshUrl: path },
|
||||
""
|
||||
);
|
||||
updateHistoryState({ refreshUrl: path });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,6 +80,17 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
|
||||
return ensureDialogsClosed(timestamp);
|
||||
};
|
||||
|
||||
const buildHistoryState = (
|
||||
data: Record<string, unknown> | undefined,
|
||||
from?: string
|
||||
) => {
|
||||
const state = typeof data === "object" ? data : undefined;
|
||||
if (from === undefined) {
|
||||
return state ?? null;
|
||||
}
|
||||
return { ...state, from };
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
if (!canProceed) {
|
||||
@@ -63,37 +98,32 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
}
|
||||
const replace = options?.replace || false;
|
||||
|
||||
if (__DEMO__) {
|
||||
if (!path.includes("#")) {
|
||||
// The demo routes with the hash instead of the pathname. Resolve the
|
||||
// path like the browser would do for pushState, and keep the query
|
||||
// parameters in the URL query instead of inside the hash.
|
||||
const url = new URL(
|
||||
path,
|
||||
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
|
||||
);
|
||||
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
|
||||
}
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
} else if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root ? { root: true } : (options?.data ?? null),
|
||||
if (__DEMO__ && !path.includes("#")) {
|
||||
// The demo routes with the hash instead of the pathname. Resolve the
|
||||
// path like the browser would do for pushState, and keep the query
|
||||
// parameters in the URL query instead of inside the hash.
|
||||
const url = new URL(
|
||||
path,
|
||||
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
|
||||
);
|
||||
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
|
||||
}
|
||||
|
||||
const { history } = mainWindow;
|
||||
|
||||
if (replace) {
|
||||
// A replaced entry keeps its predecessor, so it keeps `from`.
|
||||
const { root, from } = history.state ?? {};
|
||||
const data = root ? { root: true } : options?.data;
|
||||
history.replaceState(buildHistoryState(data, from), "", path);
|
||||
} else {
|
||||
history.pushState(
|
||||
buildHistoryState(options?.data, currentPath()),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
@@ -101,8 +131,17 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate back in history, with fallback to a default path if no history exists.
|
||||
* This prevents a user from getting stuck when they navigate directly to a page with no history.
|
||||
* Whether the previous history entry is a page this app navigated away from.
|
||||
* `history.length` cannot answer this: a login redirect goes through
|
||||
* `location.assign`, which leaves /auth/authorize right behind the requested
|
||||
* page, and going back there would bounce the user out of the app.
|
||||
*/
|
||||
export const canGoBack = (): boolean =>
|
||||
mainWindow.history.state?.from !== undefined;
|
||||
|
||||
/**
|
||||
* Navigate back to the page we came from, falling back to a path when the
|
||||
* previous entry is not ours (deep link, login redirect, fresh tab).
|
||||
*/
|
||||
export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
@@ -110,14 +149,12 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have history to go back to
|
||||
const { history } = mainWindow;
|
||||
if (history.length > 1) {
|
||||
history.back();
|
||||
// Read after closing dialogs: their history entries are popped by then, so
|
||||
// this is the state of the page entry.
|
||||
if (canGoBack()) {
|
||||
mainWindow.history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
// No history available, navigate to fallback path
|
||||
const fallback = fallbackPath || "/";
|
||||
navigate(fallback, { replace: true });
|
||||
await navigate(fallbackPath || "/", { replace: true });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { mainWindow } from "../dom/get_main_window";
|
||||
|
||||
/**
|
||||
* The path of the page currently shown by the app. The demo routes with the
|
||||
* hash instead of the pathname, see navigate().
|
||||
*/
|
||||
export const currentPath = (): string =>
|
||||
__DEMO__
|
||||
? mainWindow.location.hash.substring(1)
|
||||
: mainWindow.location.pathname;
|
||||
@@ -65,6 +65,21 @@ export class HaButton extends Button {
|
||||
box-shadow: var(--ha-button-box-shadow);
|
||||
}
|
||||
|
||||
:host([size="xs"]) .button {
|
||||
--wa-form-control-height: var(
|
||||
--ha-button-height,
|
||||
var(--button-height, 24px)
|
||||
);
|
||||
font-size: var(--ha-font-size-m);
|
||||
--wa-form-control-padding-inline: var(--ha-space-2);
|
||||
}
|
||||
|
||||
/* A default 24px icon would fill the whole xs button. */
|
||||
:host([size="xs"]) slot[name="start"]::slotted(*),
|
||||
:host([size="xs"]) slot[name="end"]::slotted(*) {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
|
||||
:host([size="s"]) .button {
|
||||
--wa-form-control-height: var(
|
||||
--ha-button-height,
|
||||
|
||||
@@ -1,11 +1,89 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { animate } from "@lit-labs/motion";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
|
||||
const THUMB_SIZE = 40;
|
||||
|
||||
@customElement("ha-icon-button-group")
|
||||
export class HaIconButtonGroup extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`<slot></slot>`;
|
||||
@state() private _thumbX = 0;
|
||||
|
||||
@state() private _thumbVisible = false;
|
||||
|
||||
@state() private _thumbBorderOnly = false;
|
||||
|
||||
// When the thumb appears, only fade it in at its new position instead of
|
||||
// also sliding it from wherever it was last visible.
|
||||
private _thumbAppearing = false;
|
||||
|
||||
private _observer = new MutationObserver(() => this._updateThumb());
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._observer.disconnect();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
class="thumb ${classMap({
|
||||
visible: this._thumbVisible,
|
||||
"border-only": this._thumbBorderOnly,
|
||||
})}"
|
||||
style=${styleMap({ left: `${this._thumbX}px` })}
|
||||
${animate(() => ({
|
||||
properties: this._thumbAppearing ? ["opacity"] : ["left", "opacity"],
|
||||
keyframeOptions: {
|
||||
duration: this._animationDuration(),
|
||||
easing: "ease-in-out",
|
||||
},
|
||||
skipInitial: true,
|
||||
}))}
|
||||
></div>
|
||||
<slot @slotchange=${this._handleSlotchange}></slot>
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
this._thumbAppearing = false;
|
||||
}
|
||||
|
||||
private _animationDuration(): number {
|
||||
return (
|
||||
parseFloat(
|
||||
getComputedStyle(this).getPropertyValue("--ha-animation-duration-fast")
|
||||
) || 150
|
||||
);
|
||||
}
|
||||
|
||||
private _handleSlotchange(ev: Event) {
|
||||
this._observer.disconnect();
|
||||
const slot = ev.target as HTMLSlotElement;
|
||||
for (const el of slot.assignedElements()) {
|
||||
this._observer.observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ["selected", "disabled"],
|
||||
});
|
||||
}
|
||||
// Positions are only valid once the slotted buttons are laid out.
|
||||
requestAnimationFrame(() => this._updateThumb());
|
||||
}
|
||||
|
||||
private _updateThumb() {
|
||||
const selected = this.querySelector<HTMLElement>(
|
||||
"ha-icon-button-toggle[selected]:not([disabled])"
|
||||
);
|
||||
if (!selected) {
|
||||
this._thumbVisible = false;
|
||||
return;
|
||||
}
|
||||
this._thumbAppearing = !this._thumbVisible;
|
||||
this._thumbBorderOnly = selected.hasAttribute("border-only");
|
||||
this._thumbX =
|
||||
selected.offsetLeft + (selected.offsetWidth - THUMB_SIZE) / 2;
|
||||
this._thumbVisible = true;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -21,6 +99,32 @@ export class HaIconButtonGroup extends LitElement {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
/* The selected toggle's circle is drawn here so it can slide between
|
||||
toggles; their own circles are suppressed below. */
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: calc(50% - 20px);
|
||||
opacity: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background-color: var(
|
||||
--ha-icon-button-group-thumb-color,
|
||||
var(--primary-text-color)
|
||||
);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.thumb.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.thumb.border-only {
|
||||
background-color: transparent;
|
||||
border: 2px solid
|
||||
var(--ha-icon-button-group-thumb-color, var(--primary-text-color));
|
||||
}
|
||||
::slotted(ha-icon-button-toggle) {
|
||||
--ha-icon-button-toggle-thumb-opacity: 0;
|
||||
}
|
||||
::slotted(.separator) {
|
||||
background-color: rgba(var(--rgb-primary-text-color), 0.15);
|
||||
width: 1px;
|
||||
|
||||
@@ -44,8 +44,10 @@ export class HaIconButtonToggle extends HaIconButton {
|
||||
color: var(--primary-background-color);
|
||||
background-color: unset;
|
||||
}
|
||||
/* ha-icon-button-group zeroes this so its sliding thumb draws the
|
||||
circle instead. */
|
||||
:host([selected]:not([disabled])) ha-button::part(base)::before {
|
||||
opacity: 1;
|
||||
opacity: var(--ha-icon-button-toggle-thumb-opacity, 1);
|
||||
}
|
||||
::slotted(*) {
|
||||
display: block;
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
type TargetType,
|
||||
} from "../../data/target";
|
||||
import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import { buttonLinkStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
|
||||
@@ -221,30 +220,28 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
? html`
|
||||
<div slot="end" class="summary">
|
||||
${
|
||||
showEntities &&
|
||||
!this.expand &&
|
||||
entries?.referenced_entities.length
|
||||
? html`<button
|
||||
class="main link"
|
||||
this.expand || !entries.referenced_entities.length
|
||||
? html`<span class="main">
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</span>`
|
||||
: html`<ha-button
|
||||
appearance="filled"
|
||||
variant="brand"
|
||||
size="xs"
|
||||
@click=${this._openDetails}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries?.referenced_entities.length,
|
||||
count: entries.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</button>`
|
||||
: showEntities
|
||||
? html`<span class="main">
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries?.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</span>`
|
||||
: nothing
|
||||
</ha-button>`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
@@ -812,7 +809,6 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
};
|
||||
|
||||
static styles = [
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
:host {
|
||||
--md-list-item-top-space: 0;
|
||||
@@ -883,16 +879,6 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
button.link {
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
button.link:hover,
|
||||
button.link:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.state {
|
||||
width: fit-content;
|
||||
font-size: var(--ha-font-size-s);
|
||||
|
||||
@@ -146,15 +146,13 @@ export const updateDeviceRegistryEntry = (
|
||||
...updates,
|
||||
});
|
||||
|
||||
export const removeConfigEntryFromDevice = (
|
||||
export const removeDeviceFromRegistry = (
|
||||
hass: HomeAssistant,
|
||||
deviceId: string,
|
||||
configEntryId: string
|
||||
deviceId: string
|
||||
) =>
|
||||
hass.callWS<DeviceRegistryEntry>({
|
||||
type: "config/device_registry/remove_config_entry",
|
||||
hass.callWS<null>({
|
||||
type: "config/device_registry/remove",
|
||||
device_id: deviceId,
|
||||
config_entry_id: configEntryId,
|
||||
});
|
||||
|
||||
export const sortDeviceRegistryByName = (
|
||||
|
||||
@@ -98,20 +98,31 @@ export const showDialog = async (
|
||||
return false;
|
||||
}
|
||||
LOADED[dialogTag] = {
|
||||
element: dialogImport().then(() => {
|
||||
const dialogEl = document.createElement(dialogTag) as
|
||||
HassDialogNext | HassDialog;
|
||||
element: dialogImport().then(
|
||||
() => {
|
||||
const dialogEl = document.createElement(dialogTag) as
|
||||
HassDialogNext | HassDialog;
|
||||
|
||||
if ("showDialog" in dialogEl) {
|
||||
// provide hass for legacy persistent dialogs
|
||||
element.provideHass(dialogEl);
|
||||
if ("showDialog" in dialogEl) {
|
||||
// provide hass for legacy persistent dialogs
|
||||
element.provideHass(dialogEl);
|
||||
}
|
||||
|
||||
dialogEl.addEventListener("dialog-closed", _handleClosed);
|
||||
dialogEl.addEventListener("dialog-closed", _handleClosedFocus);
|
||||
|
||||
return dialogEl;
|
||||
},
|
||||
(err) => {
|
||||
// Don't cache a rejected import (e.g. a stale build's chunk 404s
|
||||
// while the app stayed open): drop the entry so a later open
|
||||
// re-imports instead of being permanently stuck on the rejected
|
||||
// promise. The rejection still propagates to the global stale-build
|
||||
// handler (logging-mixin) for recovery.
|
||||
delete LOADED[dialogTag];
|
||||
throw err;
|
||||
}
|
||||
|
||||
dialogEl.addEventListener("dialog-closed", _handleClosed);
|
||||
dialogEl.addEventListener("dialog-closed", _handleClosedFocus);
|
||||
|
||||
return dialogEl;
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -367,10 +367,10 @@ class MoreInfoLight extends LitElement {
|
||||
width: auto;
|
||||
}
|
||||
.wheel {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
border-radius: var(--ha-border-radius-xl);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
.wheel.color {
|
||||
background-image: url("/static/images/color_wheel.png");
|
||||
|
||||
@@ -40,7 +40,11 @@ import {
|
||||
getEntityEntryContext,
|
||||
} from "../../common/entity/context/get_entity_context";
|
||||
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import {
|
||||
getHistoryState,
|
||||
navigate,
|
||||
updateHistoryState,
|
||||
} from "../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { withViewTransition } from "../../common/util/view-transition";
|
||||
@@ -268,16 +272,12 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
private _setView(view: MoreInfoView) {
|
||||
history.replaceState(
|
||||
{
|
||||
...history.state,
|
||||
dialogParams: {
|
||||
...history.state?.dialogParams,
|
||||
view,
|
||||
},
|
||||
updateHistoryState({
|
||||
dialogParams: {
|
||||
...getHistoryState()?.dialogParams,
|
||||
view,
|
||||
},
|
||||
""
|
||||
);
|
||||
});
|
||||
this._currView = view;
|
||||
}
|
||||
|
||||
@@ -1063,6 +1063,10 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
outline: none;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
/* Keep the content width constant when the scrollbar toggles;
|
||||
otherwise width-dependent content can flicker at the overflow
|
||||
threshold (#53228). */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.content-wrapper.settings-view .fade-bottom {
|
||||
|
||||
@@ -191,6 +191,10 @@ interface EMOutgoingMessageFocusElement extends EMMessage {
|
||||
};
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageReloadAndClearCache extends EMMessage {
|
||||
type: "frontend/reload_and_clear_cache";
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -220,6 +224,7 @@ type EMOutgoingMessageWithoutAnswer =
|
||||
| EMOutgoingMessageImprovConfigureDevice
|
||||
| EMOutgoingMessageAddEntityTo
|
||||
| EMOutgoingMessageFocusElement
|
||||
| EMOutgoingMessageReloadAndClearCache
|
||||
| EMOutgoingMessageAssistSettings;
|
||||
|
||||
export interface EMIncomingMessageRestart {
|
||||
@@ -511,14 +516,26 @@ export class ExternalMessaging {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Sending message to external app", msg);
|
||||
}
|
||||
if (window.externalAppV2) {
|
||||
window.externalAppV2.postMessage(
|
||||
JSON.stringify({ type: "externalBus", payload: msg })
|
||||
);
|
||||
} else if (window.externalApp) {
|
||||
window.externalApp.externalBus(JSON.stringify(msg));
|
||||
} else {
|
||||
window.webkit!.messageHandlers.externalBus.postMessage(msg);
|
||||
}
|
||||
fireExternalBusMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a message to the companion app's external bus without needing an
|
||||
* `ExternalMessaging` instance (i.e. without `hass`). Returns `false` when no
|
||||
* external bridge is present, so callers can fall back to browser behavior.
|
||||
*/
|
||||
export const fireExternalBusMessage = (msg: EMMessage): boolean => {
|
||||
if (window.externalAppV2) {
|
||||
window.externalAppV2.postMessage(
|
||||
JSON.stringify({ type: CALLBACK_EXTERNAL_BUS, payload: msg })
|
||||
);
|
||||
} else if (window.externalApp) {
|
||||
window.externalApp.externalBus(JSON.stringify(msg));
|
||||
} else if (window.webkit?.messageHandlers?.externalBus) {
|
||||
window.webkit.messageHandlers.externalBus.postMessage(msg);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { goBack } from "../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* returns to the page the user came from instead.
|
||||
*/
|
||||
export const handleBackClick = (
|
||||
ev: MouseEvent,
|
||||
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 (path && !isNavigationClick(ev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (backCallback) {
|
||||
backCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
goBack(path);
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { goBack } from "../common/navigate";
|
||||
import { getHistoryState, goBack } from "../common/navigate";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-top-app-bar-fixed";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { reloadForUpdate } from "../util/recover-stale-build";
|
||||
import "../components/ha-alert";
|
||||
|
||||
@customElement("hass-error-screen")
|
||||
@@ -19,6 +20,9 @@ class HassErrorScreen extends LitElement {
|
||||
|
||||
@property() public error?: string;
|
||||
|
||||
@property({ type: Boolean, attribute: "show-reload" }) public showReload =
|
||||
false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this.toolbar) {
|
||||
return this._renderContent();
|
||||
@@ -27,7 +31,7 @@ class HassErrorScreen extends LitElement {
|
||||
return html`
|
||||
<ha-top-app-bar-fixed
|
||||
.narrow=${this.narrow}
|
||||
.backButton=${!(this.rootnav || history.state?.root)}
|
||||
.backButton=${!(this.rootnav || getHistoryState()?.root)}
|
||||
>
|
||||
${this._renderContent()}
|
||||
</ha-top-app-bar-fixed>
|
||||
@@ -39,6 +43,19 @@ class HassErrorScreen extends LitElement {
|
||||
<div class="content">
|
||||
<ha-alert alert-type="error">${this.error}</ha-alert>
|
||||
<slot>
|
||||
${
|
||||
this.showReload
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
size="s"
|
||||
@click=${this._handleReload}
|
||||
>
|
||||
${this.hass?.localize("ui.common.refresh")}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-button appearance="plain" size="s" @click=${this._handleBack}>
|
||||
${this.hass?.localize("ui.common.back")}
|
||||
</ha-button>
|
||||
@@ -51,6 +68,12 @@ class HassErrorScreen extends LitElement {
|
||||
goBack();
|
||||
}
|
||||
|
||||
private _handleReload(): void {
|
||||
// Dirty-aware: reloads when clean, or defers with a toast when an editor
|
||||
// has unsaved changes.
|
||||
reloadForUpdate();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { getHistoryState } from "../common/navigate";
|
||||
import "../components/animation/ha-fade-in";
|
||||
import "../components/ha-top-app-bar-fixed";
|
||||
import "../components/ha-spinner";
|
||||
@@ -27,7 +28,7 @@ class HassLoadingScreen extends LitElement {
|
||||
return html`
|
||||
<ha-top-app-bar-fixed
|
||||
.narrow=${this.narrow}
|
||||
.backButton=${!(this.rootnav || history.state?.root)}
|
||||
.backButton=${!(this.rootnav || getHistoryState()?.root)}
|
||||
>
|
||||
${this._renderContent()}
|
||||
</ha-top-app-bar-fixed>
|
||||
|
||||
@@ -5,6 +5,7 @@ import memoizeOne from "memoize-one";
|
||||
import { navigate } from "../common/navigate";
|
||||
import { computeRouteTail } from "../common/url/route";
|
||||
import type { Route } from "../types";
|
||||
import { recoverFromStaleBuild } from "../util/recover-stale-build";
|
||||
import { PanelReady } from "./panel-ready";
|
||||
|
||||
const extractPage = (path: string, defaultPage: string) => {
|
||||
@@ -182,10 +183,21 @@ export class HassRouterPage extends ReactiveElement {
|
||||
this._showLoadingScreenTimeout = undefined;
|
||||
}
|
||||
|
||||
// Show error screen
|
||||
this.appendChild(
|
||||
this.createErrorScreen(`Error while loading page ${newPage}.`)
|
||||
// A stale build (the panel's hashed chunk 404s after an upgrade while
|
||||
// the app stayed open) is recoverable: reload onto the current build
|
||||
// (or prompt when there are unsaved edits) instead of dead-ending.
|
||||
const message = err instanceof Error ? err.message : String(err ?? "");
|
||||
const stale = recoverFromStaleBuild(message, this);
|
||||
|
||||
// Show error screen, offering a reload action for a stale build. Set
|
||||
// `showReload` on the returned element rather than through
|
||||
// createErrorScreen's signature, so router subclasses that override
|
||||
// createErrorScreen (e.g. ToolsRouter) can't drop it.
|
||||
const errorScreen = this.createErrorScreen(
|
||||
`Error while loading page ${newPage}.`
|
||||
);
|
||||
errorScreen.showReload = stale;
|
||||
this.appendChild(errorScreen);
|
||||
});
|
||||
|
||||
// If we don't show loading screen, just show the panel.
|
||||
|
||||
+11
-19
@@ -4,8 +4,9 @@ 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 { goBack } from "../common/navigate";
|
||||
import { getHistoryState } from "../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import { handleBackClick } from "./back-navigation";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
@@ -37,19 +38,14 @@ class HassSubpage extends LitElement {
|
||||
<div class="toolbar ${classMap({ narrow: this.narrow })}">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
this.mainPage || history.state?.root
|
||||
this.mainPage || (!backPath && getHistoryState()?.root)
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: backPath
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
href=${backPath}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
@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">
|
||||
@@ -79,12 +75,8 @@ class HassSubpage extends LitElement {
|
||||
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
|
||||
}
|
||||
|
||||
private _backTapped(): void {
|
||||
if (this.backCallback) {
|
||||
this.backCallback();
|
||||
return;
|
||||
}
|
||||
goBack();
|
||||
private _backTapped(ev: MouseEvent): void {
|
||||
handleBackClick(ev, this.backPath, this.backCallback);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -14,9 +14,10 @@ import { canShowPage } from "../common/config/can_show_page";
|
||||
import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { goBack, navigate } from "../common/navigate";
|
||||
import { getHistoryState, navigate } from "../common/navigate";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import { handleBackClick } from "./back-navigation";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import "../components/ha-svg-icon";
|
||||
@@ -173,19 +174,14 @@ export class HassTabsSubpage extends LitElement {
|
||||
<slot name="toolbar">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
this.mainPage || (!backPath && history.state?.root)
|
||||
this.mainPage || (!backPath && getHistoryState()?.root)
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: backPath
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
@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
|
||||
@@ -246,12 +242,8 @@ export class HassTabsSubpage extends LitElement {
|
||||
this._content.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
private _backTapped(): void {
|
||||
if (this.backCallback) {
|
||||
this.backCallback();
|
||||
return;
|
||||
}
|
||||
goBack();
|
||||
private _backTapped(ev: MouseEvent): void {
|
||||
handleBackClick(ev, this.backPath, this.backCallback);
|
||||
}
|
||||
|
||||
private _isActiveTabPath(tabPath: string, currentPath: string): boolean {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { HomeAssistant, Route } from "../types";
|
||||
import { storeState } from "../util/ha-pref-storage";
|
||||
import { renderLaunchScreenContent } from "../util/launch-screen";
|
||||
import { checkOnboardingSurveyToast } from "../util/onboarding-survey";
|
||||
import { reloadForUpdate } from "../util/recover-stale-build";
|
||||
import {
|
||||
registerServiceWorker,
|
||||
supportsServiceWorker,
|
||||
@@ -247,13 +248,11 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
if (registration) {
|
||||
registration.update();
|
||||
} else if (oldVersion) {
|
||||
// @ts-ignore Firefox supports forceGet
|
||||
location.reload(true);
|
||||
reloadForUpdate();
|
||||
}
|
||||
});
|
||||
} else if (oldVersion) {
|
||||
// @ts-ignore Firefox supports forceGet
|
||||
location.reload(true);
|
||||
reloadForUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,6 +644,7 @@ class HaConfigAreaPage extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/areas/dashboard"
|
||||
.header=${html`${
|
||||
area.icon
|
||||
? html`<ha-icon
|
||||
@@ -902,7 +903,7 @@ class HaConfigAreaPage extends LitElement {
|
||||
destructive: true,
|
||||
confirm: async () => {
|
||||
await deleteAreaRegistryEntry(this.hass!, area!.area_id);
|
||||
afterNextRender(() => goBack("/config"));
|
||||
afterNextRender(() => goBack("/config/areas/dashboard"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,8 +86,6 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
|
||||
@state() private _hierarchy?: AreasFloorHierarchy;
|
||||
|
||||
private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _blockHierarchyUpdate = false;
|
||||
|
||||
private _blockHierarchyUpdateTimeout?: number;
|
||||
@@ -168,9 +166,7 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
.isWide=${this.isWide}
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
back-path="/config"
|
||||
.tabs=${configSections.areas}
|
||||
.route=${this.route}
|
||||
has-fab
|
||||
|
||||
@@ -986,7 +986,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
private async _delete() {
|
||||
if (this.automationId) {
|
||||
await deleteAutomation(this.hass, this.automationId);
|
||||
goBack("/config");
|
||||
goBack(this.dashboardPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -443,9 +443,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
back-path="/config"
|
||||
id="entity_id"
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.automations}
|
||||
|
||||
@@ -147,6 +147,10 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
|
||||
protected domainHooks!: EditorDomainHooks<TConfig>;
|
||||
|
||||
protected get dashboardPath(): string {
|
||||
return `/config/${this.domainHooks.domain}/dashboard`;
|
||||
}
|
||||
|
||||
protected entityRegCreated?: (
|
||||
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
|
||||
) => void;
|
||||
@@ -252,7 +256,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
protected backTapped = async () => {
|
||||
const result = await this.confirmUnsavedChanged();
|
||||
if (result) {
|
||||
afterNextRender(() => goBack("/config"));
|
||||
afterNextRender(() => goBack(this.dashboardPath));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -300,7 +304,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
),
|
||||
text: html`<pre>${alertText}</pre>`,
|
||||
});
|
||||
goBack("/config");
|
||||
goBack(this.dashboardPath);
|
||||
return;
|
||||
}
|
||||
const entity = this.entityRegistry?.find(
|
||||
@@ -317,7 +321,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
`ui.panel.config.${domain}.editor.load_error_not_editable`
|
||||
),
|
||||
});
|
||||
goBack("/config");
|
||||
goBack(this.dashboardPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
@@ -110,6 +110,7 @@ export class HaAutomationTrace extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/automation/dashboard"
|
||||
.header=${title}
|
||||
.scrollable=${this.narrow}
|
||||
>
|
||||
@@ -452,11 +453,7 @@ export class HaAutomationTrace extends LitElement {
|
||||
if (runId) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("run_id");
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${location.pathname}?${params.toString()}`
|
||||
);
|
||||
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
|
||||
}
|
||||
|
||||
await showAlertDialog(this, {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
|
||||
import {
|
||||
extractSearchParam,
|
||||
@@ -173,11 +174,7 @@ export const ManualEditorMixin = <TConfig>(
|
||||
}
|
||||
|
||||
protected clearParam(param: string) {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
constructUrlCurrentPath(removeSearchParam(param))
|
||||
);
|
||||
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
|
||||
}
|
||||
|
||||
protected async openSidebar(ev: CustomEvent<SidebarConfig>) {
|
||||
|
||||
@@ -74,8 +74,6 @@ class HaConfigBackupOverview extends LitElement {
|
||||
|
||||
@state() private _config?: BackupConfig;
|
||||
|
||||
private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("config") && !this._config) {
|
||||
@@ -206,9 +204,7 @@ class HaConfigBackupOverview extends LitElement {
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config/system"
|
||||
}
|
||||
back-path="/config/system"
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.backup.overview.header")}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import { nextRender } from "../../../common/util/render-status";
|
||||
import "../../../components/ha-alert";
|
||||
@@ -118,7 +119,7 @@ class HaConfigBackupSettings extends LitElement {
|
||||
}
|
||||
|
||||
private _clearHash() {
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
replaceCurrentUrl(window.location.pathname);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
|
||||
@@ -21,6 +21,7 @@ export class CloudForgotPassword extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.forgot_password.title"
|
||||
)}
|
||||
|
||||
@@ -45,6 +45,7 @@ export class CloudLoginPanel extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
header="Home Assistant Cloud"
|
||||
>
|
||||
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
|
||||
|
||||
@@ -38,6 +38,7 @@ export class CloudRegister extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
|
||||
>
|
||||
<div class="content">
|
||||
|
||||
@@ -66,8 +66,6 @@ class HaConfigSectionUpdates extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _showSkipped = false;
|
||||
|
||||
@state() private _supervisorInfo?: HassioSupervisorInfo;
|
||||
@@ -155,9 +153,7 @@ class HaConfigSectionUpdates extends LitElement {
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config/system"
|
||||
}
|
||||
back-path="/config/system"
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.updates.caption")}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
mdiRobot,
|
||||
mdiScriptText,
|
||||
mdiShapeOutline,
|
||||
mdiTextureBox,
|
||||
mdiTools,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
@@ -44,6 +45,7 @@ import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/item/ha-list-item-base";
|
||||
@@ -65,7 +67,7 @@ import {
|
||||
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import {
|
||||
removeConfigEntryFromDevice,
|
||||
removeDeviceFromRegistry,
|
||||
updateDeviceRegistryEntry,
|
||||
} from "../../../data/device/device_registry";
|
||||
import type { DiagnosticInfo } from "../../../data/diagnostics";
|
||||
@@ -986,6 +988,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
return html`<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/devices/dashboard"
|
||||
.header=${deviceName}
|
||||
>
|
||||
<ha-tooltip for="edit-settings-button" slot="toolbar-icon">
|
||||
@@ -1035,12 +1038,27 @@ export class HaConfigDevicePage extends LitElement {
|
||||
${
|
||||
area
|
||||
? html`<div class="header-name">
|
||||
<a href="/config/areas/area/${area.area_id}"
|
||||
>${this.hass.localize(
|
||||
<ha-button
|
||||
href="/config/areas/area/${area.area_id}"
|
||||
size="s"
|
||||
appearance="plain"
|
||||
>
|
||||
${
|
||||
area.icon
|
||||
? html`<ha-icon
|
||||
slot="start"
|
||||
.icon=${area.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiTextureBox}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.area",
|
||||
{ area: area.name || "Unnamed Area" }
|
||||
)}</a
|
||||
>
|
||||
)}
|
||||
</ha-button>
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
@@ -1218,11 +1236,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
}
|
||||
|
||||
try {
|
||||
await removeConfigEntryFromDevice(
|
||||
this.hass,
|
||||
this.deviceId,
|
||||
entry.entry_id
|
||||
);
|
||||
await removeDeviceFromRegistry(this.hass, this.deviceId);
|
||||
} catch (err: unknown) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
@@ -1747,12 +1761,14 @@ export class HaConfigDevicePage extends LitElement {
|
||||
.header-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: var(--ha-space-2);
|
||||
padding-inline-start: var(--ha-space-2);
|
||||
padding-inline-end: initial;
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.header-name ha-icon,
|
||||
.header-name ha-svg-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
|
||||
.column,
|
||||
.fullwidth {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
PROTOCOL_INTEGRATIONS,
|
||||
protocolIntegrationPicked,
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import {
|
||||
getHistoryState,
|
||||
navigate,
|
||||
updateHistoryState,
|
||||
} from "../../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import {
|
||||
hasRejectedItems,
|
||||
@@ -65,7 +69,7 @@ import type {
|
||||
DeviceRegistryEntry,
|
||||
} from "../../../data/device/device_registry";
|
||||
import {
|
||||
removeConfigEntryFromDevice,
|
||||
removeDeviceFromRegistry,
|
||||
updateDeviceRegistryEntry,
|
||||
} from "../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
@@ -142,7 +146,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter: string = history.state?.filter || "";
|
||||
private _filter: string = getHistoryState()?.filter || "";
|
||||
|
||||
@state()
|
||||
private _filters: DataTableFilters = {};
|
||||
@@ -262,7 +266,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
}
|
||||
|
||||
this._fromUrl = true;
|
||||
this._filter = history.state?.filter || "";
|
||||
this._filter = getHistoryState()?.filter || "";
|
||||
|
||||
this._filters = {
|
||||
"ha-filter-states": {
|
||||
@@ -778,9 +782,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
back-path="/config"
|
||||
.tabs=${configSections.devices}
|
||||
.route=${this.route}
|
||||
.searchLabel=${this.hass.localize(
|
||||
@@ -1043,7 +1045,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
history.replaceState({ filter: this._filter }, "");
|
||||
updateHistoryState({ filter: this._filter });
|
||||
}
|
||||
|
||||
private _addDevice() {
|
||||
@@ -1206,19 +1208,9 @@ ${rejected
|
||||
dismissText: this.hass.localize("ui.common.cancel"),
|
||||
destructive: true,
|
||||
confirm: async () => {
|
||||
const proms: Promise<DeviceRegistryEntry>[] = [];
|
||||
const proms: Promise<null>[] = [];
|
||||
this._selectedCanDelete.forEach((deviceId) => {
|
||||
const entries = this.hass!.devices[deviceId]?.config_entries;
|
||||
entries.forEach((entryId) => {
|
||||
if (
|
||||
this.entries.find((entry) => entry.entry_id === entryId)
|
||||
?.supports_remove_device
|
||||
) {
|
||||
proms.push(
|
||||
removeConfigEntryFromDevice(this.hass!, deviceId, entryId)
|
||||
);
|
||||
}
|
||||
});
|
||||
proms.push(removeDeviceFromRegistry(this.hass!, deviceId));
|
||||
});
|
||||
const results = await Promise.allSettled(proms);
|
||||
if (hasRejectedItems(results)) {
|
||||
|
||||
@@ -78,8 +78,6 @@ class HaConfigEnergy extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _info?: EnergyInfo;
|
||||
|
||||
@state() private _preferences?: EnergyPreferences;
|
||||
@@ -126,11 +124,7 @@ class HaConfigEnergy extends LitElement {
|
||||
return html`
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack")
|
||||
? undefined
|
||||
: "/config/lovelace/dashboards"
|
||||
}
|
||||
back-path="/config/lovelace/dashboards"
|
||||
.route=${this.route}
|
||||
.tabs=${TABS}
|
||||
>
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
PROTOCOL_INTEGRATIONS,
|
||||
protocolIntegrationPicked,
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import { getHistoryState, updateHistoryState } from "../../../common/navigate";
|
||||
import { slugify } from "../../../common/string/slugify";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import {
|
||||
@@ -192,7 +193,7 @@ export class HaConfigEntities extends LitElement {
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter: string = history.state?.filter || "";
|
||||
private _filter: string = getHistoryState()?.filter || "";
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@@ -810,9 +811,7 @@ export class HaConfigEntities extends LitElement {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
back-path="/config"
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.devices}
|
||||
.columns=${this._columns(this.hass.localize, filteredEntities)}
|
||||
@@ -1111,7 +1110,7 @@ export class HaConfigEntities extends LitElement {
|
||||
}
|
||||
|
||||
this._fromUrl = true;
|
||||
this._filter = history.state?.filter || "";
|
||||
this._filter = getHistoryState()?.filter || "";
|
||||
|
||||
this._filters = {
|
||||
"ha-filter-states": [],
|
||||
@@ -1246,7 +1245,7 @@ export class HaConfigEntities extends LitElement {
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
history.replaceState({ filter: this._filter }, "");
|
||||
updateHistoryState({ filter: this._filter });
|
||||
}
|
||||
|
||||
private _handleSelectionChanged(
|
||||
|
||||
@@ -24,7 +24,7 @@ import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../../common/entity/compute_area_name";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { getHistoryState, navigate } from "../../../common/navigate";
|
||||
import type {
|
||||
LocalizeFunc,
|
||||
LocalizeKeys,
|
||||
@@ -644,9 +644,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
back-path="/config"
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.devices}
|
||||
.searchLabel=${this.hass.localize(
|
||||
@@ -1009,7 +1007,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
this._fromUrl = true;
|
||||
this._filter = history.state?.filter || "";
|
||||
this._filter = getHistoryState()?.filter || "";
|
||||
|
||||
this._filters = {
|
||||
"ha-filter-floor-areas": area ? { areas: [area] } : undefined,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type DisableConfigEntryResult,
|
||||
} from "../../../data/config_entries";
|
||||
import {
|
||||
removeConfigEntryFromDevice,
|
||||
removeDeviceFromRegistry,
|
||||
updateDeviceRegistryEntry,
|
||||
type DeviceRegistryEntry,
|
||||
} from "../../../data/device/device_registry";
|
||||
@@ -315,7 +315,6 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
};
|
||||
|
||||
private _handleDeleteDevice = async () => {
|
||||
const entry = this.entry;
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
text: this.hass.localize("ui.panel.config.devices.confirm_delete"),
|
||||
confirmText: this.hass.localize("ui.common.delete"),
|
||||
@@ -328,11 +327,7 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
}
|
||||
|
||||
try {
|
||||
await removeConfigEntryFromDevice(
|
||||
this.hass!,
|
||||
this.device.id,
|
||||
entry.entry_id
|
||||
);
|
||||
await removeDeviceFromRegistry(this.hass!, this.device.id);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.devices.error_delete"),
|
||||
|
||||
@@ -373,7 +373,11 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
: this._manifest?.documentation;
|
||||
|
||||
return html`
|
||||
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/integrations/dashboard"
|
||||
>
|
||||
${
|
||||
documentationLink
|
||||
? html`
|
||||
@@ -901,7 +905,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
if (row) {
|
||||
row.scrollIntoView({
|
||||
block: "center",
|
||||
block: "start",
|
||||
});
|
||||
row.classList.add("highlight");
|
||||
}
|
||||
@@ -1539,6 +1543,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
ha-config-entry-row {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
scroll-margin-top: var(--ha-space-10);
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
PROTOCOL_INTEGRATIONS,
|
||||
protocolIntegrationPicked,
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import {
|
||||
getHistoryState,
|
||||
navigate,
|
||||
updateHistoryState,
|
||||
} from "../../../common/navigate";
|
||||
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import { nextRender } from "../../../common/util/render-status";
|
||||
@@ -163,9 +167,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
window.location.hash.substring(1)
|
||||
);
|
||||
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _filter: string = history.state?.filter || "";
|
||||
@state() private _filter: string = getHistoryState()?.filter || "";
|
||||
|
||||
@state() private _logInfos?: Record<string, IntegrationLogInfo>;
|
||||
|
||||
@@ -526,9 +528,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
return html`
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
.backPath=${
|
||||
this._searchParams.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
back-path="/config"
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.devices}
|
||||
has-fab
|
||||
@@ -885,7 +885,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
this._filter = (ev.target as HaInputSearch).value ?? "";
|
||||
history.replaceState({ filter: this._filter }, "");
|
||||
updateHistoryState({ filter: this._filter });
|
||||
}
|
||||
|
||||
private async _highlightEntry() {
|
||||
|
||||
+1
@@ -249,6 +249,7 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
|
||||
const entry = this._data.find((ent) => ent.address === ev.detail.id);
|
||||
showBluetoothDeviceInfoDialog(this, {
|
||||
entry: entry!,
|
||||
deviceId: this._sourceDevices[ev.detail.id]?.id,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+20
@@ -2,6 +2,7 @@ import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-dialog-footer";
|
||||
@@ -50,6 +51,14 @@ class DialogBluetoothDeviceInfo extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _openDevice(): void {
|
||||
if (!this._params?.deviceId) {
|
||||
return;
|
||||
}
|
||||
navigate(`/config/devices/device/${this._params.deviceId}`);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
@@ -137,6 +146,17 @@ class DialogBluetoothDeviceInfo extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
<ha-dialog-footer slot="footer">
|
||||
${
|
||||
this._params.deviceId
|
||||
? html`
|
||||
<ha-button slot="primaryAction" @click=${this._openDevice}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.bluetooth.open_device"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
|
||||
+1
@@ -3,6 +3,7 @@ import type { BluetoothDeviceData } from "../../../../../data/bluetooth";
|
||||
|
||||
export interface BluetoothDeviceInfoDialogParams {
|
||||
entry: BluetoothDeviceData;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
export const loadBluetoothDeviceInfoDialog = () =>
|
||||
|
||||
@@ -95,6 +95,7 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) {
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
back-path="/config/integrations/integration/dhcp"
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.data=${this._dataWithIds(this._data)}
|
||||
.noDataText=${this.hass.localize(
|
||||
|
||||
@@ -61,7 +61,11 @@ export class MQTTConfigPanel extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage .narrow=${this.narrow} .hass=${this.hass}>
|
||||
<hass-subpage
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
back-path="/config/integrations/integration/mqtt"
|
||||
>
|
||||
<div class="content">
|
||||
<ha-card
|
||||
.header=${this.hass.localize("ui.panel.config.mqtt.settings_title")}
|
||||
|
||||
@@ -99,6 +99,7 @@ export class SSDPConfigPanel extends SubscribeMixin(LitElement) {
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
back-path="/config/integrations/integration/ssdp"
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.initialGroupColumn=${this._activeGrouping}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
|
||||
@@ -106,6 +106,7 @@ export class ZeroconfConfigPanel extends SubscribeMixin(LitElement) {
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
back-path="/config/integrations/integration/zeroconf"
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.initialGroupColumn=${this._activeGrouping}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
|
||||
@@ -76,6 +76,7 @@ class ZHAAddDevicesPage extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/zha/dashboard"
|
||||
.header=${this.hass.localize("ui.panel.config.zha.add_device")}
|
||||
>
|
||||
<ha-button
|
||||
|
||||
@@ -10,7 +10,7 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { cache } from "lit/directives/cache";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { goBack, navigate } from "../../../../../common/navigate";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import { narrowViewportContext } from "../../../../../data/context";
|
||||
import type { ZHADevice } from "../../../../../data/zha";
|
||||
@@ -102,7 +102,7 @@ class ZHADevicePage extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.narrow=${this._narrow}
|
||||
.header=${header}
|
||||
.backCallback=${this._goBack}
|
||||
.backPath=${this._backPath}
|
||||
>
|
||||
<div class="loading">
|
||||
<ha-spinner size="large"></ha-spinner>
|
||||
@@ -130,7 +130,7 @@ class ZHADevicePage extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.route=${this.route}
|
||||
.tabs=${tabNavigation}
|
||||
.backCallback=${this._goBack}
|
||||
.backPath=${this._backPath}
|
||||
>
|
||||
<div class="container">
|
||||
<zha-device-summary-card
|
||||
@@ -253,13 +253,11 @@ class ZHADevicePage extends LitElement {
|
||||
return ["clusters", "bindings", "signature", "neighbors"].includes(tab);
|
||||
}
|
||||
|
||||
private _goBack = (): void => {
|
||||
goBack(
|
||||
this._device
|
||||
? `/config/devices/device/${this._device.device_reg_id}`
|
||||
: "/config/zha/dashboard"
|
||||
);
|
||||
};
|
||||
private get _backPath(): string {
|
||||
return this._device
|
||||
? `/config/devices/device/${this._device.device_reg_id}`
|
||||
: "/config/zha/dashboard";
|
||||
}
|
||||
|
||||
private _getTabs = memoizeOne((device: ZHADevice | undefined) => {
|
||||
const tabs: ZHADevicePageTab[] = ["clusters", "bindings", "signature"];
|
||||
|
||||
+1
@@ -55,6 +55,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/zha/dashboard"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.zha.visualization.header"
|
||||
)}
|
||||
|
||||
+1
-1
@@ -640,7 +640,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _handleBack(): void {
|
||||
goBack("/config");
|
||||
goBack("/config/integrations/integration/zwave_js");
|
||||
}
|
||||
|
||||
private _fetchData = async () => {
|
||||
|
||||
+12
-2
@@ -34,7 +34,12 @@ class ZWaveJSConfigEntryPicker extends LitElement {
|
||||
|
||||
if (this._configEntries.length === 0) {
|
||||
return html`
|
||||
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
|
||||
<hass-subpage
|
||||
header="Z-Wave"
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
back-path="/config/integrations/integration/zwave_js"
|
||||
>
|
||||
<div class="content">
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
@@ -51,7 +56,12 @@ class ZWaveJSConfigEntryPicker extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
|
||||
<hass-subpage
|
||||
header="Z-Wave"
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
back-path="/config/integrations/integration/zwave_js"
|
||||
>
|
||||
<div class="content">
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
subscribeLabFeatures,
|
||||
} from "../../../data/labs";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
@@ -38,7 +39,7 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _preview_features: LabPreviewFeature[] = [];
|
||||
@state() private _preview_features?: LabPreviewFeature[];
|
||||
|
||||
@state() private _highlightedPreviewFeature?: string;
|
||||
|
||||
@@ -98,6 +99,10 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (this._preview_features === undefined) {
|
||||
return html`<hass-loading-screen></hass-loading-screen>`;
|
||||
}
|
||||
|
||||
const sortedFeatures = this._sortedPreviewFeatures(
|
||||
this.hass.localize,
|
||||
this._preview_features
|
||||
|
||||
@@ -51,6 +51,8 @@ export class HaConfigLovelaceResources extends LitElement {
|
||||
|
||||
@state() private _resources: LovelaceResource[] = [];
|
||||
|
||||
@state() private _loaded = false;
|
||||
|
||||
@state() private _lovelaceInfo?: LovelaceInfo;
|
||||
|
||||
@state()
|
||||
@@ -134,7 +136,7 @@ export class HaConfigLovelaceResources extends LitElement {
|
||||
);
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this.hass || this._resources === undefined) {
|
||||
if (!this.hass || !this._loaded) {
|
||||
return html` <hass-loading-screen></hass-loading-screen> `;
|
||||
}
|
||||
|
||||
@@ -175,6 +177,7 @@ export class HaConfigLovelaceResources extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
back-path="/config/lovelace/dashboards"
|
||||
.tabs=${lovelaceResourcesTabs}
|
||||
.columns=${this._columns(this.hass.language, this.hass.localize)}
|
||||
.data=${this._resources}
|
||||
@@ -228,6 +231,7 @@ export class HaConfigLovelaceResources extends LitElement {
|
||||
]);
|
||||
this._resources = resources;
|
||||
this._lovelaceInfo = lovelaceInfo;
|
||||
this._loaded = true;
|
||||
}
|
||||
|
||||
private _editResource(ev: CustomEvent) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
subscribeRepairsIssueRegistry,
|
||||
} from "../../../data/repairs";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./ha-config-repairs";
|
||||
@@ -32,7 +33,7 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _repairsIssues: RepairsIssue[] = [];
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
@state() private _loaded = false;
|
||||
|
||||
@state() private _showIgnored = false;
|
||||
|
||||
@@ -60,6 +61,7 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
|
||||
this._repairsIssues = repairs.issues.sort(
|
||||
(a, b) => severitySort[a.severity] - severitySort[b.severity]
|
||||
);
|
||||
this._loaded = true;
|
||||
const integrations = new Set<string>();
|
||||
for (const issue of this._repairsIssues) {
|
||||
integrations.add(issue.domain);
|
||||
@@ -70,6 +72,10 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this._loaded) {
|
||||
return html`<hass-loading-screen></hass-loading-screen>`;
|
||||
}
|
||||
|
||||
const issues = this._getFilteredIssues(
|
||||
this._showIgnored,
|
||||
this._repairsIssues
|
||||
@@ -77,9 +83,7 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config/system"
|
||||
}
|
||||
back-path="/config/system"
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.repairs.caption")}
|
||||
|
||||
@@ -459,9 +459,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
back-path="/config"
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.automations}
|
||||
.searchLabel=${this.hass.localize(
|
||||
|
||||
@@ -939,7 +939,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
{ err_no: err.status_code }
|
||||
),
|
||||
});
|
||||
goBack("/config");
|
||||
goBack("/config/scene/dashboard");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1084,7 +1084,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
if (this._mode === "live") {
|
||||
applyScene(this.hass, this._storedStates);
|
||||
}
|
||||
afterNextRender(() => goBack("/config"));
|
||||
afterNextRender(() => goBack("/config/scene/dashboard"));
|
||||
}
|
||||
|
||||
private _deleteTapped(): void {
|
||||
@@ -1111,7 +1111,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
if (this._mode === "live") {
|
||||
applyScene(this.hass, this._storedStates);
|
||||
}
|
||||
goBack("/config");
|
||||
goBack("/config/scene/dashboard");
|
||||
}
|
||||
|
||||
private async _confirmUnsavedChanged(): Promise<boolean> {
|
||||
|
||||
@@ -896,7 +896,7 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
|
||||
private async _delete() {
|
||||
await deleteScript(this.hass, this.scriptId!);
|
||||
goBack("/config");
|
||||
goBack(this.dashboardPath);
|
||||
}
|
||||
|
||||
private async _promptScriptAlias(): Promise<boolean> {
|
||||
|
||||
@@ -430,9 +430,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
back-path="/config"
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.automations}
|
||||
.searchLabel=${this.hass.localize(
|
||||
|
||||
@@ -14,7 +14,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
@@ -106,6 +106,7 @@ export class HaScriptTrace extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/script/dashboard"
|
||||
.header=${title}
|
||||
.scrollable=${this.narrow}
|
||||
>
|
||||
@@ -433,11 +434,7 @@ export class HaScriptTrace extends LitElement {
|
||||
if (runId) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("run_id");
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${location.pathname}?${params.toString()}`
|
||||
);
|
||||
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
|
||||
}
|
||||
|
||||
await showAlertDialog(this, {
|
||||
|
||||
+7
-1
@@ -16,6 +16,7 @@ import {
|
||||
listAssistPipelines,
|
||||
} from "../../../../data/assist_pipeline";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import "../../../../layouts/hass-loading-screen";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
|
||||
interface AssistDeviceExtra extends AssistDevice {
|
||||
@@ -124,10 +125,15 @@ class AssistDevicesPage extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._devices) {
|
||||
return html`<hass-loading-screen></hass-loading-screen>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/voice-assistants/assistants"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.assistants.pipeline.devices.title"
|
||||
)}
|
||||
@@ -143,7 +149,7 @@ class AssistDevicesPage extends LitElement {
|
||||
this.hass.states,
|
||||
this._pipelines,
|
||||
this._preferred,
|
||||
this._devices || []
|
||||
this._devices
|
||||
)}
|
||||
auto-height
|
||||
@row-click=${this._handleRowClicked}
|
||||
|
||||
@@ -51,6 +51,7 @@ export class AssistPipelineDebug extends LitElement {
|
||||
return html`<hass-subpage
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
back-path="/config/voice-assistants/debug"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.debug.header"
|
||||
)}
|
||||
|
||||
@@ -60,6 +60,7 @@ export class AssistPipelineRunDebug extends LitElement {
|
||||
<hass-subpage
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
back-path="/config/voice-assistants/assistants"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.debug.pipeline.header"
|
||||
)}
|
||||
|
||||
@@ -29,8 +29,6 @@ export class HaConfigVoiceAssistantsAssistants extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return html`<hass-loading-screen></hass-loading-screen>`;
|
||||
@@ -39,9 +37,7 @@ export class HaConfigVoiceAssistantsAssistants extends LitElement {
|
||||
return html`
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
back-path="/config"
|
||||
.route=${this.route}
|
||||
.tabs=${voiceAssistantTabs}
|
||||
>
|
||||
|
||||
@@ -492,9 +492,7 @@ export class VoiceAssistantsExpose extends LitElement {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
back-path="/config"
|
||||
.route=${this.route}
|
||||
.tabs=${voiceAssistantTabs}
|
||||
.columns=${this._columns(
|
||||
|
||||
@@ -57,8 +57,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _storageItems?: Zone[];
|
||||
|
||||
@state() private _stateItems?: HassEntity[];
|
||||
@@ -248,9 +246,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
.route=${this.route}
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
back-path="/config"
|
||||
.tabs=${configSections.areas}
|
||||
has-fab
|
||||
>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { mdiCreation } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import type { LightEntity } from "../../../data/light";
|
||||
import { LightEntityFeature } from "../../../data/light";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
|
||||
import { hasConfigChanged } from "../common/has-changed";
|
||||
import { HuiModeSelectCardFeatureBase } from "./hui-mode-select-card-feature-base";
|
||||
import type {
|
||||
LightEffectCardFeatureConfig,
|
||||
LovelaceCardFeatureContext,
|
||||
} from "./types";
|
||||
|
||||
const supportsLightEffectCardFeatureFromState = (stateObj: HassEntity) => {
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
return (
|
||||
domain === "light" &&
|
||||
supportsFeature(stateObj, LightEntityFeature.EFFECT) &&
|
||||
!!stateObj.attributes.effect_list?.length
|
||||
);
|
||||
};
|
||||
|
||||
export const supportsLightEffectCardFeature = (
|
||||
hass: HomeAssistant,
|
||||
context: LovelaceCardFeatureContext
|
||||
) => {
|
||||
const stateObj = context.entity_id
|
||||
? hass.states[context.entity_id]
|
||||
: undefined;
|
||||
if (!stateObj) return false;
|
||||
return supportsLightEffectCardFeatureFromState(stateObj);
|
||||
};
|
||||
|
||||
@customElement("hui-light-effect-card-feature")
|
||||
class HuiLightEffectCardFeature
|
||||
extends HuiModeSelectCardFeatureBase<
|
||||
LightEntity,
|
||||
LightEffectCardFeatureConfig
|
||||
>
|
||||
implements LovelaceCardFeature
|
||||
{
|
||||
protected readonly _attribute = "effect";
|
||||
|
||||
protected readonly _modesAttribute = "effect_list";
|
||||
|
||||
protected get _configuredModes() {
|
||||
const effects = this._config?.effects;
|
||||
return effects?.length ? effects : undefined;
|
||||
}
|
||||
|
||||
protected readonly _dropdownIconPath = mdiCreation;
|
||||
|
||||
protected readonly _allowIconsStyle = false;
|
||||
|
||||
protected readonly _hideLabel = false;
|
||||
|
||||
protected readonly _serviceDomain = "light";
|
||||
|
||||
protected readonly _serviceAction = "turn_on";
|
||||
|
||||
static getStubConfig(): LightEffectCardFeatureConfig {
|
||||
return {
|
||||
type: "light-effect",
|
||||
};
|
||||
}
|
||||
|
||||
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
|
||||
await import("../editor/config-elements/hui-light-effect-card-feature-editor");
|
||||
return document.createElement("hui-light-effect-card-feature-editor");
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
return (
|
||||
changedProps.has("_currentValue") ||
|
||||
changedProps.has("context") ||
|
||||
changedProps.has("_stateObj") ||
|
||||
hasConfigChanged(this, changedProps)
|
||||
);
|
||||
}
|
||||
|
||||
protected _isSupported(): boolean {
|
||||
return !!(
|
||||
this._stateObj && supportsLightEffectCardFeatureFromState(this._stateObj)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-light-effect-card-feature": HuiLightEffectCardFeature;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { supportsLawnMowerCommandCardFeature } from "./hui-lawn-mower-commands-c
|
||||
import { supportsLightBrightnessCardFeature } from "./hui-light-brightness-card-feature";
|
||||
import { supportsLightColorFavoritesCardFeature } from "./hui-light-color-favorites-card-feature";
|
||||
import { supportsLightColorTempCardFeature } from "./hui-light-color-temp-card-feature";
|
||||
import { supportsLightEffectCardFeature } from "./hui-light-effect-card-feature";
|
||||
import { supportsLockCommandsCardFeature } from "./hui-lock-commands-card-feature";
|
||||
import { supportsLockOpenDoorCardFeature } from "./hui-lock-open-door-card-feature";
|
||||
import { supportsMediaPlayerPlaybackCardFeature } from "./hui-media-player-playback-card-feature";
|
||||
@@ -88,6 +89,7 @@ export const UI_FEATURE_TYPES = [
|
||||
"light-brightness",
|
||||
"light-color-temp",
|
||||
"light-color-favorites",
|
||||
"light-effect",
|
||||
"lock-commands",
|
||||
"lock-open-door",
|
||||
"media-player-playback",
|
||||
@@ -143,6 +145,7 @@ export const SUPPORTS_FEATURE_TYPES: Record<UiFeatureType, SupportsFeature> = {
|
||||
"light-brightness": supportsLightBrightnessCardFeature,
|
||||
"light-color-temp": supportsLightColorTempCardFeature,
|
||||
"light-color-favorites": supportsLightColorFavoritesCardFeature,
|
||||
"light-effect": supportsLightEffectCardFeature,
|
||||
"lock-commands": supportsLockCommandsCardFeature,
|
||||
"lock-open-door": supportsLockOpenDoorCardFeature,
|
||||
"media-player-playback": supportsMediaPlayerPlaybackCardFeature,
|
||||
|
||||
@@ -47,6 +47,11 @@ export interface LightColorFavoritesCardFeatureConfig {
|
||||
type: "light-color-favorites";
|
||||
}
|
||||
|
||||
export interface LightEffectCardFeatureConfig {
|
||||
type: "light-effect";
|
||||
effects?: string[];
|
||||
}
|
||||
|
||||
export interface LockCommandsCardFeatureConfig {
|
||||
type: "lock-commands";
|
||||
}
|
||||
@@ -341,6 +346,7 @@ export type LovelaceCardFeatureConfig =
|
||||
| LightBrightnessCardFeatureConfig
|
||||
| LightColorTempCardFeatureConfig
|
||||
| LightColorFavoritesCardFeatureConfig
|
||||
| LightEffectCardFeatureConfig
|
||||
| LockCommandsCardFeatureConfig
|
||||
| LockOpenDoorCardFeatureConfig
|
||||
| MediaPlayerPlaybackCardFeatureConfig
|
||||
|
||||
@@ -28,6 +28,7 @@ const DOMAIN_VARIANTS: Record<string, TileVariant[]> = {
|
||||
TILE_TOGGLE_VARIANT,
|
||||
["light-color-temp"],
|
||||
["light-color-favorites"],
|
||||
["light-effect"],
|
||||
],
|
||||
cover: [
|
||||
TILE_VARIANT,
|
||||
|
||||
@@ -23,6 +23,7 @@ import "../card-features/hui-lawn-mower-commands-card-feature";
|
||||
import "../card-features/hui-light-brightness-card-feature";
|
||||
import "../card-features/hui-light-color-temp-card-feature";
|
||||
import "../card-features/hui-light-color-favorites-card-feature";
|
||||
import "../card-features/hui-light-effect-card-feature";
|
||||
import "../card-features/hui-lock-commands-card-feature";
|
||||
import "../card-features/hui-lock-open-door-card-feature";
|
||||
import "../card-features/hui-media-player-playback-card-feature";
|
||||
@@ -82,6 +83,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
|
||||
"light-brightness",
|
||||
"light-color-temp",
|
||||
"light-color-favorites",
|
||||
"light-effect",
|
||||
"lock-commands",
|
||||
"lock-open-door",
|
||||
"media-player-playback",
|
||||
|
||||
@@ -52,6 +52,7 @@ import { supportsHumidifierToggleCardFeature } from "../../card-features/hui-hum
|
||||
import { supportsLawnMowerCommandCardFeature } from "../../card-features/hui-lawn-mower-commands-card-feature";
|
||||
import { supportsLightBrightnessCardFeature } from "../../card-features/hui-light-brightness-card-feature";
|
||||
import { supportsLightColorTempCardFeature } from "../../card-features/hui-light-color-temp-card-feature";
|
||||
import { supportsLightEffectCardFeature } from "../../card-features/hui-light-effect-card-feature";
|
||||
import { supportsLockCommandsCardFeature } from "../../card-features/hui-lock-commands-card-feature";
|
||||
import { supportsLockOpenDoorCardFeature } from "../../card-features/hui-lock-open-door-card-feature";
|
||||
import { supportsMediaPlayerPlaybackCardFeature } from "../../card-features/hui-media-player-playback-card-feature";
|
||||
@@ -114,6 +115,7 @@ const UI_FEATURE_TYPES = [
|
||||
"light-brightness",
|
||||
"light-color-temp",
|
||||
"light-color-favorites",
|
||||
"light-effect",
|
||||
"lock-commands",
|
||||
"lock-open-door",
|
||||
"media-player-playback",
|
||||
@@ -160,6 +162,7 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
|
||||
"lawn-mower-commands",
|
||||
"media-player-playback",
|
||||
"light-color-favorites",
|
||||
"light-effect",
|
||||
"media-player-sound-mode",
|
||||
"media-player-source",
|
||||
"media-player-volume-buttons",
|
||||
@@ -206,6 +209,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
|
||||
"light-brightness": supportsLightBrightnessCardFeature,
|
||||
"light-color-temp": supportsLightColorTempCardFeature,
|
||||
"light-color-favorites": supportsLightColorFavoritesCardFeature,
|
||||
"light-effect": supportsLightEffectCardFeature,
|
||||
"lock-commands": supportsLockCommandsCardFeature,
|
||||
"lock-open-door": supportsLockOpenDoorCardFeature,
|
||||
"media-player-playback": supportsMediaPlayerPlaybackCardFeature,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { LightEntity } from "../../../../data/light";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type {
|
||||
LightEffectCardFeatureConfig,
|
||||
LovelaceCardFeatureContext,
|
||||
} from "../../card-features/types";
|
||||
import type { LovelaceCardFeatureEditor } from "../../types";
|
||||
import {
|
||||
customizableListData,
|
||||
customizableListSchema,
|
||||
processCustomizableListValue,
|
||||
} from "./customizable-list-feature";
|
||||
|
||||
@customElement("hui-light-effect-card-feature-editor")
|
||||
export class HuiLightEffectCardFeatureEditor
|
||||
extends LitElement
|
||||
implements LovelaceCardFeatureEditor
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
|
||||
|
||||
@state() private _config?: LightEffectCardFeatureConfig;
|
||||
|
||||
public setConfig(config: LightEffectCardFeatureConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne((stateObj: LightEntity | undefined) =>
|
||||
customizableListSchema({
|
||||
field: "effects",
|
||||
options:
|
||||
stateObj?.attributes.effect_list?.map((effect) => ({
|
||||
value: effect,
|
||||
label: this.hass!.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
"effect",
|
||||
effect
|
||||
),
|
||||
})) ?? [],
|
||||
})
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const stateObj = this.context?.entity_id
|
||||
? (this.hass.states[this.context.entity_id] as LightEntity | undefined)
|
||||
: undefined;
|
||||
|
||||
const data = customizableListData(this._config, "effects");
|
||||
const schema = this._schema(stateObj);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${schema}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(
|
||||
ev: ValueChangedEvent<LightEffectCardFeatureConfig>
|
||||
): void {
|
||||
const stateObj = this.context?.entity_id
|
||||
? (this.hass!.states[this.context.entity_id] as LightEntity | undefined)
|
||||
: undefined;
|
||||
const defaults = stateObj?.attributes.effect_list ?? [];
|
||||
const config = processCustomizableListValue<LightEffectCardFeatureConfig>(
|
||||
ev.detail.value,
|
||||
"effects",
|
||||
defaults
|
||||
);
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) =>
|
||||
this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.features.types.light-effect.${schema.name}`
|
||||
);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-light-effect-card-feature-editor": HuiLightEffectCardFeatureEditor;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { navigate, replaceCurrentUrl } from "../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
import {
|
||||
@@ -509,9 +509,7 @@ export class LovelacePanel extends LitElement {
|
||||
};
|
||||
|
||||
if ("editMode" in props) {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
replaceCurrentUrl(
|
||||
constructUrlCurrentPath(
|
||||
props.editMode
|
||||
? addSearchParam({ edit: "1" })
|
||||
|
||||
@@ -27,8 +27,7 @@ import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { UndoRedoController } from "../../common/controllers/undo-redo-controller";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { isNavigationClick } from "../../common/dom/is-navigation-click";
|
||||
import { goBack, navigate } from "../../common/navigate";
|
||||
import { goBack, navigate, replaceCurrentUrl } from "../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
|
||||
@@ -76,6 +75,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 { handleBackClick } from "../../layouts/back-navigation";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
@@ -677,11 +677,7 @@ class HUIRoot extends LitElement {
|
||||
);
|
||||
|
||||
private _clearParam(param: string) {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
constructUrlCurrentPath(removeSearchParam(param))
|
||||
);
|
||||
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
@@ -893,44 +889,39 @@ class HUIRoot extends LitElement {
|
||||
};
|
||||
|
||||
private _goBack(): void {
|
||||
const views = this.lovelace?.config.views ?? [];
|
||||
const curViewConfig =
|
||||
typeof this._curView === "number" ? views[this._curView] : undefined;
|
||||
|
||||
const backPath = sanitizeNavigationPath(
|
||||
curViewConfig?.back_path ?? this.backPath
|
||||
);
|
||||
|
||||
if (backPath) {
|
||||
navigate(backPath, { replace: true });
|
||||
} else if (history.length > 1) {
|
||||
goBack();
|
||||
} else if (!views[0].subview) {
|
||||
navigate(this.route!.prefix, { replace: true });
|
||||
} else {
|
||||
navigate("/");
|
||||
const configuredBackPath = this._configuredBackPath;
|
||||
if (configuredBackPath) {
|
||||
navigate(configuredBackPath, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const views = this.lovelace?.config.views ?? [];
|
||||
// Falling back to the dashboard root only makes sense when its first view
|
||||
// is a real one.
|
||||
goBack(views[0]?.subview ? undefined : this.route?.prefix);
|
||||
}
|
||||
|
||||
private _handleBackClick(ev: MouseEvent): void {
|
||||
if (this._backPath && !isNavigationClick(ev)) {
|
||||
return;
|
||||
}
|
||||
this._goBack();
|
||||
handleBackClick(ev, this._backPath, () => this._goBack());
|
||||
}
|
||||
|
||||
private get _backPath(): string | undefined {
|
||||
private get _configuredBackPath(): string | undefined {
|
||||
const views = this.lovelace?.config.views ?? [];
|
||||
const curViewConfig =
|
||||
typeof this._curView === "number" ? views[this._curView] : undefined;
|
||||
|
||||
const backPath = sanitizeNavigationPath(
|
||||
curViewConfig?.back_path ?? this.backPath
|
||||
);
|
||||
return sanitizeNavigationPath(curViewConfig?.back_path ?? this.backPath);
|
||||
}
|
||||
|
||||
if (backPath) {
|
||||
return backPath;
|
||||
private get _backPath(): string | undefined {
|
||||
if (this._configuredBackPath) {
|
||||
return this._configuredBackPath;
|
||||
}
|
||||
|
||||
const views = this.lovelace?.config.views ?? [];
|
||||
const curViewConfig =
|
||||
typeof this._curView === "number" ? views[this._curView] : undefined;
|
||||
|
||||
return curViewConfig?.subview ? this.route!.prefix : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { repeat } from "lit/directives/repeat";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { clamp } from "../../../common/number/clamp";
|
||||
import { getHistoryState, updateHistoryState } from "../../../common/navigate";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-ripple";
|
||||
import "../../../components/ha-sortable";
|
||||
@@ -137,7 +138,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
"section-visibility-changed",
|
||||
this._sectionVisibilityChanged
|
||||
);
|
||||
this._sidebarTabActive = Boolean(window.history.state?.sidebar);
|
||||
this._sidebarTabActive = Boolean(getHistoryState()?.sidebar);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
@@ -509,10 +510,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
this._sidebarTabActive = !this._sidebarTabActive;
|
||||
|
||||
// Add sidebar state to history
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, sidebar: this._sidebarTabActive },
|
||||
""
|
||||
);
|
||||
updateHistoryState({ sidebar: this._sidebarTabActive });
|
||||
|
||||
// Restore scroll position after view updates
|
||||
this.updateComplete.then(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { replaceCurrentUrl } from "../../common/navigate";
|
||||
import { nextRender } from "../../common/util/render-status";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-card";
|
||||
@@ -90,7 +91,7 @@ class HaProfileSectionGeneral extends LitElement {
|
||||
}
|
||||
|
||||
private _clearHash() {
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
replaceCurrentUrl(window.location.pathname);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
|
||||
@@ -209,7 +209,6 @@ class HaRefreshTokens extends LitElement {
|
||||
<ha-button
|
||||
variant="danger"
|
||||
appearance="filled"
|
||||
size="s"
|
||||
@click=${this._deleteAllTokens}
|
||||
>
|
||||
${this.hass.localize(
|
||||
@@ -352,6 +351,10 @@ class HaRefreshTokens extends LitElement {
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -379,8 +379,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
|
||||
// @ts-ignore
|
||||
this.hass!.callWS({ type: "get_config" }).then((config: HassConfig) => {
|
||||
if (config.safe_mode) {
|
||||
// @ts-ignore Firefox supports forceGet
|
||||
location.reload(true);
|
||||
location.reload();
|
||||
}
|
||||
this._updateHass({ config });
|
||||
this.checkDataBaseMigration();
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PropertyValues } from "lit";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import type { SystemLogLevel } from "../data/system_log";
|
||||
import type { Constructor } from "../types";
|
||||
import { recoverFromStaleBuild } from "../util/recover-stale-build";
|
||||
import type { HassBaseEl } from "./hass-base-mixin";
|
||||
|
||||
interface WriteLogParams {
|
||||
@@ -25,7 +26,31 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
|
||||
class extends superClass {
|
||||
protected hassConnected() {
|
||||
super.hassConnected();
|
||||
// Resource-load errors (<script>, modulepreload <link>) do not bubble,
|
||||
// so observe them in the capture phase. A stale build's hashed chunk
|
||||
// 404 lands here (legacy build / modulepreload); recover instead of
|
||||
// dead-ending.
|
||||
window.addEventListener(
|
||||
"error",
|
||||
(ev) => {
|
||||
const target = ev.target as
|
||||
(HTMLScriptElement & HTMLLinkElement) | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "SCRIPT" || target.tagName === "LINK")
|
||||
) {
|
||||
recoverFromStaleBuild(target.src || target.href, this);
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
window.addEventListener("error", async (ev) => {
|
||||
// A stale build can surface as a runtime error while evaluating a
|
||||
// freshly (re)loaded chunk; recover rather than log it.
|
||||
if (recoverFromStaleBuild(ev.error?.message || ev.message, this)) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!this.hass?.connected) {
|
||||
return;
|
||||
}
|
||||
@@ -59,6 +84,20 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
|
||||
}
|
||||
});
|
||||
window.addEventListener("unhandledrejection", async (ev) => {
|
||||
// A failed dynamic import() of a stale build's chunk rejects here
|
||||
// (dialogs, more-info, cards, config-flow, …); recover rather than
|
||||
// silently logging it at debug level.
|
||||
const reason: any = ev.reason;
|
||||
const reasonMessage =
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: typeof reason === "string"
|
||||
? reason
|
||||
: "";
|
||||
if (recoverFromStaleBuild(reasonMessage, this)) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!this.hass?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { ReactiveElement, PropertyValues } from "lit";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { updateHistoryState } from "../common/navigate";
|
||||
import { closeLastDialog } from "../dialogs/make-dialog-manager";
|
||||
import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin";
|
||||
import type { Constructor } from "../types";
|
||||
@@ -20,10 +21,7 @@ export const urlSyncMixin = <
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (mainWindow.history.length === 1) {
|
||||
mainWindow.history.replaceState(
|
||||
{ ...mainWindow.history.state, root: true },
|
||||
""
|
||||
);
|
||||
updateHistoryState({ root: true });
|
||||
}
|
||||
mainWindow.addEventListener("popstate", this._popstateChangeListener);
|
||||
}
|
||||
|
||||
@@ -2543,6 +2543,7 @@
|
||||
"dismiss": "Dismiss",
|
||||
"no_matching_link_found": "No matching My link found for {path}",
|
||||
"new_version_available": "A new version of the frontend is available. This page will update in {seconds, plural, one {# second} other {# seconds}}.",
|
||||
"new_version_available_reload": "A new version of the frontend is available. It will be applied once you have saved or discarded your changes.",
|
||||
"update_now": "Update now",
|
||||
"theme_save_failed": "Unable to save theme settings to your user profile.",
|
||||
"theme_preferences_unavailable": "Unable to load user profile theme settings.",
|
||||
@@ -7445,6 +7446,7 @@
|
||||
"updated": "Updated",
|
||||
"device": "Device",
|
||||
"device_information": "Device information",
|
||||
"open_device": "Open device",
|
||||
"advertisement_data": "Advertisement data",
|
||||
"manufacturer_data": "Manufacturer data",
|
||||
"service_data": "Service data",
|
||||
@@ -10591,6 +10593,11 @@
|
||||
"light-color-temp": {
|
||||
"label": "Light color temperature"
|
||||
},
|
||||
"light-effect": {
|
||||
"label": "Light effect",
|
||||
"effects": "Effects",
|
||||
"customize": "Customize effects"
|
||||
},
|
||||
"lock-commands": {
|
||||
"label": "Lock commands"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { fireExternalBusMessage } from "../external_app/external_messaging";
|
||||
import * as staleBuildPatterns from "./stale-build-patterns.json";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
// Patterns live in a JSON single source (stale-build-patterns.json) so the
|
||||
// build can inject the exact same ones into the inline boot-time guard
|
||||
// (src/html/_bootstrap_recovery.html.template), which runs before any bundle
|
||||
// loads and therefore cannot import from here.
|
||||
const patterns = ((staleBuildPatterns as any).default ??
|
||||
staleBuildPatterns) as { hashedEntry: string; moduleError: string };
|
||||
const HASHED_ENTRY = new RegExp(patterns.hashedEntry, "i");
|
||||
const MODULE_ERROR = new RegExp(patterns.moduleError, "i");
|
||||
|
||||
const RELOAD_STORAGE_KEY = "haStaleBuildReload";
|
||||
const RELOAD_COOLDOWN = 60_000;
|
||||
// Reuse the service worker update toast id so we never show two competing
|
||||
// "new version available" toasts (see register-service-worker.ts).
|
||||
const UPDATE_TOAST_ID = "frontend-update-available";
|
||||
|
||||
let reloading = false;
|
||||
let toastShown = false;
|
||||
|
||||
/**
|
||||
* True when the given string looks like a failed load of a content-hashed
|
||||
* frontend chunk — i.e. a stale build referencing files that no longer exist
|
||||
* on the server after an upgrade. Accepts either a URL (from a resource
|
||||
* `error` event) or an error message (from a rejected dynamic `import()`).
|
||||
*/
|
||||
export const isStaleBuildError = (urlOrMessage: string | undefined): boolean =>
|
||||
!!urlOrMessage &&
|
||||
(HASHED_ENTRY.test(urlOrMessage) || MODULE_ERROR.test(urlOrMessage));
|
||||
|
||||
const dropCachesAndReload = async (bust: number): Promise<void> => {
|
||||
// A service worker serves chunks CacheFirst (with `ignoreSearch`), so a
|
||||
// cache-busting navigation alone would still be answered from the stale
|
||||
// cache. Drop the worker and its caches first, then navigate with a
|
||||
// cache-busting query so a fresh index.html is fetched. (Android's WebView
|
||||
// has a service worker, so this path recovers it too.)
|
||||
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
|
||||
try {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(registrations.map((reg) => reg.unregister()));
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
if ("caches" in window) {
|
||||
try {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(mainWindow.location.href);
|
||||
url.searchParams.set("ha_cache_bust", String(bust));
|
||||
mainWindow.location.replace(url.href);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reload the page onto the current build.
|
||||
*
|
||||
* Returns `true` when a reload was actually initiated, `false` when the loop
|
||||
* guard blocked it — so callers can fall back to logging / an error screen
|
||||
* instead of silently swallowing a still-failing chunk.
|
||||
*
|
||||
* Guarded by a monotonic sessionStorage counter — NOT the boot guard's URL
|
||||
* param, which core.ts strips on every successful connect — so a recovery that
|
||||
* boots and then immediately re-triggers the same missing chunk (a deep-linked
|
||||
* panel, more-info from the URL, or a preloaded route) cannot reload-loop.
|
||||
*/
|
||||
export const reloadFresh = (): boolean => {
|
||||
if (reloading) {
|
||||
return false;
|
||||
}
|
||||
const now = Date.now();
|
||||
let attempts = 0;
|
||||
let last = 0;
|
||||
let storageOk = true;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(RELOAD_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
attempts = Number(parsed.n) || 0;
|
||||
last = Number(parsed.t) || 0;
|
||||
}
|
||||
} catch (_err) {
|
||||
storageOk = false;
|
||||
}
|
||||
if (now - last > RELOAD_COOLDOWN) {
|
||||
attempts = 0;
|
||||
}
|
||||
if (attempts >= 1) {
|
||||
// Already reloaded once recently and it still failed: stop, to avoid a
|
||||
// reload loop on a genuinely broken (not merely stale) deploy.
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
RELOAD_STORAGE_KEY,
|
||||
JSON.stringify({ n: attempts + 1, t: now })
|
||||
);
|
||||
} catch (_err) {
|
||||
storageOk = false;
|
||||
}
|
||||
if (!storageOk) {
|
||||
// Without a durable cooldown marker (e.g. private mode) we can't stop a
|
||||
// loop across reloads, so fail closed rather than risk reloading forever.
|
||||
return false;
|
||||
}
|
||||
reloading = true;
|
||||
|
||||
// `frontend/reload_and_clear_cache` is a WKWebView (iOS/macOS companion)
|
||||
// command; the Android bridges (externalApp/externalAppV2) don't handle it.
|
||||
// Only send it to the WebKit bridge — everything else takes the browser
|
||||
// path below (Android's WebView has a service worker, so it recovers there).
|
||||
if (window.webkit?.messageHandlers?.externalBus) {
|
||||
fireExternalBusMessage({ type: "frontend/reload_and_clear_cache" });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fire and forget; the page navigates away once it resolves.
|
||||
void dropCachesAndReload(now);
|
||||
return true;
|
||||
};
|
||||
|
||||
const showStaleBuildToast = (rootEl?: HTMLElement): void => {
|
||||
if (toastShown) {
|
||||
return;
|
||||
}
|
||||
const el =
|
||||
rootEl ??
|
||||
(mainWindow.document.querySelector("home-assistant") as HTMLElement | null);
|
||||
if (!el) {
|
||||
// Nowhere to anchor the toast yet; a later trigger can retry.
|
||||
return;
|
||||
}
|
||||
toastShown = true;
|
||||
// This toast is only shown while an editor is dirty, so it deliberately has
|
||||
// no immediate-reload action that could discard unsaved work: it reloads
|
||||
// automatically once the user saves or discards.
|
||||
const reloadWhenClean = () => {
|
||||
if (!window.isDirtyState) {
|
||||
window.removeEventListener("dirty-state-changed", reloadWhenClean);
|
||||
reloadFresh();
|
||||
}
|
||||
};
|
||||
window.addEventListener("dirty-state-changed", reloadWhenClean);
|
||||
showToast(el, {
|
||||
id: UPDATE_TOAST_ID,
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available_reload",
|
||||
},
|
||||
duration: -1,
|
||||
dismissable: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Reload onto the current build, but never over unsaved work: when an editor
|
||||
* is dirty, show a non-dismissable toast and defer the reload until the dirty
|
||||
* state clears. Returns `true` when a reload or the deferral toast was started.
|
||||
*/
|
||||
export const reloadForUpdate = (rootEl?: HTMLElement): boolean => {
|
||||
if (window.isDirtyState) {
|
||||
showStaleBuildToast(rootEl);
|
||||
return true;
|
||||
}
|
||||
return reloadFresh();
|
||||
};
|
||||
|
||||
/**
|
||||
* Recover from a failed lazy load caused by a stale build (a content-hashed
|
||||
* chunk that 404s after an upgrade while the app stayed open).
|
||||
*
|
||||
* - clean: reload onto the current build.
|
||||
* - dirty (an editor has unsaved changes): show a non-dismissable toast and
|
||||
* auto-reload once the user saves/discards, so unsaved work is never lost.
|
||||
*
|
||||
* Returns `true` when the error was a stale-build error and recovery was
|
||||
* actually started, so callers can skip their own error UI / logging. Returns
|
||||
* `false` for a non-stale error, or when the loop guard blocked the reload (so
|
||||
* the caller still surfaces/logs the failure).
|
||||
*/
|
||||
export const recoverFromStaleBuild = (
|
||||
urlOrMessage: string | undefined,
|
||||
rootEl?: HTMLElement
|
||||
): boolean => {
|
||||
// In dev/demo the entry files are unhashed and rebuild churn can throw
|
||||
// transient import errors; never auto-reload there.
|
||||
if (__DEV__ || __DEMO__) {
|
||||
return false;
|
||||
}
|
||||
if (!isStaleBuildError(urlOrMessage)) {
|
||||
return false;
|
||||
}
|
||||
return reloadForUpdate(rootEl);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { NavigateOptions } from "../../src/common/navigate";
|
||||
import { canGoBack, goBack, navigate } from "../../src/common/navigate";
|
||||
|
||||
// navigate() closes open dialogs before touching history.
|
||||
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
|
||||
closeAllDialogs: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
// Fabricates a raw entry like a document load, which navigate() never produces.
|
||||
const setEntry = (path: string, state: unknown = null) => {
|
||||
window.history.replaceState(state, "", path);
|
||||
};
|
||||
|
||||
describe("navigate", () => {
|
||||
beforeEach(() => {
|
||||
setEntry("/config");
|
||||
});
|
||||
|
||||
it("stamps the path we came from on pushed entries", async () => {
|
||||
await navigate("/config/devices/dashboard");
|
||||
|
||||
expect(window.location.pathname).toEqual("/config/devices/dashboard");
|
||||
expect(window.history.state).toMatchObject({ from: "/config" });
|
||||
});
|
||||
|
||||
it("keeps caller data alongside the stamp", async () => {
|
||||
await navigate("/config/areas", { data: { scrollPosition: 42 } });
|
||||
|
||||
expect(window.history.state).toMatchObject({
|
||||
scrollPosition: 42,
|
||||
from: "/config",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores caller data that is not an object", async () => {
|
||||
await navigate("/config/areas", { data: 42 } as unknown as NavigateOptions);
|
||||
|
||||
expect(window.history.state).toEqual({ from: "/config" });
|
||||
});
|
||||
|
||||
it("keeps the stamp when replacing, the predecessor is unchanged", async () => {
|
||||
await navigate("/config/cloud");
|
||||
await navigate("/config/cloud/account", { replace: true });
|
||||
|
||||
expect(window.location.pathname).toEqual("/config/cloud/account");
|
||||
expect(window.history.state).toMatchObject({ from: "/config" });
|
||||
});
|
||||
|
||||
it("does not stamp an entry the app did not push", () => {
|
||||
expect(window.history.state).toBeNull();
|
||||
expect(canGoBack()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("goBack", () => {
|
||||
beforeEach(() => {
|
||||
setEntry("/config/cloud/remote");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("goes back when we came from another page in the app", async () => {
|
||||
const back = vi
|
||||
.spyOn(window.history, "back")
|
||||
.mockImplementation(() => undefined);
|
||||
await navigate("/config/cloud/remote");
|
||||
|
||||
await goBack("/config/cloud/account");
|
||||
|
||||
expect(back).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to the given path when the previous entry is not ours", async () => {
|
||||
const back = vi
|
||||
.spyOn(window.history, "back")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await goBack("/config/cloud/account");
|
||||
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(window.location.pathname).toEqual("/config/cloud/account");
|
||||
});
|
||||
|
||||
it("falls back to the root when no path is given", async () => {
|
||||
vi.spyOn(window.history, "back").mockImplementation(() => undefined);
|
||||
|
||||
await goBack();
|
||||
|
||||
expect(window.location.pathname).toEqual("/");
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ afterEach(() => {
|
||||
describe("hass-subpage back path", () => {
|
||||
it("links to a path on the current origin", async () => {
|
||||
const backButton = await mount("/config/system");
|
||||
expect(backButton!.getAttribute("href")).toEqual("/config/system");
|
||||
expect(backButton!.href).toEqual("/config/system");
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
@@ -36,7 +36,7 @@ describe("hass-subpage back path", () => {
|
||||
"does not link to %s",
|
||||
async (backPath) => {
|
||||
const backButton = await mount(backPath);
|
||||
expect(backButton!.hasAttribute("href")).toBe(false);
|
||||
expect(backButton!.href).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { supportsLightEffectCardFeature } from "../../../../src/panels/lovelace/card-features/hui-light-effect-card-feature";
|
||||
import { LightEntityFeature } from "../../../../src/data/light";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
const entity = (
|
||||
entityId: string,
|
||||
attributes: HassEntity["attributes"] = {}
|
||||
): HassEntity =>
|
||||
({
|
||||
entity_id: entityId,
|
||||
state: "on",
|
||||
attributes,
|
||||
last_changed: "",
|
||||
last_updated: "",
|
||||
context: { id: "", parent_id: null, user_id: null },
|
||||
}) as HassEntity;
|
||||
|
||||
const hassWith = (...entities: HassEntity[]): HomeAssistant =>
|
||||
({
|
||||
states: Object.fromEntries(entities.map((e) => [e.entity_id, e])),
|
||||
}) as unknown as HomeAssistant;
|
||||
|
||||
describe("supportsLightEffectCardFeature", () => {
|
||||
it("supports a light entity with EFFECT and a populated effect_list", () => {
|
||||
const stateObj = entity("light.test", {
|
||||
supported_features: LightEntityFeature.EFFECT,
|
||||
effect_list: ["candle", "fire"],
|
||||
});
|
||||
const hass = hassWith(stateObj);
|
||||
expect(
|
||||
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not support a light entity with EFFECT but an empty effect_list", () => {
|
||||
const stateObj = entity("light.test", {
|
||||
supported_features: LightEntityFeature.EFFECT,
|
||||
effect_list: [],
|
||||
});
|
||||
const hass = hassWith(stateObj);
|
||||
expect(
|
||||
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not support a light entity with EFFECT but no effect_list", () => {
|
||||
const stateObj = entity("light.test", {
|
||||
supported_features: LightEntityFeature.EFFECT,
|
||||
});
|
||||
const hass = hassWith(stateObj);
|
||||
expect(
|
||||
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not support a light entity without the EFFECT feature flag", () => {
|
||||
const stateObj = entity("light.test", {
|
||||
supported_features: 0,
|
||||
effect_list: ["candle", "fire"],
|
||||
});
|
||||
const hass = hassWith(stateObj);
|
||||
expect(
|
||||
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not support other domains", () => {
|
||||
const stateObj = entity("switch.test");
|
||||
const hass = hassWith(stateObj);
|
||||
expect(
|
||||
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not support a context with no entity_id", () => {
|
||||
const hass = hassWith();
|
||||
expect(supportsLightEffectCardFeature(hass, {})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ShowToastParams } from "../../src/managers/notification-manager";
|
||||
import type {
|
||||
isStaleBuildError,
|
||||
recoverFromStaleBuild,
|
||||
} from "../../src/util/recover-stale-build";
|
||||
|
||||
interface RecoverModule {
|
||||
isStaleBuildError: typeof isStaleBuildError;
|
||||
recoverFromStaleBuild: typeof recoverFromStaleBuild;
|
||||
}
|
||||
|
||||
const STALE_URL = "/frontend_latest/core.abc12345.js";
|
||||
// Set by reloadFresh() right before it navigates; used here to observe that
|
||||
// the reload path ran without having to mock window.location (jsdom forbids
|
||||
// redefining it).
|
||||
const RELOAD_KEY = "haStaleBuildReload";
|
||||
|
||||
describe("recover-stale-build", () => {
|
||||
let mod: RecoverModule;
|
||||
let root: HTMLElement;
|
||||
let notifications: ShowToastParams[];
|
||||
let serviceWorkerDescriptor: PropertyDescriptor | undefined;
|
||||
let cachesDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
const latestNotification = () => notifications[notifications.length - 1];
|
||||
const reloadMarker = () => sessionStorage.getItem(RELOAD_KEY);
|
||||
|
||||
beforeEach(async () => {
|
||||
globalThis.__DEV__ = false;
|
||||
globalThis.__DEMO__ = false;
|
||||
window.isDirtyState = false;
|
||||
sessionStorage.clear();
|
||||
|
||||
// No controlling service worker → reloadFresh() takes the synchronous
|
||||
// path straight to the (jsdom no-op) navigate.
|
||||
serviceWorkerDescriptor = Object.getOwnPropertyDescriptor(
|
||||
navigator,
|
||||
"serviceWorker"
|
||||
);
|
||||
cachesDescriptor = Object.getOwnPropertyDescriptor(window, "caches");
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: { controller: null },
|
||||
});
|
||||
|
||||
// Capture toasts fired via showToast (a "hass-notification" event).
|
||||
notifications = [];
|
||||
root = document.createElement("home-assistant");
|
||||
root.addEventListener("hass-notification", (event) => {
|
||||
notifications.push((event as CustomEvent<ShowToastParams>).detail);
|
||||
});
|
||||
document.body.append(root);
|
||||
|
||||
// Fresh module state per test (resets the reloading/toastShown singletons).
|
||||
vi.resetModules();
|
||||
mod = await import("../../src/util/recover-stale-build");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.remove();
|
||||
if (serviceWorkerDescriptor) {
|
||||
Object.defineProperty(
|
||||
navigator,
|
||||
"serviceWorker",
|
||||
serviceWorkerDescriptor
|
||||
);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
}
|
||||
if (cachesDescriptor) {
|
||||
Object.defineProperty(window, "caches", cachesDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(window, "caches");
|
||||
}
|
||||
Reflect.deleteProperty(window, "externalApp");
|
||||
Reflect.deleteProperty(window, "webkit");
|
||||
window.isDirtyState = false;
|
||||
globalThis.__DEV__ = false;
|
||||
globalThis.__DEMO__ = false;
|
||||
});
|
||||
|
||||
describe("isStaleBuildError", () => {
|
||||
it.each([
|
||||
"/frontend_latest/core.abc12345.js",
|
||||
"https://ha.local/frontend_es5/panel-config.deadbeef99.js",
|
||||
"Failed to fetch dynamically imported module: /frontend_latest/x.abcdef12.js",
|
||||
"Importing a module script failed.",
|
||||
"error loading dynamically imported module",
|
||||
"ChunkLoadError: Loading chunk 5 failed",
|
||||
])("detects a stale-build error: %s", (message) => {
|
||||
expect(mod.isStaleBuildError(message)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"/frontend_latest/core.js", // dev, unhashed
|
||||
"/static/translations/en-abc12345.json", // hashed, but not an entry chunk
|
||||
"TypeError: x is not a function",
|
||||
])("ignores non-stale input: %s", (message) => {
|
||||
expect(mod.isStaleBuildError(message)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverFromStaleBuild", () => {
|
||||
it("does nothing in development", () => {
|
||||
globalThis.__DEV__ = true;
|
||||
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(false);
|
||||
expect(reloadMarker()).toBeNull();
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does nothing in demo", () => {
|
||||
globalThis.__DEMO__ = true;
|
||||
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(false);
|
||||
expect(reloadMarker()).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a non-stale error", () => {
|
||||
expect(mod.recoverFromStaleBuild("TypeError: boom", root)).toBe(false);
|
||||
expect(reloadMarker()).toBeNull();
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reloads onto the current build when clean", () => {
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
|
||||
|
||||
// reloadFresh() ran (marker written before navigating) and did not toast.
|
||||
expect(reloadMarker()).not.toBeNull();
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("drops the service worker and caches before reloading", async () => {
|
||||
const unregister = vi.fn().mockResolvedValue(true);
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: {
|
||||
controller: {},
|
||||
getRegistrations: vi.fn().mockResolvedValue([{ unregister }]),
|
||||
},
|
||||
});
|
||||
const cacheDelete = vi.fn().mockResolvedValue(true);
|
||||
Object.defineProperty(window, "caches", {
|
||||
configurable: true,
|
||||
value: {
|
||||
keys: vi.fn().mockResolvedValue(["a", "b"]),
|
||||
delete: cacheDelete,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
|
||||
|
||||
await vi.waitFor(() => expect(unregister).toHaveBeenCalledOnce());
|
||||
expect(cacheDelete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses the companion-app command when the WebKit bridge is present", () => {
|
||||
const postMessage = vi.fn();
|
||||
(
|
||||
window as unknown as {
|
||||
webkit: {
|
||||
messageHandlers: {
|
||||
externalBus: { postMessage: typeof postMessage };
|
||||
};
|
||||
};
|
||||
}
|
||||
).webkit = { messageHandlers: { externalBus: { postMessage } } };
|
||||
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
|
||||
|
||||
// Asks the native app to purge its cache and reload instead of the
|
||||
// browser path.
|
||||
expect(postMessage).toHaveBeenCalledOnce();
|
||||
expect(postMessage.mock.calls[0][0]).toMatchObject({
|
||||
type: "frontend/reload_and_clear_cache",
|
||||
});
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("defers with a toast instead of reloading when dirty", () => {
|
||||
window.isDirtyState = true;
|
||||
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
|
||||
|
||||
// Took the toast branch, not the reload branch, and the toast has no
|
||||
// immediate-reload action that could discard unsaved work.
|
||||
expect(reloadMarker()).toBeNull();
|
||||
expect(latestNotification()).toMatchObject({
|
||||
id: "frontend-update-available",
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available_reload",
|
||||
},
|
||||
duration: -1,
|
||||
dismissable: false,
|
||||
});
|
||||
expect(latestNotification().action).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not reload again while the cooldown marker is set (loop guard)", async () => {
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
|
||||
const firstMarker = reloadMarker();
|
||||
expect(firstMarker).not.toBeNull();
|
||||
|
||||
// Simulate the reloaded page: a fresh module (the in-memory reloading
|
||||
// flag is reset) but the sessionStorage cooldown marker persists.
|
||||
vi.resetModules();
|
||||
const reloaded: RecoverModule =
|
||||
await import("../../src/util/recover-stale-build");
|
||||
|
||||
// Blocked by the cooldown → returns false so the caller still surfaces it.
|
||||
expect(
|
||||
reloaded.recoverFromStaleBuild("/frontend_latest/app.def67890.js", root)
|
||||
).toBe(false);
|
||||
expect(reloadMarker()).toBe(firstMarker);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user