Compare commits

...
23 Commits
Author SHA1 Message Date
Simon LamonandGitHub 6b2824181e Merge branch 'dev' into fix-53536 2026-08-13 07:11:28 +02:00
Bram KragtenandGitHub f360a22927 Wait for analytics data during onboarding (#53621) 2026-08-13 06:56:16 +02:00
Paul BotteinandGitHub a67111e41f Render state and numeric state entities as row targets (#53610)
* Render state and numeric state entities as row targets

* Add tests for entity-less trigger and condition descriptions
2026-08-13 06:53:44 +02:00
4b7d3a7e4f Add rspack persistent cache (nightly writes, CI reads) (#53611)
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-12 22:30:42 +02:00
Petar PetrovandGitHub 88be7adafa Stop number box rows from reserving unused space (#53614) 2026-08-12 14:42:09 +01:00
Paul BotteinandGitHub 91a6d737b3 Fix row target badge height and font (#53612) 2026-08-12 10:17:08 +01:00
Petar PetrovandGitHub 22c3a6fe67 Don't trim the selected value in pickers (#53613) 2026-08-12 10:16:39 +01:00
Petar PetrovandGitHub bcc799970a Fix lowercase view button in blueprint in-use dialog (#53609) 2026-08-12 09:55:34 +01:00
Petar PetrovandGitHub 31d4a37c15 Fix time condition summary when before is midnight (#53608) 2026-08-12 09:51:51 +01:00
Paul BotteinandGitHub 49ea96e091 Add icon button group animation (#53597)
* Animate the selected toggle circle in ha-icon-button-group

* Round the light color wheels to fit the selected ring

* Use lit motion
2026-08-12 10:19:07 +03:00
08b33ccbc1 Decouple translations artifact from the nightly build (#53602)
* Skip backend translations download in nightly build

The nightly only builds the app (build-app), which does not merge backend
translations — the shipped app fetches those from core at runtime. The
backend Lokalise export is a whole-project download across all languages
and the slowest part of the translations step. Skipping it, as the release
already does, cuts several minutes off every nightly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Decouple translations artifact into a parallel job

The full translations (including the slow Lokalise backend/core export) are
only needed for the uploaded `translations` artifact, not the wheel:
build-app does not merge backend translations. Move that download and the
artifact upload into a separate `translations` job that runs in parallel
with the build, so the backend export no longer sits on the build's
critical path. Both jobs run in the same workflow run, so consumers still
find both the `wheels` and `translations` artifacts.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-11 20:48:38 +02:00
Aidan TimsonandGitHub 048e754149 Match tokens card actions position and size of button with others (#53605) 2026-08-11 18:22:19 +02:00
Aidan TimsonandGitHub 3a30ea5973 Fix loading states for async config pages (#53604) 2026-08-11 15:32:53 +02:00
Petar Petrov 01aff39431 Add device context to energy individual device labels 2026-08-11 16:26:36 +03:00
Aidan TimsonandGitHub f7836fd3d5 Show loading screen while Labs features load (#53601) 2026-08-11 16:11:04 +03:00
Paul BotteinandGitHub 03d8c092ce Render the target picker entities count as a button (#53598)
* Implement the xs button size

* Render the target picker entities count as a button
2026-08-11 15:40:14 +03:00
Aidan TimsonandGitHub b3aa3c83d5 Change area navigation to icon button in device page (#53600)
* Add area navigation button to device page

* Remove area button tooltip
2026-08-11 15:00:00 +03:00
85c7d071fe Recover from a stale build after boot (#53582)
* Recover from a stale build after boot (lazy-chunk 404s)

When the app stays open across a Home Assistant upgrade, the previous
build's content-hashed lazy chunks are deleted, so opening a dialog,
more-info, card, or panel that was not yet loaded 404s. Today that
dead-ends: dialogs fail silently, panels show a Back-only error screen.

Add a shared recovery authority (recover-stale-build.ts): detect a stale
hashed-chunk load failure and either reload onto the current build (drop
the service worker + caches, cache-busting nav, one-shot cooldown guard)
or, when an editor has unsaved changes, show a non-dismissable toast that
reloads once the dirty state clears. Hook it into the global
error/unhandledrejection handlers, the router's swallowed load error, and
give hass-error-screen a reload action. Chunk-error patterns come from a
JSON single source shared with the boot guard.

Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Wire stale-build recovery into more paths, add tests

- make-dialog-manager: don't cache a rejected dialog import, so a stale
  chunk 404 (or transient failure) no longer permanently breaks that
  dialog until a full page reload — a later open re-imports.
- home-assistant: _checkUpdate now uses reloadFresh() instead of the
  no-longer-effective location.reload(true) (forceGet is ignored by
  modern browsers).
- connection-mixin: drop the dead reload(true) forceGet arg on the
  safe_mode reload.
- Add unit tests for isStaleBuildError and the recoverFromStaleBuild
  clean / dirty / dev-demo / non-stale / loop-guard branches.

Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Recover in the companion app via the reload_and_clear_cache command

The iOS/Android companion app (WKWebView) has no service worker, and its
document HTTP cache is not cleared by the Cache API, so the web-level
reload path is ineffective there. When an external bus is present,
reloadFresh() now fires the native `frontend/reload_and_clear_cache`
command (shipped in home-assistant/iOS#5190) so the app purges its cache
and reloads; browsers still take the web path.

Adds the outgoing message type and extracts the bus transport into an
exported fireExternalBusMessage() so it can be sent without a hass/bus
reference.

Closes #53405. Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Address Copilot review feedback

- reloadFresh: only send frontend/reload_and_clear_cache to the WebKit
  (iOS) bridge; Android and browsers take the service-worker + cache-clear
  path (Android's WebView has a service worker). Fail closed when the
  sessionStorage cooldown marker can't be persisted so it can't loop. Return
  whether a reload was actually started, so a guard-blocked failure is still
  surfaced/logged instead of silently swallowed.
- Add a dirty-aware reloadForUpdate() and route _checkUpdate and the error
  screen's Refresh button through it; drop the dirty toast's immediate
  action (it auto-reloads once changes are saved/discarded).
- Set showReload on the error-screen element at the call site so router
  overrides (e.g. ToolsRouter) can't drop it.
- Tests: exercise the WebKit bridge and the SW/cache-clear branch; assert
  the dirty toast has no reload action.

Part of home-assistant/epics#113.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-11 13:37:44 +02:00
Aidan TimsonandGitHub 6fe34bbf7e Add device link to Bluetooth advertisement dialog (#53599) 2026-08-11 14:27:06 +03:00
Aidan TimsonandGitHub 5291a84c87 Align highlighted config entries to the top (#53595)
* Align highlighted config entries to the top

* Apply suggestion from @timmo001
2026-08-11 10:54:58 +00:00
Paul BotteinandGitHub c0575fcb42 Unify back navigation across subpages (#53501)
* Unify back navigation across subpages

* Address back navigation review findings

* Fix state

* Add missing back path

* Remove back path from editors to fix unsaved changes prompt

* Use more specific back fallback paths
2026-08-11 10:39:38 +00:00
ea98a85088 Reserve scrollbar gutter in more-info dialog to prevent flicker (#53522)
* Reserve scrollbar gutter in more-info dialog to prevent flicker

* Update src/dialogs/more-info/ha-more-info-dialog.ts

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-11 10:33:12 +00:00
Bram KragtenandGitHub a1370e331f Replace remove device command (#53594) 2026-08-11 11:22:28 +02:00
115 changed files with 2448 additions and 585 deletions
+4
View File
@@ -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 }}
+14
View File
@@ -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:
+53 -3
View File
@@ -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:
+1
View File
@@ -7,6 +7,7 @@ dist/
/hass_frontend/
/translations/
/.compress-cache/
/.rspack-cache/
# Composite action source, not build output
!/.github/actions/build/
+85 -1
View File
@@ -1,4 +1,6 @@
const { existsSync } = require("fs");
const fs = require("fs");
const { existsSync } = fs;
const path = require("path");
const rspack = require("@rspack/core");
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -16,6 +18,61 @@ 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",
];
// Our own build logic — the config, loaders and babel plugins. Their contents
// (not their paths) go into the cache version, so a change invalidates the
// cache the same way `buildDependencies` would, but without tying validity to
// absolute paths — rspack compares buildDependencies by path, which breaks a
// cache reused on another machine/checkout (a different workspace path).
const CONFIG_FILES = [
__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"),
];
// Content hash of the toolchain versions and our own build files, used as the
// persistent cache `version`. Everything here is path-independent so the cache
// stays valid when reused on a different machine or checkout path.
const cacheVersion = () => {
const parts = [
...TOOLCHAIN_PACKAGES.map(
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
),
...CONFIG_FILES.map(
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
),
];
return require("crypto")
.createHash("sha256")
.update(parts.join("\n"))
.digest("hex")
.slice(0, 16);
};
class LogStartCompilePlugin {
ignoredFirst = false;
@@ -376,6 +433,33 @@ 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,
// Content-based version (node major + toolchain versions + our own
// build files). Everything is path-independent, so the cache stays
// valid when reused on another machine/checkout. Runtime deps are
// deliberately absent — rspack's node_modules snapshot invalidates
// their modules per-package, so a single unrelated bump keeps the
// rest warm. buildDependencies is intentionally not used: rspack
// compares it by absolute path, which breaks cross-machine reuse.
version: `node${process.versions.node.split(".")[0]}-${cacheVersion()}`,
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
View File
@@ -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.
+13
View File
@@ -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`
+4 -2
View File
@@ -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
View File
@@ -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 });
};
+10
View File
@@ -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;
+15
View File
@@ -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,
+108 -4
View File
@@ -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;
+3 -1
View File
@@ -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;
+1 -2
View File
@@ -472,10 +472,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
if (disabled) {
return;
}
const newValue = value?.trim();
const newTab = ev.ctrlKey || ev.metaKey;
this._fireSelectedEvents(newValue, index, newTab);
this._fireSelectedEvents(value, index, newTab);
};
private _fireSelectedEvents(value: string, index: number, newTab = false) {
@@ -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);
+210 -82
View File
@@ -94,6 +94,45 @@ const localizeTimeString = (
}
};
// Seconds since midnight for a literal `HH:MM(:SS)` time, undefined for
// anything else (entity ids contain a dot, and malformed input is ignored).
const literalTimeToSeconds = (value: unknown): number | undefined => {
if (typeof value !== "string" || value.includes(".")) {
return undefined;
}
const chunks = value.split(":");
if (chunks.length < 2 || chunks.length > 3) {
return undefined;
}
const hours = Number(chunks[0]);
const minutes = Number(chunks[1]);
const seconds = chunks.length > 2 ? Number(chunks[2]) : 0;
if (
!Number.isFinite(hours) ||
!Number.isFinite(minutes) ||
!Number.isFinite(seconds)
) {
return undefined;
}
return hours * 3600 + minutes * 60 + seconds;
};
const numericThresholdSuffix = (config: {
above?: number | string;
below?: number | string;
}): "above" | "below" | "above_below" | undefined => {
if (config.above !== undefined && config.below !== undefined) {
return "above_below";
}
if (config.above !== undefined) {
return "above";
}
if (config.below !== undefined) {
return "below";
}
return undefined;
};
const formatNumericLimitValue = (
hass: HomeAssistant,
value?: number | string
@@ -107,18 +146,26 @@ const formatNumericLimitValue = (
: value;
};
export interface DescribeOptions {
// Skip the user defined alias and describe the underlying config.
ignoreAlias?: boolean;
// Leave the entities out of the sentence, for rows that render them as
// target badges.
hideEntities?: boolean;
}
export const describeTrigger = (
trigger: Trigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
): string => {
try {
const description = tryDescribeTrigger(
trigger,
hass,
entityRegistry,
ignoreAlias
options
);
if (typeof description !== "string") {
throw new Error(String(description));
@@ -140,7 +187,7 @@ const tryDescribeTrigger = (
trigger: Trigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
) => {
if (isTriggerList(trigger)) {
const triggers = ensureArray(trigger.triggers);
@@ -156,14 +203,15 @@ const tryDescribeTrigger = (
});
}
if (trigger.alias && !ignoreAlias) {
if (trigger.alias && !options?.ignoreAlias) {
return trigger.alias;
}
const description = describeLegacyTrigger(
trigger as LegacyTrigger,
hass,
entityRegistry
entityRegistry,
options?.hideEntities
);
if (description) {
@@ -187,7 +235,8 @@ const tryDescribeTrigger = (
const describeLegacyTrigger = (
trigger: LegacyTrigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[]
entityRegistry: EntityRegistryEntry[],
hideEntities = false
) => {
// Event Trigger
if (trigger.trigger === "event" && trigger.event_type) {
@@ -218,28 +267,16 @@ const describeLegacyTrigger = (
}
// Numeric State Trigger
if (trigger.trigger === "numeric_state" && trigger.entity_id) {
const entities: string[] = [];
if (
trigger.trigger === "numeric_state" &&
(trigger.entity_id || hideEntities)
) {
const states = hass.states;
const stateObj = Array.isArray(trigger.entity_id)
? hass.states[trigger.entity_id[0]]
: (hass.states[trigger.entity_id] as HassEntity | undefined);
if (Array.isArray(trigger.entity_id)) {
for (const entity of trigger.entity_id.values()) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
} else if (trigger.entity_id) {
entities.push(
states[trigger.entity_id]
? computeStateName(states[trigger.entity_id])
: trigger.entity_id
);
}
const attribute = trigger.attribute
? stateObj
? computeAttributeNameDisplay(
@@ -255,6 +292,39 @@ const describeLegacyTrigger = (
? describeDuration(hass.locale, trigger.for)
: undefined;
if (hideEntities) {
const suffix = numericThresholdSuffix(trigger);
if (!suffix) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.label`
);
}
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
{
attribute: attribute,
above: formatNumericLimitValue(hass, trigger.above),
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
}
const entities: string[] = [];
if (Array.isArray(trigger.entity_id)) {
for (const entity of trigger.entity_id.values()) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
} else if (trigger.entity_id) {
entities.push(
states[trigger.entity_id]
? computeStateName(states[trigger.entity_id])
: trigger.entity_id
);
}
if (trigger.above !== undefined && trigger.below !== undefined) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.above-below`,
@@ -296,14 +366,14 @@ const describeLegacyTrigger = (
// State Trigger
if (trigger.trigger === "state") {
const entities: string[] = [];
const states = hass.states;
const entityArray: string[] = ensureArray(trigger.entity_id);
const stateObj = hass.states[entityArray?.[0]] as HassEntity | undefined;
let attribute = "";
if (trigger.attribute) {
const stateObj = Array.isArray(trigger.entity_id)
? hass.states[trigger.entity_id[0]]
: (hass.states[trigger.entity_id] as HassEntity | undefined);
attribute = stateObj
? computeAttributeNameDisplay(
hass.localize,
@@ -314,17 +384,6 @@ const describeLegacyTrigger = (
: trigger.attribute;
}
const entityArray: string[] = ensureArray(trigger.entity_id);
if (entityArray) {
for (const entity of entityArray) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
}
const stateObj = hass.states[entityArray[0]] as HassEntity | undefined;
let fromChoice = "other";
let fromString = "";
if (trigger.from !== undefined) {
@@ -404,6 +463,32 @@ const describeLegacyTrigger = (
duration = describeDuration(hass.locale, trigger.for) ?? "";
}
if (hideEntities) {
return hass.localize(
`${triggerTranslationBaseKey}.state.description.changed`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
anyChange: toChoice === "special" ? "true" : "false",
fromChoice: fromChoice,
fromString: fromString,
toChoice: toChoice,
toString: toString,
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
}
);
}
const entities: string[] = [];
if (entityArray) {
for (const entity of entityArray) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
}
return hass.localize(
`${triggerTranslationBaseKey}.state.description.full`,
{
@@ -893,14 +978,14 @@ export const describeCondition = (
condition: Condition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
): string => {
try {
const description = tryDescribeCondition(
condition,
hass,
entityRegistry,
ignoreAlias
options
);
if (typeof description !== "string") {
throw new Error(String(description));
@@ -922,7 +1007,7 @@ const tryDescribeCondition = (
condition: Condition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
) => {
if (typeof condition === "string" && hasTemplate(condition)) {
return hass.localize(
@@ -930,7 +1015,7 @@ const tryDescribeCondition = (
);
}
if (condition.alias && !ignoreAlias) {
if (condition.alias && !options?.ignoreAlias) {
return condition.alias;
}
@@ -952,7 +1037,8 @@ const tryDescribeCondition = (
const description = describeLegacyCondition(
condition as LegacyCondition,
hass,
entityRegistry
entityRegistry,
options?.hideEntities
);
if (description) {
@@ -978,7 +1064,8 @@ const tryDescribeCondition = (
const describeLegacyCondition = (
condition: LegacyCondition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[]
entityRegistry: EntityRegistryEntry[],
hideEntities = false
) => {
if (condition.condition === "or") {
const conditions = ensureArray(condition.conditions);
@@ -1035,17 +1122,20 @@ const describeLegacyCondition = (
// State Condition
if (condition.condition === "state") {
if (!condition.entity_id) {
if (!condition.entity_id && !hideEntities) {
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.no_entity`
);
}
const stateObj = hass.states[
Array.isArray(condition.entity_id)
? condition.entity_id[0]
: condition.entity_id
] as HassEntity | undefined;
let attribute = "";
if (condition.attribute) {
const stateObj = Array.isArray(condition.entity_id)
? hass.states[condition.entity_id[0]]
: (hass.states[condition.entity_id] as HassEntity | undefined);
attribute = stateObj
? computeAttributeNameDisplay(
hass.localize,
@@ -1056,27 +1146,7 @@ const describeLegacyCondition = (
: condition.attribute;
}
const entities: string[] = [];
if (Array.isArray(condition.entity_id)) {
for (const entity of condition.entity_id.values()) {
if (hass.states[entity]) {
entities.push(computeStateName(hass.states[entity]) || entity);
}
}
} else if (condition.entity_id) {
entities.push(
hass.states[condition.entity_id]
? computeStateName(hass.states[condition.entity_id])
: condition.entity_id
);
}
const states: string[] = [];
const stateObj = hass.states[
Array.isArray(condition.entity_id)
? condition.entity_id[0]
: condition.entity_id
] as HassEntity | undefined;
if (Array.isArray(condition.state)) {
for (const state of condition.state.values()) {
states.push(
@@ -1093,7 +1163,7 @@ const describeLegacyCondition = (
: state
);
}
} else if (condition.state !== "") {
} else if (condition.state != null && condition.state !== "") {
states.push(
stateObj
? condition.attribute
@@ -1114,6 +1184,37 @@ const describeLegacyCondition = (
duration = describeDuration(hass.locale, condition.for) || "";
}
if (hideEntities) {
if (states.length === 0) {
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
}
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.is`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
states: formatListWithOrs(hass.locale, states),
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
}
);
}
const entities: string[] = [];
if (Array.isArray(condition.entity_id)) {
for (const entity of condition.entity_id.values()) {
if (hass.states[entity]) {
entities.push(computeStateName(hass.states[entity]) || entity);
}
}
} else if (condition.entity_id) {
entities.push(
hass.states[condition.entity_id]
? computeStateName(hass.states[condition.entity_id])
: condition.entity_id
);
}
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.full`,
{
@@ -1136,15 +1237,14 @@ const describeLegacyCondition = (
}
// Numeric State Condition
if (condition.condition === "numeric_state" && condition.entity_id) {
const entity_ids = ensureArray(condition.entity_id);
if (
condition.condition === "numeric_state" &&
(condition.entity_id || hideEntities)
) {
const entity_ids = condition.entity_id
? ensureArray(condition.entity_id)
: [];
const stateObj = hass.states[entity_ids[0]] as HassEntity | undefined;
const entity = formatListWithAnds(
hass.locale,
entity_ids.map((id) =>
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
)
);
const attribute = condition.attribute
? stateObj
@@ -1157,6 +1257,30 @@ const describeLegacyCondition = (
: condition.attribute
: undefined;
if (hideEntities) {
const suffix = numericThresholdSuffix(condition);
if (!suffix) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.label`
);
}
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
{
attribute,
above: formatNumericLimitValue(hass, condition.above),
below: formatNumericLimitValue(hass, condition.below),
}
);
}
const entity = formatListWithAnds(
hass.locale,
entity_ids.map((id) =>
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
)
);
if (condition.above !== undefined && condition.below !== undefined) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.above-below`,
@@ -1232,12 +1356,16 @@ const describeLegacyCondition = (
let hasTime = "";
if (after !== undefined && before !== undefined) {
if (
typeof condition.after === "string" &&
!condition.after.includes(".") &&
typeof condition.before === "string" &&
!condition.before.includes(".") &&
condition.after > condition.before
const afterSeconds = literalTimeToSeconds(condition.after);
const beforeSeconds = literalTimeToSeconds(condition.before);
if (beforeSeconds === 0) {
// A window ending at midnight runs to the end of the day, so the
// "before" boundary adds nothing to the summary.
hasTime = "after";
} else if (
afterSeconds !== undefined &&
beforeSeconds !== undefined &&
afterSeconds > beforeSeconds
) {
hasTime = "after_before_or";
} else {
+4 -6
View File
@@ -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 = (
+55
View File
@@ -22,6 +22,7 @@ import {
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { formatTime24h } from "../common/datetime/format_time";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
import { groupBy } from "../common/util/group-by";
@@ -36,6 +37,7 @@ import type {
import {
fetchStatistics,
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
VOLUME_UNITS,
} from "./recorder";
@@ -311,6 +313,59 @@ export interface EnergySourceByType {
export const energySourcesByType = (prefs: EnergyPreferences) =>
groupBy(prefs.energy_sources, (item) => item.type) as EnergySourceByType;
/**
* Display name of a configured statistic. A name set by the user always wins;
* otherwise the entity is named the same way the rest of the UI names
* entities, so devices sharing an entity name stay distinguishable.
* Statistics without an entity (external or removed) keep the statistic label.
*/
export const computeEnergyLabel = (
hass: HomeAssistant,
statisticId: string,
statisticsMetaData?: StatisticsMetaData,
customName?: string
): string => {
if (customName) {
return customName;
}
const stateObj = hass.states[statisticId];
if (stateObj) {
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
}
return getStatisticLabel(hass, statisticId, statisticsMetaData);
};
/**
* Device labels keyed by statistic id. Cards that show live power or flow
* key their nodes by `stat_rate` instead of `stat_consumption`; devices
* without the requested statistic are left out.
*/
export const computeEnergyDeviceLabels = (
hass: HomeAssistant,
devices: DeviceConsumptionEnergyPreference[],
statsMetadata?: Record<string, StatisticsMetaData>,
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
): Record<string, string> => {
const labels: Record<string, string> = {};
for (const device of devices) {
const statisticId = device[statisticKey];
if (statisticId) {
labels[statisticId] = computeEnergyLabel(
hass,
statisticId,
statsMetadata?.[statisticId],
device.name
);
}
}
return labels;
};
export interface EnergyData {
start: Date;
end?: Date;
+23 -12
View File
@@ -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");
+14 -10
View File
@@ -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 {
+26 -9
View File
@@ -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;
};
+30
View File
@@ -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);
};
+26 -3
View File
@@ -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`
+2 -1
View File
@@ -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>
+15 -3
View File
@@ -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
View File
@@ -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 {
+11 -19
View File
@@ -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 {
+3 -4
View File
@@ -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();
}
}
}
+69 -11
View File
@@ -6,9 +6,13 @@ import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-analytics";
import "../components/ha-button";
import "../components/ha-spinner";
import "../components/ha-svg-icon";
import type { Analytics } from "../data/analytics";
import { setAnalyticsPreferences } from "../data/analytics";
import {
getAnalyticsDetails,
setAnalyticsPreferences,
} from "../data/analytics";
import { onboardAnalyticsStep } from "../data/onboarding";
import type { HomeAssistant } from "../types";
import { documentationUrl } from "../util/documentation-url";
@@ -22,9 +26,11 @@ class OnboardingAnalytics extends LitElement {
@state() private _error?: string;
@state() private _analyticsDetails: Analytics = {
preferences: {},
};
// Undefined while we are still waiting for the analytics integration to be
// set up (Home Assistant may still be starting up during onboarding).
@state() private _analyticsDetails?: Analytics;
private _retryTimeout?: number;
protected render(): TemplateResult {
return html`
@@ -40,13 +46,26 @@ class OnboardingAnalytics extends LitElement {
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>
</p>
<ha-analytics
translation_key_panel="page-onboarding"
@analytics-preferences-changed=${this._preferencesChanged}
.localize=${this.localize}
.analytics=${this._analyticsDetails}
>
</ha-analytics>
${
this._analyticsDetails
? html`
<ha-analytics
translation_key_panel="page-onboarding"
@analytics-preferences-changed=${this._preferencesChanged}
.localize=${this.localize}
.analytics=${this._analyticsDetails}
>
</ha-analytics>
`
: html`
<div class="loading">
<ha-spinner></ha-spinner>
<p>
${this.localize("ui.panel.page-onboarding.analytics.waiting")}
</p>
</div>
`
}
${this._error ? html`<div class="error">${this._error}</div>` : ""}
<div class="footer">
<ha-button @click=${this._save} .disabled=${!this._analyticsDetails}>
@@ -63,6 +82,35 @@ class OnboardingAnalytics extends LitElement {
this._save(ev);
}
});
this._loadAnalyticsDetails();
}
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this._retryTimeout) {
clearTimeout(this._retryTimeout);
this._retryTimeout = undefined;
}
}
private async _loadAnalyticsDetails(): Promise<void> {
try {
// The analytics integration registers its WebSocket commands during
// setup, but only stores its data once the config entry is set up. On a
// fresh install we can reach this step before that happened, so keep
// retrying until it is ready instead of failing on save.
this._analyticsDetails = await getAnalyticsDetails(this.hass);
this._error = undefined;
} catch (err: any) {
if (err.code === "not_found") {
this._retryTimeout = window.setTimeout(
() => this._loadAnalyticsDetails(),
1000
);
return;
}
this._error = err.message;
}
}
private _preferencesChanged(
@@ -76,6 +124,9 @@ class OnboardingAnalytics extends LitElement {
private async _save(ev) {
ev.preventDefault();
if (!this._analyticsDetails) {
return;
}
try {
await setAnalyticsPreferences(
this.hass,
@@ -98,6 +149,13 @@ class OnboardingAnalytics extends LitElement {
.error {
color: var(--error-color);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 24px 0;
}
a {
color: var(--primary-color);
text-decoration: none;
@@ -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
@@ -52,7 +52,6 @@ import type {
AutomationClipboard,
Condition,
ConditionSidebarConfig,
PlatformCondition,
} from "../../../../data/automation";
import { isCondition, testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
@@ -64,7 +63,6 @@ import {
type ValidConfig,
} from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceCondition } from "../../../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import {
@@ -76,6 +74,8 @@ import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
import "../target/ha-automation-row-targets";
import "./ha-automation-condition-editor";
import type HaAutomationConditionEditor from "./ha-automation-condition-editor";
@@ -182,12 +182,14 @@ export default class HaAutomationConditionRow extends LitElement {
const descriptionHasTarget =
"target" in (this.conditionDescriptions[this.condition.condition] || {});
const target = descriptionHasTarget
? (this.condition as PlatformCondition).target
: "device_id" in this.condition &&
(this.condition as DeviceCondition).device_id
? { device_id: [(this.condition as DeviceCondition).device_id] }
: undefined;
const hasEntityTarget =
this.condition.condition === "state" ||
this.condition.condition === "numeric_state";
const target = this._getTarget(descriptionHasTarget, hasEntityTarget);
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this._isNew;
const conditionTargetSpec =
this.conditionDescriptions[this.condition.condition]?.target;
@@ -224,13 +226,15 @@ export default class HaAutomationConditionRow extends LitElement {
}
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
describeCondition(this.condition, this.hass, this._entityReg, {
hideEntities: true,
})
)}
${
target !== undefined || (descriptionHasTarget && !this._isNew)
target !== undefined || targetRequired
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
targetRequired,
conditionTargetSpec,
this.condition.condition !== "device"
)
@@ -600,6 +604,30 @@ export default class HaAutomationConditionRow extends LitElement {
`;
}
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
private _getTarget(
descriptionHasTarget: boolean,
hasEntityTarget: boolean
): HassServiceTarget | undefined {
if (descriptionHasTarget && "target" in this.condition) {
return this.condition.target;
}
if (
"entity_id" in this.condition &&
this.condition.entity_id &&
hasEntityTarget
) {
return this._getEntityTarget(this.condition.entity_id);
}
if ("device_id" in this.condition && this.condition.device_id) {
return this._getDeviceTarget(this.condition.device_id);
}
return undefined;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
@@ -777,7 +805,9 @@ export default class HaAutomationConditionRow extends LitElement {
),
inputType: "string",
placeholder: capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg, true)
describeCondition(this.condition, this.hass, this._entityReg, {
ignoreAlias: true,
})
),
defaultValue: this.condition.alias,
confirmText: this.hass.localize("ui.common.submit"),
@@ -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>) {
@@ -0,0 +1,6 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
export const getDeviceTarget = (
deviceId?: string
): HassServiceTarget | undefined =>
deviceId ? { device_id: [deviceId] } : undefined;
@@ -0,0 +1,9 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../../common/array/ensure-array";
export const getEntityTarget = (
entityId?: string | string[]
): HassServiceTarget | undefined => {
const entityIds = entityId ? ensureArray(entityId).filter(Boolean) : [];
return entityIds.length ? { entity_id: entityIds } : undefined;
};
@@ -622,6 +622,8 @@ export class HaAutomationRowTargets extends LitElement {
var(--ha-color-border-neutral-quiet);
overflow: hidden;
height: 32px;
box-sizing: border-box;
font: inherit;
}
.target.warning {
background: var(--ha-color-fill-warning-normal-resting);
@@ -60,7 +60,6 @@ import { isTrigger, subscribeTrigger } from "../../../../data/automation";
import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceTrigger } from "../../../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import type { TriggerDescriptions } from "../../../../data/trigger";
@@ -74,6 +73,8 @@ import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
import "../target/ha-automation-row-targets";
import "./ha-automation-trigger-editor";
import type HaAutomationTriggerEditor from "./ha-automation-trigger-editor";
@@ -214,11 +215,12 @@ export default class HaAutomationTriggerRow extends LitElement {
"target" in
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger];
const target = descriptionHasTarget
? (this.trigger as PlatformTrigger).target
: type === "device" && (this.trigger as DeviceTrigger).device_id
? { device_id: (this.trigger as DeviceTrigger).device_id }
: undefined;
const hasEntityTarget = type === "state" || type === "numeric_state";
const target = this._getTarget(type, descriptionHasTarget, hasEntityTarget);
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this._isNew;
const triggerTargetSpec =
type === "platform"
@@ -248,12 +250,16 @@ export default class HaAutomationTriggerRow extends LitElement {
></ha-trigger-icon>`
}
<h3 slot="header">
${describeTrigger(this.trigger, this.hass, this._entityReg)}
${capitalizeFirstLetter(
describeTrigger(this.trigger, this.hass, this._entityReg, {
hideEntities: true,
})
)}
${
target !== undefined || (descriptionHasTarget && !this._isNew)
target !== undefined || targetRequired
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
targetRequired,
triggerTargetSpec,
type !== "device"
)
@@ -595,6 +601,27 @@ export default class HaAutomationTriggerRow extends LitElement {
`;
}
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
private _getTarget(
type: string,
descriptionHasTarget: boolean,
hasEntityTarget: boolean
): HassServiceTarget | undefined {
if (descriptionHasTarget && "target" in this.trigger) {
return this.trigger.target;
}
if (hasEntityTarget && "entity_id" in this.trigger) {
return this._getEntityTarget(this.trigger.entity_id);
}
if (type === "device" && "device_id" in this.trigger) {
return this._getDeviceTarget(this.trigger.device_id);
}
return undefined;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
@@ -857,7 +884,9 @@ export default class HaAutomationTriggerRow extends LitElement {
),
inputType: "string",
placeholder: capitalizeFirstLetter(
describeTrigger(this.trigger, this.hass, this._entityReg, true)
describeTrigger(this.trigger, this.hass, this._entityReg, {
ignoreAlias: true,
})
),
defaultValue: this.trigger.alias,
confirmText: this.hass.localize("ui.common.submit"),
@@ -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() {
@@ -622,8 +622,7 @@ class HaBlueprintOverview extends LitElement {
}
),
confirmText: this.hass!.localize(
"ui.panel.config.blueprint.overview.blueprint_in_use_view",
{ type }
`ui.panel.config.blueprint.overview.blueprint_in_use_view_${blueprint.domain}`
),
});
if (result) {
@@ -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)) {
@@ -11,6 +11,8 @@ import { css, html, LitElement, nothing } from "lit";
import { repeat } from "lit/directives/repeat";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
@@ -23,9 +25,11 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -104,18 +108,7 @@ export class EnergyDeviceSettingsWater extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderName(device)}
${this._renderIssueIndicator(
this.validationResult?.device_consumption_water[
index
@@ -155,6 +148,32 @@ export class EnergyDeviceSettingsWater extends LitElement {
`;
}
private _renderName(device: DeviceConsumptionEnergyPreference) {
const name = computeEnergyLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption],
device.name
);
const areaId = getEntityAreaId(
device.stat_consumption,
this.hass.entities,
this.hass.devices
);
const area = areaId ? this.hass.areas[areaId] : undefined;
const areaName = area ? computeAreaName(area) : undefined;
return html`
<div class="content">
<span class="label">${name}</span>
${
areaName
? html`<span class="label secondary">${areaName}</span>`
: nothing
}
</div>
`;
}
private _renderIssueIndicator(
issues: EnergyValidationIssue[] | undefined,
index: number
@@ -280,6 +299,22 @@ export class EnergyDeviceSettingsWater extends LitElement {
haStyle,
energyCardStyles,
css`
.row {
height: 58px;
}
.content {
display: flex;
flex-direction: column;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.secondary {
color: var(--secondary-text-color);
font-size: 0.9em;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
@@ -11,6 +11,8 @@ import { css, html, LitElement, nothing } from "lit";
import { repeat } from "lit/directives/repeat";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
@@ -23,9 +25,11 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -104,18 +108,7 @@ export class EnergyDeviceSettings extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderName(device)}
${this._renderIssueIndicator(
this.validationResult?.device_consumption[
index
@@ -155,6 +148,32 @@ export class EnergyDeviceSettings extends LitElement {
`;
}
private _renderName(device: DeviceConsumptionEnergyPreference) {
const name = computeEnergyLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption],
device.name
);
const areaId = getEntityAreaId(
device.stat_consumption,
this.hass.entities,
this.hass.devices
);
const area = areaId ? this.hass.areas[areaId] : undefined;
const areaName = area ? computeAreaName(area) : undefined;
return html`
<div class="content">
<span class="label">${name}</span>
${
areaName
? html`<span class="label secondary">${areaName}</span>`
: nothing
}
</div>
`;
}
private _renderIssueIndicator(
issues: EnergyValidationIssue[] | undefined,
index: number
@@ -276,6 +295,22 @@ export class EnergyDeviceSettings extends LitElement {
haStyle,
energyCardStyles,
css`
.row {
height: 58px;
}
.content {
display: flex;
flex-direction: column;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.secondary {
color: var(--secondary-text-color);
font-size: 0.9em;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
@@ -11,9 +11,11 @@ import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import {
getStatisticLabel,
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -174,7 +176,7 @@ export class DialogEnergyDeviceSettingsWater
.value=${this._device?.name || ""}
.placeholder=${
this._device
? getStatisticLabel(
? computeEnergyLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -11,9 +11,11 @@ import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import {
getStatisticLabel,
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -170,7 +172,7 @@ export class DialogEnergyDeviceSettings
.value=${this._device?.name || ""}
.placeholder=${
this._device
? getStatisticLabel(
? computeEnergyLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -7,7 +7,6 @@ import memoizeOne from "memoize-one";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-combo-box-item";
import "../../../../components/ha-generic-picker";
@@ -15,6 +14,7 @@ import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-
import type { PickerValueRenderer } from "../../../../components/ha-picker-field";
import "../../../../components/ha-svg-icon";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { computeEnergyLabel } from "../../../../data/energy";
import { domainToName } from "../../../../data/integration";
import {
getStatisticLabel,
@@ -73,20 +73,18 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const friendlyName = computeStateName(stateObj); // Keep this for search
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: statisticId,
primary: name || entityName || deviceName || statisticId,
secondary,
// Match the label shown in the device list and the graphs.
primary: computeEnergyLabel(
this.hass,
statisticId,
this.statsMetadata?.[statisticId],
name
),
secondary: areaName,
stateObj,
search_labels: {
entityName: entityName || null,
+1 -7
View File
@@ -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() {
@@ -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,
});
}
@@ -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"
@@ -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"];
@@ -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"
)}
@@ -640,7 +640,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
}
private _handleBack(): void {
goBack("/config");
goBack("/config/integrations/integration/zwave_js");
}
private _fetchData = async () => {
@@ -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(
+6 -1
View File
@@ -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(
+3 -3
View File
@@ -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> {
+1 -1
View File
@@ -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> {
+1 -3
View File
@@ -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(
+3 -6
View File
@@ -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, {
@@ -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(
+1 -5
View File
@@ -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
>
@@ -8,13 +8,13 @@ import type {
} from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
getSuggestedPeriod,
getSummedData,
} from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import type { Statistics } from "../../../../data/recorder";
import {
calculateStatisticSumGrowth,
getStatisticLabel,
isExternalStatistic,
} from "../../../../data/recorder";
import type { HomeAssistant } from "../../../../types";
@@ -69,13 +69,13 @@ interface ProcessContext {
end: Date;
compareStart?: Date;
untrackedOrder: number;
deviceLabels: Record<string, string>;
}
function processDataSet(
ctx: ProcessContext,
computedStyle: CSSStyleDeclaration,
statistics: Statistics,
statisticsMetaData: Record<string, StatisticsMetaData>,
devices: DeviceConsumptionEnergyPreference[],
sorted_devices: string[],
childMap: Record<string, string[]>,
@@ -167,12 +167,7 @@ function processDataSet(
}
const name =
(source.name ||
getStatisticLabel(
ctx.hass,
source.stat_consumption,
statisticsMetaData[source.stat_consumption]
)) +
ctx.deviceLabels[source.stat_consumption] +
(source.stat_consumption in childMap
? ` (${ctx.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked")})`
: "");
@@ -351,6 +346,8 @@ export function generateEnergyDevicesDetailGraphData(
const data = energyData.stats;
const compareData = energyData.statsCompare;
const devices = energyData.prefs.device_consumption;
const ctx: ProcessContext = {
hass,
config,
@@ -358,10 +355,13 @@ export function generateEnergyDevicesDetailGraphData(
end,
compareStart,
untrackedOrder,
deviceLabels: computeEnergyDeviceLabels(
hass,
devices,
energyData.statsMetadata
),
};
const devices = energyData.prefs.device_consumption;
const childMap: Record<string, string[]> = {};
devices.forEach((d) => {
if (d.included_in_stat) {
@@ -425,7 +425,6 @@ export function generateEnergyDevicesDetailGraphData(
ctx,
computedStyles,
compareData,
energyData.statsMetadata,
energyData.prefs.device_consumption,
sorted_devices,
childMap,
@@ -468,7 +467,6 @@ export function generateEnergyDevicesDetailGraphData(
ctx,
computedStyles,
data,
energyData.statsMetadata,
energyData.prefs.device_consumption,
sorted_devices,
childMap,
@@ -16,6 +16,7 @@ import "../../../../components/chart/ha-chart-tooltip-marker";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
getEnergyDataCollection,
getSummedData,
validateEnergyCollectionKey,
@@ -91,6 +92,8 @@ export class HuiEnergyDevicesGraphCard
private _compoundStats: string[] = [];
private _deviceLabels: Record<string, string> = {};
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
@@ -295,9 +298,8 @@ export class HuiEnergyDevicesGraphCard
? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})`
: "";
return (
(this._data?.prefs.device_consumption.find(
(d) => d.stat_consumption === statisticId
)?.name ||
// The untracked slice is not a statistic, so it has no label.
(this._deviceLabels[statisticId] ||
getStatisticLabel(
this.hass,
statisticId,
@@ -377,6 +379,12 @@ export class HuiEnergyDevicesGraphCard
.map((d) => d.included_in_stat)
.filter(Boolean) as string[];
this._deviceLabels = computeEnergyDeviceLabels(
this.hass,
energyData.prefs.device_consumption,
energyData.statsMetadata
);
const devices = energyData.prefs.device_consumption;
const devicesTotals: Record<string, number> = {};
devices.forEach((device) => {
@@ -8,6 +8,7 @@ import "../../../../components/ha-svg-icon";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
energySourcesByType,
getEnergyDataCollection,
getSummedData,
@@ -272,8 +273,14 @@ class HuiEnergySankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption,
this._data.statsMetadata
);
const deviceLabel = (statConsumption: string) =>
deviceLabels[statConsumption] ||
getStatisticLabel(
this.hass,
statConsumption,
@@ -7,6 +7,7 @@ import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import {
computeEnergyDeviceLabels,
formatPowerShort,
getEnergyDataCollection,
getPowerFromState,
@@ -278,6 +279,13 @@ class HuiPowerSankeyCard
}
}
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption,
this._data.statsMetadata,
"stat_rate"
);
const {
deviceNodes,
parentLinks,
@@ -294,7 +302,7 @@ class HuiPowerSankeyCard
initialUntracked: homeNode.value,
getId: (device) => device.stat_rate,
getValue: (id) => this._getCurrentPower(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
getEntityId: (id) => id,
});
links.push(...deviceLinks);
@@ -6,6 +6,7 @@ import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import type { EnergyData } from "../../../../data/energy";
import {
computeEnergyDeviceLabels,
formatFlowRateShort,
getEnergyDataCollection,
getFlowRateFromState,
@@ -241,6 +242,13 @@ class HuiWaterFlowSankeyCard
}
}
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption_water,
this._data.statsMetadata,
"stat_rate"
);
const {
deviceNodes,
parentLinks,
@@ -257,7 +265,7 @@ class HuiWaterFlowSankeyCard
initialUntracked: effectiveTotalInflow,
getId: (device) => device.stat_rate,
getValue: (id) => this._getCurrentFlowRate(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
getEntityId: (id) => id,
});
links.push(...deviceLinks);
@@ -7,6 +7,7 @@ import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import type { EnergyData } from "../../../../data/energy";
import {
computeEnergyDeviceLabels,
getEnergyDataCollection,
validateEnergyCollectionKey,
} from "../../../../data/energy";
@@ -215,8 +216,14 @@ class HuiWaterSankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption_water,
this._data!.statsMetadata
);
const deviceLabel = (statConsumption: string) =>
deviceLabels[statConsumption] ||
getStatisticLabel(
this.hass,
statConsumption,
@@ -92,7 +92,7 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
</div>
`
: html`
<div class="flex state">
<div class="flex box">
<ha-input
.disabled=${stateObj.state === UNAVAILABLE}
pattern="[0-9]+([\\.][0-9]+)?"
@@ -128,6 +128,10 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
min-width: 45px;
text-align: end;
}
.box {
flex-grow: 0;
min-width: 45px;
}
ha-input {
width: 100%;
}
@@ -99,7 +99,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
</div>
`
: html`
<div class="flex state">
<div class="flex box">
<ha-input
auto-validate
.disabled=${stateObj.state === UNAVAILABLE}
@@ -136,6 +136,10 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
min-width: 45px;
text-align: end;
}
.box {
flex-grow: 0;
min-width: 45px;
}
ha-input::part(wa-input) {
text-align: end;
direction: ltr !important;
+2 -4
View File
@@ -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" })
+24 -33
View File
@@ -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(() => {

Some files were not shown because too many files have changed in this diff Show More