Compare commits

..

54 Commits

Author SHA1 Message Date
Petar Petrov 8894c357ec Use extractApiErrorMessage in Update all error handler 2026-05-28 09:02:29 +03:00
Petar Petrov 479b25532b Add isSystemUpdate helper to classify HA Core/OS/Supervisor reliably 2026-05-28 09:01:43 +03:00
Petar Petrov ac120ad309 Remove loading state from Update all button 2026-05-28 08:53:56 +03:00
Petar Petrov 5a2e4b0da5 Address review: loading state, try/catch sources, install helper 2026-05-27 11:34:04 +03:00
Petar Petrov 9e56fd379e Use getUpdateType for system/addon classification 2026-05-26 16:52:39 +03:00
Petar Petrov 109d21aa81 Surface errors when Update all fails 2026-05-26 16:46:43 +03:00
Petar Petrov aacb8e3c09 Use ha-button for Update all and localize integration names 2026-05-26 16:44:07 +03:00
Petar Petrov 6def14718d Group pending updates by category on updates page 2026-05-26 16:22:47 +03:00
Aidan Timson fb0a54231a Show device name tip with link to editor, disable update button when state is clean (#52024) 2026-05-26 13:32:16 +02:00
Jan-Philipp Benecke a147fc4fee Fix padding of vertical tile card content (#52198) 2026-05-26 08:21:05 +03:00
karwosts a300085208 Fix calendar panel for non-admin (#52203) 2026-05-26 08:20:26 +03:00
renovate[bot] 44989a6972 Update dependency date-fns to v4.3.0 (#52205)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 08:19:30 +03:00
Yosi Levy 54a8e6c294 RTL fix for automation row (#52200) 2026-05-25 12:38:34 +02:00
Yosi Levy bfec22d828 RTL fix for new suggestion tree (#52199) 2026-05-25 11:52:54 +02:00
steven cde6450cfc Fix stale wake word display after wake word change in voice satellite set up wizard (#52194)
Fix stale wake word display after wake word change in satellite wizard

The config re-fetch was fire-and-forgotten, so the step transition to
STEP.WAKEWORD raced ahead with stale assistConfiguration. Awaiting the
fetch ensures the fresh active_wake_words are in place before rendering.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:41:56 +00:00
Petar Petrov ab39e70629 Recover brand icons after Home Assistant restart (#52158)
* Recover brand icons after Home Assistant restart

* Make _refreshBrandsAccessToken async
2026-05-25 09:36:50 +02:00
J. Nick Koston 69f209e3c3 Teach Bluetooth UI about auto scanning mode (#52192)
* Teach Bluetooth UI about auto scanning mode

* Drop unreachable auto cases and add isScannerStateMismatch tests
2026-05-25 09:30:03 +02:00
J. Nick Koston f4c5561a54 Show raw advertisement bytes in Bluetooth device info (#52193)
* Show raw advertisement bytes in Bluetooth device info

* Use plain div for raw hex to avoid fragile pre whitespace
2026-05-25 09:24:46 +02:00
renovate[bot] 5147937a6f Update dependency generate-license-file to v4.2.1 (#52195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 09:24:42 +02:00
renovate[bot] ee39605aa7 Update dependency intl-messageformat to v11.2.7 (#52197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 09:24:38 +02:00
renovate[bot] 4af4f1dc51 Update dependency idb-keyval to v6.2.4 (#52190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 14:01:55 +00:00
renovate[bot] a2d8859d94 Update dependency @date-fns/tz to v1.5.0 (#52187)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 14:03:30 +03:00
renovate[bot] afea8180c4 Update dependency idb-keyval to v6.2.3 (#52186)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 13:53:50 +03:00
dependabot[bot] b9c077489d Bump github/codeql-action from 4.35.4 to 4.35.5 (#52183)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.4 to 4.35.5.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/68bde559dea0fdcac2102bfdf6230c5f70eb485e...9e0d7b8d25671d64c341c19c0152d693099fb5ba)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 09:33:38 +03:00
karwosts 440bb32056 Remove unintended sort from select selector (#52179) 2026-05-23 21:14:24 +02:00
renovate[bot] 8f371621ad Update dependency @rspack/core to v2.0.4 (#52178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 16:18:20 +03:00
renovate[bot] 61815b20e3 Update vitest monorepo to v4.1.7 (#52173)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 13:03:54 +02:00
ildar170975 1942fa3a77 hui-entity-editor: fix vertical spacings (#52170)
fix spacings
2026-05-23 10:04:46 +02:00
renovate[bot] 865e67a06f Update Yarn to v4.15.0 (#52169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 10:03:23 +02:00
renovate[bot] 412dce4c1f Update dependency tinykeys to v4 (#52172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 10:02:58 +02:00
Jan-Philipp Benecke ced2ac7ad5 Fix ha-drawer z-index (#52167)
Fix ha-drawer index
2026-05-22 20:33:05 +02:00
renovate[bot] 6649f52bcd Update dependency tinykeys to v3.1.0 (#52166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 17:38:26 +00:00
Pascal Vizeli 7dbd6ae5a2 Add-on iframe: delegate microphone + camera Permissions Policy (#52068)
* Add-on iframe: delegate microphone + camera Permissions Policy

The add-on ingress iframe in ``ha-panel-app.ts`` ships without an
``allow=`` attribute, so the Permissions Policy default of *deny*
applies for ``microphone`` and ``camera`` on the cross-origin
iframe. An add-on that wants to call ``getUserMedia`` — voice
notes, dictation, video calls, photo capture — fails silently with
``NotAllowedError`` before the browser even surfaces the permission
prompt.

The failure is most visible on the Android Companion app, where
there's no "open in a new tab" escape: the user presses the mic
button and nothing happens, no toast, no logs.

Delegate ``microphone``, ``camera``, and ``clipboard-write`` to the
add-on iframe. Add-ons are first-party software the user explicitly
installs, and Chrome's runtime permission prompt still gates the
hardware access — the ``allow=`` attribute just lets the iframe
*request* the prompt instead of being blocked at the policy layer.

``clipboard-write`` is bundled in because the next-most-frequent
silent-fail in add-on land is ``navigator.clipboard.writeText`` for
"copy link" / "copy code" affordances, blocked by the same
mechanism.

* Sandbox add-on ingress iframe without allow-same-origin

Split IFRAME_SANDBOX into two constants: IFRAME_SANDBOX (without
allow-same-origin) for add-on ingress iframes that need origin
isolation, and IFRAME_SANDBOX_SAME_ORIGIN for external iframes
that need same-origin access.

This ensures add-on iframes can't inherit camera/microphone
permissions already granted to the Home Assistant origin, and
prevents same-origin iframes from removing their own sandbox.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 19:31:56 +02:00
Wendelin e1528d21b3 Migrate md-lists cloud dashboard and devtools (#52163)
Migrate lists in cloud and dev tools=
2026-05-22 18:24:16 +03:00
renovate[bot] 79cb3137f2 Update tsparticles to v4.0.5 (#52162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 18:17:04 +03:00
Jan-Philipp Benecke 313360701a Revamp ZHA group page UI (#52124) 2026-05-22 16:24:59 +02:00
renovate[bot] b100d9577d Update formatjs monorepo (#52159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 15:56:02 +03:00
Wendelin 44ce303302 Fix dropdown keyboard scroll (#52157) 2026-05-22 13:47:43 +01:00
Aidan Timson 8f76613068 Add more quick links to device page (#52137)
* Add more quick links to device page

* Move shared keys to common location
2026-05-22 12:45:37 +03:00
Aidan Timson 85dff6640a Use ha-list-nav for each section in device page related card (#52142)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-05-22 11:22:36 +02:00
Aidan Timson ab7c892b6b Add to for area page (#52141)
* Setup add to area page

* Remove 3 buttons, move to single add to button next to add a picture button

* Use normal size buttons

* Restructure layout with picture

* Remove div when both conditions are met

* Use mixin

* Fix imports
2026-05-22 12:02:38 +03:00
Wendelin 3fe57ad724 webawesome 3.7.0 (#52155) 2026-05-22 11:24:16 +03:00
pcan08 1caf1d99b5 Migrate theme-picker to ha-generic-picker (#52067)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Wendelin <w@pe8.at>
2026-05-22 06:32:39 +00:00
renovate[bot] 483df2fa2f Update dependency marked to v18.0.4 (#52153)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:52:04 +03:00
Stefan Agner e0adb006b6 Show time zone picker in onboarding when browser can't resolve IANA zone (#52146)
Some environments (e.g. Android WebView/emulator) return a UTC offset like
"+00:00" from Intl.DateTimeFormat().resolvedOptions().timeZone instead of an
IANA zone name. Submitting that to saveCoreConfig fails with "invalid time
zone", leaving users stuck on the country step.

Detect this by checking the resolved value against the google-timezones-json
list used by ha-timezone-picker, and surface the picker on the core-config
step when no IANA zone could be detected from the browser or the location
detect API.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:26:50 +03:00
renovate[bot] 50e34015b3 Update tsparticles to v4.0.4 (#52152)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:23:24 +03:00
renovate[bot] c1c926c631 Update tsparticles to v4.0.3 (#52148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:11:18 +03:00
renovate[bot] c41afac57c Update dependency typescript-eslint to v8.59.4 (#52147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:10:58 +03:00
Aidan Timson 8856c26929 Add quick links to area page, add area query param support (#52133)
* Add quick links to area page

* Typing

* Add query param support for areas

* hint for the rest

* Add support for helpers

* Add counts
2026-05-21 18:59:20 +02:00
Wendelin 4a0fe3190c Backups: Migrate md-list to new list-base (#52136)
Migrate md-list to new list-base
2026-05-21 18:57:24 +02:00
karwosts 08f7e97462 Use display precision in statistic card (#52138) 2026-05-21 18:53:04 +02:00
Joakim Plate a5791c8c08 Switch to power-standby for media player (#52127) 2026-05-21 18:53:01 +02:00
Wendelin 6a98a74c58 Fix trigger time margin bottom (#52144)
Fix trigger time margin
2026-05-21 18:39:35 +02:00
renovate[bot] c1df3bc38e Update Node.js to v24.16.0 (#52140) 2026-05-21 17:03:13 +02:00
104 changed files with 4337 additions and 2347 deletions
+3 -3
View File
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
+1 -1
View File
@@ -1 +1 @@
24.15.0
24.16.0
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.14.1.cjs
yarnPath: .yarn/releases/yarn-4.15.0.cjs
+25 -25
View File
@@ -38,24 +38,24 @@
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.4.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.5",
"@formatjs/intl-displaynames": "7.3.7",
"@formatjs/intl-durationformat": "0.10.11",
"@formatjs/intl-getcanonicallocales": "3.2.8",
"@formatjs/intl-listformat": "8.3.7",
"@formatjs/intl-locale": "5.3.7",
"@formatjs/intl-numberformat": "9.3.8",
"@formatjs/intl-pluralrules": "6.3.7",
"@formatjs/intl-relativetimeformat": "12.3.7",
"@formatjs/intl-datetimeformat": "7.4.6",
"@formatjs/intl-displaynames": "7.3.8",
"@formatjs/intl-durationformat": "0.10.12",
"@formatjs/intl-getcanonicallocales": "3.2.9",
"@formatjs/intl-listformat": "8.3.8",
"@formatjs/intl-locale": "5.3.8",
"@formatjs/intl-numberformat": "9.3.9",
"@formatjs/intl-pluralrules": "6.3.8",
"@formatjs/intl-relativetimeformat": "12.3.8",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.3.1-ha.3",
"@home-assistant/webawesome": "3.7.0-ha.0",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -74,8 +74,8 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.21",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.0.2",
"@tsparticles/preset-links": "4.0.2",
"@tsparticles/engine": "4.0.5",
"@tsparticles/preset-links": "4.0.5",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -86,7 +86,7 @@
"core-js": "3.49.0",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.2.1",
"date-fns": "4.3.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
@@ -97,8 +97,8 @@
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.6",
"idb-keyval": "6.2.4",
"intl-messageformat": "11.2.7",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -106,7 +106,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.3",
"marked": "18.0.4",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -118,7 +118,7 @@
"sortablejs": "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"tinykeys": "4.0.0",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
@@ -141,7 +141,7 @@
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.11",
"@rspack/core": "2.0.3",
"@rspack/core": "2.0.4",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -160,7 +160,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.6",
"@vitest/coverage-v8": "4.1.7",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
@@ -175,7 +175,7 @@
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.5",
"generate-license-file": "4.1.1",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.6.0",
"gulp": "5.0.1",
@@ -201,9 +201,9 @@
"terser-webpack-plugin": "5.6.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.3",
"typescript-eslint": "8.59.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.6",
"vitest": "4.1.7",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -219,8 +219,8 @@
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.14.1",
"packageManager": "yarn@4.15.0",
"volta": {
"node": "24.15.0"
"node": "24.16.0"
}
}
+15 -1
View File
@@ -1,6 +1,20 @@
import timezones from "google-timezones-json";
import { TimeZone } from "../../data/translation";
const RESOLVED_TIME_ZONE = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
// Some environments (e.g. Android emulator) return a UTC offset like "+00:00"
// instead of an IANA zone name. Only accept values that are known IANA zones,
// matching the list used by ha-timezone-picker.
const RESOLVED_TIME_ZONE =
RESOLVED_RAW &&
(RESOLVED_RAW === "UTC" ||
RESOLVED_RAW === "Etc/UTC" ||
RESOLVED_RAW in timezones)
? RESOLVED_RAW
: undefined;
export const HAS_RESOLVED_IANA_TIME_ZONE = RESOLVED_TIME_ZONE !== undefined;
// Browser time zone can be determined from Intl, with fallback to UTC for polyfill or no support.
export const LOCAL_TIME_ZONE = RESOLVED_TIME_ZONE ?? "UTC";
@@ -128,7 +128,9 @@ export class HaAutomationRow extends LitElement {
}
.row {
display: flex;
padding: 0 0 0 var(--ha-space-3);
padding-left: var(--ha-space-3);
padding-inline-start: var(--ha-space-3);
padding-inline-end: initial;
min-height: 48px;
align-items: flex-start;
cursor: pointer;
@@ -144,6 +146,8 @@ export class HaAutomationRow extends LitElement {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--ha-color-on-neutral-quiet);
margin-left: calc(var(--ha-space-2) * -1);
margin-inline-start: calc(var(--ha-space-2) * -1);
margin-inline-end: initial;
}
:host([building-block]) .leading-icon-wrapper {
background-color: var(--ha-color-fill-neutral-loud-resting);
+1
View File
@@ -294,6 +294,7 @@ export class HaDrawer extends LitElement {
border-inline-end: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12));
box-sizing: border-box;
transition: width var(--ha-animation-duration-normal) ease;
z-index: 6;
}
.app-content {
+3
View File
@@ -109,6 +109,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ attribute: "custom-value-label" })
public customValueLabel?: string;
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -271,6 +273,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.selectedSection=${this.selectedSection}
.searchKeys=${this.searchKeys}
.customValueLabel=${this.customValueLabel}
.noSort=${this.noSort}
></ha-picker-combo-box>
`;
}
+3 -1
View File
@@ -167,6 +167,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public clearable = false;
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@@ -342,7 +344,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _getItems = () => {
let items = [...(this.getItems(this._search, this._selectedSection) || [])];
if (!this.sections?.length) {
if (!this.sections?.length && !this.noSort) {
items = items.sort((entityA, entityB) => {
const sortLabelA =
typeof entityA === "string" ? entityA : entityA.sorting_label;
@@ -199,6 +199,7 @@ export class HaSelectSelector extends LitElement {
: nothing}
<ha-generic-picker
no-sort
.hass=${this.hass}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -215,6 +216,7 @@ export class HaSelectSelector extends LitElement {
if (this.selector.select?.custom_value) {
return html`
<ha-generic-picker
no-sort
.hass=${this.hass}
.label=${this.label}
.helper=${this.helper}
+63 -37
View File
@@ -1,13 +1,17 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import type { HaSelectOption, HaSelectSelectEvent } from "./ha-select";
import "./ha-select";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
const DEFAULT_THEME = "default";
const SEARCH_KEYS = [{ name: "primary", weight: 1 }];
@customElement("ha-theme-picker")
export class HaThemePicker extends LitElement {
@property() public value?: string;
@@ -25,52 +29,74 @@ export class HaThemePicker extends LitElement {
@property({ type: Boolean }) public required = false;
@property({ attribute: "no-theme-label" }) public noThemeLabel?: string;
private _getThemeOptions = memoizeOne(
(
themes: Record<string, unknown>,
locale: string,
includeDefault: boolean
): PickerComboBoxItem[] => {
const items: PickerComboBoxItem[] = [];
if (includeDefault) {
items.push({ id: DEFAULT_THEME, primary: "Home Assistant" });
}
const themeNames = Object.keys(themes).sort((a, b) =>
caseInsensitiveStringCompare(a, b, locale)
);
for (const theme of themeNames) {
items.push({ id: theme, primary: theme });
}
return items;
}
);
private _getItems = () =>
this._getThemeOptions(
this.hass?.themes.themes || {},
this.hass?.locale.language || "en",
this.includeDefault
);
private _valueRenderer = (value: string): TemplateResult =>
html`<span slot="headline"
>${this._getItems().find((i) => i.id === value)?.primary ?? value}</span
>`;
protected render(): TemplateResult {
const options: HaSelectOption[] = Object.keys(
this.hass?.themes.themes || {}
).map((theme) => ({
value: theme,
}));
if (this.includeDefault) {
options.unshift({
value: DEFAULT_THEME,
label: "Home Assistant",
});
}
if (!this.required) {
options.unshift({
value: "remove",
label: this.hass!.localize("ui.components.theme-picker.no_theme"),
});
}
return html`
<ha-select
.label=${this.label ||
this.hass!.localize("ui.components.theme-picker.theme")}
.value=${this.value}
<ha-generic-picker
.label=${this.label ??
this.hass?.localize("ui.components.theme-picker.theme") ??
"Theme"}
.placeholder=${this.noThemeLabel ??
this.hass?.localize("ui.components.theme-picker.no_theme")}
.helper=${this.helper}
.required=${this.required}
.value=${this.value}
.valueRenderer=${this._valueRenderer}
.getItems=${this._getItems}
.searchKeys=${SEARCH_KEYS}
.disabled=${this.disabled}
@selected=${this._changed}
.options=${options}
></ha-select>
.required=${this.required}
@value-changed=${this._changed}
popover-placement="bottom"
></ha-generic-picker>
`;
}
static styles = css`
ha-select {
ha-generic-picker {
width: 100%;
display: block;
}
`;
private _changed(ev: HaSelectSelectEvent): void {
if (!this.hass || ev.detail.value === "") {
return;
}
this.value = ev.detail.value === "remove" ? undefined : ev.detail.value;
private _changed(ev: ValueChangedEvent<string | undefined>): void {
ev.stopPropagation();
this.value = ev.detail.value;
fireEvent(this, "value-changed", { value: this.value });
}
}
+1
View File
@@ -112,6 +112,7 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
text-align: center;
justify-content: center;
padding: 10px 0;
}
.vertical ::slotted([slot="info"]) {
width: 100%;
-1
View File
@@ -615,7 +615,6 @@ export interface BaseSidebarConfig {
export interface TriggerSidebarConfig extends BaseSidebarConfig {
save: (value: Trigger) => void;
editId: () => void;
rename: () => void;
disable: () => void;
duplicate: () => void;
+12 -7
View File
@@ -125,15 +125,20 @@ export const getTriggerInfos = (
}
const map = new Map<string, TriggerInfo>();
for (const t of flattenTriggers(triggers)) {
if (isTriggerList(t) || !t.id || map.get(t.id)) {
if (isTriggerList(t) || !t.id) {
continue;
}
map.set(t.id, {
id: t.id,
label: describeTrigger(t, hass, entityRegistry),
triggerType: t.trigger,
count: 1,
});
const existing = map.get(t.id);
if (existing) {
existing.count++;
} else {
map.set(t.id, {
id: t.id,
label: describeTrigger(t, hass, entityRegistry),
triggerType: t.trigger,
count: 1,
});
}
}
return Array.from(map.values());
};
+11 -2
View File
@@ -17,6 +17,7 @@ export interface BluetoothDeviceData extends DataTableRowData {
source: string;
time: number;
tx_power: number;
raw: string | null;
}
export interface BluetoothConnectionData extends DataTableRowData {
@@ -58,13 +59,21 @@ export interface BluetoothAllocationsData {
allocated: string[];
}
export type BluetoothScannerMode = "active" | "passive";
export type BluetoothScannerRequestedMode = BluetoothScannerMode | "auto";
export interface BluetoothScannerState {
source: string;
adapter: string;
current_mode: "active" | "passive" | null;
requested_mode: "active" | "passive" | null;
current_mode: BluetoothScannerMode | null;
requested_mode: BluetoothScannerRequestedMode | null;
}
export const isScannerStateMismatch = (state: BluetoothScannerState): boolean =>
state.requested_mode !== "auto" &&
state.current_mode !== state.requested_mode;
export const subscribeBluetoothScannersDetailsUpdates = (
conn: Connection,
store: Store<BluetoothScannersDetails>
+4 -1
View File
@@ -5,7 +5,10 @@ export interface DataTableFilter {
export type DataTableFilters = Record<string, DataTableFilter>;
export type DataTableFiltersValue = string[] | { key: string[] } | undefined;
export type DataTableFiltersValue =
| string[]
| Record<"key" | string, string[]>
| undefined;
export type DataTableFiltersValues = Record<string, DataTableFiltersValue>;
+3 -3
View File
@@ -15,7 +15,7 @@ import {
mdiPlaylistMusic,
mdiPlayPause,
mdiPodcast,
mdiPower,
mdiPowerStandby,
mdiPowerOff,
mdiPowerOn,
mdiRepeat,
@@ -295,7 +295,7 @@ export const computeMediaControls = (
return supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_ON)
? [
{
icon: mdiPower,
icon: mdiPowerStandby,
action: "turn_on",
},
]
@@ -316,7 +316,7 @@ export const computeMediaControls = (
if (supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_OFF)) {
buttons.push({
icon: assumedState ? mdiPowerOff : mdiPower,
icon: assumedState ? mdiPowerOff : mdiPowerStandby,
action: "turn_off",
});
}
+38
View File
@@ -62,6 +62,44 @@ export const getTriggerIds = (triggers: Trigger[]): string[] =>
.map((trigger) => trigger.id)
.filter((id): id is string => !!id);
export const getNextNumericTriggerId = (triggers: Trigger[]): string => {
let max = 0;
for (const id of getTriggerIds(triggers)) {
const num = Number(id);
if (Number.isInteger(num) && num > max) {
max = num;
}
}
return String(max + 1);
};
const computeUniqueId = (id: string, existing: Set<string>): string => {
if (!existing.has(id)) {
return id;
}
// Split into a base and a trailing integer suffix so we can bump the
// suffix on collision (e.g. "foo2" -> "foo3"); if there's no trailing
// digit we start at 2 ("foo" -> "foo2").
const match = id.match(/^(.*?)(\d+)$/);
let base: string;
let num: number;
if (match) {
base = match[1];
num = Number(match[2]) + 1;
} else {
base = id;
num = 2;
}
while (existing.has(`${base}${num}`)) {
num++;
}
return `${base}${num}`;
};
export const getUniqueTriggerId = (id: string, triggers: Trigger[]): string =>
computeUniqueId(id, new Set(getTriggerIds(triggers)));
export interface TriggerDescription {
target?: TargetSelector["target"];
fields: Record<
+18
View File
@@ -87,6 +87,19 @@ const HOME_ASSISTANT_CORE_TITLE = "Home Assistant Core";
const HOME_ASSISTANT_SUPERVISOR_TITLE = "Home Assistant Supervisor";
const HOME_ASSISTANT_OS_TITLE = "Home Assistant Operating System";
// The hassio integration sets these as hard-coded `_attr_title` on the Core,
// Operating System, and Supervisor update entities. They are not translated,
// so a title comparison is the reliable way to identify them without depending
// on the (lazily-fetched) entity sources.
export const isSystemUpdate = (entity: UpdateEntity): boolean => {
const title = entity.attributes.title || "";
return (
title === HOME_ASSISTANT_CORE_TITLE ||
title === HOME_ASSISTANT_OS_TITLE ||
title === HOME_ASSISTANT_SUPERVISOR_TITLE
);
};
export const filterUpdateEntities = (
entities: HassEntities,
language?: string
@@ -133,6 +146,11 @@ export const filterUpdateEntitiesParameterized = (
return updateCanInstall(entity, showSkipped);
});
export const installUpdates = (hass: HomeAssistant, entityIds: string[]) =>
hass.callService("update", "install", {
entity_id: entityIds,
});
export const checkForEntityUpdates = async (
element: HTMLElement,
hass: HomeAssistant
+3
View File
@@ -3,6 +3,7 @@ import type { LocalizeFunc } from "../../common/translations/localize";
import { createSearchParam } from "../../common/url/search-params";
import type { SingleHassServiceTarget } from "../../data/target";
import {
ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM,
@@ -105,6 +106,8 @@ export function addToActionHandler(
searchParams[ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM] = target.entity_id;
} else if (target.device_id) {
searchParams[ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM] = target.device_id;
} else if (target.area_id) {
searchParams[ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM] = target.area_id;
}
const params = (addElement: string) =>
@@ -343,9 +343,9 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
this._step = this._previousSteps.pop()!;
}
private _goToNextStep(ev?: CustomEvent) {
private async _goToNextStep(ev?: CustomEvent) {
if (ev?.detail?.updateConfig) {
this._fetchAssistConfiguration();
await this._fetchAssistConfiguration();
}
if (ev?.detail?.nextStep) {
this._nextStep = ev.detail.nextStep;
+55 -23
View File
@@ -1,18 +1,25 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { LOCAL_TIME_ZONE } from "../common/datetime/resolve-time-zone";
import memoizeOne from "memoize-one";
import {
HAS_RESOLVED_IANA_TIME_ZONE,
LOCAL_TIME_ZONE,
} from "../common/datetime/resolve-time-zone";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
import "../components/ha-button";
import { COUNTRIES } from "../components/ha-country-picker";
import "../components/ha-form/ha-form";
import type { HaForm } from "../components/ha-form/ha-form";
import type { HaFormSchema } from "../components/ha-form/types";
import "../components/ha-spinner";
import type { ConfigUpdateValues } from "../data/core";
import { saveCoreConfig } from "../data/core";
import { countryCurrency } from "../data/currency";
import { onboardCoreConfigStep } from "../data/onboarding";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HomeAssistant } from "../types";
import { getLocalLanguage } from "../util/common-translation";
import "./onboarding-location";
@@ -28,7 +35,9 @@ class OnboardingCoreConfig extends LitElement {
private _elevation = "0";
private _timeZone: ConfigUpdateValues["time_zone"] = LOCAL_TIME_ZONE;
@state() private _timeZone: ConfigUpdateValues["time_zone"] = LOCAL_TIME_ZONE;
@state() private _timeZoneDetected = HAS_RESOLVED_IANA_TIME_ZONE;
private _language: ConfigUpdateValues["language"] = getLocalLanguage();
@@ -42,7 +51,29 @@ class OnboardingCoreConfig extends LitElement {
@state() private _skipCore = false;
@query("ha-country-picker") private _countryPicker?: HTMLElement;
@query("ha-form") private _form?: HaForm;
private _schema = memoizeOne((includeTimeZone: boolean): HaFormSchema[] => [
{
name: "country",
required: true,
selector: { country: null },
},
...(includeTimeZone
? ([
{
name: "time_zone",
required: true,
selector: { timezone: null },
},
] satisfies HaFormSchema[])
: []),
]);
private _computeLabel = (schema: HaFormSchema) =>
this.hass.localize(
`ui.panel.config.core.section.core.core_config.${schema.name}` as any
);
protected render(): TemplateResult {
if (!this._location) {
@@ -68,17 +99,17 @@ class OnboardingCoreConfig extends LitElement {
)}
</p>
<ha-country-picker
class="flex"
<ha-form
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.country"
) || "Country"}
required
.data=${{
country: this._country ?? "",
time_zone: this._timeZone,
}}
.schema=${this._schema(!this._timeZoneDetected)}
.computeLabel=${this._computeLabel}
.disabled=${this._working}
.value=${this._countryValue}
@value-changed=${this._handleCountryChanged}
></ha-country-picker>
@value-changed=${this._handleFormChanged}
></ha-form>
<div class="footer">
<ha-button @click=${this._save} .disabled=${this._working}>
@@ -99,12 +130,12 @@ class OnboardingCoreConfig extends LitElement {
});
}
private get _countryValue() {
return this._country || "";
}
private _handleCountryChanged(ev: ValueChangedEvent<string>) {
this._country = ev.detail.value;
private _handleFormChanged(ev: CustomEvent) {
const value = ev.detail.value as { country?: string; time_zone?: string };
this._country = value.country || undefined;
if (value.time_zone) {
this._timeZone = value.time_zone;
}
}
private async _locationChanged(ev) {
@@ -123,11 +154,12 @@ class OnboardingCoreConfig extends LitElement {
}
if (ev.detail.value.timezone) {
this._timeZone = ev.detail.value.timezone;
this._timeZoneDetected = true;
}
if (ev.detail.value.unit_system) {
this._unitSystem = ev.detail.value.unit_system;
}
if (this._country) {
if (this._country && this._timeZoneDetected) {
this._skipCore = true;
this._save(ev);
return;
@@ -145,11 +177,11 @@ class OnboardingCoreConfig extends LitElement {
fireEvent(this, "onboarding-progress", { increase: 0.5 });
await this.updateComplete;
setTimeout(() => this._countryPicker!.focus(), 100);
setTimeout(() => this._form?.focus(), 100);
}
private async _save(ev) {
if (!this._location || !this._country) {
if (!this._location || !this._country || !this._timeZone) {
return;
}
ev.preventDefault();
@@ -166,7 +198,7 @@ class OnboardingCoreConfig extends LitElement {
this._unitSystem || ["US", "MM", "LR"].includes(this._country)
? "us_customary"
: "metric",
time_zone: this._timeZone || "UTC",
time_zone: this._timeZone,
currency: this._currency || countryCurrency[this._country] || "EUR",
country: this._country,
language: this._language,
+3
View File
@@ -6,6 +6,7 @@ import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { IFRAME_SANDBOX } from "../../util/iframe";
import { navigate } from "../../common/navigate";
import { computeRouteTail } from "../../common/url/route";
import { nextRender } from "../../common/util/render-status";
@@ -136,6 +137,8 @@ class HaPanelApp extends LitElement {
})}
title=${this._addon.name}
src=${this._addon.ingress_url!}
.sandbox=${IFRAME_SANDBOX}
allow="microphone; camera; clipboard-write"
@load=${this._checkLoaded}
${ref(this._iframeRef)}
>
+14 -10
View File
@@ -190,16 +190,20 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
.label=${this.hass.localize("ui.common.refresh")}
@click=${this._handleRefresh}
></ha-icon-button>
${showPane && this.hass.user?.is_admin
? html`<ha-list slot="pane" multi}>${calendarItems}</ha-list>
<ha-list-item
graphic="icon"
slot="pane-footer"
@click=${this._addCalendar}
>
<ha-svg-icon .path=${mdiPlus} slot="graphic"></ha-svg-icon>
${this.hass.localize("ui.components.calendar.create_calendar")}
</ha-list-item>`
${showPane
? html`<ha-list slot="pane" multi>${calendarItems}</ha-list>${this
.hass.user?.is_admin
? html`<ha-list-item
graphic="icon"
slot="pane-footer"
@click=${this._addCalendar}
>
<ha-svg-icon .path=${mdiPlus} slot="graphic"></ha-svg-icon>
${this.hass.localize(
"ui.components.calendar.create_calendar"
)}
</ha-list-item>`
: nothing}`
: nothing}
<ha-full-calendar
add-fab
@@ -0,0 +1,225 @@
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { consume, type ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import {
mdiPalette,
mdiPlayCircleOutline,
mdiPlaylistCheck,
mdiRobotOutline,
mdiScriptTextOutline,
} from "@mdi/js";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-adaptive-dialog";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import {
areasContext,
internationalizationContext,
} from "../../../data/context";
import type { SceneEntities } from "../../../data/scene";
import { showSceneEditor } from "../../../data/scene";
import {
addToActionHandler,
type AddToActionKey,
} from "../../../dialogs/more-info/add-to";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { AreaAddToDialogParams } from "./show-dialog-area-add-to";
@customElement("dialog-area-add-to")
class DialogAreaAddTo extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: areasContext, subscribe: true })
private _areas!: ContextType<typeof areasContext>;
@state() private _params?: AreaAddToDialogParams;
@state() private _open = false;
public showDialog(params: AreaAddToDialogParams): void {
this._params = params;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-adaptive-dialog
.open=${this._open}
header-title=${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.title"
)}
@closed=${this._dialogClosed}
>
${this._renderOptions()}
</ha-adaptive-dialog>
`;
}
private _renderOptions() {
if (!this._params) {
return nothing;
}
const area = this._areas[this._params.areaId];
const areaName = computeAreaName(area) || this._params.areaId;
return html`
<h3 class="section-header">
${this._i18n.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
</h3>
<ha-list>
${this._renderActionItem(
"automation_trigger",
mdiRobotOutline,
"ui.dialogs.more_info_control.add_to.actions.automation_trigger",
areaName
)}
${this._renderActionItem(
"automation_condition",
mdiPlaylistCheck,
"ui.dialogs.more_info_control.add_to.actions.automation_condition",
areaName
)}
${this._renderActionItem(
"automation_action",
mdiPlayCircleOutline,
"ui.dialogs.more_info_control.add_to.actions.automation_action",
areaName
)}
</ha-list>
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.script.scripts_heading")}
</h3>
<ha-list>
${this._renderActionItem(
"script_action",
mdiScriptTextOutline,
"ui.dialogs.more_info_control.add_to.actions.script_action",
areaName
)}
</ha-list>
${this._renderSceneSection(areaName)}
`;
}
private _renderSceneSection(areaName: string) {
if (!this._params?.entityIds.length) {
return nothing;
}
return html`
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.scene.scenes_heading")}
</h3>
<ha-list>
<ha-list-item
graphic="icon"
@click=${this._handleCreateScene}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${mdiPalette}></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.scene",
{ target: areaName }
)}
</ha-list-item>
</ha-list>
`;
}
private _renderActionItem(
key: AddToActionKey,
path: string,
translationKey:
| "ui.dialogs.more_info_control.add_to.actions.automation_trigger"
| "ui.dialogs.more_info_control.add_to.actions.automation_condition"
| "ui.dialogs.more_info_control.add_to.actions.automation_action"
| "ui.dialogs.more_info_control.add_to.actions.script_action",
areaName: string
) {
return html`
<ha-list-item
graphic="icon"
data-type=${key}
@click=${this._handleAction}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${path}></ha-svg-icon>
${this._i18n.localize(translationKey, { target: areaName })}
</ha-list-item>
`;
}
private _handleAction(ev: Event) {
if (!this._params) {
return;
}
const key = (ev.currentTarget as HTMLElement).dataset
.type as AddToActionKey;
this.closeDialog();
addToActionHandler(key, { area_id: this._params.areaId });
}
private _handleCreateScene() {
if (!this._params) {
return;
}
const entities: SceneEntities = {};
for (const entityId of this._params.entityIds) {
entities[entityId] = "";
}
this.closeDialog();
showSceneEditor({ entities }, this._params.areaId);
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-adaptive-dialog {
--dialog-content-padding: 0;
}
.section-header {
padding: var(--ha-space-2) var(--ha-space-4) 0;
margin: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-area-add-to": DialogAreaAddTo;
}
}
+229 -21
View File
@@ -1,6 +1,21 @@
import { consume } from "@lit/context";
import { mdiDelete, mdiDotsVertical, mdiImagePlus, mdiPencil } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket/dist/types";
import {
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiImagePlus,
mdiPalette,
mdiPencil,
mdiPlus,
mdiRobot,
mdiScriptText,
mdiShape,
mdiTools,
} from "@mdi/js";
import type {
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -10,7 +25,7 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { goBack } from "../../../common/navigate";
import { goBack, navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { groupBy } from "../../../common/util/group-by";
@@ -18,11 +33,13 @@ import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-list";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import {
@@ -38,6 +55,7 @@ import {
computeEntityRegistryName,
sortEntityRegistryByName,
} from "../../../data/entity/entity_registry";
import { subscribeLabFeature } from "../../../data/labs";
import type { SceneEntity } from "../../../data/scene";
import type { ScriptEntity } from "../../../data/script";
import type { RelatedResult } from "../../../data/search";
@@ -46,9 +64,15 @@ import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
import "../../logbook/ha-logbook";
import {
loadAreaAddToDialog,
showAreaAddToDialog,
} from "./show-dialog-area-add-to";
import {
loadAreaRegistryDetailDialog,
showAreaRegistryDetailDialog,
@@ -59,8 +83,60 @@ declare interface NameAndEntity<EntityType extends HassEntity> {
entity: EntityType;
}
type AreaQuickLinkKey =
| "devices"
| "entities"
| "helpers"
| "automations"
| "scenes"
| "scripts";
const NAVIGATION_ACTIONS: {
value: string;
path: string;
icon: string;
countKey: AreaQuickLinkKey;
}[] = [
{
value: "navigate-devices",
path: "/config/devices/dashboard",
icon: mdiDevices,
countKey: "devices",
},
{
value: "navigate-entities",
path: "/config/entities",
icon: mdiShape,
countKey: "entities",
},
{
value: "navigate-helpers",
path: "/config/helpers",
icon: mdiTools,
countKey: "helpers",
},
{
value: "navigate-automations",
path: "/config/automation/dashboard",
icon: mdiRobot,
countKey: "automations",
},
{
value: "navigate-scenes",
path: "/config/scene/dashboard",
icon: mdiPalette,
countKey: "scenes",
},
{
value: "navigate-scripts",
path: "/config/script/dashboard",
icon: mdiScriptText,
countKey: "scripts",
},
] as const;
@customElement("ha-config-area-page")
class HaConfigAreaPage extends LitElement {
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public areaId!: string;
@@ -77,6 +153,8 @@ class HaConfigAreaPage extends LitElement {
@state() private _related?: RelatedResult;
@state() private _newTriggersConditions = false;
private _logbookTime = { recent: 86400 };
private _memberships = memoizeOne(
@@ -128,9 +206,35 @@ class HaConfigAreaPage extends LitElement {
.concat(memberships.indirectEntities.map((entry) => entry.entity_id))
);
private _getQuickLinkCounts = memoizeOne(
(
memberships: {
devices: DeviceRegistryEntry[];
entities: EntityRegistryEntry[];
indirectEntities: EntityRegistryEntry[];
},
related?: RelatedResult
) => {
const allEntityIds = this._allEntities(memberships);
const entityIds = related?.entity ?? allEntityIds;
return {
devices: related?.device?.length ?? memberships.devices.length,
entities: entityIds.length,
helpers: entityIds.filter((entityId) =>
isHelperDomain(computeDomain(entityId))
).length,
automations: related?.automation?.length ?? 0,
scenes: related?.scene?.length ?? 0,
scripts: related?.script?.length ?? 0,
};
}
);
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog();
loadAreaAddToDialog();
}
protected updated(changedProps: PropertyValues<this>) {
@@ -140,6 +244,23 @@ class HaConfigAreaPage extends LitElement {
}
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
if (!isComponentLoaded(this.hass!.config, "automation")) {
return [];
}
return [
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersConditions = feature.enabled;
}
),
];
}
protected render() {
if (!this.hass.areas || !this.hass.devices || !this.hass.entities) {
return nothing;
@@ -162,6 +283,10 @@ class HaConfigAreaPage extends LitElement {
this._entityReg
);
const { devices, entities } = memberships;
const quickLinkCounts = this._getQuickLinkCounts(
memberships,
this._related
);
// Pre-compute the entity and device names, so we can sort by them
if (devices) {
@@ -245,6 +370,21 @@ class HaConfigAreaPage extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
${NAVIGATION_ACTIONS.map(
(action) => html`
<ha-dropdown-item value=${action.value}>
<ha-svg-icon slot="icon" .path=${action.icon}></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.areas.quick_links.${action.countKey}`,
{ count: quickLinkCounts[action.countKey] }
)}
<ha-icon-next slot="details"></ha-icon-next>
</ha-dropdown-item>
`
)}
<wa-divider></wa-divider>
<ha-dropdown-item value="edit" .data=${area}>
<ha-svg-icon slot="icon" .path=${mdiPencil}> </ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.edit_settings")}
@@ -271,15 +411,41 @@ class HaConfigAreaPage extends LitElement {
class="img-edit-btn"
></ha-icon-button>
</div>`
: html`<ha-button
appearance="filled"
size="small"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
: nothing}
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon
.path=${mdiImagePlus}
slot="start"
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.areas.add_picture"
)}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon
slot="start"
.path=${mdiPlus}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.title"
)}
</ha-button>`
: nothing}
</div>`}
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
@@ -612,9 +778,39 @@ class HaConfigAreaPage extends LitElement {
this._related = await findRelated(this.hass, "area", this.areaId);
}
private _handleMenuAction(ev: HaDropdownSelectEvent) {
private _showAddToDialog() {
const area = this.hass.areas[this.areaId];
if (!area) {
return;
}
showAreaAddToDialog(this, {
areaId: area.area_id,
entityIds: this._areaEntityIds,
});
}
private get _areaEntityIds(): string[] {
const memberships = this._memberships(
this.areaId,
Object.values(this.hass.devices),
this._entityReg
);
return this._allEntities(memberships);
}
private _handleMenuAction(
ev: HaDropdownSelectEvent<string, AreaRegistryEntry>
) {
const action = ev.detail?.item?.value;
const entry = (ev.detail?.item as any)?.data as AreaRegistryEntry;
const entry = ev.detail?.item?.data;
const navAction = NAVIGATION_ACTIONS.find((a) => a.value === action);
if (navAction) {
navigate(`${navAction.path}?historyBack=1&area=${this.areaId}`);
return;
}
switch (action) {
case "edit":
this._openDialog(entry);
@@ -625,15 +821,19 @@ class HaConfigAreaPage extends LitElement {
}
}
private _showSettings(ev: MouseEvent) {
const entry: AreaRegistryEntry = (ev.currentTarget! as any).entry;
this._openDialog(entry);
private _showSettings(
ev: HASSDomCurrentTargetEvent<
HTMLButtonElement & { entry: AreaRegistryEntry }
>
) {
this._openDialog(ev.currentTarget.entry);
}
private _openEntity(ev) {
const entry: EntityRegistryEntry = (ev.currentTarget as any).entity;
private _openEntity(
ev: HASSDomCurrentTargetEvent<HTMLElement & { entity: EntityRegistryEntry }>
) {
showMoreInfoDialog(this, {
entityId: entry.entity_id,
entityId: ev.currentTarget.entity.entity_id,
});
}
@@ -675,6 +875,14 @@ class HaConfigAreaPage extends LitElement {
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
.action-buttons {
display: flex;
gap: var(--ha-space-2);
flex-wrap: wrap;
justify-content: space-around;
}
img {
border-radius: var(
--ha-card-border-radius,
@@ -0,0 +1,19 @@
import { fireEvent } from "../../../common/dom/fire_event";
export interface AreaAddToDialogParams {
areaId: string;
entityIds: string[];
}
export const loadAreaAddToDialog = () => import("./dialog-area-add-to");
export const showAreaAddToDialog = (
element: HTMLElement,
params: AreaAddToDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-area-add-to",
dialogImport: loadAreaAddToDialog,
dialogParams: params,
});
};
@@ -143,6 +143,7 @@ export default class HaAutomationAction extends AutomationSortableListMixin<Acti
const addActionTargetFromQuery = getAddAutomationElementTargetFromQuery(
this.hass.states,
this.hass.devices,
this.hass.areas,
"action"
);
@@ -130,6 +130,7 @@ import "./add-automation-element/ha-automation-add-items";
import "./add-automation-element/ha-automation-add-search";
import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog";
import {
ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
@@ -311,6 +312,7 @@ class DialogAddAutomationElement
const queryTarget = getAddAutomationElementTargetFromQuery(
this.hass.states,
this.hass.devices,
this.hass.areas,
params.type
);
this._openedFromQuery = !!queryTarget;
@@ -320,6 +322,7 @@ class DialogAddAutomationElement
searchParams.delete(ADD_AUTOMATION_ELEMENT_QUERY_PARAM);
searchParams.delete(ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM);
searchParams.delete(ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM);
searchParams.delete(ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM);
mainWindow.history.replaceState(
mainWindow.history.state,
"",
@@ -1,6 +1,7 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import {
mdiAlert,
mdiAppleKeyboardCommand,
mdiArrowDown,
mdiArrowUp,
@@ -587,7 +588,9 @@ export default class HaAutomationConditionRow extends LitElement {
private _getTriggerInfos = memoizeOne(getTriggerInfos);
private _renderTriggerConditionDescription(condition: TriggerCondition) {
const ids = ensureArray(condition.id ?? []).filter((id) => id !== "");
const ids = ensureArray(condition.id ?? [])
.map((id) => (typeof id === "string" ? id : String(id)))
.filter((id) => id !== "");
const prefix = capitalizeFirstLetter(
this.hass
.localize(
@@ -605,44 +608,77 @@ export default class HaAutomationConditionRow extends LitElement {
</div>`;
}
const triggers = ensureArray(this._automationConfig?.triggers || []);
const triggerInfos = this._getTriggerInfos(
triggers,
ensureArray(this._automationConfig?.triggers || []),
this.hass,
this._entityReg
);
const infoById = new Map(triggerInfos.map((info) => [info.id, info]));
return html`${prefix}
${ids
.filter((id) => infoById.get(id))
.map((id) => {
const info = infoById.get(id)!;
${ids.map((id) => {
const info = infoById.get(id);
if (!info) {
return html`<div class="trigger">
<ha-trigger-id-chip id=${`trigger-${id}`} warning .triggerId=${id}>
<ha-svg-icon slot="start" .path=${mdiAlert}></ha-svg-icon>
</ha-trigger-id-chip>
${ids.length < 4
? html`<span
>${this.hass.localize("state.default.unavailable")}</span
>`
: nothing}
const triggerIcon = html`<ha-trigger-icon
.slot=${ids.length < 4 ? "start" : ""}
.hass=${this.hass}
.trigger=${info.triggerType}
></ha-trigger-icon>`;
return html`
<div class="trigger">
${ids.length < 4 ? triggerIcon : nothing}
<ha-trigger-id-chip id=${`trigger-${id}`} .triggerId=${id}>
</ha-trigger-id-chip>
${ids.length < 4
? html`<span>${info.label}</span>`
: html`<ha-tooltip .for=${`trigger-${id}`}></ha-tooltip>`}
<ha-tooltip .for=${`trigger-${id}`}>
${ids.length >= 4
? html`<ha-tooltip .for=${`trigger-${id}`}>
${ids.length >= 4
? html`<div>${triggerIcon}${info.label}</div>`
: nothing}
</ha-tooltip>`
? html`<div>
${this.hass.localize("state.default.unavailable")}
</div>`
: nothing}
</div>
`;
})}`;
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.unavailable_info",
{ id: html`<b>${id}</b>` }
)}
</ha-tooltip>
</div>`;
}
const triggerIcon = html`<ha-trigger-icon
.slot=${ids.length < 4 ? "start" : ""}
.hass=${this.hass}
.trigger=${info.triggerType}
></ha-trigger-icon>`;
const isDuplicateId = info.count > 1;
return html`
<div class="trigger">
${ids.length < 4 ? triggerIcon : nothing}
<ha-trigger-id-chip
id=${`trigger-${id}`}
.triggerId=${id}
.warning=${isDuplicateId}
>
${isDuplicateId
? html`<ha-svg-icon slot="start" .path=${mdiAlert}></ha-svg-icon>`
: nothing}
</ha-trigger-id-chip>
${ids.length < 4
? html`<span>${info.label}</span>`
: html`<ha-tooltip .for=${`trigger-${id}`}></ha-tooltip>`}
${isDuplicateId || ids.length >= 4
? html`<ha-tooltip .for=${`trigger-${id}`}>
${ids.length >= 4
? html`<div>${triggerIcon}${info.label}</div>`
: nothing}
${isDuplicateId
? this.hass.localize(
"ui.panel.config.automation.editor.triggers.duplicate_id_warning"
)
: nothing}
</ha-tooltip>`
: nothing}
</div>
`;
})}`;
}
private _renderTargets = memoizeOne(
@@ -128,6 +128,7 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
const addConditionTargetFromQuery = getAddAutomationElementTargetFromQuery(
this.hass.states,
this.hass.devices,
this.hass.areas,
"condition"
);
@@ -1,5 +1,6 @@
import { consume } from "@lit/context";
import { css, html, LitElement } from "lit";
import { mdiAlert } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../../../common/array/ensure-array";
@@ -62,8 +63,8 @@ export class HaTriggerCondition extends LitElement {
}
protected render() {
const selectedIds: (string | number)[] = ensureArray(
this.condition.id || []
const selectedIds = ensureArray(this.condition.id || []).filter(
(id): id is string => typeof id === "string" && id !== ""
);
const triggerInfos = this._triggerInfos(
@@ -88,11 +89,43 @@ export class HaTriggerCondition extends LitElement {
`;
}
private _renderOptions(
selectedIds: (string | number)[],
triggerInfos: TriggerInfo[]
) {
private _renderOptions(selectedIds: string[], triggerInfos: TriggerInfo[]) {
const unknownTriggerIds = selectedIds.filter(
(id) => !triggerInfos.some((info) => info.id === id)
);
const alertIcon = html`<ha-svg-icon
slot="start"
.path=${mdiAlert}
></ha-svg-icon>`;
return html`
${unknownTriggerIds.map(
(id) => html`
<ha-list-item-option
.value=${id}
.selected=${true}
appearance="checkbox"
>
<div class="option" slot="headline">
<ha-trigger-id-chip
id=${`trigger-${id}`}
warning
.triggerId=${id}
>
${alertIcon}
</ha-trigger-id-chip>
${this.hass.localize("state.default.unavailable")}
<ha-tooltip .for=${`trigger-${id}`}>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.unavailable_info",
{ id: html`<b>${id}</b>` }
)}
</ha-tooltip>
</div>
</ha-list-item-option>
`
)}
${triggerInfos.map(
(info) => html`
<ha-list-item-option
@@ -103,10 +136,18 @@ export class HaTriggerCondition extends LitElement {
<div class="option" slot="headline">
<ha-trigger-id-chip
id=${`trigger-${info.id}`}
.warning=${info.count > 1}
.triggerId=${info.id}
>
${info.count > 1 ? alertIcon : nothing}
</ha-trigger-id-chip>
${info.label}
${info.label}${info.count > 1
? html`<ha-tooltip .for=${`trigger-${info.id}`}
>${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.duplicated_info"
)}</ha-tooltip
>`
: nothing}
</div>
</ha-list-item-option>
`
@@ -768,10 +768,19 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
const hasUrlFilter =
this._searchParms.has("blueprint") || this._searchParms.has("label");
this._searchParms.has("area") ||
this._searchParms.has("blueprint") ||
this._searchParms.has("device") ||
this._searchParms.has("label");
if (!hasUrlFilter) {
this._filters = this._storageFilters;
}
if (this._searchParms.has("area")) {
this._filterArea();
}
if (this._searchParms.has("device")) {
this._filterDevice();
}
if (this._searchParms.has("blueprint")) {
this._filterBlueprint();
}
@@ -871,6 +880,38 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
this._filteredEntityIds = filteredEntityIds;
}
private _filterArea() {
const area = this._searchParms.get("area");
if (!area) {
return;
}
this._fromUrl = true;
this._filters = {
...this._filters,
"ha-filter-floor-areas": {
value: { areas: [area] },
items: undefined,
},
};
this._applyFilters();
}
private _filterDevice() {
const device = this._searchParms.get("device");
if (!device) {
return;
}
this._fromUrl = true;
this._filters = {
...this._filters,
"ha-filter-devices": {
value: [device],
items: undefined,
},
};
this._applyFilters();
}
private _filterLabel() {
const label = this._searchParms.get("label");
if (!label) {
@@ -8,6 +8,7 @@ export const PASTE_VALUE = "__paste__";
export const ADD_AUTOMATION_ELEMENT_QUERY_PARAM = "add_automation_element";
export const ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM = "target_entity_id";
export const ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM = "target_device_id";
export const ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM = "target_area_id";
/** Parameters for the add automation element dialog. */
export interface AddAutomationElementDialogParams {
@@ -21,6 +22,7 @@ export interface AddAutomationElementDialogParams {
export const getAddAutomationElementTargetFromQuery = (
states: HomeAssistant["states"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
type: AddAutomationElementDialogParams["type"]
): SingleHassServiceTarget | undefined => {
const params = new URLSearchParams(window.location.search);
@@ -39,6 +41,11 @@ export const getAddAutomationElementTargetFromQuery = (
return { device_id: deviceId };
}
const areaId = params.get(ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM);
if (areaId && areas[areaId]) {
return { area_id: areaId };
}
return undefined;
};
@@ -14,15 +14,13 @@ import {
mdiStopCircleOutline,
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import { fireEvent } from "../../../../common/dom/fire_event";
import { handleStructError } from "../../../../common/structs/handle-errors";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import type {
LegacyTrigger,
Trigger,
@@ -37,7 +35,6 @@ import {
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import "../ha-automation-comment";
import "../ha-trigger-id-chip";
import { overflowStyles, sidebarEditorStyles } from "../styles";
import "../trigger/ha-automation-trigger-editor";
import type HaAutomationTriggerEditor from "../trigger/ha-automation-trigger-editor";
@@ -60,6 +57,8 @@ export default class HaAutomationSidebarTrigger extends LitElement {
@property({ type: Number, attribute: "sidebar-key" })
public sidebarKey?: number;
@state() private _requestShowId = false;
@state() private _warnings?: string[];
@query(".sidebar-editor")
@@ -67,6 +66,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("config")) {
this._requestShowId = false;
this._warnings = undefined;
if (this.config) {
this.yamlMode = this.config.yamlMode;
@@ -111,21 +111,11 @@ export default class HaAutomationSidebarTrigger extends LitElement {
@wa-select=${this._handleDropdownSelect}
>
<span slot="title">${title}</span>
<div slot="subtitle" class="subtitle">
${subtitle}
${"id" in this.config.config
? html`<ha-trigger-id-chip
id="trigger-id-chip"
.triggerId=${(
this.config.config as Exclude<Trigger, TriggerList>
).id}
>
</ha-trigger-id-chip>`
: nothing}
${rowDisabled
? `(${this.hass.localize("ui.panel.config.automation.editor.actions.disabled")})`
: nothing}
</div>
<span slot="subtitle"
>${subtitle}${rowDisabled
? ` (${this.hass.localize("ui.panel.config.automation.editor.actions.disabled")})`
: ""}</span
>
<ha-dropdown-item
slot="menu-items"
value="rename"
@@ -157,16 +147,18 @@ export default class HaAutomationSidebarTrigger extends LitElement {
</div>
</ha-dropdown-item>`
: nothing}
${type !== "list"
? html` <ha-dropdown-item
${!this.yamlMode &&
!("id" in this.config.config) &&
!this._requestShowId
? html`<ha-dropdown-item
slot="menu-items"
value="edit_id"
value="show_id"
.disabled=${this.disabled || type === "list"}
>
<ha-svg-icon slot="icon" .path=${mdiIdentifier}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.triggers.${"id" in this.config.config ? "edit" : "add"}_id`
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
@@ -343,6 +335,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
@value-changed=${this._valueChangedSidebar}
@yaml-changed=${this._yamlChangedSidebar}
.uiSupported=${this.config.uiSupported}
.showId=${this._requestShowId}
.yamlMode=${this.yamlMode}
.disabled=${this.disabled}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@@ -393,6 +386,10 @@ export default class HaAutomationSidebarTrigger extends LitElement {
fireEvent(this, "toggle-yaml-mode");
};
private _showTriggerId = () => {
this._requestShowId = true;
};
private _handleDropdownSelect(ev: HaDropdownSelectEvent) {
const action = ev.detail?.item?.value;
@@ -407,8 +404,8 @@ export default class HaAutomationSidebarTrigger extends LitElement {
case "edit_comment":
this.config.editComment();
break;
case "edit_id":
this.config.editId();
case "show_id":
this._showTriggerId();
break;
case "duplicate":
this.config.duplicate();
@@ -434,16 +431,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
}
}
static styles = [
sidebarEditorStyles,
overflowStyles,
css`
.subtitle {
display: flex;
gap: var(--ha-space-1);
}
`,
];
static styles = [sidebarEditorStyles, overflowStyles];
}
declare global {
@@ -1,119 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
import { internationalizationContext } from "../../../../data/context";
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { EditTriggerIdDialogParams } from "./show-edit-trigger-id";
@customElement("ha-automation-edit-trigger-id-dialog")
class HaAutomationEditTriggerIdDialog extends DialogMixin<EditTriggerIdDialogParams>(
LitElement
) {
@state() private _newId = "";
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n!: ContextType<typeof internationalizationContext>;
connectedCallback() {
super.connectedCallback();
this._setInitialId();
}
private _setInitialId() {
if (this.params?.id) {
this._newId = this.params.id;
}
}
protected render() {
if (!this.params) {
return nothing;
}
const title = this._i18n.localize(
`ui.panel.config.automation.editor.triggers.${
this.params.id ? "edit_id" : "add_id"
}`
);
return html`
<ha-dialog open header-title=${title}>
<ha-input
autofocus
.label=${this._i18n.localize(
"ui.panel.config.automation.editor.triggers.id"
)}
.value=${this._newId}
@input=${this._idChanged}
@keydown=${this._handleKeyDown}
></ha-input>
<ha-alert alert-type="info">
${this._i18n.localize(
"ui.panel.config.automation.editor.triggers.id_description"
)}
</ha-alert>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this._i18n.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._save}>
${this._i18n.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _idChanged(ev: InputEvent) {
const target = ev.target as HaInput;
this._newId = target.value ?? "";
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
ev.preventDefault();
this._save();
}
}
private _save(): void {
const trimmed = this._newId.trim();
this.params!.onUpdate(trimmed || undefined);
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-input {
width: 100%;
}
ha-alert {
display: block;
margin-top: var(--ha-space-6);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-edit-trigger-id-dialog": HaAutomationEditTriggerIdDialog;
}
}
@@ -6,6 +6,7 @@ import { dynamicElement } from "../../../../common/dom/dynamic-element-directive
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import "../../../../components/input/ha-input";
import type { Trigger } from "../../../../data/automation";
import { migrateAutomationTrigger } from "../../../../data/automation";
import type { TriggerDescription } from "../../../../data/trigger";
@@ -30,6 +31,8 @@ export default class HaAutomationTriggerEditor extends LitElement {
@property({ type: Boolean, attribute: "sidebar" }) public inSidebar = false;
@property({ type: Boolean, attribute: "show-id" }) public showId = false;
@property({ attribute: false }) public description?: TriggerDescription;
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
@@ -39,6 +42,8 @@ export default class HaAutomationTriggerEditor extends LitElement {
const yamlMode = this.yamlMode || !this.uiSupported;
const showId = "id" in this.trigger || this.showId;
return html`
<div
class=${classMap({
@@ -72,6 +77,18 @@ export default class HaAutomationTriggerEditor extends LitElement {
></ha-yaml-editor>
`
: html`
${showId && !isTriggerList(this.trigger)
? html`
<ha-input
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.id"
)}
.value=${this.trigger.id || ""}
.disabled=${this.disabled}
@change=${this._idChanged}
></ha-input>
`
: nothing}
<div @value-changed=${this._onUiChanged}>
${this.description
? html`<ha-automation-trigger-platform
@@ -91,6 +108,24 @@ export default class HaAutomationTriggerEditor extends LitElement {
`;
}
private _idChanged(ev: CustomEvent) {
if (isTriggerList(this.trigger)) return;
const newId = (ev.target as any).value;
if (newId === (this.trigger.id ?? "")) {
return;
}
const value = { ...this.trigger };
if (!newId) {
delete value.id;
} else {
value.id = newId;
}
fireEvent(this, "value-changed", {
value,
});
}
private _onYamlChange(ev: CustomEvent) {
ev.stopPropagation();
if (!ev.detail.isValid) {
@@ -125,6 +160,9 @@ export default class HaAutomationTriggerEditor extends LitElement {
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
}
ha-input {
margin-bottom: var(--ha-space-3);
}
`,
];
}
@@ -1,6 +1,7 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import {
mdiAlert,
mdiAppleKeyboardCommand,
mdiArrowDown,
mdiArrowUp,
@@ -11,7 +12,6 @@ import {
mdiContentPaste,
mdiDelete,
mdiDotsVertical,
mdiIdentifier,
mdiPlayCircleOutline,
mdiPlaylistEdit,
mdiPlusCircleMultipleOutline,
@@ -29,6 +29,7 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../../common/array/ensure-array";
import { storage } from "../../../../common/decorators/storage";
import { transform } from "../../../../common/decorators/transform";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
@@ -53,12 +54,17 @@ import "../../../../components/ha-tooltip";
import { TRIGGER_ICONS } from "../../../../components/ha-trigger-icon";
import type {
AutomationClipboard,
AutomationConfig,
PlatformTrigger,
Trigger,
TriggerList,
TriggerSidebarConfig,
} from "../../../../data/automation";
import { isTrigger, subscribeTrigger } from "../../../../data/automation";
import {
automationConfigContext,
isTrigger,
subscribeTrigger,
} from "../../../../data/automation";
import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
@@ -80,7 +86,6 @@ import { overflowStyles, rowStyles } from "../styles";
import "../target/ha-automation-row-targets";
import "./ha-automation-trigger-editor";
import type HaAutomationTriggerEditor from "./ha-automation-trigger-editor";
import { showEditTriggerIdDialog } from "./show-edit-trigger-id";
import "./types/ha-automation-trigger-calendar";
import "./types/ha-automation-trigger-conversation";
import "./types/ha-automation-trigger-device";
@@ -182,6 +187,30 @@ export default class HaAutomationTriggerRow extends LitElement {
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@state()
@consume({ context: automationConfigContext, subscribe: true })
@transform<AutomationConfig, boolean>({
transformer: function (this: HaAutomationTriggerRow, value) {
if (
!this.trigger ||
isTriggerList(this.trigger) ||
!(this.trigger as Exclude<Trigger, TriggerList>).id
) {
return false;
}
const triggerId = (this.trigger as Exclude<Trigger, TriggerList>).id;
// count how often this trigger id is used in the automation, if more than once, show warning
return (
ensureArray(value?.triggers || []).filter(
(trigger) =>
(trigger as Exclude<Trigger, TriggerList>).id === triggerId
).length > 1
);
},
watch: ["trigger"],
})
private _duplicateTriggerId = false;
get selected() {
return this._selected;
}
@@ -250,11 +279,25 @@ export default class HaAutomationTriggerRow extends LitElement {
<h3 slot="header">
${type !== "list" && (this.trigger as Exclude<Trigger, TriggerList>).id
? html`<ha-trigger-id-chip
id="trigger-id-chip"
slot="leading-icon"
.triggerId=${(this.trigger as Exclude<Trigger, TriggerList>).id}
>
</ha-trigger-id-chip>`
id="trigger-id-chip"
.warning=${this._duplicateTriggerId}
slot="leading-icon"
.triggerId=${(this.trigger as Exclude<Trigger, TriggerList>).id}
>
${this._duplicateTriggerId
? html`<ha-svg-icon
slot="start"
.path=${mdiAlert}
></ha-svg-icon>`
: nothing}
</ha-trigger-id-chip>
${this._duplicateTriggerId
? html`<ha-tooltip for="trigger-id-chip">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.duplicate_id_warning"
)}
</ha-tooltip>`
: nothing} `
: nothing}
${describeTrigger(this.trigger, this.hass, this._entityReg)}
${target !== undefined || (descriptionHasTarget && !this._isNew)
@@ -333,17 +376,6 @@ export default class HaAutomationTriggerRow extends LitElement {
)}
</ha-dropdown-item>`
: nothing}
${type !== "list"
? html`<ha-dropdown-item value="edit_id" .disabled=${this.disabled}>
<ha-svg-icon slot="icon" .path=${mdiIdentifier}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.triggers.${"id" in this.trigger ? "edit" : "add"}_id`
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-dropdown-item>`
: nothing}
<wa-divider></wa-divider>
<ha-dropdown-item value="duplicate" .disabled=${this.disabled}>
@@ -717,7 +749,6 @@ export default class HaAutomationTriggerRow extends LitElement {
this.focus();
}
},
editId: this._editTriggerId,
rename: () => {
this._renameTrigger();
},
@@ -829,34 +860,6 @@ export default class HaAutomationTriggerRow extends LitElement {
});
}
private _editTriggerId = () => {
if (isTriggerList(this.trigger)) {
return;
}
const trigger = this.trigger as Exclude<Trigger, TriggerList>;
showEditTriggerIdDialog(this, {
id: trigger.id,
onUpdate: (newId) => {
if (newId === (trigger.id ?? undefined)) {
return;
}
const value: Trigger = { ...trigger };
if (newId) {
value.id = newId;
} else {
delete value.id;
}
fireEvent(this, "value-changed", {
value,
});
if (this._selected && this.optionsInSidebar) {
this.openSidebar(value); // refresh sidebar
}
},
});
};
private _renameTrigger = async (): Promise<void> => {
if (isTriggerList(this.trigger)) return;
const alias = await showPromptDialog(this, {
@@ -1041,9 +1044,6 @@ export default class HaAutomationTriggerRow extends LitElement {
case "edit_comment":
this._editCommentTrigger();
break;
case "edit_id":
this._editTriggerId();
break;
case "duplicate":
this._duplicateTrigger();
break;
@@ -22,7 +22,12 @@ import {
} from "../../../../data/automation";
import { subscribeLabFeature } from "../../../../data/labs";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
import {
getNextNumericTriggerId,
getUniqueTriggerId,
isTriggerList,
subscribeTriggers,
} from "../../../../data/trigger";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
import { AutomationSortableListMixin } from "../ha-automation-sortable-list-mixin";
@@ -71,6 +76,11 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
protected override pasteItem(ev: CustomEvent) {
if (this.root && ev.detail.item) {
const pasted = deepClone(ev.detail.item) as Trigger;
if (!isTriggerList(pasted)) {
pasted.id = pasted.id
? getUniqueTriggerId(pasted.id, this.triggers)
: getNextNumericTriggerId(this.triggers);
}
ev.detail.item = pasted;
}
super.pasteItem(ev);
@@ -81,6 +91,11 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
const incoming = ensureArray(ev.detail.value) as Trigger[];
if (this.root && incoming.length === 1) {
const trigger = deepClone(incoming[0]);
if (!isTriggerList(trigger)) {
trigger.id = trigger.id
? getUniqueTriggerId(trigger.id, this.triggers)
: getNextNumericTriggerId(this.triggers);
}
ev.detail.value = trigger;
}
super.insertAfter(ev);
@@ -90,6 +105,11 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
if (this.root) {
const index = (ev.target as any).index;
const duplicated = deepClone(this.triggers[index]);
if (!isTriggerList(duplicated)) {
duplicated.id = duplicated.id
? getUniqueTriggerId(duplicated.id, this.triggers)
: getNextNumericTriggerId(this.triggers);
}
fireEvent(this, "value-changed", {
// @ts-expect-error Requires library bump to ES2023
value: this.triggers.toSpliced(index + 1, 0, duplicated),
@@ -247,6 +267,11 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
let triggers: Trigger[];
if (value === PASTE_VALUE) {
const pasted = deepClone(this._clipboard!.trigger!);
if (this.root && !isTriggerList(pasted)) {
pasted.id = pasted.id
? getUniqueTriggerId(pasted.id, this.triggers)
: getNextNumericTriggerId(this.triggers);
}
triggers = this.triggers.concat(pasted);
} else {
let newTrigger: Trigger;
@@ -267,6 +292,9 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
...(target?.entity_id ? { entity_id: target.entity_id } : {}),
};
}
if (this.root && !isTriggerList(newTrigger)) {
newTrigger.id = getNextNumericTriggerId(this.triggers);
}
triggers = this.triggers.concat(newTrigger);
}
this.focusLastItemOnChange = true;
@@ -283,6 +311,7 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
const addTriggerTargetFromQuery = getAddAutomationElementTargetFromQuery(
this.hass.states,
this.hass.devices,
this.hass.areas,
"trigger"
);
@@ -1,22 +0,0 @@
import type { LitElement } from "lit";
import { fireEvent } from "../../../../common/dom/fire_event";
export const loadEditTriggerIdDialog = () =>
import("./ha-automation-edit-trigger-id-dialog");
export interface EditTriggerIdDialogParams {
id?: string;
onUpdate: (newId: string | undefined) => void;
}
export const showEditTriggerIdDialog = (
element: LitElement,
dialogParams: EditTriggerIdDialogParams
): void => {
fireEvent(element, "show-dialog", {
parentElement: element,
dialogTag: "ha-automation-edit-trigger-id-dialog",
dialogImport: loadEditTriggerIdDialog,
dialogParams,
});
};
@@ -1,5 +1,5 @@
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { firstWeekdayIndex } from "../../../../../common/datetime/first_weekday";
@@ -225,6 +225,13 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
`ui.panel.config.automation.editor.triggers.type.time.${schema.name}`
);
};
static styles = css`
:host {
display: block;
margin-bottom: var(--ha-space-3);
}
`;
}
declare global {
@@ -1,11 +1,11 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-select";
import "../../../../../components/input/ha-input";
import type { HaInput } from "../../../../../components/input/ha-input";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/list/ha-list-base";
import type { SupervisorUpdateConfig } from "../../../../../data/supervisor/update";
import type { HomeAssistant, ValueChangedEvent } from "../../../../../types";
@@ -20,8 +20,8 @@ class HaBackupConfigAddon extends LitElement {
protected render() {
return html`
<ha-md-list>
<ha-md-list-item>
<ha-list-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
`ui.panel.config.backup.schedule.update_preference.label`
@@ -52,8 +52,8 @@ class HaBackupConfigAddon extends LitElement {
},
]}
></ha-select>
</ha-md-list-item>
<ha-md-list-item>
</ha-list-item-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(`ui.panel.config.backup.schedule.retention`)}
</span>
@@ -77,8 +77,8 @@ class HaBackupConfigAddon extends LitElement {
)}
</span>
</ha-input>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-base>
</ha-list-base>
`;
}
@@ -106,13 +106,12 @@ class HaBackupConfigAddon extends LitElement {
}
static styles = css`
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
ha-list-base {
--ha-row-item-padding-inline: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: wrap;
}
ha-select {
min-width: 210px;
@@ -7,10 +7,10 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-switch";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/list/ha-list-base";
import type {
BackupAgent,
BackupAgentsConfig,
@@ -181,7 +181,7 @@ class HaBackupConfigAgents extends LitElement {
return html`
${allAgents.length > 0
? html`
<ha-md-list>
<ha-list-base>
${availableAgents.map((agent) => {
const agentId = agent.agent_id;
const name = computeBackupAgentName(
@@ -196,7 +196,7 @@ class HaBackupConfigAgents extends LitElement {
!this.cloudStatus.active_subscription;
return html`
<ha-md-list-item>
<ha-list-item-base>
${this._renderAgentIcon(agentId)}
<div slot="headline" class="name">${name}</div>
${description
@@ -220,7 +220,7 @@ class HaBackupConfigAgents extends LitElement {
!this._value.includes(agentId)}
@change=${this._agentToggled}
></ha-switch>
</ha-md-list-item>
</ha-list-item-base>
`;
})}
${unavailableAgents.length > 0 && this.showSettings
@@ -239,7 +239,7 @@ class HaBackupConfigAgents extends LitElement {
);
return html`
<ha-md-list-item>
<ha-list-item-base>
${this._renderAgentIcon(agentId)}
<div slot="headline" class="name">${name}</div>
<ha-icon-button
@@ -248,12 +248,12 @@ class HaBackupConfigAgents extends LitElement {
path=${mdiDelete}
@click=${this._deleteAgent}
></ha-icon-button>
</ha-md-list-item>
</ha-list-item-base>
`;
})}
`
: nothing}
</ha-md-list>
</ha-list-base>
`
: html`
<p>
@@ -293,30 +293,25 @@ class HaBackupConfigAgents extends LitElement {
}
static styles = css`
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
ha-list-base {
--ha-row-item-padding-inline: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
}
ha-md-list-item .name {
ha-list-item-base .name {
word-break: break-word;
}
ha-md-list-item img {
ha-list-item-base img {
width: 48px;
}
ha-md-list-item ha-svg-icon[slot="start"] {
ha-list-item-base ha-svg-icon[slot="start"] {
--mdc-icon-size: 48px;
color: var(--primary-text-color);
}
ha-md-list-item [slot="supporting-text"] {
display: flex;
align-items: center;
flex-direction: row;
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: wrap;
}
ha-list-item-base::part(end) {
gap: var(--ha-space-2);
line-height: var(--ha-line-height-condensed);
}
.unencrypted-warning {
display: flex;
@@ -338,7 +333,7 @@ class HaBackupConfigAgents extends LitElement {
.separator {
display: none;
}
ha-md-list-item [slot="supporting-text"] {
ha-list-item-base [slot="supporting-text"] {
display: flex;
align-items: flex-start;
flex-direction: column;
@@ -15,13 +15,13 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-expansion-panel";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-select";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-switch";
import type { HaSwitch } from "../../../../../components/ha-switch";
import "../../../../../components/ha-tooltip";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/list/ha-list-base";
import { fetchHassioAddonsInfo } from "../../../../../data/hassio/addon";
import type { HostDisksUsage } from "../../../../../data/hassio/host";
import { fetchHostDisksUsage } from "../../../../../data/hassio/host";
@@ -238,8 +238,8 @@ class HaBackupConfigData extends LitElement {
return html`
${this._renderSizeEstimate()}
<ha-md-list>
<ha-md-list-item>
<ha-list-base>
<ha-list-item-base>
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
<span slot="headline">
${this.hass.localize("ui.panel.config.backup.data.ha_settings")}
@@ -260,10 +260,10 @@ class HaBackupConfigData extends LitElement {
.checked=${data.homeassistant}
.disabled=${this.forceHomeAssistant || data.database}
></ha-switch>
</ha-md-list-item>
</ha-list-item-base>
${this._showDbOption
? html`<ha-md-list-item>
? html`<ha-list-item-base>
<ha-svg-icon slot="start" .path=${mdiChartBox}></ha-svg-icon>
<span slot="headline">
${this.hass.localize("ui.panel.config.backup.data.history")}
@@ -279,11 +279,11 @@ class HaBackupConfigData extends LitElement {
@change=${this._switchChanged}
.checked=${data.database}
></ha-switch>
</ha-md-list-item>`
</ha-list-item-base>`
: nothing}
${isHassio
? html`
<ha-md-list-item>
<ha-list-item-base>
<ha-svg-icon
slot="start"
.path=${mdiPlayBoxMultiple}
@@ -302,9 +302,9 @@ class HaBackupConfigData extends LitElement {
@change=${this._switchChanged}
.checked=${data.media}
></ha-switch>
</ha-md-list-item>
</ha-list-item-base>
<ha-md-list-item>
<ha-list-item-base>
<ha-svg-icon slot="start" .path=${mdiFolder}></ha-svg-icon>
<span slot="headline">
${this.hass.localize(
@@ -322,11 +322,11 @@ class HaBackupConfigData extends LitElement {
@change=${this._switchChanged}
.checked=${data.share}
></ha-switch>
</ha-md-list-item>
</ha-list-item-base>
${this._hasLocalAddons(this._addons)
? html`
<ha-md-list-item>
<ha-list-item-base>
<ha-svg-icon
slot="start"
.path=${mdiFolder}
@@ -347,12 +347,12 @@ class HaBackupConfigData extends LitElement {
@change=${this._switchChanged}
.checked=${data.local_addons}
></ha-switch>
</ha-md-list-item>
</ha-list-item-base>
`
: nothing}
${this._addons.length
? html`
<ha-md-list-item>
<ha-list-item-base>
<ha-svg-icon
slot="start"
.path=${mdiPuzzle}
@@ -392,12 +392,12 @@ class HaBackupConfigData extends LitElement {
},
]}
></ha-select>
</ha-md-list-item>
</ha-list-item-base>
`
: nothing}
`
: nothing}
</ha-md-list>
</ha-list-base>
${isHassio && this._showAddons && this._addons.length
? html`
<ha-expansion-panel
@@ -551,13 +551,15 @@ class HaBackupConfigData extends LitElement {
ha-spinner {
--ha-spinner-size: 24px;
}
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
ha-list-base {
--ha-row-item-padding-inline: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: wrap;
}
ha-list-item-base::part(start) {
color: var(--ha-color-text-secondary);
}
ha-select {
min-width: 210px;
@@ -2,8 +2,8 @@ import { mdiDownload } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/list/ha-list-base";
import type { HomeAssistant } from "../../../../../types";
import { showChangeBackupEncryptionKeyDialog } from "../../dialogs/show-dialog-change-backup-encryption-key";
import { showSetBackupEncryptionKeyDialog } from "../../dialogs/show-dialog-set-backup-encryption-key";
@@ -24,8 +24,8 @@ class HaBackupConfigEncryptionKey extends LitElement {
protected render() {
if (this._value) {
return html`
<ha-md-list>
<ha-md-list-item>
<ha-list-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
@@ -47,8 +47,8 @@ class HaBackupConfigEncryptionKey extends LitElement {
"ui.panel.config.backup.encryption_key.download_emergency_kit_action"
)}
</ha-button>
</ha-md-list-item>
<ha-md-list-item>
</ha-list-item-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.show_encryption_key"
@@ -69,8 +69,8 @@ class HaBackupConfigEncryptionKey extends LitElement {
"ui.panel.config.backup.encryption_key.show_encryption_key_action"
)}
</ha-button>
</ha-md-list-item>
<ha-md-list-item>
</ha-list-item-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.change_encryption_key"
@@ -92,14 +92,14 @@ class HaBackupConfigEncryptionKey extends LitElement {
"ui.panel.config.backup.encryption_key.change_encryption_key_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-base>
</ha-list-base>
`;
}
return html`
<ha-md-list>
<ha-md-list-item>
<ha-list-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.set_encryption_key"
@@ -115,8 +115,8 @@ class HaBackupConfigEncryptionKey extends LitElement {
"ui.panel.config.backup.encryption_key.set_encryption_key_action"
)}</ha-button
>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-base>
</ha-list-base>
`;
}
@@ -149,15 +149,13 @@ class HaBackupConfigEncryptionKey extends LitElement {
}
static styles = css`
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
ha-list-base {
--ha-row-item-padding-inline: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: wrap;
}
ha-button[size="small"] ha-svg-icon {
--mdc-icon-size: 16px;
}
@@ -3,11 +3,12 @@ import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { clamp } from "../../../../../common/number/clamp";
import "../../../../../components/ha-expansion-panel";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-select";
import type { HaSelect } from "../../../../../components/ha-select";
import "../../../../../components/input/ha-input";
import type { HaInput } from "../../../../../components/input/ha-input";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/item/ha-row-item";
import type { BackupConfig, Retention } from "../../../../../data/backup";
import type { HomeAssistant, ValueChangedEvent } from "../../../../../types";
@@ -104,7 +105,7 @@ class HaBackupConfigRetention extends LitElement {
}
return html`
<ha-md-list-item>
<ha-list-item-base>
<span slot="headline">
${this.headline ??
this.hass.localize(`ui.panel.config.backup.schedule.retention`)}
@@ -125,7 +126,7 @@ class HaBackupConfigRetention extends LitElement {
),
}))}
></ha-select>
</ha-md-list-item>
</ha-list-item-base>
${this._preset === RetentionPreset.CUSTOM
? html`<ha-expansion-panel
@@ -135,7 +136,7 @@ class HaBackupConfigRetention extends LitElement {
)}
outlined
>
<ha-md-list-item>
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.custom_retention_label"
@@ -171,7 +172,7 @@ class HaBackupConfigRetention extends LitElement {
),
},
]}
></ha-select> </ha-md-list-item
></ha-select></ha-row-item
></ha-expansion-panel> `
: nothing}
`;
@@ -244,10 +245,17 @@ class HaBackupConfigRetention extends LitElement {
}
static styles = css`
ha-md-list-item {
--md-item-overflow: visible;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
ha-row-item,
ha-list-item-base {
--ha-row-item-padding-inline: 0;
}
ha-row-item::part(end) {
align-items: flex-start;
}
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: wrap;
}
ha-select {
min-width: 210px;
@@ -279,9 +287,6 @@ class HaBackupConfigRetention extends LitElement {
--expansion-panel-content-padding: 0 16px;
margin-bottom: 16px;
}
ha-md-list-item.days {
--md-item-align-items: flex-start;
}
`;
}
@@ -6,11 +6,12 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../../../components/ha-checkbox";
import "../../../../../components/ha-expansion-panel";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-select";
import "../../../../../components/ha-time-input";
import "../../../../../components/ha-tip";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/item/ha-row-item";
import "../../../../../components/list/ha-list-base";
import type {
BackupConfig,
BackupDay,
@@ -116,8 +117,8 @@ class HaBackupConfigSchedule extends LitElement {
const data = this._getData(this.value);
return html`
<ha-md-list>
<ha-md-list-item>
<ha-list-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.schedule"
@@ -140,7 +141,7 @@ class HaBackupConfigSchedule extends LitElement {
),
}))}
></ha-select>
</ha-md-list-item>
</ha-list-item-base>
${data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS
? html`<ha-expansion-panel
expanded
@@ -149,7 +150,7 @@ class HaBackupConfigSchedule extends LitElement {
)}
outlined
>
<ha-md-list-item class="days">
<ha-row-item class="days">
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.backup_every"
@@ -172,14 +173,14 @@ class HaBackupConfigSchedule extends LitElement {
`
)}
</div>
</ha-md-list-item>
</ha-row-item>
</ha-expansion-panel>`
: nothing}
${data.recurrence === BackupScheduleRecurrence.DAILY ||
(data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS &&
data.days.length > 0)
? html`
<ha-md-list-item>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.time"
@@ -214,7 +215,7 @@ class HaBackupConfigSchedule extends LitElement {
),
}))}
></ha-select>
</ha-md-list-item>
</ha-list-item-base>
${data.time_option === BackupScheduleTime.CUSTOM
? html`<ha-expansion-panel
expanded
@@ -223,7 +224,7 @@ class HaBackupConfigSchedule extends LitElement {
)}
outlined
>
<ha-md-list-item>
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.custom_time_label"
@@ -248,14 +249,14 @@ class HaBackupConfigSchedule extends LitElement {
.locale=${this.hass.locale}
>
</ha-time-input>
</ha-md-list-item>
</ha-row-item>
</ha-expansion-panel>`
: nothing}
`
: nothing}
${this.supervisor
? html`
<ha-md-list-item>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
`ui.panel.config.backup.schedule.update_preference.label`
@@ -286,7 +287,7 @@ class HaBackupConfigSchedule extends LitElement {
},
]}
></ha-select>
</ha-md-list-item>
</ha-list-item-base>
`
: nothing}
@@ -308,7 +309,7 @@ class HaBackupConfigSchedule extends LitElement {
>`,
})}</ha-tip
>
</ha-md-list>
</ha-list-base>
`;
}
@@ -398,13 +399,14 @@ class HaBackupConfigSchedule extends LitElement {
}
static styles = css`
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
ha-list-base {
--ha-row-item-padding-inline: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
ha-row-item::part(headline),
ha-row-item::part(supporting-text),
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: wrap;
}
ha-select {
min-width: 210px;
@@ -430,8 +432,8 @@ class HaBackupConfigSchedule extends LitElement {
text-align: unset;
margin: 16px 0;
}
ha-md-list-item.days {
--md-item-align-items: flex-start;
ha-row-item-base.days::part(end) {
align-items: flex-start;
}
a {
color: var(--primary-color);
@@ -2,8 +2,6 @@ import memoizeOne from "memoize-one";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-card";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-button";
import "./ha-backup-data-picker";
import type { HomeAssistant } from "../../../../types";
@@ -120,22 +118,6 @@ class HaBackupDetailsRestore extends LitElement {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-list-item-two-line-container-height: 64px;
}
ha-md-list-item [slot="supporting-text"] {
display: flex;
align-items: center;
flex-direction: row;
gap: var(--ha-space-2);
line-height: var(--ha-line-height-condensed);
}
`;
}
@@ -4,8 +4,8 @@ import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/item/ha-list-item-base";
import "../../../../components/list/ha-list-base";
import {
computeBackupSize,
computeBackupType,
@@ -59,8 +59,8 @@ class HaBackupDetailsSummary extends LitElement {
</div>
<div class="card-content">
${errors.length ? this._renderErrorSummary(errors) : nothing}
<ha-md-list class="summary">
<ha-md-list-item>
<ha-list-base class="summary">
<ha-list-item-base>
<span slot="headline">
${this.hass.localize("ui.panel.config.backup.backup_type")}
</span>
@@ -69,8 +69,8 @@ class HaBackupDetailsSummary extends LitElement {
`ui.panel.config.backup.type.${computeBackupType(this.backup, this.isHassio)}`
)}
</span>
</ha-md-list-item>
<ha-md-list-item>
</ha-list-item-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.details.summary.size"
@@ -79,16 +79,16 @@ class HaBackupDetailsSummary extends LitElement {
<span slot="supporting-text">
${bytesToString(computeBackupSize(this.backup))}
</span>
</ha-md-list-item>
<ha-md-list-item>
</ha-list-item-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.details.summary.created"
)}
</span>
<span slot="supporting-text">${formattedDate}</span>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-base>
</ha-list-base>
</div>
</ha-card>
`;
@@ -148,23 +148,17 @@ class HaBackupDetailsSummary extends LitElement {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
padding: 0;
ha-list-base {
--ha-row-item-padding-inline: 0;
padding-bottom: var(--ha-space-3);
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-list-item-two-line-container-height: 64px;
ha-list-base.summary ha-list-item-base::part(headline) {
font-size: var(--ha-font-size-s);
color: var(--ha-color-text-secondary);
}
ha-md-list.summary ha-md-list-item {
--md-list-item-supporting-text-size: 1rem;
--md-list-item-label-text-size: 0.875rem;
--md-list-item-label-text-color: var(--secondary-text-color);
--md-list-item-supporting-text-color: var(--primary-text-color);
}
ha-md-list-item [slot="supporting-text"] {
ha-list-item-base [slot="supporting-text"] {
font-size: var(--ha-font-size-m);
color: var(--ha-color-text-primary);
display: flex;
align-items: center;
flex-direction: row;
@@ -4,9 +4,9 @@ import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-nav";
import {
getSupervisorUpdateConfig,
type SupervisorUpdateConfig,
@@ -73,11 +73,8 @@ class HaBackupOverviewAppUpdateBackup extends LitElement {
)}
</div>
<div class="card-content">
<ha-md-list>
<ha-md-list-item
type="link"
href="/config/backup/app-update-backups"
>
<ha-list-nav>
<ha-list-item-button href="/config/backup/app-update-backups">
<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>
<div slot="headline">${this._appUpdateBackupDescription()}</div>
<div slot="supporting-text">
@@ -86,8 +83,8 @@ class HaBackupOverviewAppUpdateBackup extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-button>
</ha-list-nav>
</div>
</ha-card>
`;
@@ -106,11 +103,6 @@ class HaBackupOverviewAppUpdateBackup extends LitElement {
padding-right: 0;
padding-top: 0;
}
ha-md-list {
padding-top: 0;
padding-bottom: 0;
}
`,
];
}
@@ -7,8 +7,8 @@ import { isComponentLoaded } from "../../../../../common/config/is_component_loa
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-nav";
import type { BackupContent, BackupType } from "../../../../../data/backup";
import {
computeBackupSize,
@@ -69,13 +69,10 @@ class HaBackupOverviewBackups extends LitElement {
${this.hass.localize("ui.panel.config.backup.overview.backups.title")}
</div>
<div class="card-content">
<ha-md-list>
<ha-list-nav>
${stats.map(
([type, { count, size }]) => html`
<ha-md-list-item
type="link"
href="/config/backup/backups?type=${type}"
>
<ha-list-item-button href="/config/backup/backups?type=${type}">
<ha-svg-icon
slot="start"
.path=${TYPE_ICONS[type]}
@@ -93,10 +90,10 @@ class HaBackupOverviewBackups extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-list-item-button>
`
)}
</ha-md-list>
</ha-list-nav>
</div>
<div class="card-actions">
<ha-button appearance="filled" href="/config/backup/backups?type=all">
@@ -134,6 +131,9 @@ class HaBackupOverviewBackups extends LitElement {
padding-right: 0;
padding-bottom: 0;
}
ha-list-item-button::part(start) {
color: var(--ha-color-text-secondary);
}
`,
];
}
@@ -5,10 +5,10 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/list/ha-list-base";
import type { BackupAgent } from "../../../../../data/backup";
import {
computeBackupAgentName,
@@ -332,7 +332,7 @@ export class HaBackupOverviewProgress extends LitElement {
? this._handleAgentCollapseEnd
: undefined}
>
<ha-md-list class="agent-list">
<ha-list-base class="agent-list">
${this.agents.map((agent) => {
const name = computeBackupAgentName(
this.hass.localize,
@@ -344,7 +344,7 @@ export class HaBackupOverviewProgress extends LitElement {
if (agentPercent !== undefined) {
if (agentPercent >= 100) {
return html`
<ha-md-list-item>
<ha-list-item-base>
${this._renderAgentIcon(agent.agent_id)}
<div slot="headline">${name}</div>
<div slot="supporting-text">
@@ -357,11 +357,11 @@ export class HaBackupOverviewProgress extends LitElement {
class="agent-complete"
.path=${mdiCheck}
></ha-svg-icon>
</ha-md-list-item>
</ha-list-item-base>
`;
}
return html`
<ha-md-list-item>
<ha-list-item-base>
${this._renderAgentIcon(agent.agent_id)}
<div slot="headline">${name}</div>
<div slot="supporting-text">
@@ -373,12 +373,12 @@ export class HaBackupOverviewProgress extends LitElement {
${agentPercent}%
</span>
<ha-spinner slot="end" size="tiny"></ha-spinner>
</ha-md-list-item>
</ha-list-item-base>
`;
}
return html`
<ha-md-list-item>
<ha-list-item-base>
${this._renderAgentIcon(agent.agent_id)}
<div slot="headline">${name}</div>
<div slot="supporting-text">
@@ -387,10 +387,10 @@ export class HaBackupOverviewProgress extends LitElement {
)}
</div>
<ha-spinner slot="end" size="tiny"></ha-spinner>
</ha-md-list-item>
</ha-list-item-base>
`;
})}
</ha-md-list>
</ha-list-base>
</div>
`;
}
@@ -506,14 +506,13 @@ export class HaBackupOverviewProgress extends LitElement {
margin-top: var(--ha-space-4);
overflow: hidden;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
ha-list-item-base {
--ha-row-item-padding-inline: 0;
}
ha-md-list-item img {
ha-list-item-base img {
width: 48px;
}
ha-md-list-item ha-svg-icon[slot="start"] {
ha-list-item-base ha-svg-icon[slot="start"] {
--mdc-icon-size: 48px;
color: var(--primary-text-color);
}
@@ -521,7 +520,7 @@ export class HaBackupOverviewProgress extends LitElement {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
ha-md-list-item [slot="supporting-text"] {
ha-list-item-base::part(supporting-text) {
display: flex;
align-items: center;
}
@@ -7,9 +7,9 @@ import { navigate } from "../../../../../common/navigate";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-nav";
import type { BackupAgent, BackupConfig } from "../../../../../data/backup";
import {
BackupScheduleRecurrence,
@@ -213,11 +213,8 @@ class HaBackupBackupsSummary extends LitElement {
)}
</div>
<div class="card-content">
<ha-md-list>
<ha-md-list-item
type="link"
href="/config/backup/settings#schedule"
>
<ha-list-nav>
<ha-list-item-button href="/config/backup/settings#schedule">
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<div slot="headline">
${this._scheduleDescription(this.config)}
@@ -228,8 +225,8 @@ class HaBackupBackupsSummary extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item type="link" href="/config/backup/settings#data">
</ha-list-item-button>
<ha-list-item-button href="/config/backup/settings#data">
<ha-svg-icon slot="start" .path=${mdiDatabase}></ha-svg-icon>
<div slot="headline">
${this._showDbOption &&
@@ -247,13 +244,10 @@ class HaBackupBackupsSummary extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-list-item-button>
${isHassio
? html`
<ha-md-list-item
type="link"
href="/config/backup/settings#data"
>
<ha-list-item-button href="/config/backup/settings#data">
<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>
<div slot="headline">
${this._addonsDescription(this.config)}
@@ -264,13 +258,10 @@ class HaBackupBackupsSummary extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-list-item-button>
`
: nothing}
<ha-md-list-item
type="link"
href="/config/backup/settings#locations"
>
<ha-list-item-button href="/config/backup/settings#locations">
<ha-svg-icon slot="start" .path=${mdiUpload}></ha-svg-icon>
<div slot="headline">
${this._locationsDescription(this.config)}
@@ -281,8 +272,8 @@ class HaBackupBackupsSummary extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-button>
</ha-list-nav>
</div>
<div class="card-actions">
<ha-button @click=${this._configure} appearance="filled">
@@ -321,6 +312,9 @@ class HaBackupBackupsSummary extends LitElement {
padding-right: 0;
padding-bottom: 0;
}
ha-list-item-button::part(start) {
color: var(--ha-color-text-secondary);
}
`,
];
}
@@ -13,9 +13,9 @@ import type { LocalizeKeys } from "../../../../../common/translations/localize";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/list/ha-list-base";
import type { BackupConfig, BackupContent } from "../../../../../data/backup";
import {
BackupScheduleRecurrence,
@@ -65,15 +65,15 @@ class HaBackupOverviewBackups extends LitElement {
) {
return html`
<ha-backup-summary-card .heading=${heading} .status=${status}>
<ha-md-list>
<ha-md-list-item>
<ha-list-base>
<ha-list-item-base>
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
<span slot="headline" class=${headline === null ? "skeleton" : ""}
>${headline}</span
>
</ha-md-list-item>
</ha-list-item-base>
${description || description === null
? html`<ha-md-list-item>
? html`<ha-list-item-base>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span
slot="headline"
@@ -90,9 +90,9 @@ class HaBackupOverviewBackups extends LitElement {
.path=${mdiInformationOutline}
></ha-icon-button>`
: nothing}
</ha-md-list-item>`
</ha-list-item-base>`
: nothing}
</ha-md-list>
</ha-list-base>
</ha-backup-summary-card>
`;
}
@@ -347,13 +347,12 @@ class HaBackupOverviewBackups extends LitElement {
justify-content: flex-end;
border-top: none;
}
ha-md-list {
background: none;
ha-list-item-base {
--ha-row-item-padding-block: var(--ha-space-2);
--ha-row-item-min-height: 40x;
}
ha-md-list-item {
--md-list-item-top-space: 8px;
--md-list-item-bottom-space: 8px;
--md-list-item-one-line-container-height: 40x;
ha-list-item-base::part(start) {
color: var(--ha-color-text-secondary);
}
span.skeleton {
position: relative;
@@ -11,9 +11,10 @@ import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/item/ha-row-item";
import "../../../../components/list/ha-list-base";
import type {
BackupConfig,
BackupMutableConfig,
@@ -366,36 +367,34 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
@click=${this._copyKeyToClipboard}
></ha-icon-button>
</div>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_description"
)}
</span>
<ha-button
size="small"
appearance="plain"
slot="end"
@click=${this._downloadKey}
>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_description"
)}
</span>
<ha-button
size="small"
appearance="plain"
slot="end"
@click=${this._downloadKey}
>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_action"
)}
</ha-button>
</ha-row-item>
`;
case "setup":
return html`
<ha-md-list class="full">
<ha-md-list-item type="button" @click=${this._useRecommended}>
<ha-list-base class="full">
<ha-list-item-button @click=${this._useRecommended}>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.dialogs.onboarding.setup.recommended_heading"
@@ -407,8 +406,8 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item type="button" @click=${this._nextStep}>
</ha-list-item-button>
<ha-list-item-button @click=${this._nextStep}>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.dialogs.onboarding.setup.custom_heading"
@@ -420,8 +419,8 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-button>
</ha-list-base>
`;
case "schedule":
return html`
@@ -547,16 +546,12 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6);
--ha-dialog-max-height: min(605px, 100% - 48px);
}
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
ha-row-item {
--ha-row-item-padding-inline: 0;
}
ha-md-list.full {
--md-list-item-leading-space: 24px;
--md-list-item-trailing-space: 24px;
margin-left: -24px;
margin-right: -24px;
ha-list-base.full {
--ha-row-item-padding-inline: var(--ha-space-6);
margin: 0 calc(-1 * var(--ha-space-6));
}
p {
margin-top: 0;
@@ -5,12 +5,11 @@ import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-dialog";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/item/ha-row-item";
import {
downloadEmergencyKit,
generateEncryptionKey,
@@ -160,26 +159,28 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
@click=${this._copyOldKeyToClipboard}
></ha-icon-button>
</div>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_old_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_old_emergency_kit_description"
)}
</span>
<ha-button slot="end" @click=${this._downloadOld}>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_old_emergency_kit_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_old_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_old_emergency_kit_description"
)}
</span>
<ha-button
slot="end"
appearance="filled"
@click=${this._downloadOld}
>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_old_emergency_kit_action"
)}
</ha-button>
</ha-row-item>
`;
case "new":
return html`
@@ -195,26 +196,28 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
@click=${this._copyKeyToClipboard}
></ha-icon-button>
</div>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_description"
)}
</span>
<ha-button slot="end" @click=${this._downloadNew}>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_description"
)}
</span>
<ha-button
slot="end"
appearance="filled"
@click=${this._downloadNew}
>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_action"
)}
</ha-button>
</ha-row-item>
`;
case "done":
return html`
@@ -281,10 +284,8 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
ha-dialog {
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6);
}
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
ha-row-item {
--ha-row-item-padding-inline: 0;
}
.encryption-key {
border: 1px solid var(--divider-color);
@@ -12,11 +12,10 @@ import "../../../../components/ha-dialog-header";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-select";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
import "../../../../components/item/ha-row-item";
import type {
BackupAgent,
BackupConfig,
@@ -296,41 +295,39 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
@change=${this._nameChanged}
>
</ha-input>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.dialogs.generate.sync.locations"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.dialogs.generate.sync.locations_description"
)}
</span>
<ha-select
slot="end"
@selected=${this._selectChanged}
.value=${this._formData.agents_mode}
.options=${[
{
value: "all",
label: this.hass.localize(
"ui.panel.config.backup.dialogs.generate.sync.locations_options.all",
{ count: this._allAgentIds.length }
),
disabled: !!disabledAgentIds.length,
},
{
value: "custom",
label: this.hass.localize(
"ui.panel.config.backup.dialogs.generate.sync.locations_options.custom"
),
},
]}
></ha-select>
</ha-md-list-item>
</ha-md-list>
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.dialogs.generate.sync.locations"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.dialogs.generate.sync.locations_description"
)}
</span>
<ha-select
slot="end"
@selected=${this._selectChanged}
.value=${this._formData.agents_mode}
.options=${[
{
value: "all",
label: this.hass.localize(
"ui.panel.config.backup.dialogs.generate.sync.locations_options.all",
{ count: this._allAgentIds.length }
),
disabled: !!disabledAgentIds.length,
},
{
value: "custom",
label: this.hass.localize(
"ui.panel.config.backup.dialogs.generate.sync.locations_options.custom"
),
},
]}
></ha-select>
</ha-row-item>
${disabledAgentIds.length
? html`
<ha-alert
@@ -443,24 +440,19 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
ha-dialog {
--dialog-content-padding: 24px;
}
ha-md-list {
background: none;
padding: 0;
ha-row-item {
--ha-row-item-padding-inline: 0;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
}
ha-md-list-item ha-select {
ha-row-item ha-select {
min-width: 210px;
}
@media all and (max-width: 450px) {
ha-md-list-item ha-select {
ha-row-item ha-select {
min-width: 160px;
width: 160px;
}
}
ha-md-list-item ha-select > span {
ha-row-item ha-select > span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
@@ -3,11 +3,11 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-list";
import "../../../../components/ha-dialog";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/list/ha-list-base";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
@@ -52,7 +52,7 @@ class DialogNewBackup extends LitElement implements HassDialog {
)}
@closed=${this._dialogClosed}
>
<ha-md-list
<ha-list-base
innerRole="listbox"
itemRoles="option"
.innerAriaLabel=${this.hass.localize(
@@ -60,9 +60,8 @@ class DialogNewBackup extends LitElement implements HassDialog {
)}
rootTabbable
>
<ha-md-list-item
<ha-list-item-button
@click=${this._automatic}
type="button"
.disabled=${!this._params.config.create_backup.password}
>
<ha-svg-icon slot="start" .path=${mdiCalendarSync}></ha-svg-icon>
@@ -77,8 +76,8 @@ class DialogNewBackup extends LitElement implements HassDialog {
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item @click=${this._manual} type="button">
</ha-list-item-button>
<ha-list-item-button @click=${this._manual}>
<ha-svg-icon slot="start" .path=${mdiGestureTap}></ha-svg-icon>
<span slot="headline">
${this.hass.localize(
@@ -91,8 +90,8 @@ class DialogNewBackup extends LitElement implements HassDialog {
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-button>
</ha-list-base>
</ha-dialog>
`;
}
@@ -115,10 +114,6 @@ class DialogNewBackup extends LitElement implements HassDialog {
ha-dialog {
--dialog-content-padding: 0;
}
ha-md-list {
background: none;
}
ha-icon-next {
width: 24px;
}
@@ -8,8 +8,7 @@ import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/item/ha-row-item";
import {
downloadEmergencyKit,
generateEncryptionKey,
@@ -135,31 +134,29 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
@click=${this._copyKeyToClipboard}
></ha-icon-button>
</div>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_description"
)}
</span>
<ha-button
size="small"
appearance="plain"
slot="end"
@click=${this._download}
>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_description"
)}
</span>
<ha-button
size="small"
appearance="plain"
slot="end"
@click=${this._download}
>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_action"
)}
</ha-button>
</ha-row-item>
`;
case "done":
return html`
@@ -209,10 +206,8 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
ha-dialog {
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6);
}
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
ha-row-item {
--ha-row-item-padding-inline: 0;
}
.encryption-key {
border: 1px solid var(--divider-color);
@@ -5,12 +5,11 @@ import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-dialog";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/item/ha-row-item";
import { downloadEmergencyKit } from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
@@ -74,26 +73,24 @@ class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
@click=${this._copyKeyToClipboard}
></ha-icon-button>
</div>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_description"
)}
</span>
<ha-button slot="end" @click=${this._download}>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_description"
)}
</span>
<ha-button slot="end" appearance="filled" @click=${this._download}>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_action"
)}
</ha-button>
</ha-row-item>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
@@ -128,10 +125,8 @@ class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
ha-dialog {
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6);
}
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
ha-row-item {
--ha-row-item-padding-inline: 0;
}
.encryption-key {
border: 1px solid var(--divider-color);
@@ -12,16 +12,17 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import { navigate } from "../../../common/navigate";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-spinner";
import "../../../components/item/ha-list-item-base";
import "../../../components/list/ha-list-base";
import type {
BackupAgent,
BackupConfig,
@@ -44,7 +45,6 @@ import "./components/ha-backup-details-restore";
import "./components/ha-backup-details-summary";
import { showRestoreBackupDialog } from "./dialogs/show-dialog-restore-backup";
import { downloadBackup } from "./helper/download_backup";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
interface Agent extends BackupContentAgent {
id: string;
@@ -171,7 +171,7 @@ class HaConfigBackupDetails extends LitElement {
)}
</div>
<div class="card-content">
<ha-md-list>
<ha-list-base>
${this._agents.map((agent) => {
const agentId = agent.id;
@@ -186,7 +186,7 @@ class HaConfigBackupDetails extends LitElement {
const unencrypted = !agent.protected;
return html`
<ha-md-list-item>
<ha-list-item-base>
${
isLocalAgent(agentId)
? html`
@@ -282,10 +282,10 @@ class HaConfigBackupDetails extends LitElement {
`
: nothing
}
</ha-md-list-item>
</ha-list-item-base>
`;
})}
</ha-md-list>
</ha-list-base>
</div>
</ha-card>
`}
@@ -374,26 +374,20 @@ class HaConfigBackupDetails extends LitElement {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
padding: 0;
ha-list-item-base {
--ha-row-item-padding-inline: 0;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-list-item-two-line-container-height: 64px;
}
ha-md-list-item img {
ha-list-item-base img {
width: 48px;
}
ha-md-list-item ha-svg-icon[slot="start"] {
ha-list-item-base ha-svg-icon[slot="start"] {
--mdc-icon-size: 48px;
color: var(--primary-text-color);
}
ha-button.danger {
--mdc-theme-primary: var(--error-color);
}
ha-md-list-item [slot="supporting-text"] {
ha-list-item-base [slot="supporting-text"] {
display: flex;
align-items: center;
flex-direction: row;
@@ -2,15 +2,14 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-spinner";
import "../../../components/ha-switch";
import "../../../components/item/ha-row-item";
import type {
BackupAgent,
BackupAgentConfig,
@@ -143,10 +142,36 @@ class HaConfigBackupDetails extends LitElement {
"ui.panel.config.backup.location.encryption.description"
)}
</p>
<ha-md-list>
${CLOUD_AGENT === this.agentId
${CLOUD_AGENT === this.agentId
? html`
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_encrypted"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_encrypted_cloud_description"
)}
</span>
<ha-button
href="https://www.nabucasa.com/config/backups/"
target="_blank"
slot="end"
rel="noreferrer noopener"
appearance="plain"
size="small"
>
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_encrypted_cloud_learn_more"
)}
</ha-button>
</ha-row-item>
`
: encrypted
? html`
<ha-md-list-item>
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_encrypted"
@@ -154,82 +179,54 @@ class HaConfigBackupDetails extends LitElement {
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_encrypted_cloud_description"
`ui.panel.config.backup.location.encryption.location_encrypted_description`
)}
</span>
<ha-button
href="https://www.nabucasa.com/config/backups/"
target="_blank"
slot="end"
rel="noreferrer noopener"
appearance="plain"
size="small"
@click=${this._turnOffEncryption}
variant="danger"
>
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_encrypted_cloud_learn_more"
"ui.panel.config.backup.location.encryption.encryption_turn_off"
)}
</ha-button>
</ha-md-list-item>
</ha-row-item>
`
: encrypted
? html`
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_encrypted"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.location.encryption.location_encrypted_description`
)}
</span>
<ha-button
slot="end"
@click=${this._turnOffEncryption}
variant="danger"
>
${this.hass.localize(
"ui.panel.config.backup.location.encryption.encryption_turn_off"
)}
</ha-button>
</ha-md-list-item>
`
: html`
<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.backup.location.encryption.warning_encryption_turn_off"
: html`
<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.backup.location.encryption.warning_encryption_turn_off"
)}
>
${this.hass.localize(
"ui.panel.config.backup.location.encryption.warning_encryption_turn_off_description"
)}
</ha-alert>
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_unencrypted"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.location.encryption.location_unencrypted_description`
)}
</span>
<ha-button
slot="end"
@click=${this._turnOnEncryption}
>
${this.hass.localize(
"ui.panel.config.backup.location.encryption.warning_encryption_turn_off_description"
"ui.panel.config.backup.location.encryption.encryption_turn_on"
)}
</ha-alert>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_unencrypted"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.location.encryption.location_unencrypted_description`
)}
</span>
<ha-button
slot="end"
@click=${this._turnOnEncryption}
>
${this.hass.localize(
"ui.panel.config.backup.location.encryption.encryption_turn_on"
)}
</ha-button>
</ha-md-list-item>
`}
</ha-md-list>
</ha-button>
</ha-row-item>
`}
</div>
</ha-card>
`}
@@ -336,29 +333,16 @@ class HaConfigBackupDetails extends LitElement {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
padding: 0;
ha-row-item {
--ha-row-item-padding-inline: 0;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-list-item-two-line-container-height: 64px;
}
ha-md-list-item img {
ha-row-item img {
width: 48px;
}
ha-md-list-item ha-svg-icon[slot="start"] {
ha-row-item ha-svg-icon[slot="start"] {
--mdc-icon-size: 48px;
color: var(--primary-text-color);
}
ha-md-list.summary ha-md-list-item {
--md-list-item-supporting-text-size: 1rem;
--md-list-item-label-text-size: 0.875rem;
--md-list-item-label-text-color: var(--secondary-text-color);
--md-list-item-supporting-text-color: var(--primary-text-color);
}
.warning {
color: var(--error-color);
}
@@ -371,12 +355,8 @@ class HaConfigBackupDetails extends LitElement {
ha-backup-data-picker {
display: block;
}
ha-md-list-item [slot="supporting-text"] {
display: flex;
align-items: center;
flex-direction: row;
gap: var(--ha-space-2);
line-height: var(--ha-line-height-condensed);
ha-row-item::part(supporting-text) {
white-space: wrap;
}
.dot {
display: block;
@@ -6,8 +6,8 @@ import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-switch";
import "../../../../components/item/ha-row-item";
import { formatDate } from "../../../../common/datetime/format_date";
import type { HaSwitch } from "../../../../components/ha-switch";
@@ -143,7 +143,7 @@ export class CloudRemotePref extends LitElement {
"ui.panel.config.cloud.account.remote.security_options"
)}
>
<ha-md-list-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.external_activation"
@@ -160,9 +160,9 @@ export class CloudRemotePref extends LitElement {
@change=${this._toggleAllowRemoteEnabledChanged}
>
</ha-switch>
</ha-md-list-item>
</ha-row-item>
<hr />
<ha-md-list-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.certificate_info"
@@ -194,7 +194,7 @@ export class CloudRemotePref extends LitElement {
"ui.panel.config.cloud.account.remote.more_info"
)}
</ha-button>
</ha-md-list-item>
</ha-row-item>
</ha-expansion-panel>
</div>
</ha-card>
@@ -281,10 +281,12 @@ export class CloudRemotePref extends LitElement {
ha-expansion-panel {
margin-top: 16px;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-item-overflow: visible;
ha-row-item {
--ha-row-item-padding-inline: 0;
}
ha-row-item::part(headline),
ha-row-item::part(supporting-text) {
white-space: wrap;
}
ha-expansion-panel {
--expansion-panel-content-padding: 0 16px;
@@ -4,9 +4,9 @@ import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-spinner";
import "../../../../components/ha-switch";
import "../../../../components/item/ha-row-item";
import type { CloudStatusLoggedIn, CloudWebhook } from "../../../../data/cloud";
import { createCloudhook, deleteCloudhook } from "../../../../data/cloud";
import type { Webhook, WebhookError } from "../../../../data/webhook";
@@ -76,7 +76,7 @@ export class CloudWebhooks extends LitElement {
`
: this._localHooks.map(
(entry) => html`
<ha-md-list-item .entry=${entry}>
<ha-row-item .entry=${entry}>
<span slot="headline"
>${entry.name}
${entry.domain !== entry.name.toLowerCase()
@@ -108,7 +108,7 @@ export class CloudWebhooks extends LitElement {
@click=${this._enableWebhook}
>
</ha-switch>`}
</ha-md-list-item>
</ha-row-item>
`
)}
<div class="footer">
@@ -237,12 +237,12 @@ export class CloudWebhooks extends LitElement {
.footer a {
color: var(--primary-color);
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-item-overflow: visible;
ha-row-item {
--ha-row-item-padding-inline: 0;
}
ha-md-list-item [slot="supporting-text"] {
ha-row-item::part(headline),
ha-row-item::part(supporting-text) {
white-space: wrap;
word-break: break-all;
}
`,
@@ -11,10 +11,14 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type {
HassioSupervisorInfo,
@@ -25,9 +29,13 @@ import {
reloadSupervisor,
setSupervisorOption,
} from "../../../data/hassio/supervisor";
import { domainToName } from "../../../data/integration";
import type { UpdateEntity } from "../../../data/update";
import {
checkForEntityUpdates,
filterUpdateEntitiesParameterized,
installUpdates,
isSystemUpdate,
} from "../../../data/update";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
@@ -35,6 +43,17 @@ import type { HomeAssistant } from "../../../types";
import "../dashboard/ha-config-updates";
import { showJoinBetaDialog } from "./updates/show-dialog-join-beta";
interface UpdateGroup {
key: string;
title: string;
entities: UpdateEntity[];
showUpdateAll: boolean;
}
const SYSTEM_KEY = "__system__";
const APPS_KEY = "__apps__";
const INTEGRATIONS_KEY = "__integrations__";
@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -47,16 +66,51 @@ class HaConfigSectionUpdates extends LitElement {
@state() private _supervisorInfo?: HassioSupervisorInfo;
@state() private _entitySources?: EntitySources;
@state() private _loadedIntegrationTitles = new Set<string>();
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass.config, "hassio")) {
this._refreshSupervisorInfo();
}
this._loadEntitySources();
}
private async _loadEntitySources() {
try {
this._entitySources = await fetchEntitySourcesWithCache(this.hass);
} catch (_err) {
// Non-fatal: grouping falls back to entity registry platform lookup.
}
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
this._loadIntegrationTitles();
}
private async _loadIntegrationTitles() {
const domains = new Set<string>();
for (const entity of Object.values(this.hass.states)) {
if (!entity.entity_id.startsWith("update.")) continue;
const platform = this.hass.entities[entity.entity_id]?.platform;
if (platform && !this._loadedIntegrationTitles.has(platform)) {
domains.add(platform);
}
}
if (!domains.size) return;
const toLoad = Array.from(domains);
toLoad.forEach((d) => this._loadedIntegrationTitles.add(d));
await this.hass.loadBackendTranslation("title", toLoad);
this.requestUpdate();
}
protected render(): TemplateResult {
const canInstallUpdates = this._filterInstallableUpdateEntities(
const installableUpdates = this._filterInstallableUpdateEntities(
this.hass.states,
this._showSkipped
);
@@ -65,6 +119,8 @@ class HaConfigSectionUpdates extends LitElement {
this._showSkipped
);
const groups = this._groupUpdates(installableUpdates, this._entitySources);
return html`
<hass-subpage
.backPath=${this._searchParms.has("historyBack")
@@ -118,36 +174,52 @@ class HaConfigSectionUpdates extends LitElement {
</ha-dropdown>
</div>
<div class="content">
${canInstallUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
${groups.map(
(group) => html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${this.hass.localize("ui.panel.config.updates.title", {
count: canInstallUpdates.length,
})}
${group.title}
</div>
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.updateEntities=${canInstallUpdates}
showAll
></ha-config-updates>
${group.showUpdateAll
? html`
<ha-button
appearance="plain"
size="small"
.group=${group}
@click=${this._updateAll}
>
${this.hass.localize(
"ui.panel.config.updates.update_all"
)}
</ha-button>
`
: nothing}
</div>
</ha-card>
`
: nothing}
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.updateEntities=${group.entities}
showAll
></ha-config-updates>
</div>
</ha-card>
`
)}
${notInstallableUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="title" role="heading" aria-level="2">
${this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: notInstallableUpdates.length,
}
)}
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: notInstallableUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.hass=${this.hass}
@@ -159,7 +231,7 @@ class HaConfigSectionUpdates extends LitElement {
</ha-card>
`
: nothing}
${canInstallUpdates.length + notInstallableUpdates.length
${groups.length + notInstallableUpdates.length
? nothing
: html`
<ha-card outlined>
@@ -211,6 +283,22 @@ class HaConfigSectionUpdates extends LitElement {
checkForEntityUpdates(this, this.hass);
}
private async _updateAll(ev: Event) {
const group = (ev.currentTarget as any).group as UpdateGroup;
try {
await installUpdates(
this.hass,
group.entities.map((entity) => entity.entity_id)
);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.updates.update_all_failed"),
text: extractApiErrorMessage(err),
warning: true,
});
}
}
private _filterInstallableUpdateEntities = memoizeOne(
(entities: HassEntities, showSkipped: boolean) =>
filterUpdateEntitiesParameterized(entities, showSkipped, false)
@@ -221,6 +309,101 @@ class HaConfigSectionUpdates extends LitElement {
filterUpdateEntitiesParameterized(entities, showSkipped, true)
);
private _groupUpdates = memoizeOne(
(
entities: UpdateEntity[],
entitySources: EntitySources | undefined
): UpdateGroup[] => {
if (!entities.length) {
return [];
}
const localize = this.hass.localize;
const systemEntities: UpdateEntity[] = [];
const appEntities: UpdateEntity[] = [];
const byDomain = new Map<string, UpdateEntity[]>();
const otherIntegrationEntities: UpdateEntity[] = [];
for (const entity of entities) {
if (isSystemUpdate(entity)) {
systemEntities.push(entity);
continue;
}
const domain =
entitySources?.[entity.entity_id]?.domain ??
this.hass.entities[entity.entity_id]?.platform;
if (domain === "hassio") {
appEntities.push(entity);
continue;
}
if (!domain) {
otherIntegrationEntities.push(entity);
continue;
}
if (!byDomain.has(domain)) {
byDomain.set(domain, []);
}
byDomain.get(domain)!.push(entity);
}
const multiInstanceGroups: UpdateGroup[] = [];
byDomain.forEach((entries, domain) => {
if (entries.length >= 2) {
multiInstanceGroups.push({
key: domain,
title: domainToName(localize, domain),
entities: entries,
showUpdateAll: true,
});
} else {
otherIntegrationEntities.push(...entries);
}
});
multiInstanceGroups.sort((a, b) =>
caseInsensitiveStringCompare(
a.title,
b.title,
this.hass.locale.language
)
);
const groups: UpdateGroup[] = [];
if (systemEntities.length) {
groups.push({
key: SYSTEM_KEY,
title: localize("ui.panel.config.updates.group_system"),
entities: systemEntities,
showUpdateAll: false,
});
}
groups.push(...multiInstanceGroups);
if (otherIntegrationEntities.length) {
groups.push({
key: INTEGRATIONS_KEY,
title: localize("ui.panel.config.updates.group_integrations"),
entities: otherIntegrationEntities,
showUpdateAll: true,
});
}
if (appEntities.length) {
groups.push({
key: APPS_KEY,
title: localize("ui.panel.config.updates.group_apps"),
entities: appEntities,
showUpdateAll: true,
});
}
return groups;
}
);
static styles = css`
.content {
padding: 28px 20px 0;
@@ -247,8 +430,15 @@ class HaConfigSectionUpdates extends LitElement {
padding: 0;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-2);
padding: var(--ha-space-4) var(--ha-space-2) 0 var(--ha-space-4);
}
.title {
padding: var(--ha-space-4) var(--ha-space-4) 0;
font-size: var(--ha-font-size-l);
}
@@ -1,24 +1,24 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
import "../../../../components/ha-md-list";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/entity/ha-entity-picker";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "./ha-debug-connection-row";
import "./ha-debug-disable-view-transition-row";
import "./ha-debug-viewport-environment-card";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/list/ha-list-base";
import type { ExtEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import { getExtendedEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import {
getStatisticMetadata,
validateStatistics,
} from "../../../../data/recorder";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { getExtendedEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { ExtEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import "./ha-debug-connection-row";
import "./ha-debug-disable-view-transition-row";
import "./ha-debug-viewport-environment-card";
@customElement("developer-tools-debug")
class HaPanelDevDebug extends SubscribeMixin(LitElement) {
@@ -34,14 +34,14 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
"ui.panel.config.developer-tools.tabs.debug.title"
)}
>
<ha-md-list>
<ha-list-base>
<ha-debug-connection-row
.hass=${this.hass}
></ha-debug-connection-row>
<ha-debug-disable-view-transition-row
.hass=${this.hass}
></ha-debug-disable-view-transition-row>
</ha-md-list>
</ha-list-base>
</ha-card>
<ha-card
.header=${this.hass.localize(
@@ -131,11 +131,6 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
max-width: 600px;
margin: 0 auto;
}
ha-md-list {
padding-top: 0;
padding-bottom: 0;
background: none;
}
`,
];
}
@@ -1,7 +1,7 @@
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/ha-md-list-item";
import "../../../../components/item/ha-list-item-base";
import "../../../../components/ha-switch";
import type { HaSwitch } from "../../../../components/ha-switch";
import type { HomeAssistant } from "../../../../types";
@@ -14,7 +14,7 @@ class HaDebugConnectionRow extends LitElement {
protected render(): TemplateResult {
return html`
<ha-md-list-item>
<ha-list-item-base>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.developer-tools.tabs.debug.debug_connection.title"
@@ -30,7 +30,7 @@ class HaDebugConnectionRow extends LitElement {
.checked=${this.hass.debugConnection}
@change=${this._checkedChanged}
></ha-switch>
</ha-md-list-item>
</ha-list-item-base>
`;
}
@@ -3,9 +3,9 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { storage } from "../../../../common/decorators/storage";
import { setViewTransitionDisabled } from "../../../../common/util/view-transition";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-switch";
import type { HaSwitch } from "../../../../components/ha-switch";
import "../../../../components/item/ha-list-item-base";
import type { HomeAssistant } from "../../../../types";
@customElement("ha-debug-disable-view-transition-row")
@@ -17,7 +17,7 @@ class HaDebugDisableViewTransitionRow extends LitElement {
protected render(): TemplateResult {
return html`
<ha-md-list-item>
<ha-list-item-base>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.developer-tools.tabs.debug.disable_view_transition.title"
@@ -33,7 +33,7 @@ class HaDebugDisableViewTransitionRow extends LitElement {
.checked=${this._disabled}
@change=${this._checkedChanged}
></ha-switch>
</ha-md-list-item>
</ha-list-item-base>
`;
}
+236 -160
View File
@@ -7,10 +7,14 @@ import {
mdiDownload,
mdiMicrophone,
mdiOpenInNew,
mdiPalette,
mdiPencil,
mdiPlus,
mdiRestore,
mdiRobot,
mdiScriptText,
mdiShapeOutline,
mdiTools,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
@@ -29,6 +33,7 @@ import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { navigate } from "../../../common/navigate";
import { stringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { computeRTL } from "../../../common/util/compute_rtl";
@@ -41,8 +46,9 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/item/ha-list-item-base";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-nav";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
@@ -89,6 +95,7 @@ import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
import "../../logbook/ha-logbook";
@@ -101,6 +108,51 @@ import {
showDeviceRegistryDetailDialog,
} from "./device-registry-detail/show-dialog-device-registry-detail";
type DeviceQuickLinkKey =
| "entities"
| "helpers"
| "automations"
| "scenes"
| "scripts";
const NAVIGATION_ACTIONS: {
value: string;
path: string;
icon: string;
countKey: DeviceQuickLinkKey;
}[] = [
{
value: "navigate-entities",
path: "/config/entities",
icon: mdiShapeOutline,
countKey: "entities",
},
{
value: "navigate-helpers",
path: "/config/helpers",
icon: mdiTools,
countKey: "helpers",
},
{
value: "navigate-automations",
path: "/config/automation/dashboard",
icon: mdiRobot,
countKey: "automations",
},
{
value: "navigate-scenes",
path: "/config/scene/dashboard",
icon: mdiPalette,
countKey: "scenes",
},
{
value: "navigate-scripts",
path: "/config/script/dashboard",
icon: mdiScriptText,
countKey: "scripts",
},
] as const;
export interface EntityRegistryStateEntry extends EntityRegistryEntry {
stateName?: string | null;
}
@@ -224,6 +276,18 @@ export class HaConfigDevicePage extends LitElement {
),
}));
private _getQuickLinkCounts = memoizeOne(
(entities: EntityRegistryEntry[], related?: RelatedResult) => ({
entities: entities.length,
helpers: entities.filter((entity) =>
isHelperDomain(computeDomain(entity.entity_id))
).length,
automations: related?.automation?.length ?? 0,
scenes: related?.scene?.length ?? 0,
scripts: related?.script?.length ?? 0,
})
);
private _deviceIdInList = memoizeOne((deviceId: string) => [deviceId]);
private _entityIds = memoizeOne(
@@ -363,6 +427,7 @@ export class HaConfigDevicePage extends LitElement {
this.hass.devices
);
const entitiesByCategory = this._entitiesByCategory(entities);
const quickLinkCounts = this._getQuickLinkCounts(entities, this._related);
const batteryEntity = this._batteryEntity(entities);
const batteryChargingEntity = this._batteryChargingEntity(entities);
const battery = batteryEntity
@@ -375,35 +440,42 @@ export class HaConfigDevicePage extends LitElement {
: undefined;
const area = device.area_id ? this.hass.areas[device.area_id] : undefined;
const deviceInfo: TemplateResult[] = integrations.map(
(integration) =>
html`<a
slot="actions"
href=${`/config/integrations/integration/${integration.domain}#config_entry=${integration.entry_id}`}
>
<ha-list-item graphic="icon" hasMeta>
<img
slot="graphic"
alt=${domainToName(this.hass.localize, integration.domain)}
src=${brandsUrl(
{
domain: integration.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>
${domainToName(this.hass.localize, integration.domain)}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>`
);
const deviceInfo: TemplateResult[] = integrations.length
? [
html`<ha-list-nav slot="actions">
${integrations.map(
(integration) =>
html`<ha-list-item-button
href=${`/config/integrations/integration/${integration.domain}#config_entry=${integration.entry_id}`}
.headline=${domainToName(
this.hass.localize,
integration.domain
)}
>
<img
slot="start"
alt=${domainToName(this.hass.localize, integration.domain)}
src=${brandsUrl(
{
domain: integration.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
width="24"
height="24"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`
)}
</ha-list-nav>`,
]
: [];
const actions = [...(this._deviceActions || [])];
if (Array.isArray(this._diagnosticDownloadLinks)) {
@@ -502,41 +574,36 @@ export class HaConfigDevicePage extends LitElement {
"ui.panel.config.devices.automation.automations_heading"
)}
</h3>
${this._related.automation?.length
? html`
<div class="items">
${this._getRelated(
this._related
).automation.map((automation) =>
automation
? html`<a
href=${ifDefined(
automation.attributes.id
? `/config/automation/edit/${encodeURIComponent(automation.attributes.id)}`
: `/config/automation/show/${automation.entity_id}`
)}
>
<ha-list-item
hasMeta
.automation=${automation}
>
${computeStateName(automation)}
<ha-icon-next
slot="meta"
></ha-icon-next>
</ha-list-item>
</a>`
: nothing
)}
</div>
`
: html`
<ha-list-item noninteractive>
${this.hass.localize(
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
>
${this._related.automation?.length
? this._getRelated(
this._related
).automation.map((automation) =>
automation
? html`<ha-list-item-button
.headline=${computeStateName(
automation
)}
.href=${automation.attributes.id
? `/config/automation/edit/${encodeURIComponent(automation.attributes.id)}`
: `/config/automation/show/${automation.entity_id}`}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-button>`
: nothing
)
: html`<ha-list-item-base
.headline=${this.hass.localize(
"ui.panel.config.devices.automation.no_automations"
)}
</ha-list-item>
`}
></ha-list-item-base>`}
</ha-list-nav>
`
: nothing}
${isComponentLoaded(this.hass.config, "script")
@@ -546,12 +613,14 @@ export class HaConfigDevicePage extends LitElement {
"ui.panel.config.devices.script.scripts_heading"
)}
</h3>
${this._related.script?.length
? html`
<div class="items">
${this._getRelated(
this._related
).script.map((script) => {
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
>
${this._related.script?.length
? this._getRelated(this._related).script.map(
(script) => {
if (!script) {
return nothing;
}
@@ -562,28 +631,23 @@ export class HaConfigDevicePage extends LitElement {
? `/config/script/edit/${entry.unique_id}`
: `/config/script/show/${script.entity_id}`;
return html`
<a href=${url}>
<ha-list-item
hasMeta
.script=${script}
>
${computeStateName(script)}
<ha-icon-next
slot="meta"
></ha-icon-next>
</ha-list-item>
</a>
<ha-list-item-button
.headline=${computeStateName(script)}
.href=${url}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-button>
`;
})}
</div>
`
: html`
<ha-list-item noninteractive>
${this.hass.localize(
}
)
: html`<ha-list-item-base
.headline=${this.hass.localize(
"ui.panel.config.devices.script.no_scripts"
)}
</ha-list-item>
`}
></ha-list-item-base>`}
</ha-list-nav>
`
: nothing}
${hasSceneSupport
@@ -593,67 +657,69 @@ export class HaConfigDevicePage extends LitElement {
"ui.panel.config.devices.scene.scenes_heading"
)}
</h3>
${this._related.scene?.length
? html`
<div class="items">
${this._getRelated(this._related).scene.map(
(scene) =>
scene?.attributes.id
? html`
<a
href=${`/config/scene/edit/${scene.attributes.id}`}
>
<ha-list-item
hasMeta
.scene=${scene}
>
${computeStateName(scene)}
<ha-icon-next
slot="meta"
></ha-icon-next>
</ha-list-item>
</a>
`
: html`
<ha-list-item
.id="scene-${slugify(
scene.entity_id
)}"
hasMeta
.scene=${scene}
>
${computeStateName(scene)}
<ha-icon-next
slot="meta"
></ha-icon-next>
</ha-list-item>
<ha-tooltip
.for="scene-${slugify(
scene.entity_id
)}"
placement=${computeRTL(
this.hass.language,
this.hass.translationMetadata
.translations
)
? "left"
: "right"}
>
${this.hass.localize(
"ui.panel.config.devices.cant_edit"
)}
</ha-tooltip>
`
)}
</div>
`
: html`
<ha-list-item noninteractive>
${this.hass.localize(
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
>
${this._related.scene?.length
? this._getRelated(this._related).scene.map(
(scene) => {
if (!scene) {
return nothing;
}
const sceneId = `scene-${slugify(
scene.entity_id
)}`;
return scene.attributes.id
? html`
<ha-list-item-button
.headline=${computeStateName(
scene
)}
.href=${`/config/scene/edit/${scene.attributes.id}`}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-button>
`
: html`
<ha-list-item-base
id=${sceneId}
.headline=${computeStateName(
scene
)}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-base>
<ha-tooltip
.for=${sceneId}
placement=${computeRTL(
this.hass.language,
this.hass.translationMetadata
.translations
)
? "left"
: "right"}
>
${this.hass.localize(
"ui.panel.config.devices.cant_edit"
)}
</ha-tooltip>
`;
}
)
: html`<ha-list-item-base
.headline=${this.hass.localize(
"ui.panel.config.devices.scene.no_scenes"
)}
</ha-list-item>
`}
></ha-list-item-base>`}
</ha-list-nav>
`
: nothing}
`
@@ -705,16 +771,18 @@ export class HaConfigDevicePage extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
<a href=${`/config/entities?historyBack=1&device=${this.deviceId}`}>
<ha-dropdown-item>
<ha-svg-icon .path=${mdiShapeOutline} slot="icon"></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
<ha-icon-next slot="details"></ha-icon-next>
</ha-dropdown-item>
</a>
${NAVIGATION_ACTIONS.map(
(action) => html`
<ha-dropdown-item value=${action.value}>
<ha-svg-icon slot="icon" .path=${action.icon}></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.devices.quick_links.${action.countKey}`,
{ count: quickLinkCounts[action.countKey] }
)}
<ha-icon-next slot="details"></ha-icon-next>
</ha-dropdown-item>
`
)}
<wa-divider></wa-divider>
@@ -1361,6 +1429,11 @@ export class HaConfigDevicePage extends LitElement {
private _handleToolbarMenuAction(ev: HaDropdownSelectEvent) {
const action = ev.detail?.item?.value;
const navAction = NAVIGATION_ACTIONS.find((a) => a.value === action);
if (navAction) {
navigate(`${navAction.path}?historyBack=1&device=${this.deviceId}`);
return;
}
if (action === "reset_entity_ids") {
this._resetEntityIds();
}
@@ -1680,8 +1753,11 @@ export class HaConfigDevicePage extends LitElement {
height: 18px;
}
.items {
padding-bottom: var(--ha-space-4);
ha-list-item-base ha-icon-next,
ha-list-item-button ha-icon-next {
color: var(--secondary-text-color);
--mdc-icon-size: 24px;
display: block;
}
ha-card:has(ha-logbook) {
@@ -251,12 +251,13 @@ export class HaConfigDeviceDashboard extends LitElement {
}
private _setFiltersFromUrl() {
const area = this._searchParms.get("area");
const domain = this._searchParms.get("domain");
const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry");
const label = this._searchParms.has("label");
if (!domain && !configEntry && !label) {
if (!area && !domain && !configEntry && !label) {
return;
}
@@ -271,6 +272,10 @@ export class HaConfigDeviceDashboard extends LitElement {
],
items: undefined,
},
"ha-filter-floor-areas": {
value: area ? { areas: [area] } : undefined,
items: undefined,
},
"ha-filter-integrations": {
value: domain ? [domain] : [],
items: undefined,
@@ -39,11 +39,15 @@ export class EntitySettingsHelperTab extends LitElement {
@state() private _submitting = false;
@state() private _dirty = false;
@state() private _componentLoaded?: boolean;
@query("entity-registry-settings-editor")
private _registryEditor?: EntityRegistrySettingsEditor;
private _originalItemJson?: string;
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
this._componentLoaded = isComponentLoaded(
@@ -120,7 +124,9 @@ export class EntitySettingsHelperTab extends LitElement {
</ha-button>
<ha-button
@click=${this._updateItem}
.disabled=${!!this._submitting || !!(this._item && !this._item.name)}
.disabled=${!this._dirty ||
!!this._submitting ||
!!(this._item && !this._item.name)}
>
${this.hass.localize("ui.dialogs.entity_registry.editor.update")}
</ha-button>
@@ -128,8 +134,18 @@ export class EntitySettingsHelperTab extends LitElement {
`;
}
private get _isHelperDirty(): boolean {
if (!this._item || !this._originalItemJson) return false;
return JSON.stringify(this._item) !== this._originalItemJson;
}
private _updateDirty() {
this._dirty = (this._registryEditor?.dirty ?? false) || this._isHelperDirty;
}
private _entityRegistryChanged() {
this._error = undefined;
this._updateDirty();
}
private _valueChanged(ev: CustomEvent): void {
@@ -138,11 +154,15 @@ export class EntitySettingsHelperTab extends LitElement {
}
this._error = undefined;
this._item = ev.detail.value;
this._updateDirty();
}
private async _getItem() {
const items = await HELPERS_CRUD[this.entry.platform].fetch(this.hass!);
this._item = items.find((item) => item.id === this.entry.unique_id) || null;
this._originalItemJson = this._item
? JSON.stringify(this._item)
: undefined;
}
private async _updateItem(): Promise<void> {
@@ -208,6 +208,34 @@ export class EntityRegistrySettingsEditor extends LitElement {
private _deviceClassOptions?: string[][];
private _initialStateJson!: string;
private _lastDirty = false;
private _currentState() {
return {
name: this._name.trim() || null,
icon: this._icon.trim() || null,
entityId: this._entityId.trim(),
areaId: this._areaId ?? null,
labels: this._labels ?? [],
deviceClass: this._deviceClass,
disabledBy: this._disabledBy,
hiddenBy: this._hiddenBy,
unitOfMeasurement: this._unit_of_measurement,
precision: this._precision,
defaultCode: this._defaultCode,
calendarColor: this._calendarColor ?? null,
precipitationUnit: this._precipitation_unit,
pressureUnit: this._pressure_unit,
temperatureUnit: this._temperature_unit,
visibilityUnit: this._visibility_unit,
windSpeedUnit: this._wind_speed_unit,
switchAsDomain: this._switchAsDomain,
switchAsInvert: this._switchAsInvert,
};
}
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (
@@ -274,6 +302,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._wind_speed_unit = stateObj?.attributes?.wind_speed_unit;
}
this._initialStateJson = JSON.stringify(this._currentState());
this._lastDirty = false;
const deviceClasses: string[][] = OVERRIDE_DEVICE_CLASSES[domain];
if (!deviceClasses || this._hideDeviceClassOverride(domain)) {
@@ -372,6 +403,16 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._switchAsDomain = "switch";
this._switchAsInvert = false;
}
this._initialStateJson = JSON.stringify(this._currentState());
this._lastDirty = false;
}
if (this._initialStateJson) {
const dirty = this.dirty;
if (dirty !== this._lastDirty) {
this._lastDirty = dirty;
fireEvent(this, "change");
}
}
}
@@ -407,6 +448,23 @@ export class EntityRegistrySettingsEditor extends LitElement {
.disabled=${this.disabled}
@input=${this._nameChanged}
>
${this._device
? html`<span slot="hint"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.device_name_tip",
{
link: html`<button
class="link"
@click=${this._resetNameAndOpenDeviceSettings}
>
${this.hass.localize(
"ui.dialogs.entity_registry.editor.open_device_settings"
)}
</button>`,
}
)}</span
>`
: nothing}
</ha-input>`}
${this.hideIcon
? nothing
@@ -1060,6 +1118,10 @@ export class EntityRegistrySettingsEditor extends LitElement {
`;
}
public get dirty(): boolean {
return JSON.stringify(this._currentState()) !== this._initialStateJson;
}
public async updateEntry(): Promise<{
close: boolean;
entry: ExtEntityRegistryEntry;
@@ -1518,6 +1580,13 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
}
private _resetNameAndOpenDeviceSettings() {
this._name = this.entry.name || "";
fireEvent(this, "change");
this._openDeviceSettings();
}
private _openDeviceSettings() {
showDeviceRegistryDetailDialog(this, {
device: this._device!,
@@ -44,6 +44,8 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
@state() private _submitting?: boolean;
@state() private _dirty = false;
@query("entity-registry-settings-editor")
private _registryEditor?: EntityRegistrySettingsEditor;
@@ -144,7 +146,11 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
>
${this.hass.localize("ui.dialogs.entity_registry.editor.delete")}
</ha-button>
<ha-button @click=${this._updateEntry} .loading=${!!this._submitting}>
<ha-button
@click=${this._updateEntry}
.disabled=${!this._dirty || !!this._submitting}
.loading=${!!this._submitting}
>
${this.hass.localize("ui.dialogs.entity_registry.editor.update")}
</ha-button>
</div>
@@ -153,6 +159,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
private _entityRegistryChanged() {
this._error = undefined;
this._dirty = this._registryEditor?.dirty ?? false;
}
private _openDeviceSettings() {
@@ -1092,6 +1092,7 @@ export class HaConfigEntities extends LitElement {
}
private _setFiltersFromUrl() {
const area = this._searchParms.get("area");
const domain = this._searchParms.get("domain");
const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry");
@@ -1099,7 +1100,7 @@ export class HaConfigEntities extends LitElement {
const label = this._searchParms.get("label");
const voiceAssistant = this._searchParms.get("voice_assistant");
if (!domain && !configEntry && !label && !device) {
if (!area && !domain && !configEntry && !label && !device) {
return;
}
@@ -1108,6 +1109,7 @@ export class HaConfigEntities extends LitElement {
this._filters = {
"ha-filter-states": [],
"ha-filter-floor-areas": area ? { areas: [area] } : undefined,
"ha-filter-integrations": domain ? [domain] : [],
"ha-filter-devices": device ? [device] : [],
"ha-filter-labels": label ? [label] : [],
@@ -625,7 +625,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${this._searchParms.has("historyBack")
? undefined
: "/config"}
.route=${this.route}
.tabs=${configSections.devices}
.searchLabel=${this.hass.localize(
@@ -964,12 +966,13 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
};
private _setFiltersFromUrl() {
const area = this._searchParms.get("area");
const device = this._searchParms.get("device");
const label = this._searchParms.get("label");
const category = this._searchParms.get("category");
const voiceAssistant = this._searchParms.get("voice_assistant");
if (!category && !label && !device) {
if (!area && !category && !label && !device && !voiceAssistant) {
return;
}
@@ -977,6 +980,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
this._filter = history.state?.filter || "";
this._filters = {
"ha-filter-floor-areas": area ? { areas: [area] } : undefined,
"ha-filter-devices": device ? [device] : [],
"ha-filter-labels": label ? [label] : [],
"ha-filter-categories": category ? [category] : [],
@@ -19,6 +19,7 @@ import type {
HaScannerType,
} from "../../../../../data/bluetooth";
import {
isScannerStateMismatch,
subscribeBluetoothConnectionAllocations,
subscribeBluetoothScannerState,
subscribeBluetoothScannersDetails,
@@ -285,9 +286,7 @@ export class BluetoothAdapterInfoPage extends LitElement {
const scannerType: HaScannerType =
scannerDetails?.scanner_type ?? "unknown";
const isRemoteScanner = scannerType === "remote";
const hasMismatch =
scannerState &&
scannerState.current_mode !== scannerState.requested_mode;
const hasMismatch = scannerState && isScannerStateMismatch(scannerState);
const allocations = scannerDetails
? this._connectionAllocationData.find(
@@ -438,6 +437,13 @@ export class BluetoothAdapterInfoPage extends LitElement {
);
}
if (scannerState.requested_mode === "auto") {
return this.hass.localize(
"ui.panel.config.bluetooth.scanning_mode_auto_with_current",
{ current: this._formatMode(scannerState.current_mode) }
);
}
return this._formatModeLabel(scannerState.current_mode);
}
@@ -23,6 +23,7 @@ import type {
BluetoothScannerState,
} from "../../../../../data/bluetooth";
import {
isScannerStateMismatch,
subscribeBluetoothAdvertisements,
subscribeBluetoothConnectionAllocations,
subscribeBluetoothScannerState,
@@ -144,7 +145,7 @@ export class BluetoothConfigDashboard extends LitElement {
0
);
const hasMismatch = Object.values(this._scannerStates).some(
(s) => s.current_mode !== s.requested_mode
isScannerStateMismatch
);
const isOffline = adapterCount === 0;
const status = isOffline ? "offline" : hasMismatch ? "warning" : "online";
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
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 { copyToClipboard } from "../../../../../common/util/copy-clipboard";
@@ -121,6 +121,19 @@ class DialogBluetoothDeviceInfo extends LitElement {
)}
</tbody>
</table>
${this._params.entry.raw
? html`
<h4>
${this.hass.localize(
"ui.panel.config.bluetooth.raw_advertisement"
)}
</h4>
<div class="raw">
${this.showDataAsHex(this._params.entry.raw)}
</div>
`
: nothing}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@@ -133,6 +146,14 @@ class DialogBluetoothDeviceInfo extends LitElement {
</ha-dialog>
`;
}
static readonly styles: CSSResultGroup = css`
.raw {
word-break: break-all;
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-s);
}
`;
}
declare global {
@@ -0,0 +1,429 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiClose } from "@mdi/js";
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 "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-icon-button";
import "../../../../../components/input/ha-input-search";
import "../../../../../components/item/ha-list-item-option";
import type { HaListItemOption } from "../../../../../components/item/ha-list-item-option";
import "../../../../../components/list/ha-list-selectable";
import type { HaListSelectable } from "../../../../../components/list/ha-list-selectable";
import type { HaListSelectedDetail } from "../../../../../components/list/types";
import "../../../../../components/ha-spinner";
import type { ZHADeviceEndpoint, ZHAGroup } from "../../../../../data/zha";
import {
addMembersToGroup,
fetchGroup,
fetchGroupableDevices,
} from "../../../../../data/zha";
import type { HassDialog } from "../../../../../dialogs/make-dialog-manager";
import { haStyleScrollbar } from "../../../../../resources/styles";
import { loadVirtualizer } from "../../../../../resources/virtualizer";
import type { HomeAssistant } from "../../../../../types";
import type { ZHAAddGroupMembersDialogParams } from "./show-dialog-zha-add-group-members";
@customElement("dialog-zha-add-group-members")
class DialogZHAAddGroupMembers
extends LitElement
implements HassDialog<ZHAAddGroupMembersDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _deviceEndpoints: ZHADeviceEndpoint[] = [];
@state() private _filter = "";
@state() private _group?: ZHAGroup;
@state() private _loading = false;
@state() private _open = false;
@state() private _params?: ZHAAddGroupMembersDialogParams;
@state() private _processingAdd = false;
@state() private _selectedDevicesToAdd: string[] = [];
@state() private _virtualizerReady = false;
private _fetchDataToken = 0;
public showDialog(params: ZHAAddGroupMembersDialogParams): void {
this._params = params;
this._deviceEndpoints = [];
this._filter = "";
this._group = undefined;
this._selectedDevicesToAdd = [];
this._open = true;
this._fetchData();
}
public closeDialog(): boolean {
if (this._processingAdd) {
return false;
}
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._deviceEndpoints = [];
this._filter = "";
this._group = undefined;
this._loading = false;
this._processingAdd = false;
this._selectedDevicesToAdd = [];
this._virtualizerReady = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult | typeof nothing {
if (!this._params) {
return nothing;
}
const deviceEndpoints = this._filteredDeviceEndpoints;
const showSearch =
this._availableDeviceEndpoints.length > 5 || this._filter;
return html`
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.zha.groups.add_members"
)}
?prevent-scrim-close=${this._selectedDevicesToAdd.length > 0}
@after-show=${this._loadVirtualizer}
@closed=${this._dialogClosed}
>
<ha-icon-button
slot="headerNavigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
.disabled=${this._processingAdd}
@click=${this.closeDialog}
></ha-icon-button>
<div class="content">
${this._loading
? this._renderLoadingSpinner()
: html`
${showSearch
? html`
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleFilterChanged}
></ha-input-search>
`
: nothing}
<div class="list-container">
${deviceEndpoints.length
? html`
${this._virtualizerReady
? html`
<ha-list-selectable
multi
@ha-list-selected=${this._handleSelected}
>
<lit-virtualizer
scroller
class="ha-scrollbar"
.items=${deviceEndpoints}
.renderItem=${this._renderDeviceEndpoint}
.keyFunction=${this._keyFunction}
></lit-virtualizer>
</ha-list-selectable>
`
: this._renderLoadingSpinner()}
`
: html`
<div class="empty-list">
${this._filter
? this.hass.localize(
"ui.panel.config.zha.groups.no_devices_found"
)
: this.hass.localize(
"ui.panel.config.zha.groups.no_devices_to_add"
)}
</div>
`}
</div>
`}
</div>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
.disabled=${this._processingAdd}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${this._loading ||
!this._selectedDevicesToAdd.length ||
this._processingAdd}
.loading=${this._processingAdd}
@click=${this._addMembersToGroup}
>
${this.hass.localize("ui.panel.config.zha.groups.add_members")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _renderLoadingSpinner(): TemplateResult {
return html`
<div class="spinner-container">
<ha-spinner size="medium"></ha-spinner>
</div>
`;
}
private get _availableDeviceEndpoints(): ZHADeviceEndpoint[] {
if (!this._group) {
return [];
}
return this._deviceEndpoints.filter(
(deviceEndpoint) =>
!this._group!.members.some(
(member) =>
member.device.ieee === deviceEndpoint.device.ieee &&
member.endpoint_id === deviceEndpoint.endpoint_id
)
);
}
private get _filteredDeviceEndpoints(): ZHADeviceEndpoint[] {
const normalizedFilter = this._filter.trim().toLowerCase();
const deviceEndpoints = this._availableDeviceEndpoints;
if (!normalizedFilter) {
return deviceEndpoints;
}
return deviceEndpoints.filter((deviceEndpoint) =>
[
this._deviceEndpointName(deviceEndpoint),
this._deviceEndpointDetails(deviceEndpoint),
deviceEndpoint.device.ieee,
deviceEndpoint.device.manufacturer,
deviceEndpoint.device.model,
]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(normalizedFilter))
);
}
private async _loadVirtualizer(): Promise<void> {
await loadVirtualizer();
this._virtualizerReady = true;
}
private _keyFunction = (deviceEndpoint: unknown): string =>
this._deviceEndpointId(deviceEndpoint as ZHADeviceEndpoint);
private _renderDeviceEndpoint: RenderItemFunction<ZHADeviceEndpoint> = (
deviceEndpoint
) => {
const id = this._deviceEndpointId(deviceEndpoint);
return html`
<ha-list-item-option
appearance="checkbox"
.value=${id}
.selected=${this._selectedDevicesToAdd.includes(id)}
>
<span slot="headline">${this._deviceEndpointName(deviceEndpoint)}</span>
<span slot="supporting-text">
${this._deviceEndpointDetails(deviceEndpoint)}
</span>
</ha-list-item-option>
`;
};
private _deviceEndpointId(deviceEndpoint: ZHADeviceEndpoint): string {
return `${deviceEndpoint.device.ieee}_${deviceEndpoint.endpoint_id}`;
}
private _deviceEndpointName(deviceEndpoint: ZHADeviceEndpoint): string {
return deviceEndpoint.device.user_given_name || deviceEndpoint.device.name;
}
private _deviceEndpointDetails(deviceEndpoint: ZHADeviceEndpoint): string {
const entityNames = deviceEndpoint.entities.map(
(entity) => entity.name || entity.original_name || entity.entity_id
);
const entitySummary = entityNames.length
? entityNames.length > 2
? `${entityNames.slice(0, 2).join(", ")} +${entityNames.length - 2}`
: entityNames.join(", ")
: this.hass.localize("ui.panel.config.zha.groups.no_entities");
return [
deviceEndpoint.device.area_id
? this.hass.areas[deviceEndpoint.device.area_id]?.name
: undefined,
`${this.hass.localize("ui.panel.config.zha.groups.endpoint")} ${
deviceEndpoint.endpoint_id
}`,
entitySummary,
]
.filter(Boolean)
.join(" · ");
}
private async _fetchData(): Promise<void> {
const token = ++this._fetchDataToken;
this._loading = true;
const [group, deviceEndpoints] = await Promise.all([
fetchGroup(this.hass, this._params!.groupId),
fetchGroupableDevices(this.hass),
]);
if (token !== this._fetchDataToken || !this._params) {
return;
}
this._group = group;
this._deviceEndpoints = deviceEndpoints;
this._loading = false;
}
private _handleFilterChanged(ev: Event): void {
this._filter = (ev.currentTarget as HTMLInputElement).value;
}
private _handleSelected(ev: CustomEvent<HaListSelectedDetail>): void {
const list = ev.currentTarget as HaListSelectable;
let selectedDevicesToAdd = this._selectedDevicesToAdd;
ev.detail.diff?.added.forEach((index) => {
const item = list.items[index] as HaListItemOption | undefined;
if (item?.value && !selectedDevicesToAdd.includes(item.value)) {
selectedDevicesToAdd = [...selectedDevicesToAdd, item.value];
}
});
ev.detail.diff?.removed.forEach((index) => {
const item = list.items[index] as HaListItemOption | undefined;
if (item?.value) {
selectedDevicesToAdd = selectedDevicesToAdd.filter(
(selectedDeviceId) => selectedDeviceId !== item.value
);
}
});
this._selectedDevicesToAdd = selectedDevicesToAdd;
}
private async _addMembersToGroup(): Promise<void> {
this._processingAdd = true;
try {
const members = this._selectedDevicesToAdd.map((member) => {
const memberParts = member.split("_");
return { ieee: memberParts[0], endpoint_id: memberParts[1] };
});
const group = await addMembersToGroup(
this.hass,
this._params!.groupId,
members
);
this._params!.devicesAddedCallback(group);
this._processingAdd = false;
this.closeDialog();
} finally {
this._processingAdd = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.content {
display: flex;
flex-direction: column;
height: min(520px, calc(100vh - 240px));
}
ha-input-search {
display: block;
margin: 0 var(--ha-space-4) var(--ha-space-2);
}
ha-list-selectable {
display: block;
width: 100%;
height: 100%;
}
ha-list-selectable::part(base) {
width: 100%;
height: 100%;
}
.list-container {
flex: 1 1 auto;
width: 100%;
min-height: 0;
overflow: hidden;
}
lit-virtualizer {
display: block;
width: 100%;
height: 100%;
contain: size layout !important;
}
ha-list-item-option {
display: block;
width: 100%;
height: 64px;
box-sizing: border-box;
--ha-row-item-min-height: 64px;
}
.spinner-container {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
min-height: 160px;
}
ha-spinner {
display: block;
}
.empty-list {
padding: var(--ha-space-6);
color: var(--secondary-text-color);
text-align: center;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-zha-add-group-members": DialogZHAAddGroupMembers;
}
}
@@ -0,0 +1,22 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { ZHAGroup } from "../../../../../data/zha";
export interface ZHAAddGroupMembersDialogParams {
groupId: number;
groupName: string;
devicesAddedCallback: (group: ZHAGroup) => void;
}
export const loadZHAAddGroupMembersDialog = () =>
import("./dialog-zha-add-group-members");
export const showZHAAddGroupMembersDialog = (
element: HTMLElement,
params: ZHAAddGroupMembersDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-zha-add-group-members",
dialogImport: loadZHAAddGroupMembersDialog,
dialogParams: params,
});
};
@@ -3,16 +3,18 @@ import { css, html, LitElement } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import { navigate } from "../../../../../common/navigate";
import type { SelectionChangedEvent } from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/input/ha-input";
import type { ZHADeviceEndpoint, ZHAGroup } from "../../../../../data/zha";
import { addGroup, fetchGroupableDevices } from "../../../../../data/zha";
import "../../../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../../../types";
import "../../../ha-config-section";
import "../../../../../components/input/ha-input";
import "./zha-device-endpoint-data-table";
import type { ZHADeviceEndpointDataTable } from "./zha-device-endpoint-data-table";
import "./zha-device-endpoint-list";
import type {
DeviceEndpointSelectionChangedEvent,
ZHADeviceEndpointList,
} from "./zha-device-endpoint-list";
@customElement("zha-add-group-page")
export class ZHAAddGroupPage extends LitElement {
@@ -29,8 +31,8 @@ export class ZHAAddGroupPage extends LitElement {
@state() private _groupId?: string;
@query("zha-device-endpoint-data-table", true)
private _zhaDevicesDataTable!: ZHADeviceEndpointDataTable;
@query("zha-device-endpoint-list", true)
private _zhaDeviceEndpointList!: ZHADeviceEndpointList;
private _firstUpdatedCalled = false;
@@ -57,59 +59,67 @@ export class ZHAAddGroupPage extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.zha.groups.create_group")}
back-path="/config/zha/groups"
>
<ha-config-section .isWide=${!this.narrow}>
<p slot="introduction">
${this.hass.localize(
"ui.panel.config.zha.groups.create_group_details"
)}
</p>
<ha-input
type="string"
.value=${this._groupName}
@change=${this._handleNameChange}
.placeholder=${this.hass!.localize(
"ui.panel.config.zha.groups.group_name_placeholder"
)}
></ha-input>
<div class="container">
<ha-card class="details-card">
<div class="card-header">
${this.hass.localize("ui.panel.config.zha.groups.group_info")}
</div>
<div class="card-content">
<ha-input
type="text"
.value=${this._groupName}
@change=${this._handleNameChange}
.placeholder=${this.hass!.localize(
"ui.panel.config.zha.groups.group_name_placeholder"
)}
></ha-input>
<ha-input
type="number"
.value=${this._groupId}
@change=${this._handleGroupIdChange}
.placeholder=${this.hass!.localize(
"ui.panel.config.zha.groups.group_id_placeholder"
)}
></ha-input>
<ha-input
type="number"
.value=${this._groupId}
@change=${this._handleGroupIdChange}
.placeholder=${this.hass!.localize(
"ui.panel.config.zha.groups.group_id_placeholder"
)}
></ha-input>
</div>
</ha-card>
<div class="header">
${this.hass.localize("ui.panel.config.zha.groups.add_members")}
</div>
<section>
<h2>
${this.hass.localize("ui.panel.config.zha.groups.add_members")}
</h2>
<zha-device-endpoint-data-table
.hass=${this.hass}
.deviceEndpoints=${this.deviceEndpoints}
.narrow=${this.narrow}
selectable
@selection-changed=${this._handleAddSelectionChanged}
>
</zha-device-endpoint-data-table>
<div class="buttons">
<ha-button
.disabled=${!this._groupName ||
this._groupName === "" ||
this._processingAdd}
@click=${this._createGroup}
class="button"
.loading=${this._processingAdd}
<zha-device-endpoint-list
scrollable
show-device-link
.deviceEndpoints=${this.deviceEndpoints}
.narrow=${this.narrow}
.emptyText=${this.hass.localize(
"ui.panel.config.zha.groups.no_devices_to_add"
)}
selectable
@selection-changed=${this._handleAddSelectionChanged}
>
${this.hass!.localize(
"ui.panel.config.zha.groups.create"
)}</ha-button
>
</div>
</ha-config-section>
</zha-device-endpoint-list>
<div class="buttons">
<ha-button
.disabled=${!this._groupName ||
this._groupName === "" ||
this._processingAdd}
@click=${this._createGroup}
.loading=${this._processingAdd}
>
${this.hass!.localize(
"ui.panel.config.zha.groups.create"
)}</ha-button
>
</div>
</section>
</div>
</hass-subpage>
`;
}
@@ -119,7 +129,7 @@ export class ZHAAddGroupPage extends LitElement {
}
private _handleAddSelectionChanged(
ev: HASSDomEvent<SelectionChangedEvent>
ev: HASSDomEvent<DeviceEndpointSelectionChangedEvent>
): void {
this._selectedDevicesToAdd = ev.detail.value;
}
@@ -142,7 +152,7 @@ export class ZHAAddGroupPage extends LitElement {
this._selectedDevicesToAdd = [];
this._processingAdd = false;
this._groupName = "";
this._zhaDevicesDataTable.clearSelection();
this._zhaDeviceEndpointList.clearSelection();
navigate(`/config/zha/group/${group.group_id}`, { replace: true });
}
@@ -157,29 +167,54 @@ export class ZHAAddGroupPage extends LitElement {
static get styles(): CSSResultGroup {
return [
css`
.header {
font-family: var(--ha-font-family-body);
-webkit-font-smoothing: var(--ha-font-smoothing);
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
font-size: var(--ha-font-size-4xl);
font-weight: var(--ha-font-weight-normal);
.container {
box-sizing: border-box;
max-width: 720px;
margin: 0 auto;
padding: var(--ha-space-4) var(--ha-space-4)
calc(var(--ha-space-20) + var(--safe-area-inset-bottom, 0px));
}
.card-header {
padding: var(--ha-space-4) var(--ha-space-4) 0;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
opacity: var(--dark-primary-opacity);
}
.button {
float: right;
.card-content {
display: grid;
gap: var(--ha-space-4);
padding: var(--ha-space-4);
}
ha-config-section *:last-child {
padding-bottom: 24px;
section {
margin-top: var(--ha-space-8);
}
h2 {
margin: 0 0 var(--ha-space-3);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
zha-device-endpoint-list {
display: block;
min-width: 0;
}
.buttons {
align-items: flex-end;
padding: 16px;
display: flex;
justify-content: flex-end;
padding: var(--ha-space-4) 0 0;
}
.buttons .warning {
--mdc-theme-primary: var(--error-color);
@media (max-width: 600px) {
.container {
padding-inline: var(--ha-space-2);
}
}
`,
];
@@ -1,186 +0,0 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import "../../../../../components/data-table/ha-data-table";
import type {
DataTableColumnContainer,
DataTableRowData,
HaDataTable,
} from "../../../../../components/data-table/ha-data-table";
import type {
ZHADeviceEndpoint,
ZHAEntityReference,
} from "../../../../../data/zha";
import type { HomeAssistant } from "../../../../../types";
import { getAreaTableColumn } from "../../../common/data-table-columns";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { AreaRegistryEntry } from "../../../../../data/area/area_registry";
export interface DeviceEndpointRowData extends DataTableRowData {
id: string;
name: string;
area: string | undefined;
model: string;
manufacturer: string;
endpoint_id: number;
entities: ZHAEntityReference[];
}
@customElement("zha-device-endpoint-data-table")
export class ZHADeviceEndpointDataTable extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean }) public selectable = false;
@property({ attribute: false })
public deviceEndpoints: ZHADeviceEndpoint[] = [];
@query("ha-data-table", true) private _dataTable!: HaDataTable;
private _deviceEndpoints = memoizeOne(
(
deviceEndpoints: ZHADeviceEndpoint[],
areas: Record<string, AreaRegistryEntry>
) => {
const outputDevices: DeviceEndpointRowData[] = [];
deviceEndpoints.forEach((deviceEndpoint) => {
outputDevices.push({
name:
deviceEndpoint.device.user_given_name || deviceEndpoint.device.name,
area: deviceEndpoint.device.area_id
? areas[deviceEndpoint.device.area_id].name
: undefined,
model: deviceEndpoint.device.model,
manufacturer: deviceEndpoint.device.manufacturer,
id: deviceEndpoint.device.ieee + "_" + deviceEndpoint.endpoint_id,
ieee: deviceEndpoint.device.ieee,
endpoint_id: deviceEndpoint.endpoint_id,
entities: deviceEndpoint.entities,
dev_id: deviceEndpoint.device.device_reg_id,
});
});
return outputDevices;
}
);
private _columns = memoizeOne(
(localize: LocalizeFunc, narrow: boolean): DataTableColumnContainer =>
narrow
? {
name: {
title: localize("ui.panel.config.zha.groups.members"),
sortable: true,
filterable: true,
direction: "asc",
flex: 2,
template: (device) => html`
<a href=${`/config/devices/device/${device.dev_id}`}>
${device.name}
${device.area
? html` <br />
<span
style="font-size: var(--ha-font-size-s);color: var(--ha-color-text-secondary);"
>
${device.area}
</span>`
: nothing}
</a>
`,
},
endpoint_id: {
title: localize("ui.panel.config.zha.groups.endpoint"),
sortable: true,
filterable: true,
},
}
: {
name: {
title: localize("ui.panel.config.zha.groups.members"),
sortable: true,
filterable: true,
direction: "asc",
flex: 2,
template: (device) => html`
<a href=${`/config/devices/device/${device.dev_id}`}>
${device.name}
</a>
`,
},
area: getAreaTableColumn(localize),
endpoint_id: {
title: localize("ui.panel.config.zha.groups.endpoint"),
sortable: true,
filterable: true,
},
entities: {
title: localize("ui.panel.config.zha.groups.associated_entities"),
sortable: false,
filterable: false,
flex: 2,
template: (device) => html`
${device.entities.length
? device.entities.length > 3
? html`${device.entities
.slice(0, 2)
.map(
(entity) =>
html`<div
style="overflow: hidden; text-overflow: ellipsis;"
>
${entity.name || entity.original_name}
</div>`
)}
<div>+${device.entities.length - 2}</div>`
: device.entities.map(
(entity) =>
html`<div
style="overflow: hidden; text-overflow: ellipsis;"
>
${entity.name || entity.original_name}
</div>`
)
: localize(
"ui.panel.config.zha.groups.no_associated_entities"
)}
`,
},
}
);
public clearSelection() {
this._dataTable.clearSelection();
}
protected render(): TemplateResult {
return html`
<ha-data-table
.columns=${this._columns(this.hass.localize, this.narrow)}
.data=${this._deviceEndpoints(this.deviceEndpoints, this.hass.areas)}
.selectable=${this.selectable}
auto-height
.searchLabel=${this.hass.localize("ui.components.data-table.search")}
.noDataText=${this.hass.localize("ui.components.data-table.no-data")}
></ha-data-table>
`;
}
static get styles(): CSSResultGroup {
return [
css`
.table-cell-text {
word-break: break-word;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"zha-device-endpoint-data-table": ZHADeviceEndpointDataTable;
}
}
@@ -0,0 +1,397 @@
import { consume, type ContextType } from "@lit/context";
import { mdiOpenInNew } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-list";
import "../../../../../components/input/ha-input-search";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/item/ha-list-item-option";
import type { HaListItemOption } from "../../../../../components/item/ha-list-item-option";
import "../../../../../components/list/ha-list-selectable";
import type { HaListSelectable } from "../../../../../components/list/ha-list-selectable";
import type { HaListSelectedDetail } from "../../../../../components/list/types";
import {
areasContext,
internationalizationContext,
} from "../../../../../data/context";
import type {
ZHADeviceEndpoint,
ZHAEntityReference,
} from "../../../../../data/zha";
export interface DeviceEndpointRowData {
id: string;
name: string;
area: string | undefined;
model: string;
manufacturer: string;
endpoint_id: number;
entities: ZHAEntityReference[];
ieee: string;
dev_id: string;
}
export interface DeviceEndpointSelectionChangedEvent {
value: string[];
}
@customElement("zha-device-endpoint-list")
export class ZHADeviceEndpointList extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean }) public selectable = false;
@property({ type: Boolean }) public scrollable = false;
@property({ attribute: false }) public emptyText?: string;
@property({ attribute: "show-device-link", type: Boolean })
public showDeviceLink = false;
@property({ attribute: false })
public deviceEndpoints: ZHADeviceEndpoint[] = [];
@state() private _filter = "";
@state() private _selectedDeviceIds: string[] = [];
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: areasContext, subscribe: true })
private _areas!: ContextType<typeof areasContext>;
@query("ha-list-selectable") private _list?: HaListSelectable;
public clearSelection() {
this._selectedDeviceIds = [];
this._list?.clearSelection();
this._fireSelectionChanged();
}
protected render(): TemplateResult {
const allDeviceEndpoints = this._deviceEndpointRows;
const deviceEndpoints = this._filterDeviceEndpoints(allDeviceEndpoints);
const showSearch = allDeviceEndpoints.length > 5 || this._filter;
return html`
<ha-card
class=${`${showSearch ? "searchable" : ""} ${
this.scrollable ? "scrollable" : ""
}`}
>
${showSearch
? html`
<div class="search">
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleFilterChanged}
></ha-input-search>
</div>
`
: ""}
${deviceEndpoints.length
? html`
${this.selectable
? html`
<ha-list-selectable
multi
@ha-list-selected=${this._handleListSelectionChanged}
>
${repeat(
deviceEndpoints,
(deviceEndpoint) => deviceEndpoint.id,
(deviceEndpoint) =>
this._renderSelectableListRow(deviceEndpoint)
)}
</ha-list-selectable>
`
: html`
<ha-list>
${repeat(
deviceEndpoints,
(deviceEndpoint) => deviceEndpoint.id,
(deviceEndpoint) =>
this._renderReadonlyListRow(deviceEndpoint)
)}
</ha-list>
`}
`
: html`
<div class="empty-list">
${this._filter
? this._i18n.localize(
"ui.panel.config.zha.groups.no_devices_found"
)
: this.emptyText ||
this._i18n.localize("ui.components.data-table.no-data")}
</div>
`}
</ha-card>
`;
}
private get _deviceEndpointRows(): DeviceEndpointRowData[] {
return this.deviceEndpoints.map((deviceEndpoint) => ({
name: deviceEndpoint.device.user_given_name || deviceEndpoint.device.name,
area: deviceEndpoint.device.area_id
? this._areas[deviceEndpoint.device.area_id]?.name
: undefined,
model: deviceEndpoint.device.model,
manufacturer: deviceEndpoint.device.manufacturer,
id: `${deviceEndpoint.device.ieee}_${deviceEndpoint.endpoint_id}`,
ieee: deviceEndpoint.device.ieee,
endpoint_id: deviceEndpoint.endpoint_id,
entities: deviceEndpoint.entities,
dev_id: deviceEndpoint.device.device_reg_id,
}));
}
private _renderSelectableListRow(
deviceEndpoint: DeviceEndpointRowData
): TemplateResult {
const selected = this._selectedDeviceIds.includes(deviceEndpoint.id);
return html`
<ha-list-item-option
appearance="checkbox"
class="device-row"
.value=${deviceEndpoint.id}
.selected=${selected}
>
<span slot="headline">${deviceEndpoint.name}</span>
<span slot="supporting-text">
${this._deviceEndpointDetails(deviceEndpoint)}
</span>
${this.showDeviceLink
? html`
<ha-icon-button
slot="end"
.path=${mdiOpenInNew}
.href=${`/config/devices/device/${deviceEndpoint.dev_id}`}
.label=${this._i18n.localize(
"ui.panel.config.zha.groups.open_device"
)}
@click=${this._stopPropagation}
></ha-icon-button>
`
: nothing}
</ha-list-item-option>
`;
}
private _renderReadonlyListRow(
deviceEndpoint: DeviceEndpointRowData
): TemplateResult {
return html`
<ha-list-item-base class="device-row">
<span slot="headline">${deviceEndpoint.name}</span>
<span slot="supporting-text">
${this._deviceEndpointDetails(deviceEndpoint)}
</span>
${this.showDeviceLink
? html`
<ha-icon-button
slot="end"
.path=${mdiOpenInNew}
.href=${`/config/devices/device/${deviceEndpoint.dev_id}`}
.label=${this._i18n.localize(
"ui.panel.config.zha.groups.open_device"
)}
></ha-icon-button>
`
: nothing}
</ha-list-item-base>
`;
}
private _filterDeviceEndpoints(
deviceEndpoints: DeviceEndpointRowData[]
): DeviceEndpointRowData[] {
const normalizedFilter = this._filter.trim().toLowerCase();
if (!normalizedFilter) {
return deviceEndpoints;
}
return deviceEndpoints.filter((deviceEndpoint) =>
[
deviceEndpoint.name,
this._deviceEndpointDetails(deviceEndpoint),
deviceEndpoint.ieee,
deviceEndpoint.manufacturer,
deviceEndpoint.model,
]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(normalizedFilter))
);
}
private _deviceEndpointDetails(
deviceEndpoint: DeviceEndpointRowData
): string {
const entityNames = deviceEndpoint.entities.map(
(entity) => entity.name || entity.original_name || entity.entity_id
);
const entitySummary = entityNames.length
? entityNames.length > 2
? `${entityNames.slice(0, 2).join(", ")} +${entityNames.length - 2}`
: entityNames.join(", ")
: this._i18n.localize("ui.panel.config.zha.groups.no_entities");
return [
deviceEndpoint.area,
`${this._i18n.localize("ui.panel.config.zha.groups.endpoint")} ${
deviceEndpoint.endpoint_id
}`,
entitySummary,
]
.filter(Boolean)
.join(" · ");
}
private _handleFilterChanged(ev: Event): void {
this._filter = (ev.currentTarget as HTMLInputElement).value;
}
private _handleListSelectionChanged(
ev: CustomEvent<HaListSelectedDetail>
): void {
const list = ev.currentTarget as HaListSelectable;
let selectedDeviceIds = this._selectedDeviceIds;
ev.detail.diff?.added.forEach((index) => {
const item = list.items[index] as HaListItemOption | undefined;
if (item?.value) {
selectedDeviceIds = this._setSelectedDeviceId(
selectedDeviceIds,
item.value,
true
);
}
});
ev.detail.diff?.removed.forEach((index) => {
const item = list.items[index] as HaListItemOption | undefined;
if (item?.value) {
selectedDeviceIds = this._setSelectedDeviceId(
selectedDeviceIds,
item.value,
false
);
}
});
this._selectedDeviceIds = selectedDeviceIds;
this._fireSelectionChanged();
}
private _setSelectedDeviceId(
selectedDeviceIds: string[],
deviceId: string,
selected: boolean
): string[] {
if (selected) {
return selectedDeviceIds.includes(deviceId)
? selectedDeviceIds
: [...selectedDeviceIds, deviceId];
}
return selectedDeviceIds.filter((selectedDeviceId) => {
return selectedDeviceId !== deviceId;
});
}
private _fireSelectionChanged(): void {
this.dispatchEvent(
new CustomEvent<DeviceEndpointSelectionChangedEvent>(
"selection-changed",
{
detail: { value: this._selectedDeviceIds },
bubbles: true,
composed: true,
}
)
);
}
private _stopPropagation(ev: Event): void {
ev.stopPropagation();
}
static get styles(): CSSResultGroup {
return [
css`
ha-card.scrollable {
display: flex;
flex-direction: column;
overflow: hidden;
}
ha-card.searchable.scrollable {
height: min(520px, calc(100vh - 360px));
}
.search {
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
}
ha-list,
ha-list-selectable {
display: block;
width: 100%;
background: none;
padding: 0;
}
ha-list-selectable::part(base) {
width: 100%;
}
ha-card.scrollable ha-list,
ha-card.scrollable ha-list-selectable {
overflow-y: auto;
}
.device-row {
width: 100%;
--ha-row-item-min-height: 64px;
--ha-row-item-gap: var(--ha-space-3);
}
[slot="headline"],
[slot="supporting-text"] {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty-list {
padding: var(--ha-space-6);
color: var(--secondary-text-color);
text-align: center;
}
@media (max-width: 600px) {
ha-card.searchable.scrollable {
height: 440px;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"zha-device-endpoint-list": ZHADeviceEndpointList;
}
}
@@ -1,30 +1,29 @@
import { mdiDelete } from "@mdi/js";
import { mdiDelete, mdiPlus } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import { navigate } from "../../../../../common/navigate";
import type { SelectionChangedEvent } from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-list";
import "../../../../../components/ha-list-item";
import type { ZHADeviceEndpoint, ZHAGroup } from "../../../../../data/zha";
import "../../../../../components/ha-svg-icon";
import type { ZHAGroup } from "../../../../../data/zha";
import {
addMembersToGroup,
fetchGroup,
fetchGroupableDevices,
removeGroups,
removeMembersFromGroup,
} from "../../../../../data/zha";
import "../../../../../layouts/hass-error-screen";
import "../../../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../../../types";
import "../../../ha-config-section";
import { formatAsPaddedHex } from "./functions";
import "./zha-device-endpoint-data-table";
import type { ZHADeviceEndpointDataTable } from "./zha-device-endpoint-data-table";
import "./zha-device-endpoint-list";
import type {
DeviceEndpointSelectionChangedEvent,
ZHADeviceEndpointList,
} from "./zha-device-endpoint-list";
import { showZHAAddGroupMembersDialog } from "./show-dialog-zha-add-group-members";
@customElement("zha-group-page")
export class ZHAGroupPage extends LitElement {
@@ -38,25 +37,12 @@ export class ZHAGroupPage extends LitElement {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false })
public deviceEndpoints: ZHADeviceEndpoint[] = [];
@state() private _processingAdd = false;
@state() private _processingRemove = false;
@state()
private _filteredDeviceEndpoints: ZHADeviceEndpoint[] = [];
@state() private _selectedDevicesToAdd: string[] = [];
@state() private _selectedDevicesToRemove: string[] = [];
@query("#addMembers", true)
private _zhaAddMembersDataTable!: ZHADeviceEndpointDataTable;
@query("#removeMembers")
private _zhaRemoveMembersDataTable!: ZHADeviceEndpointDataTable;
private _zhaRemoveMembersList!: ZHADeviceEndpointList;
private _firstUpdatedCalled = false;
@@ -69,12 +55,8 @@ export class ZHAGroupPage extends LitElement {
public disconnectedCallback(): void {
super.disconnectedCallback();
this._processingAdd = false;
this._processingRemove = false;
this._selectedDevicesToRemove = [];
this._selectedDevicesToAdd = [];
this.deviceEndpoints = [];
this._filteredDeviceEndpoints = [];
}
protected firstUpdated(changedProperties: PropertyValues<this>): void {
@@ -102,6 +84,7 @@ export class ZHAGroupPage extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.group.name}
back-path="/config/zha/groups"
>
<ha-icon-button
slot="toolbar-icon"
@@ -109,158 +92,115 @@ export class ZHAGroupPage extends LitElement {
@click=${this._deleteGroup}
.label=${this.hass.localize("ui.panel.config.zha.groups.delete")}
></ha-icon-button>
<ha-config-section .isWide=${this.isWide}>
<div class="header">
${this.hass.localize("ui.panel.config.zha.groups.group_info")}
</div>
<p slot="introduction">
${this.hass.localize("ui.panel.config.zha.groups.group_details")}
</p>
<p><b>Name:</b> ${this.group.name}</p>
<p><b>Group Id:</b> ${formatAsPaddedHex(this.group.group_id)}</p>
<div class="header">
${this.hass.localize("ui.panel.config.zha.groups.members")}
</div>
<div class="container">
<ha-card>
<ha-list>
${this.group.members.length
? this.group.members.map(
(member) =>
html`<a
href="/config/devices/device/${member.device
.device_reg_id}"
>
<ha-list-item
>${member.device.user_given_name ||
member.device.name}</ha-list-item
>
</a>`
)
: html`
<ha-list-item> This group has no members </ha-list-item>
`}
</ha-list>
</ha-card>
${this.group.members.length
? html`
<div class="header">
${this.hass.localize(
"ui.panel.config.zha.groups.remove_members"
)}
</div>
<zha-device-endpoint-data-table
id="removeMembers"
.hass=${this.hass}
.deviceEndpoints=${this.group.members}
.narrow=${this.narrow}
selectable
@selection-changed=${this._handleRemoveSelectionChanged}
<div class="card-header">
${this.hass.localize("ui.panel.config.zha.groups.group_info")}
</div>
<div class="summary-grid">
<div>
<span class="summary-label"
>${this.hass.localize("ui.common.name")}</span
>
</zha-device-endpoint-data-table>
<span class="summary-value">${this.group.name}</span>
</div>
<div>
<span class="summary-label"
>${this.hass.localize(
"ui.panel.config.zha.groups.group_id"
)}</span
>
<span class="summary-value"
>${formatAsPaddedHex(this.group.group_id)}</span
>
</div>
<div>
<span class="summary-label"
>${this.hass.localize(
"ui.panel.config.zha.groups.members"
)}</span
>
<span class="summary-value">${this.group.members.length}</span>
</div>
</div>
</ha-card>
<div class="buttons">
<ha-button
appearance="plain"
size="small"
variant="danger"
.disabled=${!this._selectedDevicesToRemove.length ||
this._processingRemove}
@click=${this._removeMembersFromGroup}
class="button"
.loading=${this._processingRemove}
>
${this.hass!.localize(
"ui.panel.config.zha.groups.remove_members"
)}</ha-button
>
</div>
`
: nothing}
<div class="header">
${this.hass.localize("ui.panel.config.zha.groups.add_members")}
<div class="members-section">
<h2>${this.hass.localize("ui.panel.config.zha.groups.members")}</h2>
${this.group.members.length
? html`
<zha-device-endpoint-list
id="removeMembers"
scrollable
show-device-link
selectable
.deviceEndpoints=${this.group.members}
.narrow=${this.narrow}
.emptyText=${this.hass.localize(
"ui.panel.config.zha.groups.no_members"
)}
@selection-changed=${this._handleRemoveSelectionChanged}
></zha-device-endpoint-list>
`
: html`
<ha-card class="empty-card">
${this.hass.localize(
"ui.panel.config.zha.groups.no_members"
)}
</ha-card>
`}
<div class="buttons">
${this.group.members.length
? html`
<ha-button
appearance="plain"
variant="danger"
.disabled=${!this._selectedDevicesToRemove.length ||
this._processingRemove}
@click=${this._removeMembersFromGroup}
.loading=${this._processingRemove}
>
${this.hass.localize(
"ui.panel.config.zha.groups.remove_members"
)}
</ha-button>
`
: nothing}
<ha-button @click=${this._showAddMembersDialog}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.zha.groups.add_members")}
</ha-button>
</div>
</div>
<zha-device-endpoint-data-table
id="addMembers"
.hass=${this.hass}
.deviceEndpoints=${this._filteredDeviceEndpoints}
.narrow=${this.narrow}
selectable
@selection-changed=${this._handleAddSelectionChanged}
>
</zha-device-endpoint-data-table>
<div class="buttons">
<ha-button
appearance="plain"
size="small"
.disabled=${!this._selectedDevicesToAdd.length ||
this._processingAdd}
@click=${this._addMembersToGroup}
class="button"
.loading=${this._processingAdd}
>
${this.hass!.localize(
"ui.panel.config.zha.groups.add_members"
)}</ha-button
>
</div>
</ha-config-section>
</div>
</hass-subpage>
`;
}
private _showAddMembersDialog(): void {
showZHAAddGroupMembersDialog(this, {
groupId: this.groupId,
groupName: this.group!.name,
devicesAddedCallback: (group) => {
this.group = group;
this._selectedDevicesToRemove = [];
this._zhaRemoveMembersList?.clearSelection();
},
});
}
private async _fetchData() {
if (this.groupId !== null && this.groupId !== undefined) {
this.group = await fetchGroup(this.hass!, this.groupId);
this.group = await fetchGroup(this.hass, this.groupId);
}
this.deviceEndpoints = await fetchGroupableDevices(this.hass!);
// filter the groupable devices so we only show devices that aren't already in the group
this._filterDevices();
}
private _filterDevices() {
// filter the groupable devices so we only show devices that aren't already in the group
this._filteredDeviceEndpoints = this.deviceEndpoints.filter(
(deviceEndpoint) =>
!this.group!.members.some(
(member) =>
member.device.ieee === deviceEndpoint.device.ieee &&
member.endpoint_id === deviceEndpoint.endpoint_id
)
);
}
private _handleAddSelectionChanged(
ev: HASSDomEvent<SelectionChangedEvent>
): void {
this._selectedDevicesToAdd = ev.detail.value;
}
private _handleRemoveSelectionChanged(
ev: HASSDomEvent<SelectionChangedEvent>
ev: HASSDomEvent<DeviceEndpointSelectionChangedEvent>
): void {
this._selectedDevicesToRemove = ev.detail.value;
}
private async _addMembersToGroup(): Promise<void> {
this._processingAdd = true;
const members = this._selectedDevicesToAdd.map((member) => {
const memberParts = member.split("_");
return { ieee: memberParts[0], endpoint_id: memberParts[1] };
});
this.group = await addMembersToGroup(this.hass, this.groupId, members);
this._filterDevices();
this._selectedDevicesToAdd = [];
this._zhaAddMembersDataTable.clearSelection();
this._processingAdd = false;
}
private async _removeMembersFromGroup(): Promise<void> {
this._processingRemove = true;
const members = this._selectedDevicesToRemove.map((member) => {
@@ -268,9 +208,8 @@ export class ZHAGroupPage extends LitElement {
return { ieee: memberParts[0], endpoint_id: memberParts[1] };
});
this.group = await removeMembersFromGroup(this.hass, this.groupId, members);
this._filterDevices();
this._selectedDevicesToRemove = [];
this._zhaRemoveMembersDataTable.clearSelection();
this._zhaRemoveMembersList.clearSelection();
this._processingRemove = false;
}
@@ -285,30 +224,78 @@ export class ZHAGroupPage extends LitElement {
hass-subpage {
--app-header-text-color: var(--sidebar-icon-color);
}
.header {
font-family: var(--ha-font-family-body);
-webkit-font-smoothing: var(--ha-font-smoothing);
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
font-size: var(--ha-font-size-4xl);
font-weight: var(--ha-font-weight-normal);
.container {
box-sizing: border-box;
max-width: 720px;
margin: 0 auto;
padding: var(--ha-space-4) var(--ha-space-4)
calc(var(--ha-space-20) + var(--safe-area-inset-bottom, 0px));
}
.card-header {
padding: var(--ha-space-4) var(--ha-space-4) 0;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
opacity: var(--dark-primary-opacity);
}
.button {
float: right;
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--ha-space-4);
padding: var(--ha-space-4);
}
a {
color: var(--primary-color);
text-decoration: none;
.summary-label,
.summary-value {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.summary-label {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
line-height: var(--ha-line-height-condensed);
}
.summary-value {
margin-top: var(--ha-space-1);
font-size: var(--ha-font-size-l);
line-height: var(--ha-line-height-condensed);
}
.members-section {
margin-top: var(--ha-space-6);
}
h2 {
margin: 0 0 var(--ha-space-3);
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
.buttons {
align-items: flex-end;
padding: 16px;
display: flex;
gap: var(--ha-space-2);
justify-content: flex-end;
padding: var(--ha-space-4) 0 0;
}
.buttons .warning {
--mdc-theme-primary: var(--error-color);
.empty-card {
padding: var(--ha-space-6);
color: var(--secondary-text-color);
text-align: center;
}
@media (max-width: 600px) {
.summary-grid {
grid-template-columns: 1fr;
gap: var(--ha-space-2);
}
}
`,
];
+42 -2
View File
@@ -410,9 +410,15 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
if (!this._searchParms.has("label")) {
if (
!this._searchParms.has("area") &&
!this._searchParms.has("device") &&
!this._searchParms.has("label")
) {
this._filters = this._storageFilters;
}
this._filterArea();
this._filterDevice();
this._filterLabel();
}
}
@@ -454,7 +460,9 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${this._searchParms.has("historyBack")
? undefined
: "/config"}
.route=${this.route}
.tabs=${configSections.automations}
.searchLabel=${this.hass.localize(
@@ -785,6 +793,38 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
this._applyFilters();
}
private _filterArea() {
const area = this._searchParms.get("area");
if (!area) {
return;
}
this._fromUrl = true;
this._filters = {
...this._filters,
"ha-filter-floor-areas": {
value: { areas: [area] },
items: undefined,
},
};
this._applyFilters();
}
private _filterDevice() {
const device = this._searchParms.get("device");
if (!device) {
return;
}
this._fromUrl = true;
this._filters = {
...this._filters,
"ha-filter-devices": {
value: [device],
items: undefined,
},
};
this._applyFilters();
}
private _filterLabel() {
const label = this._searchParms.get("label");
if (!label) {
+45 -2
View File
@@ -431,7 +431,9 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${this._searchParms.has("historyBack")
? undefined
: "/config"}
.route=${this.route}
.tabs=${configSections.automations}
.searchLabel=${this.hass.localize(
@@ -783,10 +785,19 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
const hasUrlFilter =
this._searchParms.has("blueprint") || this._searchParms.has("label");
this._searchParms.has("area") ||
this._searchParms.has("blueprint") ||
this._searchParms.has("device") ||
this._searchParms.has("label");
if (!hasUrlFilter) {
this._filters = this._storageFilters;
}
if (this._searchParms.has("area")) {
this._filterArea();
}
if (this._searchParms.has("device")) {
this._filterDevice();
}
if (this._searchParms.has("blueprint")) {
this._filterBlueprint();
}
@@ -803,6 +814,38 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
}
}
private _filterArea() {
const area = this._searchParms.get("area");
if (!area) {
return;
}
this._fromUrl = true;
this._filters = {
...this._filters,
"ha-filter-floor-areas": {
value: { areas: [area] },
items: undefined,
},
};
this._applyFilters();
}
private _filterDevice() {
const device = this._searchParms.get("device");
if (!device) {
return;
}
this._fromUrl = true;
this._filters = {
...this._filters,
"ha-filter-devices": {
value: [device],
items: undefined,
},
};
this._applyFilters();
}
private _filterLabel() {
const label = this._searchParms.get("label");
if (!label) {
+2 -2
View File
@@ -4,7 +4,7 @@ import { ifDefined } from "lit/directives/if-defined";
import "../../layouts/hass-error-screen";
import "../../layouts/hass-subpage";
import type { HomeAssistant, PanelInfo } from "../../types";
import { IFRAME_SANDBOX } from "../../util/iframe";
import { IFRAME_SANDBOX_SAME_ORIGIN } from "../../util/iframe";
@customElement("ha-panel-iframe")
class HaPanelIframe extends LitElement {
@@ -41,7 +41,7 @@ class HaPanelIframe extends LitElement {
this.panel.title === null ? undefined : this.panel.title
)}
src=${this.panel.config.url}
.sandbox=${IFRAME_SANDBOX}
.sandbox=${IFRAME_SANDBOX_SAME_ORIGIN}
allow="fullscreen"
></iframe>
</hass-subpage>
@@ -2,7 +2,7 @@ import {
mdiPause,
mdiPlay,
mdiPlayPause,
mdiPower,
mdiPowerStandby,
mdiPowerOff,
mdiPowerOn,
mdiRepeat,
@@ -225,7 +225,7 @@ class HuiMediaPlayerPlaybackCardFeature
supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_OFF)
) {
buttons.push({
icon: assumedState ? mdiPowerOff : mdiPower,
icon: assumedState ? mdiPowerOff : mdiPowerStandby,
action: "turn_off",
});
}
@@ -237,7 +237,7 @@ class HuiMediaPlayerPlaybackCardFeature
supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_ON)
) {
buttons.push({
icon: assumedState ? mdiPowerOn : mdiPower,
icon: assumedState ? mdiPowerOn : mdiPowerStandby,
action: "turn_on",
});
}
+2 -2
View File
@@ -13,7 +13,7 @@ import type {
LovelaceGridOptions,
} from "../types";
import type { IframeCardConfig } from "./types";
import { IFRAME_SANDBOX } from "../../../util/iframe";
import { IFRAME_SANDBOX_SAME_ORIGIN } from "../../../util/iframe";
@customElement("hui-iframe-card")
export class HuiIframeCard extends LitElement implements LovelaceCard {
@@ -95,7 +95,7 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
}
const sandbox_params = this._config.disable_sandbox
? undefined
: `${sandbox_user_params} ${IFRAME_SANDBOX}`;
: `${sandbox_user_params} ${IFRAME_SANDBOX_SAME_ORIGIN}`;
return html`
<ha-card
@@ -753,9 +753,7 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
ha-icon-button[action="media_play"],
ha-icon-button[action="media_play_pause"],
ha-icon-button[action="media_pause"],
ha-icon-button[action="media_stop"],
ha-icon-button[action="turn_on"],
ha-icon-button[action="turn_off"] {
ha-icon-button[action="media_stop"] {
--ha-icon-button-size: 56px;
--mdc-icon-size: 40px;
}
@@ -844,8 +842,10 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
.narrow ha-icon-button[action="media_play"],
.narrow ha-icon-button[action="media_play_pause"],
.narrow ha-icon-button[action="media_pause"],
.narrow ha-icon-button[action="turn_on"] {
.narrow
ha-icon-button[action="media_pause"]
.narrow
ha-icon-button[action="media_stop"] {
--ha-icon-button-size: 50px;
--mdc-icon-size: 36px;
}
@@ -5,7 +5,10 @@ import { customElement, property, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { fireEvent } from "../../../common/dom/fire_event";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import { formatNumber } from "../../../common/number/format_number";
import {
formatNumber,
getNumberFormatOptions,
} from "../../../common/number/format_number";
import "../../../components/ha-alert";
import "../../../components/ha-card";
import "../../../components/ha-state-icon";
@@ -226,7 +229,14 @@ export class HuiStatisticCard extends LitElement implements LovelaceCard {
? ""
: this._value === null
? "?"
: formatNumber(this._value, this.hass.locale)}</span
: formatNumber(
this._value,
this.hass.locale,
getNumberFormatOptions(
undefined,
this.hass.entities[this._config.entity]
)
)}</span
>
<span class="measurement"
>${this._config.unit ||
@@ -229,9 +229,6 @@ export class HuiEntityEditor extends LitElement {
}
static styles = css`
ha-entity-picker {
margin-top: 8px;
}
.entity {
display: flex;
align-items: center;
@@ -253,6 +250,11 @@ export class HuiEntityEditor extends LitElement {
ha-md-list {
gap: 8px;
padding-top: 0;
display: flex;
flex-direction: column;
}
ha-md-list:has(> *) {
margin-bottom: var(--ha-space-2);
}
ha-md-list-item {
border: 1px solid var(--divider-color);
@@ -2,6 +2,7 @@ import { consume } from "@lit/context";
import {
mdiChevronDown,
mdiChevronRight,
mdiChevronLeft,
mdiMagnify,
mdiTextureBox,
} from "@mdi/js";
@@ -16,6 +17,7 @@ import { computeEntityName } from "../../../../common/entity/compute_entity_name
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { computeRTL } from "../../../../common/util/compute_rtl";
import { debounce } from "../../../../common/util/debounce";
import { mainWindow } from "../../../../common/dom/get_main_window";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-combo-box-item";
import "../../../../components/ha-domain-icon";
@@ -294,7 +296,11 @@ export class HuiSuggestionEntityTree extends LitElement {
private _renderChevron(expanded: boolean): TemplateResult {
return html`<ha-svg-icon
class="chevron"
.path=${expanded ? mdiChevronDown : mdiChevronRight}
.path=${expanded
? mdiChevronDown
: mainWindow.document.dir === "rtl"
? mdiChevronLeft
: mdiChevronRight}
></ha-svg-icon>`;
}
@@ -143,6 +143,10 @@ export class HuiViewEditor extends LitElement {
const data = {
...this._config,
type: this._type,
theme:
this._config.theme?.toLowerCase() === "backend-selected"
? undefined
: this._config.theme,
};
if (data.max_columns === undefined && this._type === SECTIONS_VIEW_LAYOUT) {
@@ -2,7 +2,7 @@ import {
mdiPause,
mdiPlay,
mdiPlayPause,
mdiPower,
mdiPowerStandby,
mdiPowerOff,
mdiPowerOn,
mdiSkipNext,
@@ -198,7 +198,7 @@ class HuiMediaPlayerEntityRow extends LitElement implements LovelaceRow {
entityState !== UNAVAILABLE
? html`
<ha-icon-button
.path=${assumedState ? mdiPowerOn : mdiPower}
.path=${assumedState ? mdiPowerOn : mdiPowerStandby}
.label=${this.hass.localize("ui.card.media_player.turn_on")}
@click=${this._turnOn}
></ha-icon-button>
@@ -216,7 +216,7 @@ class HuiMediaPlayerEntityRow extends LitElement implements LovelaceRow {
(stateActive(stateObj) || assumedState)
? html`
<ha-icon-button
.path=${assumedState ? mdiPowerOff : mdiPower}
.path=${assumedState ? mdiPowerOff : mdiPowerStandby}
.label=${this.hass.localize("ui.card.media_player.turn_off")}
@click=${this._turnOff}
></ha-icon-button>
+28 -40
View File
@@ -1,12 +1,11 @@
import type { PropertyValues, TemplateResult } from "lit";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { normalizeLuminance } from "../../common/color/palette";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-select";
import type { HaSelectSelectEvent } from "../../components/ha-select";
import "../../components/ha-settings-row";
import "../../components/ha-theme-picker";
import "../../components/input/ha-input";
import "../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../components/radio/ha-radio-group";
@@ -20,11 +19,14 @@ import {
DefaultAccentColor,
DefaultPrimaryColor,
} from "../../resources/theme/color/color.globals";
import type { HomeAssistant, ThemeSettings } from "../../types";
import type {
HomeAssistant,
ThemeSettings,
ValueChangedEvent,
} from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { clearSelectedThemeState } from "../../util/ha-pref-storage";
const USE_DEFAULT_THEME = "__USE_DEFAULT_THEME__";
const HOME_ASSISTANT_THEME = "default";
@customElement("ha-pick-theme-row")
@@ -33,8 +35,6 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
@property({ type: Boolean }) public narrow = false;
@state() _themeNames: string[] = [];
@state() private _userTheme?: ThemeSettings | null;
@state() private _migrating = false;
@@ -88,24 +88,17 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
${this.hass.localize("ui.panel.profile.themes.link_promo")}
</a>
</span>
<ha-select
<ha-theme-picker
.hass=${this.hass}
.label=${this.hass.localize("ui.panel.profile.themes.dropdown_label")}
.noThemeLabel=${this.hass.localize(
"ui.panel.profile.themes.use_default"
)}
.value=${this.hass.selectedTheme?.theme || undefined}
.disabled=${!hasThemes}
.value=${this.hass.selectedTheme?.theme || USE_DEFAULT_THEME}
@selected=${this._handleThemeSelection}
.options=${[
{
value: USE_DEFAULT_THEME,
label: this.hass.localize("ui.panel.profile.themes.use_default"),
},
{ value: HOME_ASSISTANT_THEME, label: "Home Assistant" },
...this._themeNames.map((theme) => ({
value: theme,
label: theme,
})),
]}
>
</ha-select>
include-default
@value-changed=${this._handleThemeSelection}
></ha-theme-picker>
</ha-settings-row>
${curTheme === HOME_ASSISTANT_THEME ||
(curThemeIsUseDefault &&
@@ -194,17 +187,6 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
`;
}
public willUpdate(changedProperties: PropertyValues<this>) {
const oldHass = changedProperties.get("hass") as undefined | HomeAssistant;
const themesChanged =
changedProperties.has("hass") &&
(!oldHass || oldHass.themes.themes !== this.hass.themes.themes);
if (themesChanged) {
this._themeNames = Object.keys(this.hass.themes.themes).sort();
}
}
private _handleColorChange(ev: CustomEvent) {
const target = ev.target as any;
@@ -245,13 +227,14 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
fireEvent(this, "settheme", { dark });
}
private _handleThemeSelection(ev: HaSelectSelectEvent) {
private _handleThemeSelection(
ev: ValueChangedEvent<string | undefined>
): void {
ev.stopPropagation();
const theme = ev.detail.value;
if (theme === this.hass.selectedTheme?.theme) {
return;
}
if (theme === USE_DEFAULT_THEME) {
if (theme === undefined) {
// undefined = "use default"
if (this.hass.selectedTheme?.theme) {
fireEvent(this, "settheme", {
theme: "",
@@ -261,6 +244,11 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
}
return;
}
if (theme === this.hass.selectedTheme?.theme) {
return;
}
fireEvent(this, "settheme", {
theme,
primaryColor: undefined,
@@ -320,7 +308,7 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
margin: 0 4px;
}
ha-select {
ha-theme-picker {
display: block;
width: 100%;
}
+13 -4
View File
@@ -313,8 +313,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
});
clearInterval(this.__backendPingInterval);
// Fetch the brands access token on initial connect and schedule refresh
fetchAndScheduleBrandsAccessToken(this.hass!);
this._refreshBrandsAccessToken();
this.__backendPingInterval = setInterval(() => {
if (this.hass?.connected) {
@@ -340,8 +339,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this._updateHass({ connected: true });
broadcastConnectionStatus("connected");
// Refresh the brands access token on reconnect and restart refresh schedule
fetchAndScheduleBrandsAccessToken(this.hass!);
this._refreshBrandsAccessToken();
// on reconnect always fetch config as we might miss an update while we were disconnected
// @ts-ignore
@@ -362,4 +360,15 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
clearInterval(this.__backendPingInterval);
clearBrandsTokenRefresh();
}
private async _refreshBrandsAccessToken() {
// The brands WS handler may not be registered yet after a server restart;
// fetchAndScheduleBrandsAccessToken retries internally. If the token
// changed, re-render so any brand <img> elements that rendered against a
// different (or missing) token recompute their src and re-fetch.
const changed = await fetchAndScheduleBrandsAccessToken(this.hass!);
if (changed) {
this._updateHass({});
}
}
};
+43 -4
View File
@@ -1969,6 +1969,7 @@
"entity_disabled": "This entity is disabled.",
"enable_entity": "Enable",
"open_device_settings": "Open device settings",
"device_name_tip": "Consider renaming the device instead to update all its entities at once. {link}",
"switch_as_x_confirm": "This switch will be hidden and a new {domain} will be added. Your existing configurations using the switch will continue to work.",
"switch_as_x_remove_confirm": "This {domain} will be removed and the original switch will be visible again. Your existing configurations using the {domain} will no longer work!",
"switch_as_x_change_confirm": "This {domain_1} will be removed and will be replaced by a new {domain_2}. Your existing configurations using the {domain_1} will no longer work!",
@@ -2717,6 +2718,14 @@
}
},
"common": {
"quick_links": {
"devices": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"helpers": "{count} {count, plural,\n one {helper}\n other {helpers}\n}",
"automations": "{count} {count, plural,\n one {automation}\n other {automations}\n}",
"scenes": "{count} {count, plural,\n one {scene}\n other {scenes}\n}",
"scripts": "{count} {count, plural,\n one {script}\n other {scripts}\n}"
},
"editor": {
"confirm_unsaved": "You have unsaved changes. Are you sure you want to leave?"
},
@@ -2735,6 +2744,11 @@
"updates": {
"caption": "Updates",
"description": "Manage updates of Home Assistant, apps, and devices",
"group_system": "Home Assistant",
"group_integrations": "Integrations",
"group_apps": "Apps",
"update_all": "Update all",
"update_all_failed": "Failed to start updates",
"no_updates": "No updates available",
"no_update_entities": {
"title": "Unable to check for updates",
@@ -3127,6 +3141,14 @@
"caption": "Areas",
"description": "Group devices and entities into areas",
"edit_settings": "Area settings",
"quick_links": {
"devices": "[%key:ui::panel::config::common::quick_links::devices%]",
"entities": "[%key:ui::panel::config::common::quick_links::entities%]",
"helpers": "[%key:ui::panel::config::common::quick_links::helpers%]",
"automations": "[%key:ui::panel::config::common::quick_links::automations%]",
"scenes": "[%key:ui::panel::config::common::quick_links::scenes%]",
"scripts": "[%key:ui::panel::config::common::quick_links::scripts%]"
},
"add_picture": "Add a picture",
"assigned_to_area": "Assigned to this area",
"targeting_area": "Targeting this area",
@@ -5164,10 +5186,9 @@
},
"id": "Trigger ID",
"optional": "Optional",
"add_id": "Add trigger ID",
"edit_id": "Edit trigger ID",
"id_description": "Use trigger IDs in a Triggered by condition to run different actions depending on which trigger started the automation.",
"edit_id": "Edit ID",
"duplicate": "[%key:ui::common::duplicate%]",
"duplicate_id_warning": "This trigger ID is used multiple times in this automation. Trigger IDs should be unique.",
"re_order": "Re-order",
"rename": "Rename",
"cut": "Cut",
@@ -5593,6 +5614,8 @@
"trigger": {
"label": "Triggered by",
"no_triggers": "There are no triggers with ID's set in this automation. Edit a trigger and give it a Trigger ID name.",
"duplicated_info": "This ID is used by multiple triggers. Trigger IDs should be unique.",
"unavailable_info": "No trigger has the ID {id}. Set this ID on a trigger to use it.",
"id": "Trigger",
"description": {
"picker": "Tests if the automation has been triggered by a specific trigger.",
@@ -6469,6 +6492,13 @@
"device_info": "{type} info",
"edit_settings": "Edit settings",
"restore_entity_ids": "Recreate entity IDs",
"quick_links": {
"entities": "[%key:ui::panel::config::common::quick_links::entities%]",
"helpers": "[%key:ui::panel::config::common::quick_links::helpers%]",
"automations": "[%key:ui::panel::config::common::quick_links::automations%]",
"scenes": "[%key:ui::panel::config::common::quick_links::scenes%]",
"scripts": "[%key:ui::panel::config::common::quick_links::scripts%]"
},
"unnamed_device": "Unnamed {type}",
"unknown_error": "Unknown error",
"name": "Name",
@@ -7108,6 +7138,7 @@
"scanning_mode_passive": "passive",
"scanning_mode_active_label": "Active scanning",
"scanning_mode_passive_label": "Passive scanning",
"scanning_mode_auto_with_current": "Auto ({current})",
"scanning_mode_none_label": "No scanning",
"scanner_mode_mismatch": "{name} requested {requested} mode but is operating in {current} mode. The scanner is in a bad state and needs to be power cycled.",
"scanner_mode_mismatch_remote": "For proxies: reboot the device",
@@ -7125,6 +7156,7 @@
"manufacturer_data": "Manufacturer data",
"service_data": "Service data",
"service_uuids": "Service UUIDs",
"raw_advertisement": "Raw advertisement",
"copy_to_clipboard": "[%key:ui::panel::config::automation::editor::copy_to_clipboard%]",
"area": "Area",
"scanners": "Scanners",
@@ -7326,8 +7358,15 @@
"group_details": "Here are all the details for the selected Zigbee group.",
"group_not_found": "Group not found!",
"add_members": "Add devices",
"remove_members": "Remove device",
"remove_members": "Remove devices",
"removing_members": "Removing devices",
"no_members": "This group has no devices",
"no_devices_found": "No devices found",
"no_devices_to_add": "No devices to add",
"no_entities": "No entities",
"entity_count": "{count} entity",
"entity_count_plural": "{count} entities",
"open_device": "Open device",
"create_group_details": "Enter the required details to create a new Zigbee group",
"group_name_placeholder": "Group name",
"group_id_placeholder": "Group ID (optional)",
+28 -7
View File
@@ -1,3 +1,4 @@
import { waitForMs } from "../common/util/wait";
import type { HomeAssistant } from "../types";
export interface BrandsOptions {
@@ -20,15 +21,35 @@ let _brandsRefreshInterval: ReturnType<typeof setInterval> | undefined;
// Re-fetch every 30 minutes to always have a valid token.
const TOKEN_REFRESH_MS = 30 * 60 * 1000;
export const fetchAndScheduleBrandsAccessToken = (
// Delays before each attempt. The first attempt fires immediately; subsequent
// ones back off to ride through the window after a Home Assistant restart
// where the WebSocket server accepts connections but the brands integration
// hasn't registered its WS handler yet. On older backends without the command,
// every attempt fails and we give up.
const FETCH_DELAYS_MS = [0, 500, 1000, 2000, 5000, 10000, 15000];
// Returns true if the cached token changed as a result of this call, so
// callers can decide whether they need to trigger a re-render.
export const fetchAndScheduleBrandsAccessToken = async (
hass: HomeAssistant
): Promise<void> =>
fetchBrandsAccessToken(hass).then(
() => scheduleBrandsTokenRefresh(hass),
() => {
// Ignore failures; older backends may not support this command
): Promise<boolean> => {
const previousToken = _brandsAccessToken;
/* eslint-disable no-await-in-loop -- retries are intentionally sequential */
for (const delay of FETCH_DELAYS_MS) {
if (delay) {
await waitForMs(delay);
}
);
try {
await fetchBrandsAccessToken(hass);
scheduleBrandsTokenRefresh(hass);
return _brandsAccessToken !== previousToken;
} catch {
// try next delay
}
}
/* eslint-enable no-await-in-loop */
return false;
};
export const fetchBrandsAccessToken = async (
hass: HomeAssistant

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