Compare commits

..

107 Commits

Author SHA1 Message Date
Paul Bottein
bd7b345fe2 Improve strategy edit mode 2025-08-18 13:43:18 +02:00
Norbert Rittel
939a3cdf63 Fix missing sentence-casing in People settings (#26539)
* Fix missing sentence-casing in People settings

* Remove hyphen from "presence detection integration"
2025-08-14 16:56:02 +02:00
Wendelin
208fd0662c Migrate ha-button-toggle-group to webawesome (#26506)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-08-14 14:52:39 +02:00
renovate[bot]
f133f246cb Update vaadinWebComponents monorepo to v24.8.5 (#25954)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-14 14:51:25 +02:00
Paul Bottein
b9b8997d68 Makes ha-combo-box clear value compatible with vaadin 24.8 (#26530) 2025-08-14 14:50:24 +02:00
Aidan Timson
46c4a19a13 Fix conditional card options alignment (#26534) 2025-08-14 12:27:21 +00:00
Paul Bottein
8d63654211 Create overview dashboard with lights, covers, energy and favorite entities (#26504)
* Create overview dashboard with light, cover, energy and favorite

* Add welcome message

* Add person entities

* Add editor for favorite entities

* Don't use property
2025-08-14 11:56:35 +02:00
Wendelin
3bf25f125b Automation editor sidebar (#26413) 2025-08-14 10:06:16 +02:00
Petar Petrov
8c65876413 Filter hidden entities from group more-info (#26527) 2025-08-13 17:50:00 +02:00
Bram Kragten
2ab6d49553 Update tabs when user data changes (#26524) 2025-08-13 16:24:56 +02:00
Aidan Timson
67b0cf0952 Add small amount of extra padding to ohf card in about section (#26523) 2025-08-13 17:23:01 +03:00
Bram Kragten
5138276f8a Show password forgot link on mobile (#26526)
show password forgot link on mobile
2025-08-13 17:22:17 +03:00
Paul Bottein
30e6777529 Fix related entities click behavior in the more info dialog (#26525) 2025-08-13 16:08:52 +02:00
Aidan Timson
1686ab4b9d Add valve position card feature (#26511) 2025-08-13 15:04:44 +03:00
Wendelin
b7102c0d7d Fix ha-button icon padding (#26517) 2025-08-13 13:40:44 +02:00
Petar Petrov
d41d524850 Show battery in and out energy in Sankey chart (#26490) 2025-08-13 12:40:45 +03:00
renovate[bot]
4f05f6305a Update fullcalendar monorepo to v6.1.19 (#26516)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 09:50:40 +02:00
karwosts
ba0b1239be Fix search in automation yaml editor (#26513) 2025-08-13 09:07:50 +03:00
Wendelin
708b68f35d Fix handling empty release notes in more-info-update (#26515)
Fix handling empty release nots in more-info-update
2025-08-13 09:03:25 +03:00
Aidan Timson
3108e98b97 Update ha-spinner component to webawesome (#26507) 2025-08-12 21:00:40 +02:00
Bram Kragten
ba7609cc2c Fix style variable in base chart (#26509) 2025-08-12 17:14:29 +02:00
Bram Kragten
506fd7d480 center spinner 2025-08-12 15:30:47 +02:00
Bram Kragten
9767ebe1fb Show spinner when loading application credential config (#26510) 2025-08-12 15:25:18 +02:00
Aidan Timson
539e89e7b5 Add valve open/close card feature (#26488)
* Add valve open/close card feature

* Toggle button UI if no assumed state
2025-08-12 14:26:12 +02:00
karwosts
a7eef81272 Fix search in raw configuration editor (#26496) 2025-08-12 14:31:38 +03:00
Aidan Timson
7986be103f Fix invalid loading margin while variables are loading (#26495)
* Fix invalid loading margin while variables are loading

* Fix invalid loading margin while variables are loading
2025-08-12 07:53:36 +03:00
karwosts
055e65c45e Add a dashboard condition based on user's location (#26401)
* Add a dashboard condition based on user's location

* Update src/translations/en.json

Co-authored-by: Norbert Rittel <norbert@rittel.de>

* Update src/data/person.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Use multiple: true

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-08-12 07:51:31 +03:00
renovate[bot]
fe762e9ae4 Update dependency eslint to v9.33.0 (#26502)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-12 07:47:01 +03:00
pcan08
5267c6fdfc Add fan direction feature in gallery (#26499) 2025-08-11 19:40:32 +02:00
Andrew Jackson
8eff913845 Sort subentries within integration devices by title (#26497)
Sort subentries by title
2025-08-11 17:34:36 +00:00
renovate[bot]
1c845d0052 Update dependency lint-staged to v16.1.5 (#26498)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 19:22:34 +02:00
pcan08
60a1d25e1e Add fan direction feature (#26467)
* Create fan-direction feature

* Translate direction buttons tooltip

* Update src/translations/en.json

fix fan-direction label to sentence-cased

Co-authored-by: Norbert Rittel <norbert@rittel.de>

* Update src/translations/en.json

Remove usued translation

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2025-08-11 16:32:42 +03:00
Aidan Timson
3439d1d663 Add area, device, floor and formatted state to template editor completions (#26383) 2025-08-11 15:28:34 +02:00
renovate[bot]
bf120d9cb2 Update dependency @rsdoctor/rspack-plugin to v1.2.1 (#26492)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 14:25:53 +03:00
Paul Bottein
b5a024c879 Fix default alarm modes order when customizing it in card feature (#26491) 2025-08-11 14:13:02 +03:00
renovate[bot]
602d754e5e Update dependency typescript to v5.9.2 (#26372)
* Update dependency typescript to v5.9.2

* fix types

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-08-11 11:11:15 +00:00
Wendelin
b7c4f4029d Translate service worker update toast notification (#26487)
* Translate service worker update toast notification

* Update src/managers/notification-manager.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Update src/managers/notification-manager.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Fix toast translation args

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-08-11 14:07:32 +03:00
dependabot[bot]
7fdb5d4862 Bump actions/cache from 4.2.3 to 4.2.4 (#26489)
Bumps [actions/cache](https://github.com/actions/cache) from 4.2.3 to 4.2.4.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4.2.3...v4.2.4)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 4.2.4
  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>
2025-08-11 13:32:14 +03:00
Aidan Timson
bc52ab410c Split repeat building blocks in picker (#26385)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-08-11 11:07:41 +02:00
karwosts
3b0220fa92 Support multiple for StateSelector (#25716)
* Support `multiple` for StateSelector

* lint

* Fixup after merge
2025-08-11 11:24:37 +03:00
Wendelin
a60c9f788d Fix first letter uppercase in some buttons (#26485) 2025-08-11 10:47:06 +03:00
karwosts
d9c297c06a Improve robustness of UI for if/then action (#26477) 2025-08-11 08:40:42 +03:00
renovate[bot]
3789bebb2b Update dependency @bundle-stats/plugin-webpack-filter to v4.21.2 (#26480)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 08:36:51 +03:00
renovate[bot]
bbecf5f368 Update dependency hls.js to v1.6.9 (#26474)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-10 14:30:38 +02:00
renovate[bot]
e580b30219 Update dependency @rsdoctor/rspack-plugin to v1.2.0 (#26471)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-10 14:30:12 +02:00
renovate[bot]
ed8c8ad3e3 Update dependency @rsdoctor/rspack-plugin to v1.1.11 (#26470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-09 15:19:04 +02:00
Simon Lamon
4f61d5689b Fix diagnostic download (#26466)
Diagnostic fix 2
2025-08-09 15:17:59 +02:00
renovate[bot]
60a18185d7 Update dependency @tsparticles/engine to v3.9.1 (#26420)
* Update dependency @tsparticles/engine to v3.9.1
2025-08-09 06:19:38 +00:00
karwosts
e0246b8488 Support button feature for input_button (#26444) 2025-08-09 07:52:14 +02:00
renovate[bot]
1cd0fae84a Update dependency @awesome.me/webawesome to v3.0.0-beta.4 (#26465)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-08 21:42:37 +02:00
renovate[bot]
e8a1ebbff4 Update dependency fs-extra to v11.3.1 (#26464)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-08 19:38:28 +02:00
karwosts
c5010b8502 Fix some date-range bugs (#26441) 2025-08-08 16:57:54 +03:00
Petar Petrov
a7db401b62 Show sankey chart in vertical layout on mobile (#26439)
* Show sankey chart in vertical layout on mobile

* ts fix
2025-08-08 16:37:44 +03:00
Petar Petrov
49c7dad6eb Font improvements for Sankey chart (#26438)
* Use theme vars for sankey chart font

* improve font size calculation
2025-08-08 08:43:59 +02:00
Aidan Timson
521c3d40b7 Add missing button feature translation (#26437) 2025-08-08 08:42:45 +02:00
renovate[bot]
709a1d2ef0 Update dependency typescript-eslint to v8.39.0 (#26446)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-08 08:42:06 +02:00
renovate[bot]
3c5d7b97d1 Update dependency core-js to v3.45.0 (#26449)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-08 08:41:49 +02:00
renovate[bot]
9165c8bc57 Update dependency hls.js to v1.6.8 (#26451)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-08 08:40:58 +02:00
Luke Mondy
0b3e4eab23 Add 'Not' to lovelace visibility conditions (#26408)
:Add 'Not' to lovelace visibility conditions
2025-08-07 16:40:27 +03:00
Wendelin
39d14c943c Revert "Add Mobile team and design has codeowner of the theme colors" (#26432)
Revert "Add Mobile team and design has codeowner of the theme colors (#26428)"

This reverts commit 9588987e30.
2025-08-07 15:06:41 +02:00
Wendelin
09469be93f Fix css var naming --ha-color-border-primary (#26433) 2025-08-07 15:06:13 +02:00
karwosts
6e215870ef Fix mqtt config panel (#26422) 2025-08-07 16:06:07 +03:00
Wendelin
d5985dcaaf Fix button start/end slot margins, add reduce-left-padding flag (#26431)
* Fix button start/end slot margins, add reduce-left-padding flag

* Always reduce padding when icons are there

* Revert icon padding changes
2025-08-07 15:05:56 +02:00
Timothy
bbd9d8887d Fix typo in Neutral80 color (#26430) 2025-08-07 10:07:12 +00:00
Timothy
9588987e30 Add Mobile team and design has codeowner of the theme colors (#26428) 2025-08-07 09:56:47 +00:00
Wendelin
52c05a4426 Fix plain button in legacy browsers (#26426) 2025-08-07 09:50:05 +02:00
renovate[bot]
e8224df4e5 Update dependency echarts to v6 (#26356)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-07 09:41:59 +03:00
karwosts
83a6df1621 Fix a dangerous button color (#26418) 2025-08-07 07:40:31 +02:00
renovate[bot]
c46ebc8d3e Update dependency marked to v16.1.2 (#26423)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-07 07:38:25 +02:00
Aidan Timson
fca530411f Fix Mod-S (Ctrl-S/Cmd-S) support for automation/scene/script YAML editors (#26412)
* Fix automation and script yaml mode Mod-S (Ctrl/Cmd-S) support

* Fix manual script editor

* Fix manual automation editor save

* Fix scene yaml mode
2025-08-06 18:52:41 +02:00
Norbert Rittel
c2c64b9923 Fix summary for Choose building block by counting options (#26409)
* Fix summary for Choose building block

* Sentence-case "If-then" to match label of that building block
2025-08-06 18:52:03 +02:00
renovate[bot]
9968c27a8e Update dependency lint-staged to v16.1.4 (#26414)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 18:45:36 +02:00
Wendelin
96796ac5da Add border radius css var ha prefix (#26411) 2025-08-06 12:59:34 +00:00
Bram Kragten
37def6d3e4 add save button to AI suggestions settings (#26407) 2025-08-06 14:27:58 +02:00
Bram Kragten
013d603ba0 Fix buttons in button row (#26405) 2025-08-06 11:15:09 +00:00
Bram Kragten
b76407d28d Update zwave js buttons (#26404) 2025-08-06 13:13:22 +02:00
Bram Kragten
4e969ccf09 Update color variables (#26403) 2025-08-06 10:42:21 +00:00
Bram Kragten
cdfd6431c3 Fix colors in network graphs (#26397) 2025-08-05 15:14:21 +02:00
Bram Kragten
c363995718 Fix network graph not rendering (#26396) 2025-08-05 15:05:23 +02:00
Jan-Philipp Benecke
53497aa632 Add localization for third-party data reporting in the Z-Wave JS dashboard (#26395)
* Add localization for third-party data reporting in the Z-Wave JS dashboard

* Run prettier
2025-08-05 13:00:14 +02:00
Stefan Agner
8d89b0e57f Fix System information dialog unhealthy/unsupported list (#26393)
The System Information dialog was not displaying translated list of
unhealthy and unsupported reasons because the wrong translation keys
were used. This commit updates the translation keys to the correct
ones.
2025-08-05 12:10:51 +02:00
Stefan Agner
92cf8b5579 Remove eMMC specific references in disk life time handling (#26379)
* Remove eMMC specific references in disk life time handling

Remove eMMC specific calculations and references in the disk life
time handling to generalize the code for all disk types. This includes
updating translations and UI components to reflect a more generic
approach to disk life time metrics.

* Assume 30 MB/s as the speed for disk operations

The previous code tried to estimate based on disk type, 30 MB/s for
eMMC devices and 10 MB/s for others. However, this did not work
correctly since the disk_life_time returns null for non-eMMC devices,
leading to 30 MB/s being used for all devices.

Now disk_life_time is not a eMMC indicator anymore. Simply assume a
constant speed of 30 MB/s for all disk operations explicitly.
2025-08-05 10:42:42 +02:00
karwosts
6068c32176 Pass narrow through parallel/sequence automation actions (#26386) 2025-08-04 17:51:17 +02:00
karwosts
38893324af Fix energy now button (#26384)
Update hui-energy-period-selector.ts
2025-08-04 16:19:26 +02:00
Wendelin
a39ab3c174 Improve ha button radius variables (#26382)
* Improve ha button radius variables

* fixes

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-08-04 12:58:12 +00:00
Christoph
797d2be5bf show spinner on update button (during update installation) (#26110)
* scroll to top when installing an update

* Revert "scroll to top when installing an update"

This reverts commit d0051b0c4c.

* add progress spinner to update button

* refactor disabled logic for update/skip button

* do not run update when disabled button is clicked

* refactor: use new ha-button to show progress

* refactor: move functions to update.ts
2025-08-04 14:51:38 +02:00
Wendelin
99a91e1019 Improve neutral color palette (#26381)
Improve neutral, add docs, removed unused var.
2025-08-04 12:24:42 +00:00
Wendelin
5de8d07ce0 Fix ha-buttons (#26373)
* Fix ha-button supervisor network

* Fix button appearance for entity row

* Fix logs button menu mobile width

* Fix new logs indicator
2025-08-04 14:18:31 +02:00
dependabot[bot]
3a31a4a721 Bump home-assistant/wheels from 2025.03.0 to 2025.07.0 (#26375)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-04 11:01:02 +02:00
Squazel
05f4419a92 Fix picture-glance card icon styling for unavailable/unknown entities (#26352) 2025-08-04 08:16:11 +02:00
renovate[bot]
5ea8feb86b Update rspack monorepo to v1.4.11 (#26365)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-04 06:52:21 +02:00
Jan-Philipp Benecke
8fd70b3ae6 Improve Z-Wave JS config dashboard styling (#26368) 2025-08-04 06:50:35 +02:00
renovate[bot]
343aa40bc8 Update dependency @types/luxon to v3.7.1 (#26351)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-03 08:10:32 +02:00
Jan-Philipp Benecke
6022f9a77e Do not show AI suggestion button when no inputs in save dialog (#26357) 2025-08-03 08:10:06 +02:00
renovate[bot]
bd9de0680e Update dependency @types/luxon to v3.7.0 (#26342)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-01 17:09:44 +02:00
Simon Lamon
b8000d5bc1 Fix diagnostic download on integration level (#26341) 2025-08-01 13:16:09 +02:00
Wendelin
c6efa1127f Fix dialog secondary button design (#26344) 2025-08-01 13:13:42 +02:00
Bram Kragten
688a3d91d3 Add support for sub config flows in conversation agent picker (#26336) 2025-08-01 13:13:28 +02:00
Timothy
68151a2a70 Add Bruno and Timo as codeowners of the external_app folders (#26345) 2025-08-01 11:49:47 +02:00
Wendelin
c2ca556151 Fix line-height, fix script editor buttons (#26337)
* Fix line-height

* Fix script root buttons
2025-07-31 16:52:36 +02:00
Wendelin
df86b27af4 Use tilecard button feature editor (#26335)
Use button feature editor
2025-07-31 13:27:40 +02:00
Wendelin
eba1f401cc Fix ha-button with missing label and links (#26332) 2025-07-31 12:40:17 +02:00
Wendelin
19c2f9c9e8 Revert "Use query params instead of path for media browser navigate ids" (#26333) 2025-07-31 12:38:52 +02:00
Bram Kragten
4250447d14 Fix area picker text alignment in voice wizard (#26330) 2025-07-31 11:52:33 +02:00
Joost Lekkerkerker
4666197f28 Use underscores in AI task name (#26327) 2025-07-30 21:52:09 +02:00
Franck Nijhof
a5ca36c93f Add weekdays to time trigger (#25908)
* Add weekdays to time trigger

* Update src/translations/en.json

Co-authored-by: Norbert Rittel <norbert@rittel.de>

* Localization changes

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2025-07-30 19:45:10 +02:00
Norbert Rittel
a88950e16c Correct the setup steps for Matter sharing in Google Home app (#26322)
Correct the setup steps in the Google Home app
2025-07-30 19:40:41 +02:00
119 changed files with 6425 additions and 2556 deletions

View File

@@ -37,7 +37,7 @@ jobs:
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.4
with:
path: |
node_modules/.cache/prettier

View File

@@ -74,7 +74,7 @@ jobs:
echo "home-assistant-frontend==$version" > ./requirements.txt
- name: Build wheels
uses: home-assistant/wheels@2025.03.0
uses: home-assistant/wheels@2025.07.0
with:
abi: cp313
tag: musllinux_1_2

8
CODEOWNERS Normal file
View File

@@ -0,0 +1,8 @@
# People marked here will be automatically requested for a review
# when the code that they own is touched.
# https://github.com/blog/2392-introducing-code-owners
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# Part of the frontend that mobile developper should review
src/external_app/ @bgoncal @TimoPtr
test/external_app/ @bgoncal @TimoPtr

View File

@@ -68,7 +68,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1;
margin-top: calc( 2 * max(var(--safe-area-inset-bottom), 48px) + 46px );
margin-top: calc( 2 * max(var(--safe-area-inset-bottom, 0px), 48px) + 46px );
padding-top: 48px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
@@ -76,7 +76,7 @@
padding-top: 48px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom), 48px) 0;
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
display: flex;
flex-direction: column;
align-items: center;

View File

@@ -11,6 +11,7 @@ import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { ClimateEntityFeature } from "../../../../src/data/climate";
import { FanEntityFeature } from "../../../../src/data/fan";
const ENTITIES = [
getEntity("switch", "tv_outlet", "on", {
@@ -100,6 +101,12 @@ const ENTITIES = [
ClimateEntityFeature.FAN_MODE +
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE,
}),
getEntity("fan", "fan_direction", "on", {
friendly_name: "Ceiling fan",
device_class: "fan",
direction: "reverse",
supported_features: [FanEntityFeature.DIRECTION],
}),
];
const CONFIGS = [
@@ -261,6 +268,15 @@ const CONFIGS = [
- type: target-temperature
`,
},
{
heading: "Fan direction feature",
config: `
- type: tile
entity: fan.fan_direction
features:
- type: fan-direction
`,
},
];
@customElement("demo-lovelace-tile-card")

View File

@@ -26,7 +26,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@awesome.me/webawesome": "3.0.0-beta.3",
"@awesome.me/webawesome": "3.0.0-beta.4",
"@babel/runtime": "7.28.2",
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.18.6",
@@ -46,12 +46,12 @@
"@formatjs/intl-numberformat": "8.15.4",
"@formatjs/intl-pluralrules": "5.4.4",
"@formatjs/intl-relativetimeformat": "11.4.11",
"@fullcalendar/core": "6.1.18",
"@fullcalendar/daygrid": "6.1.18",
"@fullcalendar/interaction": "6.1.18",
"@fullcalendar/list": "6.1.18",
"@fullcalendar/luxon3": "6.1.18",
"@fullcalendar/timegrid": "6.1.18",
"@fullcalendar/core": "6.1.19",
"@fullcalendar/daygrid": "6.1.19",
"@fullcalendar/interaction": "6.1.19",
"@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19",
"@lezer/highlight": "1.2.1",
"@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6",
@@ -61,7 +61,6 @@
"@material/chips": "=14.0.0-canary.53b3cad2f.0",
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
"@material/mwc-base": "0.27.0",
"@material/mwc-button": "0.27.0",
"@material/mwc-checkbox": "0.27.0",
"@material/mwc-dialog": "0.27.0",
"@material/mwc-drawer": "0.27.0",
@@ -88,10 +87,10 @@
"@shoelace-style/shoelace": "2.20.1",
"@swc/helpers": "0.5.17",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.8.1",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.7.9",
"@vaadin/vaadin-themable-mixin": "24.7.9",
"@vaadin/combo-box": "24.8.5",
"@vaadin/vaadin-themable-mixin": "24.8.5",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -100,7 +99,7 @@
"barcode-detector": "3.0.5",
"color-name": "2.0.0",
"comlink": "4.4.2",
"core-js": "3.44.0",
"core-js": "3.45.0",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.1.0",
@@ -108,12 +107,12 @@
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "5.6.0",
"echarts": "6.0.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.1.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.7",
"hls.js": "1.6.9",
"home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "10.7.16",
@@ -124,7 +123,7 @@
"lit": "3.3.1",
"lit-html": "3.3.1",
"luxon": "3.7.1",
"marked": "16.1.1",
"marked": "16.1.2",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.3",
"object-hash": "3.0.0",
@@ -154,14 +153,14 @@
"@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.0",
"@babel/preset-env": "7.28.0",
"@bundle-stats/plugin-webpack-filter": "4.21.1",
"@bundle-stats/plugin-webpack-filter": "4.21.2",
"@lokalise/node-api": "15.0.0",
"@octokit/auth-oauth-device": "8.0.1",
"@octokit/plugin-retry": "8.0.1",
"@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.1.10",
"@rspack/cli": "1.4.10",
"@rspack/core": "1.4.10",
"@rsdoctor/rspack-plugin": "1.2.1",
"@rspack/cli": "1.4.11",
"@rspack/core": "1.4.11",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22",
"@types/chromecast-caf-sender": "1.0.11",
@@ -173,7 +172,7 @@
"@types/leaflet-draw": "1.0.12",
"@types/leaflet.markercluster": "1.5.5",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.6.2",
"@types/luxon": "3.7.1",
"@types/mocha": "10.0.10",
"@types/qrcode": "1.5.5",
"@types/sortablejs": "1.15.8",
@@ -185,7 +184,7 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0",
"eslint": "9.32.0",
"eslint": "9.33.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10",
@@ -195,7 +194,7 @@
"eslint-plugin-unused-imports": "4.1.4",
"eslint-plugin-wc": "3.0.1",
"fancy-log": "2.0.0",
"fs-extra": "11.3.0",
"fs-extra": "11.3.1",
"glob": "11.0.3",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
@@ -205,7 +204,7 @@
"husky": "9.1.7",
"jsdom": "26.1.0",
"jszip": "3.10.1",
"lint-staged": "16.1.2",
"lint-staged": "16.1.5",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.5.0",
@@ -218,8 +217,8 @@
"tar": "7.4.3",
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.8.3",
"typescript-eslint": "8.38.0",
"typescript": "5.9.2",
"typescript-eslint": "8.39.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack-stats-plugin": "1.1.3",
@@ -232,11 +231,11 @@
"lit-html": "3.3.1",
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.1",
"@fullcalendar/daygrid": "6.1.18",
"@fullcalendar/daygrid": "6.1.19",
"globals": "16.3.0",
"tslib": "2.8.1",
"@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",
"@vaadin/vaadin-themable-mixin": "24.7.9"
"@vaadin/vaadin-themable-mixin": "24.8.5"
},
"packageManager": "yarn@4.9.2"
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20250811.0"
version = "20250730.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"

View File

@@ -237,10 +237,11 @@ export class HaAuthFlow extends LitElement {
@value-changed=${this._stepDataChanged}
></ha-auth-form>`
)}
${this.clientId === genClientId() &&
!["select_mfa_module", "mfa"].includes(step.step_id)
? html`
<div class="space-between">
<div class="space-between">
${this.clientId === genClientId() &&
!["select_mfa_module", "mfa"].includes(step.step_id)
? html`
<ha-formfield
class="store-token"
.label=${this.localize(
@@ -252,18 +253,16 @@ export class HaAuthFlow extends LitElement {
@change=${this._storeTokenChanged}
></ha-checkbox>
</ha-formfield>
<a
class="forgot-password"
href="https://www.home-assistant.io/docs/locked_out/#forgot-password"
target="_blank"
rel="noreferrer noopener"
>${this.localize(
"ui.panel.page-authorize.forgot_password"
)}</a
>
</div>
`
: ""}
`
: ""}
<a
class="forgot-password"
href="https://www.home-assistant.io/docs/locked_out/#forgot-password"
target="_blank"
rel="noreferrer noopener"
>${this.localize("ui.panel.page-authorize.forgot_password")}</a
>
</div>
`;
default:
return nothing;

View File

@@ -132,15 +132,13 @@ export const shiftDateRange = (
end = calcDate(endDate, addDays, locale, config, difference);
} else {
const difference =
((calcDateDifferenceProperty(
(calcDateDifferenceProperty(
endDate,
startDate,
differenceInMilliseconds,
locale,
config
) as number) +
1) *
(forward ? 1 : -1);
) as number) * (forward ? 1 : -1);
start = calcDate(startDate, addMilliseconds, locale, config, difference);
end = calcDate(endDate, addMilliseconds, locale, config, difference);
}

View File

@@ -0,0 +1,4 @@
export const preventDefaultStopPropagation = (ev) => {
ev.preventDefault();
ev.stopPropagation();
};

View File

@@ -397,7 +397,7 @@ export class HaChartBase extends LitElement {
...axis.axisPointer,
status: "show",
handle: {
color: style.getPropertyValue("primary-color"),
color: style.getPropertyValue("--primary-color"),
margin: 0,
size: 20,
...axis.axisPointer?.handle,

View File

@@ -10,8 +10,8 @@ import {
} from "../../data/device_automation";
import type { EntityRegistryEntry } from "../../data/entity_registry";
import type { HomeAssistant } from "../../types";
import "../ha-list-item";
import "../ha-select";
import "../ha-md-select-option";
import "../ha-md-select";
import { stopPropagation } from "../../common/dom/stop_propagation";
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
@@ -100,35 +100,35 @@ export abstract class HaDeviceAutomationPicker<
}
const value = this._value;
return html`
<ha-select
<ha-md-select
.label=${this.label}
.value=${value}
@selected=${this._automationChanged}
@change=${this._automationChanged}
@closed=${stopPropagation}
.disabled=${this._automations.length === 0}
>
${value === NO_AUTOMATION_KEY
? html`<ha-list-item .value=${NO_AUTOMATION_KEY}>
? html`<ha-md-select-option .value=${NO_AUTOMATION_KEY}>
${this.NO_AUTOMATION_TEXT}
</ha-list-item>`
: ""}
</ha-md-select-option>`
: nothing}
${value === UNKNOWN_AUTOMATION_KEY
? html`<ha-list-item .value=${UNKNOWN_AUTOMATION_KEY}>
? html`<ha-md-select-option .value=${UNKNOWN_AUTOMATION_KEY}>
${this.UNKNOWN_AUTOMATION_TEXT}
</ha-list-item>`
: ""}
</ha-md-select-option>`
: nothing}
${this._automations.map(
(automation, idx) => html`
<ha-list-item .value=${`${automation.device_id}_${idx}`}>
<ha-md-select-option .value=${`${automation.device_id}_${idx}`}>
${this._localizeDeviceAutomation(
this.hass,
this._entityReg,
automation
)}
</ha-list-item>
</ha-md-select-option>
`
)}
</ha-select>
</ha-md-select>
`;
}

View File

@@ -63,10 +63,10 @@ class HaEntityStatePicker extends LitElement {
const entityIds = this.entityId ? ensureArray(this.entityId) : [];
const entitiesOptions = entityIds.map<StateOption[]>((entityId) => {
const stateObj = this.hass.states[entityId];
if (!stateObj) {
return [];
}
const stateObj = this.hass.states[entityId] || {
entity_id: entityId,
attributes: {},
};
const states = getStates(this.hass, stateObj, this.attribute).filter(
(s) => !this.hideStates?.includes(s)

View File

@@ -0,0 +1,149 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { ensureArray } from "../../common/array/ensure-array";
import type { HomeAssistant } from "../../types";
import "./ha-entity-state-picker";
@customElement("ha-entity-states-picker")
export class HaEntityStatesPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string;
@property() public attribute?: string;
@property({ attribute: false }) public extraOptions?: any[];
@property({ type: Boolean, attribute: "allow-custom-value" })
public allowCustomValue;
@property() public label?: string;
@property({ type: Array }) public value?: string[];
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property({ attribute: false })
public hideStates?: string[];
private _keys: string[] = [];
private _getKey(index: number) {
if (!this._keys[index]) {
this._keys[index] = Math.random().toString();
}
return this._keys[index];
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("value")) {
this.value = ensureArray(this.value);
}
}
protected render() {
if (!this.hass) {
return nothing;
}
const value = this.value || [];
const hide = [...(this.hideStates || []), ...value];
return html`
${repeat(
value,
(_state, index) => this._getKey(index),
(state, index) => html`
<div>
<ha-entity-state-picker
.index=${index}
.hass=${this.hass}
.entityId=${this.entityId}
.attribute=${this.attribute}
.extraOptions=${this.extraOptions}
.hideStates=${hide.filter((v) => v !== state)}
.allowCustomValue=${this.allowCustomValue}
.label=${this.label}
.value=${state}
.disabled=${this.disabled}
.helper=${this.disabled && index === value.length - 1
? this.helper
: undefined}
@value-changed=${this._valueChanged}
></ha-entity-state-picker>
</div>
`
)}
<div>
${this.disabled && value.length
? nothing
: keyed(
value.length,
html`<ha-entity-state-picker
.hass=${this.hass}
.entityId=${this.entityId}
.attribute=${this.attribute}
.extraOptions=${this.extraOptions}
.hideStates=${hide}
.allowCustomValue=${this.allowCustomValue}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required && !value.length}
@value-changed=${this._addValue}
></ha-entity-state-picker>`
)}
</div>
`;
}
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const newState = ev.detail.value;
const newValue = [...this.value!];
const index = (ev.currentTarget as any)?.index;
if (index == null) {
return;
}
if (newState === undefined) {
newValue.splice(index, 1);
this._keys.splice(index, 1);
fireEvent(this, "value-changed", {
value: newValue,
});
return;
}
newValue[index] = newState;
fireEvent(this, "value-changed", {
value: newValue,
});
}
private _addValue(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: [...(this.value || []), ev.detail.value],
});
}
static override styles = css`
div {
margin-top: 8px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-entity-states-picker": HaEntityStatesPicker;
}
}

View File

@@ -0,0 +1,148 @@
import { mdiChevronUp } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-icon-button";
@customElement("ha-automation-row")
export class HaAutomationRow extends LitElement {
@property({ attribute: "left-chevron", type: Boolean })
public leftChevron = false;
@property({ type: Boolean, reflect: true })
public collapsed = false;
@property({ type: Boolean, reflect: true })
public selected = false;
@property({ type: Boolean, reflect: true })
public disabled = false;
@property({ type: Boolean, reflect: true, attribute: "building-block" })
public buildingBlock = false;
protected render(): TemplateResult {
return html`
<div
class="row"
tabindex="0"
role="button"
@keydown=${this._handleKeydown}
>
${this.leftChevron
? html`
<ha-icon-button
class="expand-button"
.path=${mdiChevronUp}
@click=${this._handleExpand}
@keydown=${this._handleExpand}
></ha-icon-button>
`
: nothing}
<div class="leading-icon-wrapper">
<slot name="leading-icon"></slot>
</div>
<slot class="header" name="header"></slot>
<slot name="icons"></slot>
</div>
`;
}
private async _handleExpand(ev) {
if (ev.defaultPrevented) {
return;
}
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") {
return;
}
ev.stopPropagation();
ev.preventDefault();
fireEvent(this, "toggle-collapsed");
}
private async _handleKeydown(ev: KeyboardEvent): Promise<void> {
if (ev.defaultPrevented) {
return;
}
if (ev.key !== "Enter" && ev.key !== " ") {
return;
}
ev.preventDefault();
ev.stopPropagation();
this.click();
}
static styles = css`
:host {
display: block;
}
.row {
display: flex;
padding: 0 8px;
min-height: 48px;
align-items: center;
cursor: pointer;
overflow: hidden;
font-weight: var(--ha-font-weight-medium);
outline: none;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
.row:focus {
outline: var(--wa-focus-ring);
outline-offset: -2px;
}
.expand-button {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--ha-color-on-neutral-quiet);
}
:host([building-block]) .leading-icon-wrapper {
background-color: var(--ha-color-fill-neutral-loud-resting);
border-radius: var(--ha-border-radius-md);
padding: 4px;
display: flex;
justify-content: center;
align-items: center;
transform: rotate(45deg);
}
::slotted([slot="leading-icon"]) {
color: var(--ha-color-on-neutral-quiet);
}
:host([building-block]) ::slotted([slot="leading-icon"]) {
--mdc-icon-size: 20px;
color: var(--white-color);
transform: rotate(-45deg);
}
:host([collapsed]) .expand-button {
transform: rotate(180deg);
}
:host([selected]) .row,
:host([selected]) .row:focus {
outline: solid;
outline-color: var(--primary-color);
outline-offset: -2px;
outline-width: 2px;
}
:host([disabled]) .row {
border-top-right-radius: var(--ha-border-radius-square);
border-top-left-radius: var(--ha-border-radius-square);
}
::slotted([slot="header"]) {
flex: 1;
overflow-wrap: anywhere;
margin: 0 12px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-row": HaAutomationRow;
}
interface HASSDomEvents {
"toggle-collapsed": undefined;
}
}

View File

@@ -0,0 +1,82 @@
import ButtonGroup from "@awesome.me/webawesome/dist/components/button-group/button-group";
import { customElement } from "lit/decorators";
import type { HaButton } from "./ha-button";
import { StateSet } from "../resources/polyfills/stateset";
export type Appearance = "accent" | "filled" | "outlined" | "plain";
/**
* Finds an ha-button element either as the current element or within its descendants.
* @param el - The HTML element to search from
* @returns The found HaButton element, or null if not found
*/
function findButton(el: HTMLElement) {
const selector = "ha-button";
return (el.closest(selector) ?? el.querySelector(selector)) as HaButton;
}
/**
* @element ha-button-group
* @extends {ButtonGroup}
* @summary
* Group buttons. Extend Webawesome to be able to work with ha-button tags
*
* @documentation https://webawesome.com/components/button-group
*/
@customElement("ha-button-group") // @ts-expect-error Intentionally overriding private methods
export class HaButtonGroup extends ButtonGroup {
attachInternals() {
const internals = super.attachInternals();
Object.defineProperty(internals, "states", {
value: new StateSet(this, internals.states),
});
return internals;
}
// @ts-expect-error updateClassNames is used in super class
// eslint-disable-next-line @typescript-eslint/naming-convention
private override updateClassNames() {
const slottedElements = [
...this.defaultSlot.assignedElements({ flatten: true }),
] as HTMLElement[];
this.hasOutlined = false;
slottedElements.forEach((el) => {
const index = slottedElements.indexOf(el);
const button = findButton(el);
if (button) {
if ((button as HaButton).appearance === "outlined")
this.hasOutlined = true;
if (this.size) button.setAttribute("size", this.size);
button.classList.add("wa-button-group__button");
button.classList.toggle(
"wa-button-group__horizontal",
this.orientation === "horizontal"
);
button.classList.toggle(
"wa-button-group__vertical",
this.orientation === "vertical"
);
button.classList.toggle("wa-button-group__button-first", index === 0);
button.classList.toggle(
"wa-button-group__button-inner",
index > 0 && index < slottedElements.length - 1
);
button.classList.toggle(
"wa-button-group__button-last",
index === slottedElements.length - 1
);
// use button-group variant
button.setAttribute("variant", this.variant);
}
});
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-button-group": HaButtonGroup;
}
}

View File

@@ -1,141 +1,72 @@
import "@material/mwc-button/mwc-button";
import type { Button } from "@material/mwc-button/mwc-button";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { ToggleButton } from "../types";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-button";
import "./ha-button-group";
/**
* @element ha-button-toggle-group
*
* @summary
* A button-group with one active selection.
*
* @attr {ToggleButton[]} buttons - the button config
* @attr {string} active - The value of the currently active button.
* @attr {("small"|"medium")} size - The size of the buttons in the group.
* @attr {("brand"|"neutral"|"success"|"warning"|"danger")} variant - The variant of the buttons in the group.
*
* @fires value-changed - Dispatched when the active button changes.
*/
@customElement("ha-button-toggle-group")
export class HaButtonToggleGroup extends LitElement {
@property({ attribute: false }) public buttons!: ToggleButton[];
@property() public active?: string;
@property({ attribute: "full-width", type: Boolean })
public fullWidth = false;
@property({ reflect: true }) size: "small" | "medium" = "medium";
@property({ type: Boolean }) public dense = false;
@queryAll("mwc-button") private _buttons?: Button[];
@property() public variant:
| "brand"
| "neutral"
| "success"
| "warning"
| "danger" = "brand";
protected render(): TemplateResult {
return html`
<div>
${this.buttons.map((button) =>
button.iconPath
? html`<ha-icon-button
.label=${button.label}
.path=${button.iconPath}
.value=${button.value}
?active=${this.active === button.value}
@click=${this._handleClick}
></ha-icon-button>`
: html`<mwc-button
style=${styleMap({
width: this.fullWidth
? `${100 / this.buttons.length}%`
: "initial",
})}
outlined
.dense=${this.dense}
.value=${button.value}
?active=${this.active === button.value}
@click=${this._handleClick}
>${button.label}</mwc-button
>`
<ha-button-group .variant=${this.variant} .size=${this.size}>
${this.buttons.map(
(button) =>
html`<ha-button
class="icon"
.value=${button.value}
@click=${this._handleClick}
.title=${button.label}
.appearance=${this.active === button.value ? "accent" : "filled"}
>
${button.iconPath
? html`<ha-svg-icon
aria-label=${button.label}
.path=${button.iconPath}
></ha-svg-icon>`
: button.label}
</ha-button>`
)}
</div>
</ha-button-group>
`;
}
protected updated() {
// Work around Safari default margin that is not reset in mwc-button as of aug 2021
this._buttons?.forEach(async (button) => {
await button.updateComplete;
(
button.shadowRoot!.querySelector("button") as HTMLButtonElement
).style.margin = "0";
});
}
private _handleClick(ev): void {
this.active = ev.currentTarget.value;
fireEvent(this, "value-changed", { value: this.active });
}
static styles = css`
div {
display: flex;
--mdc-icon-button-size: var(--button-toggle-size, 36px);
:host {
--mdc-icon-size: var(--button-toggle-icon-size, 20px);
direction: ltr;
}
mwc-button {
flex: 1;
--mdc-shape-small: 0;
--mdc-button-outline-width: 1px 0 1px 1px;
--mdc-button-outline-color: var(--primary-color);
}
ha-icon-button {
border: 1px solid var(--primary-color);
border-right-width: 0px;
}
ha-icon-button,
mwc-button {
position: relative;
cursor: pointer;
}
ha-icon-button::before,
mwc-button::before {
top: 0;
left: 0;
width: 100%;
height: 100%;
position: absolute;
background-color: var(--primary-color);
opacity: 0;
pointer-events: none;
content: "";
transition:
opacity 15ms linear,
background-color 15ms linear;
}
ha-icon-button[active]::before,
mwc-button[active]::before {
opacity: 1;
}
ha-icon-button[active] {
--icon-primary-color: var(--text-primary-color);
}
mwc-button[active] {
--mdc-theme-primary: var(--text-primary-color);
}
ha-icon-button:first-child,
mwc-button:first-child {
--mdc-shape-small: 4px 0 0 4px;
border-radius: 4px 0 0 4px;
--mdc-button-outline-width: 1px;
}
mwc-button:first-child::before {
border-radius: 4px 0 0 4px;
}
ha-icon-button:last-child,
mwc-button:last-child {
border-radius: 0 4px 4px 0;
border-right-width: 1px;
--mdc-shape-small: 0 4px 4px 0;
--mdc-button-outline-width: 1px;
}
mwc-button:last-child::before {
border-radius: 0 4px 4px 0;
}
ha-icon-button:only-child,
mwc-button:only-child {
--mdc-shape-small: 4px;
border-right-width: 1px;
}
`;
}

View File

@@ -35,7 +35,7 @@ export type Appearance = "accent" | "filled" | "outlined" | "plain";
* @attr {boolean} loading - shows a loading indicator instead of the buttons label and disable buttons click.
* @attr {boolean} disabled - Disables the button and prevents user interaction.
*/
@customElement("ha-button")
@customElement("ha-button") // @ts-expect-error Intentionally overriding private methods
export class HaButton extends Button {
variant: "brand" | "neutral" | "success" | "warning" | "danger" = "brand";
@@ -47,6 +47,42 @@ export class HaButton extends Button {
return internals;
}
// @ts-expect-error handleLabelSlotChange is used in super class
// eslint-disable-next-line @typescript-eslint/naming-convention
private override handleLabelSlotChange() {
const nodes = this.labelSlot.assignedNodes({ flatten: true });
let hasIconLabel = false;
let hasIcon = false;
let text = "";
// If there's only an icon and no text, it's an icon button
[...nodes].forEach((node) => {
if (
node.nodeType === Node.ELEMENT_NODE &&
(node as HTMLElement).localName === "ha-svg-icon"
) {
hasIcon = true;
if (!hasIconLabel)
hasIconLabel = (node as HTMLElement).hasAttribute("aria-label");
}
// Concatenate text nodes
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
}
});
this.isIconButton = text.trim() === "" && hasIcon;
if (__DEV__ && this.isIconButton && !hasIconLabel) {
// eslint-disable-next-line no-console
console.warn(
'Icon buttons must have a label for screen readers. Add <ha-svg-icon aria-label="..."> to remove this warning.',
this
);
}
}
static get styles(): CSSResultGroup {
return [
Button.styles,
@@ -75,6 +111,7 @@ export class HaButton extends Button {
var(--button-height, 32px)
);
font-size: var(--wa-font-size-s, var(--ha-font-size-m));
--wa-form-control-padding-inline: 12px;
}
:host([variant="brand"]) {
@@ -180,6 +217,11 @@ export class HaButton extends Button {
color: var(--wa-color-on-normal);
}
}
:host([appearance~="filled"]) .button {
color: var(--wa-color-on-normal);
background-color: var(--wa-color-fill-normal);
border-color: transparent;
}
:host([appearance~="filled"])
.button:not(.disabled):not(.loading):active {
background-color: var(--button-color-fill-normal-active);
@@ -218,6 +260,13 @@ export class HaButton extends Button {
slot[name="end"]::slotted(*) {
margin-inline-start: 4px;
}
.button.has-start {
padding-left: 8px;
}
.button.has-end {
padding-right: 8px;
}
`,
];
}

View File

@@ -0,0 +1,58 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
export interface CompletionItem {
label: string;
value: string;
subValue?: string;
}
@customElement("ha-code-editor-completion-items")
export class HaCodeEditorCompletionItems extends LitElement {
@property({ attribute: false }) public items: CompletionItem[] = [];
render() {
return this.items.map(
(item) => html`
<span><strong>${item.label}</strong>:</span>
<span
>${item.value}${item.subValue && item.subValue.length > 0
? // prettier-ignore
html` (<pre>${item.subValue}</pre>)`
: nothing}</span
>
`
);
}
static styles = css`
:host {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px;
white-space: pre-wrap;
flex-wrap: nowrap;
}
span {
display: flex;
align-items: center;
flex-flow: wrap;
word-wrap: break-word;
}
pre {
margin: 0 3px;
padding: 3px;
background-color: var(--markdown-code-background-color, none);
border-radius: var(--ha-border-radius-sm, 4px);
line-height: var(--ha-line-height-condensed);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-code-editor-completion-items": HaCodeEditorCompletionItems;
}
}

View File

@@ -1,6 +1,7 @@
import type {
Completion,
CompletionContext,
CompletionInfo,
CompletionResult,
CompletionSource,
} from "@codemirror/autocomplete";
@@ -9,14 +10,17 @@ import type { EditorView, KeyBinding, ViewUpdate } from "@codemirror/view";
import { mdiArrowExpand, mdiArrowCollapse } from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, ReactiveElement } from "lit";
import { css, ReactiveElement, html, render } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import type { HomeAssistant } from "../types";
import type { CompletionItem } from "./ha-code-editor-completion-items";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-code-editor-completion-items";
declare global {
interface HASSDomEvents {
@@ -324,15 +328,72 @@ export class HaCodeEditor extends ReactiveElement {
}
};
private _renderInfo = (completion: Completion): CompletionInfo => {
const key = completion.label;
const context = getEntityContext(this.hass!.states[key], this.hass!);
const completionInfo = document.createElement("div");
completionInfo.classList.add("completion-info");
const formattedState = this.hass!.formatEntityState(this.hass!.states[key]);
const completionItems: CompletionItem[] = [
{
label: this.hass!.localize(
"ui.components.entity.entity-state-picker.state"
),
value: formattedState,
subValue:
// If the state exactly matches the formatted state, don't show the raw state
this.hass!.states[key].state === formattedState
? undefined
: this.hass!.states[key].state,
},
];
if (context.device && context.device.name) {
completionItems.push({
label: this.hass!.localize("ui.components.device-picker.device"),
value: context.device.name,
});
}
if (context.area && context.area.name) {
completionItems.push({
label: this.hass!.localize("ui.components.area-picker.area"),
value: context.area.name,
});
}
if (context.floor && context.floor.name) {
completionItems.push({
label: this.hass!.localize("ui.components.floor-picker.floor"),
value: context.floor.name,
});
}
render(
html`
<ha-code-editor-completion-items
.items=${completionItems}
></ha-code-editor-completion-items>
`,
completionInfo
);
return completionInfo;
};
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
if (!states) {
return [];
}
const options = Object.keys(states).map((key) => ({
type: "variable",
label: key,
detail: states[key].attributes.friendly_name,
info: `State: ${states[key].state}`,
info: this._renderInfo,
}));
return options;
@@ -615,6 +676,20 @@ export class HaCodeEditor extends ReactiveElement {
top: calc(var(--safe-area-inset-top, 0px) + 8px);
right: calc(var(--safe-area-inset-right, 0px) + 8px);
}
.completion-info {
display: grid;
gap: 3px;
padding: 8px;
}
/* Hide completion info on narrow screens */
@media (max-width: 600px) {
.cm-completionInfo,
.completion-info {
display: none;
}
}
`;
}

View File

@@ -4,14 +4,14 @@ import { HaTextField } from "./ha-textfield";
@customElement("ha-combo-box-textfield")
export class HaComboBoxTextField extends HaTextField {
@property({ type: Boolean, attribute: "disable-set-value" })
public disableSetValue = false;
@property({ type: Boolean, attribute: "force-blank-value" })
public forceBlankValue = false;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("value")) {
if (this.disableSetValue) {
this.value = changedProps.get("value") as string;
if (changedProps.has("value") || changedProps.has("forceBlankValue")) {
if (this.forceBlankValue && this.value) {
this.value = "";
}
}
}

View File

@@ -117,7 +117,7 @@ export class HaComboBox extends LitElement {
@query("ha-combo-box-textfield", true) private _inputElement!: HaTextField;
@state({ type: Boolean }) private _disableSetValue = false;
@state({ type: Boolean }) private _forceBlankValue = false;
private _overlayMutationObserver?: MutationObserver;
@@ -188,7 +188,7 @@ export class HaComboBox extends LitElement {
class="input"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
.autocorrect=${false}
input-spellcheck="false"
.suffix=${html`<div
style="width: 28px;"
@@ -196,7 +196,7 @@ export class HaComboBox extends LitElement {
></div>`}
.icon=${this.icon}
.invalid=${this.invalid}
.disableSetValue=${this._disableSetValue}
.forceBlankValue=${this._forceBlankValue}
>
<slot name="icon" slot="leadingIcon"></slot>
</ha-combo-box-textfield>
@@ -207,6 +207,7 @@ export class HaComboBox extends LitElement {
aria-label=${ifDefined(this.hass?.localize("ui.common.clear"))}
class=${`clear-button ${this.label ? "" : "no-label"}`}
.path=${mdiClose}
?disabled=${this.disabled}
@click=${this._clearValue}
></ha-svg-icon>`
: ""}
@@ -269,10 +270,10 @@ export class HaComboBox extends LitElement {
if (opened) {
// Wait 100ms to be sure vaddin-combo-box-light already tried to set the value
setTimeout(() => {
this._disableSetValue = false;
this._forceBlankValue = false;
}, 100);
} else {
this._disableSetValue = true;
this._forceBlankValue = true;
}
}
@@ -393,7 +394,8 @@ export class HaComboBox extends LitElement {
:host([opened]) .toggle-button {
color: var(--primary-color);
}
.toggle-button[disabled] {
.toggle-button[disabled],
.clear-button[disabled] {
color: var(--disabled-text-color);
pointer-events: none;
}

View File

@@ -254,21 +254,37 @@ export class HaDateRangePicker extends LitElement {
}
private _applyDateRange() {
if (this.hass.locale.time_zone === TimeZone.server) {
const dateRangePicker = this._dateRangePicker;
let start = new Date(this._dateRangePicker.start);
let end = new Date(this._dateRangePicker.end);
const startDate = fromZonedTime(
dateRangePicker.start,
this.hass.config.time_zone
);
const endDate = fromZonedTime(
dateRangePicker.end,
this.hass.config.time_zone
);
if (this.timePicker) {
start.setSeconds(0);
start.setMilliseconds(0);
end.setSeconds(0);
end.setMilliseconds(0);
dateRangePicker.clickRange([startDate, endDate]);
if (
end.getHours() === 0 &&
end.getMinutes() === 0 &&
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate()
) {
end.setDate(end.getDate() + 1);
}
}
if (this.hass.locale.time_zone === TimeZone.server) {
start = fromZonedTime(start, this.hass.config.time_zone);
end = fromZonedTime(end, this.hass.config.time_zone);
}
if (
start.getTime() !== this._dateRangePicker.start.getTime() ||
end.getTime() !== this._dateRangePicker.end.getTime()
) {
this._dateRangePicker.clickRange([start, end]);
}
this._dateRangePicker.clickedApply();
}

View File

@@ -106,6 +106,8 @@ export const computeInitialHaFormData = (
data[field.name] = [];
} else if ("media" in selector || "target" in selector) {
data[field.name] = {};
} else if ("state" in selector) {
data[field.name] = selector.state?.multiple ? [] : "";
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`

View File

@@ -28,7 +28,7 @@ export class HaPasswordField extends LitElement {
@property() public autocomplete?: string;
@property() public autocorrect?: string;
@property({ type: Boolean }) public autocorrect = true;
@property({ attribute: "input-spellcheck" })
public inputSpellcheck?: string;

View File

@@ -93,7 +93,7 @@ class HaPushNotificationsToggle extends LitElement {
return;
}
let applicationServerKey: Uint8Array | null;
let applicationServerKey: Uint8Array<ArrayBuffer> | null;
try {
applicationServerKey = await getAppKey(this.hass);
} catch (_err) {

View File

@@ -1,19 +1,19 @@
import { ContextProvider, consume } from "@lit/context";
import { consume, ContextProvider } from "@lit/context";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { fullEntitiesContext } from "../../data/context";
import type { Action } from "../../data/script";
import { migrateAutomationAction } from "../../data/script";
import type { ActionSelector } from "../../data/selector";
import "../../panels/config/automation/action/ha-automation-action";
import type { HomeAssistant } from "../../types";
import {
subscribeEntityRegistry,
type EntityRegistryEntry,
} from "../../data/entity_registry";
import type { Action } from "../../data/script";
import { migrateAutomationAction } from "../../data/script";
import type { ActionSelector } from "../../data/selector";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import "../../panels/config/automation/action/ha-automation-action";
import type { HomeAssistant } from "../../types";
@customElement("ha-selector-action")
export class HaActionSelector extends SubscribeMixin(LitElement) {
@@ -69,6 +69,7 @@ export class HaActionSelector extends SubscribeMixin(LitElement) {
.actions=${this._actions(this.value)}
.hass=${this.hass}
.narrow=${this.narrow}
.optionsInSidebar=${!!this.selector.action?.optionsInSidebar}
></ha-automation-action>
`;
}

View File

@@ -27,6 +27,7 @@ export class HaConditionSelector extends LitElement {
.conditions=${this.value || []}
.hass=${this.hass}
.narrow=${this.narrow}
.optionsInSidebar=${!!this.selector.condition?.optionsInSidebar}
></ha-automation-condition>
`;
}

View File

@@ -121,6 +121,10 @@ const SELECTOR_SCHEMAS = {
name: "entity_id",
selector: { entity: {} },
},
{
name: "multiple",
selector: { boolean: {} },
},
] as const,
target: [] as const,
template: [] as const,

View File

@@ -4,6 +4,7 @@ import type { StateSelector } from "../../data/selector";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types";
import "../entity/ha-entity-state-picker";
import "../entity/ha-entity-states-picker";
@customElement("ha-selector-state")
export class HaSelectorState extends SubscribeMixin(LitElement) {
@@ -27,6 +28,25 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
};
protected render() {
if (this.selector.state?.multiple) {
return html`
<ha-entity-states-picker
.hass=${this.hass}
.entityId=${this.selector.state?.entity_id ||
this.context?.filter_entity}
.attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute}
.extraOptions=${this.selector.state?.extra_options}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
allow-custom-value
.hideStates=${this.selector.state?.hide_states}
></ha-entity-states-picker>
`;
}
return html`
<ha-entity-state-picker
.hass=${this.hass}

View File

@@ -1,9 +1,10 @@
import Spinner from "@shoelace-style/shoelace/dist/components/spinner/spinner.component";
import spinnerStyles from "@shoelace-style/shoelace/dist/components/spinner/spinner.styles";
import type { PropertyValues } from "lit";
import Spinner from "@awesome.me/webawesome/dist/components/spinner/spinner";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { StateSet } from "../resources/polyfills/stateset";
@customElement("ha-spinner")
export class HaSpinner extends Spinner {
@property() public size?: "tiny" | "small" | "medium" | "large";
@@ -32,21 +33,31 @@ export class HaSpinner extends Spinner {
}
}
static override styles = [
spinnerStyles,
css`
:host {
--indicator-color: var(
--ha-spinner-indicator-color,
var(--primary-color)
);
--track-color: var(--ha-spinner-divider-color, var(--divider-color));
--track-width: 4px;
--speed: 3.5s;
font-size: var(--ha-spinner-size, 48px);
}
`,
];
attachInternals() {
const internals = super.attachInternals();
Object.defineProperty(internals, "states", {
value: new StateSet(this, internals.states),
});
return internals;
}
static get styles(): CSSResultGroup {
return [
Spinner.styles,
css`
:host {
--indicator-color: var(
--ha-spinner-indicator-color,
var(--primary-color)
);
--track-color: var(--ha-spinner-divider-color, var(--divider-color));
--track-width: 4px;
--speed: 3.5s;
font-size: var(--ha-spinner-size, 48px);
}
`,
];
}
}
declare global {

View File

@@ -20,7 +20,7 @@ export class HaTextField extends TextFieldBase {
@property() public autocomplete?: string;
@property() public autocorrect?: string;
@property({ type: Boolean }) public autocorrect = true;
@property({ attribute: "input-spellcheck" })
public inputSpellcheck?: string;
@@ -57,8 +57,8 @@ export class HaTextField extends TextFieldBase {
}
}
if (changedProperties.has("autocorrect")) {
if (this.autocorrect) {
this.formElement.setAttribute("autocorrect", this.autocorrect);
if (this.autocorrect === false) {
this.formElement.setAttribute("autocorrect", "off");
} else {
this.formElement.removeAttribute("autocorrect");
}

View File

@@ -211,6 +211,7 @@ export class HaYamlEditor extends LitElement {
}
ha-code-editor {
flex-grow: 1;
min-height: 0;
}
`,
];

View File

@@ -30,6 +30,10 @@ export const ACTION_ICONS = {
wait_template: mdiCodeBraces,
wait_for_trigger: mdiTrafficLight,
repeat: mdiRefresh,
repeat_count: mdiRefresh,
repeat_while: mdiRefresh,
repeat_until: mdiRefresh,
repeat_for_each: mdiRefresh,
choose: mdiArrowDecision,
if: mdiCallSplit,
device_id: mdiDevices,
@@ -57,7 +61,10 @@ export const ACTION_GROUPS: AutomationElementGroup = {
delay: {},
wait_template: {},
wait_for_trigger: {},
repeat: {},
repeat_count: {},
repeat_while: {},
repeat_until: {},
repeat_for_each: {},
choose: {},
if: {},
stop: {},
@@ -83,3 +90,19 @@ export const isService = (key: string | undefined): boolean | undefined =>
export const getService = (key: string): string =>
key.substring(SERVICE_PREFIX.length);
export const ACTION_BUILDING_BLOCKS = [
"choose",
"if",
"parallel",
"sequence",
"repeat_while",
"repeat_until",
];
// Building blocks that have options in the sidebar
export const ACTION_COMBINED_BLOCKS = [
"repeat_count", // virtual repeat variant
"repeat_for_each", // virtual repeat variant
"wait_for_trigger",
];

View File

@@ -2,14 +2,15 @@ import type {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import { navigate } from "../common/navigate";
import { ensureArray } from "../common/array/ensure-array";
import { navigate } from "../common/navigate";
import { createSearchParam } from "../common/url/search-params";
import type { Context, HomeAssistant } from "../types";
import type { BlueprintInput } from "./blueprint";
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import type { Action, MODES } from "./script";
import { migrateAutomationAction } from "./script";
import { createSearchParam } from "../common/url/search-params";
import { CONDITION_BUILDING_BLOCKS } from "./condition";
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
export const AUTOMATION_DEFAULT_MAX = 10;
@@ -325,7 +326,7 @@ export const expandConditionWithShorthand = (
};
}
for (const condition of ["and", "or", "not"]) {
for (const condition of CONDITION_BUILDING_BLOCKS) {
if (condition in cond) {
return {
condition,

View File

@@ -50,3 +50,5 @@ export const CONDITION_GROUPS: AutomationElementGroup = {
},
},
} as const;
export const CONDITION_BUILDING_BLOCKS = ["and", "or", "not"];

View File

@@ -34,6 +34,8 @@ export interface FanEntity extends HassEntityBase {
attributes: FanEntityAttributes;
}
export type FanDirection = "forward" | "reverse";
export type FanSpeed = "off" | "low" | "medium" | "high" | "on";
export const FAN_SPEEDS: Partial<Record<number, FanSpeed[]>> = {

View File

@@ -23,6 +23,7 @@ export interface LovelaceViewElement extends HTMLElement {
badges?: HuiBadge[];
sections?: HuiSection[];
isStrategy: boolean;
allowEdit: boolean;
setConfig(config: LovelaceViewConfig): void;
}
@@ -35,6 +36,7 @@ export interface LovelaceSectionElement extends HTMLElement {
cards?: HuiCard[];
isStrategy: boolean;
importOnly?: boolean;
allowEdit: boolean;
setConfig(config: LovelaceSectionConfig): void;
}

View File

@@ -1,6 +1,6 @@
import type { HomeAssistant } from "../types";
function urlBase64ToUint8Array(base64String) {
function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");

View File

@@ -1,7 +1,10 @@
import type {
HassEntity,
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import type { HomeAssistant } from "../types";
export interface BasePerson {
@@ -67,3 +70,28 @@ export const deletePerson = (hass: HomeAssistant, personId: string) =>
type: "person/delete",
person_id: personId,
});
const cachedUserPerson: Record<string, string> = {};
export const getUserPerson = (hass: HomeAssistant): undefined | HassEntity => {
if (!hass.user?.id) {
return undefined;
}
const cachedPersonEntityId = cachedUserPerson[hass.user.id];
if (cachedPersonEntityId) {
const stateObj = hass.states[cachedPersonEntityId];
if (stateObj && stateObj.attributes.user_id === hass.user.id) {
return stateObj;
}
}
const result = Object.values(hass.states).find(
(state) =>
state.attributes.user_id === hass.user!.id &&
computeStateDomain(state) === "person"
);
if (result) {
cachedUserPerson[hass.user.id] = result.entity_id;
}
return result;
};

View File

@@ -74,7 +74,9 @@ export type Selector =
| BackupLocationSelector;
export interface ActionSelector {
action: {} | null;
action: {
optionsInSidebar?: boolean;
} | null;
}
export interface AddonSelector {
@@ -130,7 +132,9 @@ export interface ColorTempSelector {
}
export interface ConditionSelector {
condition: {} | null;
condition: {
optionsInSidebar?: boolean;
} | null;
}
export interface ConversationAgentSelector {
@@ -397,6 +401,7 @@ export interface StateSelector {
entity_id?: string | string[];
attribute?: string;
hide_states?: string[];
multiple?: boolean;
} | null;
}

View File

@@ -4,7 +4,7 @@ import type {
HassEntityBase,
HassEvent,
} from "home-assistant-js-websocket";
import { BINARY_STATE_ON } from "../common/const";
import { BINARY_STATE_ON, BINARY_STATE_OFF } from "../common/const";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { supportsFeature } from "../common/entity/supports-feature";
@@ -52,6 +52,15 @@ export const updateCanInstall = (
(showSkipped && Boolean(entity.attributes.skipped_version))) &&
supportsFeature(entity, UpdateEntityFeature.INSTALL);
export const latestVersionIsSkipped = (entity: UpdateEntity): boolean =>
!!(
entity.attributes.latest_version &&
entity.attributes.skipped_version === entity.attributes.latest_version
);
export const updateButtonIsDisabled = (entity: UpdateEntity): boolean =>
entity.state === BINARY_STATE_OFF && !latestVersionIsSkipped(entity);
export const updateIsInstalling = (entity: UpdateEntity): boolean =>
!!entity.attributes.in_progress;

View File

@@ -7,6 +7,7 @@ import { relativeTime } from "../../../common/datetime/relative_time";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-checkbox";
import "../../../components/ha-faded";
import "../../../components/ha-markdown";
@@ -26,6 +27,8 @@ import {
UpdateEntityFeature,
updateIsInstalling,
updateReleaseNotes,
latestVersionIsSkipped,
updateButtonIsDisabled,
} from "../../../data/update";
import type { HomeAssistant } from "../../../types";
import { showAlertDialog } from "../../generic/show-dialog-box";
@@ -180,11 +183,6 @@ class MoreInfoUpdate extends LitElement {
return nothing;
}
const skippedVersion =
this.stateObj.attributes.latest_version &&
this.stateObj.attributes.skipped_version ===
this.stateObj.attributes.latest_version;
const createBackupTexts = this._computeCreateBackupTexts();
return html`
@@ -251,15 +249,17 @@ class MoreInfoUpdate extends LitElement {
<hr />
${this._markdownLoading ? this._renderLoader() : nothing}
`
: html`
<hr />
<ha-markdown
@content-resize=${this._markdownLoaded}
.content=${this._releaseNotes}
class=${this._markdownLoading ? "hidden" : ""}
></ha-markdown>
${this._markdownLoading ? this._renderLoader() : nothing}
`
: this._releaseNotes
? html`
<hr />
<ha-markdown
@content-resize=${this._markdownLoaded}
.content=${this._releaseNotes}
class=${this._markdownLoading ? "hidden" : ""}
></ha-markdown>
${this._markdownLoading ? this._renderLoader() : nothing}
`
: nothing
: this.stateObj.attributes.release_summary
? html`
<hr />
@@ -312,7 +312,7 @@ class MoreInfoUpdate extends LitElement {
<ha-button
appearance="plain"
@click=${this._handleSkip}
.disabled=${skippedVersion ||
.disabled=${latestVersionIsSkipped(this.stateObj) ||
this.stateObj.state === BINARY_STATE_OFF ||
updateIsInstalling(this.stateObj)}
>
@@ -325,9 +325,8 @@ class MoreInfoUpdate extends LitElement {
? html`
<ha-button
@click=${this._handleInstall}
.disabled=${(this.stateObj.state === BINARY_STATE_OFF &&
!skippedVersion) ||
updateIsInstalling(this.stateObj)}
.loading=${updateIsInstalling(this.stateObj)}
.disabled=${updateButtonIsDisabled(this.stateObj)}
>
${this.hass.localize(
"ui.dialogs.more_info_control.update.update"

View File

@@ -144,10 +144,12 @@ export class MoreInfoDialog extends LitElement {
public closeDialog() {
this._entityId = undefined;
this._parentEntityIds = [];
this._entry = undefined;
this._childView = undefined;
this._infoEditMode = false;
this._initialView = DEFAULT_VIEW;
this._currView = DEFAULT_VIEW;
this._childView = undefined;
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
@@ -527,67 +529,69 @@ export class MoreInfoDialog extends LitElement {
`
: nothing}
</ha-dialog-header>
<div
class="content"
tabindex="-1"
dialogInitialFocus
@show-child-view=${this._showChildView}
@entity-entry-updated=${this._entryUpdated}
@toggle-edit-mode=${this._handleToggleInfoEditModeEvent}
@hass-more-info=${this._handleMoreInfoEvent}
>
${keyed(
this._entityId,
cache(
this._childView
? html`
<div class="child-view">
${dynamicElement(this._childView.viewTag, {
hass: this.hass,
entry: this._entry,
params: this._childView.viewParams,
})}
</div>
`
: this._currView === "info"
${keyed(
this._entityId,
html`
<div
class="content"
tabindex="-1"
dialogInitialFocus
@show-child-view=${this._showChildView}
@entity-entry-updated=${this._entryUpdated}
@toggle-edit-mode=${this._handleToggleInfoEditModeEvent}
@hass-more-info=${this._handleMoreInfoEvent}
>
${cache(
this._childView
? html`
<ha-more-info-info
dialogInitialFocus
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
.editMode=${this._infoEditMode}
></ha-more-info-info>
<div class="child-view">
${dynamicElement(this._childView.viewTag, {
hass: this.hass,
entry: this._entry,
params: this._childView.viewParams,
})}
</div>
`
: this._currView === "history"
: this._currView === "info"
? html`
<ha-more-info-history-and-logbook
<ha-more-info-info
dialogInitialFocus
.hass=${this.hass}
.entityId=${this._entityId}
></ha-more-info-history-and-logbook>
.entry=${this._entry}
.editMode=${this._infoEditMode}
></ha-more-info-info>
`
: this._currView === "settings"
: this._currView === "history"
? html`
<ha-more-info-settings
<ha-more-info-history-and-logbook
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
></ha-more-info-settings>
></ha-more-info-history-and-logbook>
`
: this._currView === "related"
: this._currView === "settings"
? html`
<ha-related-items
<ha-more-info-settings
.hass=${this.hass}
.itemId=${entityId}
.itemType=${SearchableDomains.has(domain)
? (domain as ItemType)
: "entity"}
></ha-related-items>
.entityId=${this._entityId}
.entry=${this._entry}
></ha-more-info-settings>
`
: nothing
)
)}
</div>
: this._currView === "related"
? html`
<ha-related-items
.hass=${this.hass}
.itemId=${entityId}
.itemType=${SearchableDomains.has(domain)
? (domain as ItemType)
: "entity"}
></ha-related-items>
`
: nothing
)}
</div>
`
)}
</ha-dialog>
`;
}

View File

@@ -72,31 +72,40 @@ class MoreInfoContent extends LitElement {
return (
stateObj.attributes &&
stateObj.attributes.entity_id &&
Array.isArray(stateObj.attributes.entity_id)
Array.isArray(stateObj.attributes.entity_id) &&
stateObj.attributes.entity_id.some(
(entityId: string) => !this.hass!.entities[entityId]?.hidden
)
);
}
private _entitiesSectionConfig = memoizeOne((entityIds: string[]) => {
const cards = entityIds.map((entityId) => {
const features: LovelaceCardFeatureConfig[] = [];
const context = { entity_id: entityId };
if (supportsCoverPositionCardFeature(this.hass!, context)) {
features.push({
type: "cover-position",
});
} else if (supportsLightBrightnessCardFeature(this.hass!, context)) {
features.push({
type: "light-brightness",
});
}
return {
type: "tile",
entity: entityId,
features_position: "inline",
features,
grid_options: { columns: 12 },
} as TileCardConfig;
});
const cards = entityIds
.map((entityId) => {
const entity = this.hass!.entities[entityId];
if (entity?.hidden) {
return null;
}
const features: LovelaceCardFeatureConfig[] = [];
const context = { entity_id: entityId };
if (supportsCoverPositionCardFeature(this.hass!, context)) {
features.push({
type: "cover-position",
});
} else if (supportsLightBrightnessCardFeature(this.hass!, context)) {
features.push({
type: "light-brightness",
});
}
return {
type: "tile",
entity: entityId,
features_position: "inline",
features,
grid_options: { columns: 12 },
} as TileCardConfig;
})
.filter(Boolean);
return {
type: "grid",
cards,

View File

@@ -44,7 +44,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1;
margin-top: calc( 2 * max(var(--safe-area-inset-bottom), 48px) + 46px );
margin-top: calc( 2 * max(var(--safe-area-inset-bottom, 0px), 48px) + 46px );
padding-top: 48px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
@@ -52,7 +52,7 @@
padding-top: 48px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom), 48px) 0;
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
display: flex;
flex-direction: column;
align-items: center;

View File

@@ -69,6 +69,7 @@ class HassTabsSubpage extends LitElement {
activeTab: PageNavigation | undefined,
_components,
_language,
_userData,
_narrow,
localizeFunc
) => {
@@ -123,6 +124,7 @@ class HassTabsSubpage extends LitElement {
this._activeTab,
this.hass.config.components,
this.hass.language,
this.hass.userData,
this.narrow,
this.localizeFunc || this.hass.localize
);

View File

@@ -1,16 +1,19 @@
import { mdiClose } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { property, query, state } from "lit/decorators";
import type { LocalizeKeys } from "../common/translations/localize";
import "../components/ha-button";
import "../components/ha-toast";
import "../components/ha-icon-button";
import "../components/ha-toast";
import type { HaToast } from "../components/ha-toast";
import type { HomeAssistant } from "../types";
export interface ShowToastParams {
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
id?: string;
message: string;
message:
| string
| { translationKey: LocalizeKeys; args?: Record<string, string> };
action?: ToastActionParams;
duration?: number;
dismissable?: boolean;
@@ -18,7 +21,9 @@ export interface ShowToastParams {
export interface ToastActionParams {
action: () => void;
text: string;
text:
| string
| { translationKey: LocalizeKeys; args?: Record<string, string> };
}
class NotificationManager extends LitElement {
@@ -62,7 +67,12 @@ class NotificationManager extends LitElement {
return html`
<ha-toast
leading
.labelText=${this._parameters.message}
.labelText=${typeof this._parameters.message !== "string"
? this.hass.localize(
this._parameters.message.translationKey,
this._parameters.message.args
)
: this._parameters.message}
.timeoutMs=${this._parameters.duration!}
@MDCSnackbar:closed=${this._toastClosed}
>
@@ -74,7 +84,12 @@ class NotificationManager extends LitElement {
slot="action"
@click=${this._buttonClicked}
>
${this._parameters?.action.text}
${typeof this._parameters?.action.text !== "string"
? this.hass.localize(
this._parameters?.action.text.translationKey,
this._parameters?.action.text.args
)
: this._parameters?.action.text}
</ha-button>
`
: nothing}

View File

@@ -385,30 +385,22 @@ export class HAFullCalendar extends LitElement {
if (!this._viewButtons) {
this._viewButtons = [
{
label: localize(
"ui.panel.lovelace.editor.card.calendar.views.dayGridMonth"
),
label: localize("ui.components.calendar.views.dayGridMonth"),
value: "dayGridMonth",
iconPath: mdiViewModule,
},
{
label: localize(
"ui.panel.lovelace.editor.card.calendar.views.dayGridWeek"
),
label: localize("ui.components.calendar.views.dayGridWeek"),
value: "dayGridWeek",
iconPath: mdiViewWeek,
},
{
label: localize(
"ui.panel.lovelace.editor.card.calendar.views.dayGridDay"
),
label: localize("ui.components.calendar.views.dayGridDay"),
value: "dayGridDay",
iconPath: mdiViewDay,
},
{
label: localize(
"ui.panel.lovelace.editor.card.calendar.views.listWeek"
),
label: localize("ui.components.calendar.views.listWeek"),
value: "listWeek",
iconPath: mdiViewAgenda,
},
@@ -493,10 +485,6 @@ export class HAFullCalendar extends LitElement {
--mdc-icon-button-size: 32px;
}
ha-button-toggle-group {
color: var(--primary-color);
}
ha-fab {
position: absolute;
bottom: 16px;

View File

@@ -7,6 +7,7 @@ import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-combo-box";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-fade-in";
import "../../../components/ha-markdown";
import "../../../components/ha-password-field";
import "../../../components/ha-spinner";
@@ -82,7 +83,7 @@ export class DialogAddApplicationCredential extends LitElement {
}
protected render() {
if (!this._params || !this._domains) {
if (!this._params) {
return nothing;
}
const selectedDomainName = this._params.selectedDomain
@@ -101,144 +102,159 @@ export class DialogAddApplicationCredential extends LitElement {
)
)}
>
<div>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert> `
: ""}
${this._params.selectedDomain && !this._description
? html`<p>
${this.hass.localize(
"ui.panel.config.application_credentials.editor.missing_credentials",
{
integration: selectedDomainName,
}
)}
${this._manifest?.is_built_in || this._manifest?.documentation
? html`<a
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._domain}`
)
: this._manifest.documentation}
target="_blank"
rel="noreferrer"
>
${!this._config
? html`<ha-fade-in .delay=${500}>
<ha-spinner size="large"></ha-spinner>
</ha-fade-in>`
: html`<div>
${this._error
? html`<ha-alert alert-type="error"
>${this._error}</ha-alert
> `
: nothing}
${this._params.selectedDomain && !this._description
? html`<p>
${this.hass.localize(
"ui.panel.config.application_credentials.editor.missing_credentials_domain_link",
"ui.panel.config.application_credentials.editor.missing_credentials",
{
integration: selectedDomainName,
}
)}
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>`
: ""}
</p>`
: ""}
${!this._params.selectedDomain || !this._description
? html`<p>
${this.hass.localize(
"ui.panel.config.application_credentials.editor.description"
)}
<a
href=${documentationUrl(
this.hass!,
"/integrations/application_credentials"
${this._manifest?.is_built_in ||
this._manifest?.documentation
? html`<a
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._domain}`
)
: this._manifest.documentation}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.application_credentials.editor.missing_credentials_domain_link",
{
integration: selectedDomainName,
}
)}
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>`
: nothing}
</p>`
: nothing}
${!this._params.selectedDomain || !this._description
? html`<p>
${this.hass.localize(
"ui.panel.config.application_credentials.editor.description"
)}
<a
href=${documentationUrl(
this.hass!,
"/integrations/application_credentials"
)}
target="_blank"
rel="noreferrer"
>
${this.hass!.localize(
"ui.panel.config.application_credentials.editor.view_documentation"
)}
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>
</p>`
: nothing}
${this._params.selectedDomain
? nothing
: html`<ha-combo-box
name="domain"
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.domain"
)}
.value=${this._domain}
.items=${this._domains}
item-id-path="id"
item-value-path="id"
item-label-path="name"
required
@value-changed=${this._handleDomainPicked}
></ha-combo-box>`}
${this._description
? html`<ha-markdown
breaks
.content=${this._description}
></ha-markdown>`
: nothing}
<ha-textfield
class="name"
name="name"
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.name"
)}
target="_blank"
rel="noreferrer"
>
${this.hass!.localize(
"ui.panel.config.application_credentials.editor.view_documentation"
.value=${this._name}
required
@input=${this._handleValueChanged}
.validationMessage=${this.hass.localize(
"ui.common.error_required"
)}
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>
</p>`
: ""}
${this._params.selectedDomain
? ""
: html`<ha-combo-box
name="domain"
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.domain"
)}
.value=${this._domain}
.items=${this._domains}
item-id-path="id"
item-value-path="id"
item-label-path="name"
required
@value-changed=${this._handleDomainPicked}
></ha-combo-box>`}
${this._description
? html`<ha-markdown
breaks
.content=${this._description}
></ha-markdown>`
: ""}
<ha-textfield
class="name"
name="name"
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.name"
)}
.value=${this._name}
required
@input=${this._handleValueChanged}
.validationMessage=${this.hass.localize("ui.common.error_required")}
dialogInitialFocus
></ha-textfield>
<ha-textfield
class="clientId"
name="clientId"
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_id"
)}
.value=${this._clientId}
required
@input=${this._handleValueChanged}
.validationMessage=${this.hass.localize("ui.common.error_required")}
dialogInitialFocus
.helper=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_id_helper"
)}
helperPersistent
></ha-textfield>
<ha-password-field
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_secret"
)}
name="clientSecret"
.value=${this._clientSecret}
required
@input=${this._handleValueChanged}
.validationMessage=${this.hass.localize("ui.common.error_required")}
.helper=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_secret_helper"
)}
helperPersistent
></ha-password-field>
</div>
dialogInitialFocus
></ha-textfield>
<ha-textfield
class="clientId"
name="clientId"
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_id"
)}
.value=${this._clientId}
required
@input=${this._handleValueChanged}
.validationMessage=${this.hass.localize(
"ui.common.error_required"
)}
dialogInitialFocus
.helper=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_id_helper"
)}
helperPersistent
></ha-textfield>
<ha-password-field
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_secret"
)}
name="clientSecret"
.value=${this._clientSecret}
required
@input=${this._handleValueChanged}
.validationMessage=${this.hass.localize(
"ui.common.error_required"
)}
.helper=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_secret_helper"
)}
helperPersistent
></ha-password-field>
</div>
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this._abortDialog}
.disabled=${this._loading}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${!this._domain || !this._clientId || !this._clientSecret}
@click=${this._addApplicationCredential}
.loading=${this._loading}
>
${this.hass.localize(
"ui.panel.config.application_credentials.editor.add"
)}
</ha-button>
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this._abortDialog}
.disabled=${this._loading}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${!this._domain ||
!this._clientId ||
!this._clientSecret}
@click=${this._addApplicationCredential}
.loading=${this._loading}
>
${this.hass.localize(
"ui.panel.config.application_credentials.editor.add"
)}
</ha-button>`}
</ha-dialog>
`;
}
@@ -341,6 +357,11 @@ export class DialogAddApplicationCredential extends LitElement {
ha-markdown {
margin-bottom: 16px;
}
ha-fade-in {
display: flex;
width: 100%;
justify-content: center;
}
`,
];
}

View File

@@ -0,0 +1,113 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
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 { migrateAutomationAction, type Action } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import "../ha-automation-editor-warning";
import { editorStyles } from "../styles";
import { getAutomationActionType } from "./ha-automation-action-row";
@customElement("ha-automation-action-editor")
export default class HaAutomationActionEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) action!: Action;
@property({ type: Boolean }) public disabled = false;
@property({ attribute: false }) public yamlMode = false;
@property({ type: Boolean }) public indent = false;
@property({ type: Boolean, reflect: true }) public selected = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, attribute: "sidebar" }) public inSidebar = false;
@property({ type: Boolean, attribute: "supported" }) public uiSupported =
false;
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
protected render() {
const yamlMode = this.yamlMode || !this.uiSupported;
const type = getAutomationActionType(this.action);
return html`
<div
class=${classMap({
"card-content": true,
disabled:
this.disabled || (this.action.enabled === false && !this.yamlMode),
yaml: yamlMode,
indent: this.indent,
})}
>
${yamlMode
? html`
${!this.uiSupported
? html`
<ha-automation-editor-warning
.alertTitle=${this.hass.localize(
"ui.panel.config.automation.editor.actions.unsupported_action"
)}
.localize=${this.hass.localize}
></ha-automation-editor-warning>
`
: nothing}
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${this.action}
@value-changed=${this._onYamlChange}
.readOnly=${this.disabled}
></ha-yaml-editor>
`
: html`
<div @value-changed=${this._onUiChanged}>
${dynamicElement(`ha-automation-action-${type}`, {
hass: this.hass,
action: this.action,
disabled: this.disabled,
narrow: this.narrow,
optionsInSidebar: this.indent,
indent: this.indent,
inSidebar: this.inSidebar,
})}
</div>
`}
</div>
`;
}
private _onYamlChange(ev: CustomEvent) {
ev.stopPropagation();
if (!ev.detail.isValid) {
return;
}
fireEvent(this, "value-changed", {
value: migrateAutomationAction(ev.detail.value),
});
}
private _onUiChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = {
...(this.action.alias ? { alias: this.action.alias } : {}),
...ev.detail.value,
};
fireEvent(this, "value-changed", { value });
}
static styles = editorStyles;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-action-editor": HaAutomationActionEditor;
}
}

View File

@@ -15,28 +15,35 @@ import {
mdiStopCircleOutline,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { storage } from "../../../../common/decorators/storage";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-alert";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-automation-row";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-service-icon";
import "../../../../components/ha-tooltip";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { ACTION_ICONS, YAML_ONLY_ACTION_TYPES } from "../../../../data/action";
import type { AutomationClipboard } from "../../../../data/automation";
import {
ACTION_BUILDING_BLOCKS,
ACTION_COMBINED_BLOCKS,
ACTION_ICONS,
YAML_ONLY_ACTION_TYPES,
} from "../../../../data/action";
import type {
AutomationClipboard,
Condition,
} from "../../../../data/automation";
import { validateConfig } from "../../../../data/config";
import {
floorsContext,
@@ -46,11 +53,12 @@ import {
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
import type { FloorRegistryEntry } from "../../../../data/floor_registry";
import type { LabelRegistryEntry } from "../../../../data/label_registry";
import type { Action, NonConditionAction } from "../../../../data/script";
import {
getActionType,
migrateAutomationAction,
import type {
Action,
NonConditionAction,
RepeatAction,
} from "../../../../data/script";
import { getActionType } from "../../../../data/script";
import { describeAction } from "../../../../data/script_i18n";
import { callExecuteScript } from "../../../../data/service";
import {
@@ -58,9 +66,12 @@ import {
showConfirmationDialog,
showPromptDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import "../ha-automation-editor-warning";
import { rowStyles } from "../styles";
import "./ha-automation-action-editor";
import type HaAutomationActionEditor from "./ha-automation-action-editor";
import "./types/ha-automation-action-choose";
import "./types/ha-automation-action-condition";
import "./types/ha-automation-action-delay";
@@ -69,28 +80,31 @@ import "./types/ha-automation-action-event";
import "./types/ha-automation-action-if";
import "./types/ha-automation-action-parallel";
import "./types/ha-automation-action-play_media";
import "./types/ha-automation-action-repeat";
import { getRepeatType } from "./types/ha-automation-action-repeat";
import "./types/ha-automation-action-sequence";
import "./types/ha-automation-action-service";
import "./types/ha-automation-action-set_conversation_response";
import "./types/ha-automation-action-stop";
import "./types/ha-automation-action-wait_for_trigger";
import "./types/ha-automation-action-wait_template";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
export const getType = (action: Action | undefined) => {
if (!action) {
return undefined;
export const getAutomationActionType = memoizeOne(
(action: Action | undefined) => {
if (!action) {
return undefined;
}
if ("action" in action) {
return getActionType(action) as "action" | "play_media";
}
if (CONDITION_BUILDING_BLOCKS.some((key) => key in action)) {
return "condition" as const;
}
return Object.keys(ACTION_ICONS).find(
(option) => option in action
) as keyof typeof ACTION_ICONS;
}
if ("action" in action) {
return getActionType(action) as "action" | "play_media";
}
if (["and", "or", "not"].some((key) => key in action)) {
return "condition" as const;
}
return Object.keys(ACTION_ICONS).find(
(option) => option in action
) as keyof typeof ACTION_ICONS;
};
);
export interface ActionElement extends LitElement {
action: Action;
@@ -118,8 +132,6 @@ export const handleChangeEvent = (element: ActionElement, ev: CustomEvent) => {
fireEvent(element, "value-changed", { value: newAction });
};
const preventDefault = (ev) => ev.preventDefault();
@customElement("ha-automation-action-row")
export default class HaAutomationActionRow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -134,6 +146,9 @@ export default class HaAutomationActionRow extends LitElement {
@property({ type: Boolean }) public last?: boolean;
@property({ type: Boolean, attribute: "sidebar" })
public optionsInSidebar = false;
@storage({
key: "automationClipboard",
state: false,
@@ -154,19 +169,27 @@ export default class HaAutomationActionRow extends LitElement {
@consume({ context: floorsContext, subscribe: true })
_floorReg!: Record<string, FloorRegistryEntry>;
@state() private _warnings?: string[];
@state() private _uiModeAvailable = true;
@state() private _yamlMode = false;
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
@state() private _selected = false;
@state() private _collapsed = false;
@state() private _warnings?: string[];
@query("ha-automation-action-editor")
private actionEditor?: HaAutomationActionEditor;
protected willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has("yamlMode")) {
this._warnings = undefined;
}
if (!changedProperties.has("action")) {
return;
}
const type = getType(this.action);
const type = getAutomationActionType(this.action);
this._uiModeAvailable =
type !== undefined && !YAML_ONLY_ACTION_TYPES.has(type as any);
if (!this._uiModeAvailable && !this._yamlMode) {
@@ -174,23 +197,207 @@ export default class HaAutomationActionRow extends LitElement {
}
}
protected updated(changedProperties: PropertyValues) {
if (!changedProperties.has("action")) {
return;
}
if (this._yamlMode) {
const yamlEditor = this._yamlEditor;
if (yamlEditor && yamlEditor.value !== this.action) {
yamlEditor.setValue(this.action);
}
}
private _renderRow() {
const type = getAutomationActionType(this.action);
return html`
${type === "service" && "action" in this.action && this.action.action
? html`
<ha-service-icon
slot="leading-icon"
class="action-icon"
.hass=${this.hass}
.service=${this.action.action}
></ha-service-icon>
`
: html`
<ha-svg-icon
slot="leading-icon"
class="action-icon"
.path=${ACTION_ICONS[type!]}
></ha-svg-icon>
`}
<h3 slot="header">
${capitalizeFirstLetter(
describeAction(
this.hass,
this._entityReg,
this._labelReg,
this._floorReg,
this.action
)
)}
</h3>
<slot name="icons" slot="icons"></slot>
${type !== "condition" &&
(this.action as NonConditionAction).continue_on_error === true
? html`<ha-tooltip
slot="icons"
.content=${this.hass.localize(
"ui.panel.config.automation.editor.actions.continue_on_error"
)}
>
<ha-svg-icon .path=${mdiAlertCircleCheck}></ha-svg-icon>
</ha-tooltip>`
: nothing}
<ha-md-button-menu
slot="icons"
@click=${preventDefaultStopPropagation}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item .clickAction=${this._runAction}>
${this.hass.localize("ui.panel.config.automation.editor.actions.run")}
<ha-svg-icon slot="start" .path=${mdiPlay}></ha-svg-icon>
</ha-md-menu-item>
${!this.optionsInSidebar
? html` <ha-md-menu-item
.clickAction=${this._renameAction}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.rename"
)}
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>`
: nothing}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
.clickAction=${this._duplicateAction}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
<ha-svg-icon slot="start" .path=${mdiContentDuplicate}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._copyAction}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
)}
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._cutAction}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
)}
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveUp}
.disabled=${this.disabled || !!this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveDown}
.disabled=${this.disabled || !!this.last}
>
${this.hass.localize("ui.panel.config.automation.editor.move_down")}
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
${!this.optionsInSidebar
? html`<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!this._uiModeAvailable || !!this._warnings}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>`
: nothing}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
.clickAction=${this._onDisable}
.disabled=${this.disabled}
>
${this.action.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
)
: this.hass.localize(
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="start"
.path=${this.action.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
class="warning"
.clickAction=${this._onDelete}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
<ha-svg-icon
class="warning"
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
${!this.optionsInSidebar
? html`${this._warnings
? html`<ha-automation-editor-warning
.localize=${this.hass.localize}
.warnings=${this._warnings}
>
</ha-automation-editor-warning>`
: nothing}
<ha-automation-action-editor
.hass=${this.hass}
.action=${this.action}
.disabled=${this.disabled}
.yamlMode=${this._yamlMode}
.narrow=${this.narrow}
.uiSupported=${this._uiSupported(type!)}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-action-editor>`
: nothing}
`;
}
protected render() {
if (!this.action) return nothing;
const type = getType(this.action);
const yamlMode = this._yamlMode;
const type = getAutomationActionType(this.action);
const blockType =
type === "repeat"
? `repeat_${getRepeatType((this.action as RepeatAction).repeat)}`
: type;
return html`
<ha-card outlined>
@@ -203,245 +410,57 @@ export default class HaAutomationActionRow extends LitElement {
</div>
`
: nothing}
<ha-expansion-panel left-chevron>
${type === "service" && "action" in this.action && this.action.action
? html`
<ha-service-icon
slot="leading-icon"
class="action-icon"
.hass=${this.hass}
.service=${this.action.action}
></ha-service-icon>
`
: html`
<ha-svg-icon
slot="leading-icon"
class="action-icon"
.path=${ACTION_ICONS[type!]}
></ha-svg-icon>
`}
<h3 slot="header">
${capitalizeFirstLetter(
describeAction(
this.hass,
this._entityReg,
this._labelReg,
this._floorReg,
this.action
)
)}
</h3>
<slot name="icons" slot="icons"></slot>
${type !== "condition" &&
(this.action as NonConditionAction).continue_on_error === true
? html`<ha-tooltip
slot="icons"
.content=${this.hass.localize(
"ui.panel.config.automation.editor.actions.continue_on_error"
)}
>
<ha-svg-icon .path=${mdiAlertCircleCheck}></ha-svg-icon>
</ha-tooltip>`
: nothing}
<ha-md-button-menu
slot="icons"
@click=${preventDefault}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item .clickAction=${this._runAction}>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.run"
)}
<ha-svg-icon slot="start" .path=${mdiPlay}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._renameAction}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.rename"
)}
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
.clickAction=${this._duplicateAction}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
<ha-svg-icon
slot="start"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._copyAction}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
)}
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._cutAction}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
)}
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveUp}
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveDown}
.disabled=${this.disabled || this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!this._uiModeAvailable}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
.clickAction=${this._onDisable}
.disabled=${this.disabled}
>
${this.action.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
)
: this.hass.localize(
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="start"
.path=${this.action.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
class="warning"
.clickAction=${this._onDelete}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
<ha-svg-icon
class="warning"
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
<div
class=${classMap({
"card-content": true,
disabled: this.action.enabled === false,
})}
>
${this._warnings
? html`<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.errors.config.editor_not_supported"
)}
>
${this._warnings!.length > 0 &&
this._warnings![0] !== undefined
? html` <ul>
${this._warnings!.map(
(warning) => html`<li>${warning}</li>`
)}
</ul>`
: ""}
${this.hass.localize(
"ui.errors.config.edit_in_yaml_supported"
)}
</ha-alert>`
: ""}
${yamlMode
? html`
${type === undefined
? html`
${this.hass.localize(
"ui.panel.config.automation.editor.actions.unsupported_action"
)}
`
: ""}
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${this.action}
.readOnly=${this.disabled}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>
`
: html`
<div
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._onUiChanged}
>
${dynamicElement(`ha-automation-action-${type}`, {
hass: this.hass,
action: this.action,
narrow: this.narrow,
disabled: this.disabled,
})}
</div>
`}
</div>
</ha-expansion-panel>
${this.optionsInSidebar
? html`<ha-automation-row
.disabled=${this.action.enabled === false}
@click=${this._toggleSidebar}
.leftChevron=${[
...ACTION_BUILDING_BLOCKS,
...ACTION_COMBINED_BLOCKS,
].includes(blockType!)}
.collapsed=${this._collapsed}
.selected=${this._selected}
@toggle-collapsed=${this._toggleCollapse}
.buildingBlock=${[
...ACTION_BUILDING_BLOCKS,
...ACTION_COMBINED_BLOCKS,
].includes(blockType!)}
>${this._renderRow()}</ha-automation-row
>`
: html`
<ha-expansion-panel left-chevron>
${this._renderRow()}
</ha-expansion-panel>
`}
</ha-card>
${this.optionsInSidebar &&
([...ACTION_BUILDING_BLOCKS, ...ACTION_COMBINED_BLOCKS].includes(
blockType!
) ||
(blockType === "condition" &&
CONDITION_BUILDING_BLOCKS.includes(
(this.action as Condition).condition
))) &&
!this._collapsed
? html`<ha-automation-action-editor
.hass=${this.hass}
.action=${this.action}
.narrow=${this.narrow}
.disabled=${this.disabled}
.uiSupported=${this._uiSupported(type!)}
indent
.selected=${this._selected}
@value-changed=${this._onValueChange}
></ha-automation-action-editor>`
: nothing}
`;
}
private _handleUiModeNotAvailable(ev: CustomEvent) {
// Prevent possible parent action-row from switching to yamlMode
ev.stopPropagation();
this._warnings = handleStructError(this.hass, ev.detail).warnings;
if (!this._yamlMode) {
this._yamlMode = true;
private _onValueChange(event: CustomEvent) {
// reload sidebar if sort, deleted,... happend
if (this._selected && this.optionsInSidebar) {
this.openSidebar(event.detail.value);
}
}
@@ -456,8 +475,10 @@ export default class HaAutomationActionRow extends LitElement {
const enabled = !(this.action.enabled ?? true);
const value = { ...this.action, enabled };
fireEvent(this, "value-changed", { value });
if (this._yamlMode) {
this._yamlEditor?.setValue(value);
this.openSidebar(value); // refresh sidebar
if (this._yamlMode && !this.optionsInSidebar) {
this.actionEditor?.yamlEditor?.setValue(value);
}
};
@@ -508,36 +529,18 @@ export default class HaAutomationActionRow extends LitElement {
destructive: true,
confirm: () => {
fireEvent(this, "value-changed", { value: null });
if (this._selected) {
fireEvent(this, "close-sidebar");
}
},
});
};
private _onYamlChange(ev: CustomEvent) {
ev.stopPropagation();
if (!ev.detail.isValid) {
return;
}
fireEvent(this, "value-changed", {
value: migrateAutomationAction(ev.detail.value),
});
}
private _onUiChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = {
...(this.action.alias ? { alias: this.action.alias } : {}),
...ev.detail.value,
};
fireEvent(this, "value-changed", { value });
}
private _switchUiMode() {
this._warnings = undefined;
this._yamlMode = false;
}
private _switchYamlMode() {
this._warnings = undefined;
this._yamlMode = true;
}
@@ -574,8 +577,11 @@ export default class HaAutomationActionRow extends LitElement {
fireEvent(this, "value-changed", {
value,
});
if (this._yamlMode) {
this._yamlEditor?.setValue(value);
if (this._selected && this.optionsInSidebar) {
this.openSidebar(value); // refresh sidebar
} else if (this._yamlMode) {
this.actionEditor?.yamlEditor?.setValue(value);
}
}
};
@@ -591,6 +597,9 @@ export default class HaAutomationActionRow extends LitElement {
private _cutAction = () => {
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
if (this._selected) {
fireEvent(this, "close-sidebar");
}
};
private _moveUp = () => {
@@ -607,82 +616,78 @@ export default class HaAutomationActionRow extends LitElement {
} else {
this._switchYamlMode();
}
this.expand();
if (!this.optionsInSidebar) {
this.expand();
}
};
private _handleUiModeNotAvailable(ev: CustomEvent) {
this._warnings = handleStructError(this.hass, ev.detail).warnings;
if (!this._yamlMode) {
this._yamlMode = true;
}
}
private _toggleSidebar(ev: Event) {
ev?.stopPropagation();
if (this._selected) {
this._selected = false;
fireEvent(this, "close-sidebar");
return;
}
this.openSidebar();
}
public openSidebar(action?: Action): void {
if (this.narrow) {
this.scrollIntoView();
}
const sidebarAction = action ?? this.action;
const actionType = getAutomationActionType(sidebarAction);
fireEvent(this, "open-sidebar", {
save: (value) => {
fireEvent(this, "value-changed", { value });
},
close: () => {
this._selected = false;
fireEvent(this, "close-sidebar");
},
rename: () => {
this._renameAction();
},
toggleYamlMode: () => {
this._toggleYamlMode();
return this._yamlMode;
},
disable: this._onDisable,
delete: this._onDelete,
config: sidebarAction,
type: "action",
uiSupported: actionType ? this._uiSupported(actionType) : false,
yamlMode: this._yamlMode,
});
this._selected = true;
}
public expand() {
this.updateComplete.then(() => {
this.shadowRoot!.querySelector("ha-expansion-panel")!.expanded = true;
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
ha-icon-button {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
ha-expansion-panel {
--expansion-panel-summary-padding: 0 0 0 8px;
--expansion-panel-content-padding: 0;
}
h3 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
.action-icon {
display: none;
}
@media (min-width: 870px) {
.action-icon {
display: inline-block;
color: var(--secondary-text-color);
opacity: 0.9;
}
}
.card-content {
padding: 16px;
}
.disabled-bar {
background: var(--divider-color, #e0e0e0);
text-align: center;
border-top-right-radius: calc(
var(--ha-card-border-radius, 12px) - var(
--ha-card-border-width,
1px
)
);
border-top-left-radius: calc(
var(--ha-card-border-radius, 12px) - var(
--ha-card-border-width,
1px
)
);
}
.warning ul {
margin: 4px 0;
}
ha-md-menu-item > ha-svg-icon {
--mdc-icon-size: 24px;
}
ha-tooltip {
cursor: default;
}
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`,
];
private _uiSupported = memoizeOne(
(type: string) =>
customElements.get(`ha-automation-action-${type}`) !== undefined
);
private _toggleCollapse() {
this._collapsed = !this._collapsed;
}
static styles = rowStyles;
}
declare global {

View File

@@ -11,16 +11,21 @@ import { nextRender } from "../../../../common/util/render-status";
import "../../../../components/ha-button";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import { getService, isService } from "../../../../data/action";
import {
ACTION_BUILDING_BLOCKS,
getService,
isService,
} from "../../../../data/action";
import type { AutomationClipboard } from "../../../../data/automation";
import type { Action } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import {
PASTE_VALUE,
VIRTUAL_ACTIONS,
showAddAutomationElementDialog,
} from "../show-add-automation-element-dialog";
import type HaAutomationActionRow from "./ha-automation-action-row";
import { getType } from "./ha-automation-action-row";
import { getAutomationActionType } from "./ha-automation-action-row";
@customElement("ha-automation-action")
export default class HaAutomationAction extends LitElement {
@@ -36,6 +41,9 @@ export default class HaAutomationAction extends LitElement {
@property({ attribute: false }) public highlightedActions?: Action[];
@property({ type: Boolean, attribute: "sidebar" }) public optionsInSidebar =
false;
@state() private _showReorder = false;
@state()
@@ -97,6 +105,7 @@ export default class HaAutomationAction extends LitElement {
@value-changed=${this._actionChanged}
.hass=${this.hass}
?highlight=${this.highlightedActions?.includes(action)}
.optionsInSidebar=${this.optionsInSidebar}
>
${this._showReorder && !this.disabled
? html`
@@ -147,7 +156,17 @@ export default class HaAutomationAction extends LitElement {
"ha-automation-action-row:last-of-type"
)!;
row.updateComplete.then(() => {
row.expand();
// on new condition open the settings in the sidebar, except for building blocks
const type = getAutomationActionType(row.action);
if (
type &&
this.optionsInSidebar &&
!ACTION_BUILDING_BLOCKS.includes(type)
) {
row.openSidebar();
} else if (!this.optionsInSidebar) {
row.expand();
}
row.scrollIntoView();
row.focus();
});
@@ -167,7 +186,7 @@ export default class HaAutomationAction extends LitElement {
showAddAutomationElementDialog(this, {
type: "action",
add: this._addAction,
clipboardItem: getType(this._clipboard?.action),
clipboardItem: getAutomationActionType(this._clipboard?.action),
});
}
@@ -175,7 +194,7 @@ export default class HaAutomationAction extends LitElement {
showAddAutomationElementDialog(this, {
type: "action",
add: this._addAction,
clipboardItem: getType(this._clipboard?.action),
clipboardItem: getAutomationActionType(this._clipboard?.action),
group: "building_blocks",
});
}
@@ -184,6 +203,8 @@ export default class HaAutomationAction extends LitElement {
let actions: Action[];
if (action === PASTE_VALUE) {
actions = this.actions.concat(deepClone(this._clipboard!.action));
} else if (action in VIRTUAL_ACTIONS) {
actions = this.actions.concat(VIRTUAL_ACTIONS[action]);
} else if (isService(action)) {
actions = this.actions.concat({
action: getService(action),
@@ -269,6 +290,7 @@ export default class HaAutomationAction extends LitElement {
// Ensure action is removed even after update
const actions = this.actions.filter((a) => a !== action);
fireEvent(this, "value-changed", { value: actions });
fireEvent(this, "close-sidebar");
}
private _actionChanged(ev: CustomEvent) {
@@ -300,15 +322,18 @@ export default class HaAutomationAction extends LitElement {
static styles = css`
.actions {
padding: 16px;
padding: 16px 0 16px 16px;
margin: -16px;
display: flex;
flex-direction: column;
gap: 16px;
}
:host([root]) .actions {
padding-right: 8px;
}
.sortable-ghost {
background: none;
border-radius: var(--ha-card-border-radius, 12px);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
.sortable-drag {
background: none;

View File

@@ -7,8 +7,8 @@ import type { Action, ChooseAction, Option } from "../../../../../data/script";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import "../../option/ha-automation-option";
import type { ActionElement } from "../ha-automation-action-row";
import "../ha-automation-action";
import type { ActionElement } from "../ha-automation-action-row";
@customElement("ha-automation-action-choose")
export class HaChooseAction extends LitElement implements ActionElement {
@@ -20,6 +20,8 @@ export class HaChooseAction extends LitElement implements ActionElement {
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean }) public indent = false;
@state() private _showDefault = false;
public static get defaultConfig(): ChooseAction {
@@ -38,6 +40,7 @@ export class HaChooseAction extends LitElement implements ActionElement {
@value-changed=${this._optionsChanged}
.hass=${this.hass}
.narrow=${this.narrow}
.optionsInSidebar=${this.indent}
></ha-automation-option>
${this._showDefault || action.default
@@ -53,6 +56,7 @@ export class HaChooseAction extends LitElement implements ActionElement {
@value-changed=${this._defaultChanged}
.hass=${this.hass}
.narrow=${this.narrow}
.optionsInSidebar=${this.indent}
></ha-automation-action>
`
: html`

View File

@@ -1,4 +1,4 @@
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
@@ -8,10 +8,24 @@ import "../../../../../components/ha-list-item";
import "../../../../../components/ha-select";
import type { HaSelect } from "../../../../../components/ha-select";
import type { Condition } from "../../../../../data/automation";
import { CONDITION_ICONS } from "../../../../../data/condition";
import {
CONDITION_BUILDING_BLOCKS,
CONDITION_ICONS,
} from "../../../../../data/condition";
import type { Entries, HomeAssistant } from "../../../../../types";
import "../../condition/ha-automation-condition-editor";
import type { ActionElement } from "../ha-automation-action-row";
import "../../condition/types/ha-automation-condition-and";
import "../../condition/types/ha-automation-condition-device";
import "../../condition/types/ha-automation-condition-not";
import "../../condition/types/ha-automation-condition-numeric_state";
import "../../condition/types/ha-automation-condition-or";
import "../../condition/types/ha-automation-condition-state";
import "../../condition/types/ha-automation-condition-sun";
import "../../condition/types/ha-automation-condition-template";
import "../../condition/types/ha-automation-condition-time";
import "../../condition/types/ha-automation-condition-trigger";
import "../../condition/types/ha-automation-condition-zone";
@customElement("ha-automation-action-condition")
export class HaConditionAction extends LitElement implements ActionElement {
@@ -21,36 +35,63 @@ export class HaConditionAction extends LitElement implements ActionElement {
@property({ attribute: false }) public action!: Condition;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, attribute: "sidebar" }) public inSidebar = false;
@property({ type: Boolean, attribute: "indent" }) public indent = false;
public static get defaultConfig(): Omit<Condition, "state" | "entity_id"> {
return { condition: "state" };
}
protected render() {
const buildingBlock = CONDITION_BUILDING_BLOCKS.includes(
this.action.condition
);
return html`
<ha-select
fixedMenuPosition
.label=${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type_select"
)}
.disabled=${this.disabled}
.value=${this.action.condition}
naturalMenuWidth
@selected=${this._typeChanged}
>
${this._processedTypes(this.hass.localize).map(
([opt, label, icon]) => html`
<ha-list-item .value=${opt} graphic="icon">
${label}<ha-svg-icon slot="graphic" .path=${icon}></ha-svg-icon
></ha-list-item>
${this.inSidebar || (!this.inSidebar && !this.indent)
? html`
<ha-select
fixedMenuPosition
.label=${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type_select"
)}
.disabled=${this.disabled}
.value=${this.action.condition}
naturalMenuWidth
@selected=${this._typeChanged}
>
${this._processedTypes(this.hass.localize).map(
([opt, label, icon]) => html`
<ha-list-item .value=${opt} graphic="icon">
${label}<ha-svg-icon
slot="graphic"
.path=${icon}
></ha-svg-icon
></ha-list-item>
`
)}
</ha-select>
`
)}
</ha-select>
<ha-automation-condition-editor
.condition=${this.action}
.disabled=${this.disabled}
.hass=${this.hass}
@value-changed=${this._conditionChanged}
></ha-automation-condition-editor>
: nothing}
${(this.indent && buildingBlock) ||
(this.inSidebar && !buildingBlock) ||
(!this.indent && !this.inSidebar)
? html`
<ha-automation-condition-editor
.condition=${this.action}
.disabled=${this.disabled}
.hass=${this.hass}
@value-changed=${this._conditionChanged}
.narrow=${this.narrow}
.uiSupported=${this._uiSupported(this.action.condition)}
.indent=${this.indent}
action
></ha-automation-condition-editor>
`
: nothing}
`;
}
@@ -100,6 +141,11 @@ export class HaConditionAction extends LitElement implements ActionElement {
}
}
private _uiSupported = memoizeOne(
(type: string) =>
customElements.get(`ha-automation-condition-${type}`) !== undefined
);
static styles = css`
ha-select {
margin-bottom: 24px;

View File

@@ -20,6 +20,8 @@ export class HaIfAction extends LitElement implements ActionElement {
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean }) public indent = false;
@state() private _showElse = false;
public static get defaultConfig(): IfAction {
@@ -39,11 +41,12 @@ export class HaIfAction extends LitElement implements ActionElement {
)}*:
</h3>
<ha-automation-condition
.conditions=${action.if}
.conditions=${action.if ?? []}
.disabled=${this.disabled}
@value-changed=${this._ifChanged}
.hass=${this.hass}
.narrow=${this.narrow}
.optionsInSidebar=${this.indent}
></ha-automation-condition>
<h3>
@@ -52,11 +55,12 @@ export class HaIfAction extends LitElement implements ActionElement {
)}*:
</h3>
<ha-automation-action
.actions=${action.then}
.actions=${action.then ?? []}
.disabled=${this.disabled}
@value-changed=${this._thenChanged}
.hass=${this.hass}
.narrow=${this.narrow}
.optionsInSidebar=${this.indent}
></ha-automation-action>
${this._showElse || action.else
? html`
@@ -71,9 +75,10 @@ export class HaIfAction extends LitElement implements ActionElement {
@value-changed=${this._elseChanged}
.hass=${this.hass}
.narrow=${this.narrow}
.optionsInSidebar=${this.indent}
></ha-automation-action>
`
: html` <div class="link-button-row">
: html`<div class="link-button-row">
<button
class="link"
@click=${this._addElse}

View File

@@ -15,8 +15,12 @@ export class HaParallelAction extends LitElement implements ActionElement {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public action!: ParallelAction;
@property({ type: Boolean }) public indent = false;
public static get defaultConfig(): ParallelAction {
return {
parallel: [],
@@ -29,9 +33,11 @@ export class HaParallelAction extends LitElement implements ActionElement {
return html`
<ha-automation-action
.actions=${action.parallel}
.narrow=${this.narrow}
.disabled=${this.disabled}
@value-changed=${this._actionsChanged}
.hass=${this.hass}
.optionsInSidebar=${this.indent}
></ha-automation-action>
`;
}

View File

@@ -11,7 +11,6 @@ import "../ha-automation-action";
import type { ActionElement } from "../ha-automation-action-row";
import { isTemplate } from "../../../../../common/string/has-template";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import "../../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
@@ -19,8 +18,10 @@ import type {
} from "../../../../../components/ha-form/types";
const OPTIONS = ["count", "while", "until", "for_each"] as const;
type RepeatType = (typeof OPTIONS)[number];
const getType = (action) => OPTIONS.find((option) => option in action);
export const getRepeatType = (action: RepeatAction["repeat"]) =>
OPTIONS.find((option) => option in action);
@customElement("ha-automation-action-repeat")
export class HaRepeatAction extends LitElement implements ActionElement {
@@ -28,30 +29,27 @@ export class HaRepeatAction extends LitElement implements ActionElement {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public action!: RepeatAction;
@property({ type: Boolean, attribute: "sidebar" }) public inSidebar = false;
@property({ type: Boolean, attribute: "indent" }) public indent = false;
public static get defaultConfig(): RepeatAction {
return { repeat: { count: 2, sequence: [] } };
}
private _schema = memoizeOne(
(localize: LocalizeFunc, type: string, template: boolean) =>
(
type: RepeatType,
template: boolean,
inSidebar: boolean,
indent: boolean
) =>
[
{
name: "type",
selector: {
select: {
mode: "dropdown",
options: OPTIONS.map((opt) => ({
value: opt,
label: localize(
`ui.panel.config.automation.editor.actions.type.repeat.type.${opt}.label`
),
})),
},
},
},
...(type === "count"
...(type === "count" && (inSidebar || (!inSidebar && !indent))
? ([
{
name: "count",
@@ -62,17 +60,20 @@ export class HaRepeatAction extends LitElement implements ActionElement {
},
] as const satisfies readonly HaFormSchema[])
: []),
...(type === "until" || type === "while"
...((type === "until" || type === "while") &&
(indent || (!inSidebar && !indent))
? ([
{
name: type,
selector: {
condition: {},
condition: {
optionsInSidebar: indent,
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
...(type === "for_each"
...(type === "for_each" && (inSidebar || (!inSidebar && !indent))
? ([
{
name: "for_each",
@@ -81,24 +82,31 @@ export class HaRepeatAction extends LitElement implements ActionElement {
},
] as const satisfies readonly HaFormSchema[])
: []),
{
name: "sequence",
selector: {
action: {},
},
},
...(indent || (!inSidebar && !indent)
? ([
{
name: "sequence",
selector: {
action: {
optionsInSidebar: indent,
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
] as const satisfies readonly HaFormSchema[]
);
protected render() {
const action = this.action.repeat;
const type = getType(action);
const type = getRepeatType(action);
const schema = this._schema(
this.hass.localize,
type ?? "count",
"count" in action && typeof action.count === "string"
? isTemplate(action.count)
: false
: false,
this.inSidebar,
this.indent
);
const data = { ...action, type };
@@ -109,6 +117,7 @@ export class HaRepeatAction extends LitElement implements ActionElement {
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
.narrow=${this.narrow}
></ha-form>`;
}
@@ -118,7 +127,7 @@ export class HaRepeatAction extends LitElement implements ActionElement {
const newType = newVal.type;
delete newVal.type;
const oldType = getType(this.action.repeat);
const oldType = getRepeatType(this.action.repeat);
if (newType !== oldType) {
if (newType === "count") {
@@ -170,10 +179,6 @@ export class HaRepeatAction extends LitElement implements ActionElement {
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => {
switch (schema.name) {
case "type":
return this.hass.localize(
"ui.panel.config.automation.editor.actions.type.repeat.type_select"
);
case "count":
return this.hass.localize(
"ui.panel.config.automation.editor.actions.type.repeat.type.count.label"

View File

@@ -15,8 +15,12 @@ export class HaSequenceAction extends LitElement implements ActionElement {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public action!: SequenceAction;
@property({ type: Boolean }) public indent = false;
public static get defaultConfig(): SequenceAction {
return {
sequence: [],
@@ -29,9 +33,11 @@ export class HaSequenceAction extends LitElement implements ActionElement {
return html`
<ha-automation-action
.actions=${action.sequence}
.narrow=${this.narrow}
.disabled=${this.disabled}
@value-changed=${this._actionsChanged}
.hass=${this.hass}
.optionsInSidebar=${this.indent}
></ha-automation-action>
`;
}

View File

@@ -1,4 +1,4 @@
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
@@ -24,6 +24,12 @@ export class HaWaitForTriggerAction
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, attribute: "sidebar" }) public inSidebar = false;
@property({ type: Boolean, attribute: "indent" }) public indent = false;
public static get defaultConfig(): WaitForTriggerAction {
return { wait_for_trigger: [] };
}
@@ -32,34 +38,43 @@ export class HaWaitForTriggerAction
const timeData = createDurationData(this.action.timeout);
return html`
<ha-duration-input
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.wait_for_trigger.timeout"
)}
.data=${timeData}
.disabled=${this.disabled}
enable-millisecond
@value-changed=${this._timeoutChanged}
></ha-duration-input>
<ha-formfield
.disabled=${this.disabled}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.wait_for_trigger.continue_timeout"
)}
>
<ha-switch
.checked=${this.action.continue_on_timeout ?? true}
.disabled=${this.disabled}
@change=${this._continueChanged}
></ha-switch>
</ha-formfield>
<ha-automation-trigger
.triggers=${ensureArray(this.action.wait_for_trigger)}
.hass=${this.hass}
.disabled=${this.disabled}
.name=${"wait_for_trigger"}
@value-changed=${this._valueChanged}
></ha-automation-trigger>
${this.inSidebar || (!this.inSidebar && !this.indent)
? html`
<ha-duration-input
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.wait_for_trigger.timeout"
)}
.data=${timeData}
.disabled=${this.disabled}
enable-millisecond
@value-changed=${this._timeoutChanged}
></ha-duration-input>
<ha-formfield
.disabled=${this.disabled}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.wait_for_trigger.continue_timeout"
)}
>
<ha-switch
.checked=${this.action.continue_on_timeout ?? true}
.disabled=${this.disabled}
@change=${this._continueChanged}
></ha-switch>
</ha-formfield>
`
: nothing}
${this.indent || (!this.inSidebar && !this.indent)
? html`<ha-automation-trigger
class=${!this.inSidebar && !this.indent ? "expansion-panel" : ""}
.triggers=${ensureArray(this.action.wait_for_trigger)}
.hass=${this.hass}
.disabled=${this.disabled}
.name=${"wait_for_trigger"}
@value-changed=${this._valueChanged}
.optionsInSidebar=${this.indent}
.narrow=${this.narrow}
></ha-automation-trigger>`
: nothing}
`;
}
@@ -86,7 +101,7 @@ export class HaWaitForTriggerAction
display: block;
margin-bottom: 24px;
}
ha-automation-trigger {
ha-automation-trigger.expansion-panel {
display: block;
margin-top: 24px;
}

View File

@@ -652,6 +652,7 @@ class DialogAddAutomationElement extends LitElement implements HassDialog {
ha-dialog {
--dialog-content-padding: 0;
--mdc-dialog-max-height: 60vh;
--mdc-dialog-max-height: 60dvh;
}
@media all and (min-width: 550px) {
ha-dialog {

View File

@@ -1,12 +1,15 @@
import { mdiContentSave } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, nothing } from "lit";
import { css, html, nothing, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-markdown";
import type { BlueprintAutomationConfig } from "../../../data/automation";
import { fetchBlueprints } from "../../../data/blueprint";
import { HaBlueprintGenericEditor } from "../blueprint/blueprint-generic-editor";
import { saveFabStyles } from "./styles";
@customElement("blueprint-automation-editor")
export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
@@ -14,6 +17,10 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ type: Boolean }) public saving = false;
@property({ type: Boolean }) public dirty = false;
protected get _config(): BlueprintAutomationConfig {
return this.config;
}
@@ -47,9 +54,24 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
></ha-markdown>`
: nothing}
${this.renderCard()}
<ha-fab
slot="fab"
class=${this.dirty ? "dirty" : ""}
.label=${this.hass.localize("ui.panel.config.automation.editor.save")}
.disabled=${this.saving}
extended
@click=${this._saveAutomation}
>
<ha-svg-icon slot="icon" .path=${mdiContentSave}></ha-svg-icon>
</ha-fab>
`;
}
private _saveAutomation() {
fireEvent(this, "save-automation");
}
protected async _getBlueprints() {
this._blueprints = await fetchBlueprints(this.hass, "automation");
}
@@ -62,6 +84,24 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
entity_id: this.stateObj.entity_id,
});
}
static get styles(): CSSResultGroup {
return [
HaBlueprintGenericEditor.styles,
saveFabStyles,
css`
:host {
position: relative;
height: 100%;
min-height: calc(100vh - 85px);
min-height: calc(100dvh - 85px);
}
ha-fab {
position: fixed;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {

View File

@@ -1,24 +1,16 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
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 type { Condition } from "../../../../data/automation";
import { expandConditionWithShorthand } from "../../../../data/automation";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "./types/ha-automation-condition-and";
import "./types/ha-automation-condition-device";
import "./types/ha-automation-condition-not";
import "./types/ha-automation-condition-numeric_state";
import "./types/ha-automation-condition-or";
import "./types/ha-automation-condition-state";
import "./types/ha-automation-condition-sun";
import "./types/ha-automation-condition-template";
import "./types/ha-automation-condition-time";
import "./types/ha-automation-condition-trigger";
import "./types/ha-automation-condition-zone";
import "../ha-automation-editor-warning";
import { editorStyles } from "../styles";
@customElement("ha-automation-condition-editor")
export default class HaAutomationConditionEditor extends LitElement {
@@ -30,46 +22,71 @@ export default class HaAutomationConditionEditor extends LitElement {
@property({ attribute: false }) public yamlMode = false;
@property({ type: Boolean }) public indent = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public selected = false;
@property({ type: Boolean, attribute: "supported" }) public uiSupported =
false;
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
private _processedCondition = memoizeOne((condition) =>
expandConditionWithShorthand(condition)
);
protected render() {
const condition = this._processedCondition(this.condition);
const supported =
customElements.get(`ha-automation-condition-${condition.condition}`) !==
undefined;
const yamlMode = this.yamlMode || !supported;
const yamlMode = this.yamlMode || !this.uiSupported;
return html`
${yamlMode
? html`
${!supported
? html`
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.unsupported_condition",
{ condition: condition.condition }
)}
`
: ""}
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${this.condition}
@value-changed=${this._onYamlChange}
.readOnly=${this.disabled}
></ha-yaml-editor>
`
: html`
<div @value-changed=${this._onUiChanged}>
${dynamicElement(
`ha-automation-condition-${condition.condition}`,
{
hass: this.hass,
condition: condition,
disabled: this.disabled,
}
)}
</div>
`}
<div
class=${classMap({
"card-content": true,
disabled:
this.disabled ||
(this.condition.enabled === false && !this.yamlMode),
yaml: yamlMode,
indent: this.indent,
})}
>
${yamlMode
? html`
${!this.uiSupported
? html`
<ha-automation-editor-warning
.alertTitle=${this.hass.localize(
"ui.panel.config.automation.editor.conditions.unsupported_condition",
{ condition: condition.condition }
)}
.localize=${this.hass.localize}
></ha-automation-editor-warning>
`
: nothing}
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${this.condition}
@value-changed=${this._onYamlChange}
.readOnly=${this.disabled}
></ha-yaml-editor>
`
: html`
<div @value-changed=${this._onUiChanged}>
${dynamicElement(
`ha-automation-condition-${condition.condition}`,
{
hass: this.hass,
condition: condition,
disabled: this.disabled,
optionsInSidebar: this.indent,
narrow: this.narrow,
}
)}
</div>
`}
</div>
`;
}
@@ -91,7 +108,20 @@ export default class HaAutomationConditionEditor extends LitElement {
fireEvent(this, "value-changed", { value });
}
static styles = haStyle;
static styles = [
editorStyles,
css`
:host([action]) .card-content {
padding: 0;
}
:host([action]) .card-content.indent {
margin-left: 0;
margin-right: 0;
padding: 0;
border-left: none;
}
`,
];
}
declare global {

View File

@@ -16,26 +16,32 @@ import {
import deepClone from "deep-clone-simple";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-automation-row";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import type {
AutomationClipboard,
Condition,
} from "../../../../data/automation";
import { testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
import { CONDITION_ICONS } from "../../../../data/condition";
import {
CONDITION_BUILDING_BLOCKS,
CONDITION_ICONS,
} from "../../../../data/condition";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
@@ -44,16 +50,27 @@ import {
showConfirmationDialog,
showPromptDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../ha-automation-editor-warning";
import { rowStyles } from "../styles";
import "./ha-automation-condition-editor";
import type HaAutomationConditionEditor from "./ha-automation-condition-editor";
import "./types/ha-automation-condition-and";
import "./types/ha-automation-condition-device";
import "./types/ha-automation-condition-not";
import "./types/ha-automation-condition-numeric_state";
import "./types/ha-automation-condition-or";
import "./types/ha-automation-condition-state";
import "./types/ha-automation-condition-sun";
import "./types/ha-automation-condition-template";
import "./types/ha-automation-condition-time";
import "./types/ha-automation-condition-trigger";
import "./types/ha-automation-condition-zone";
export interface ConditionElement extends LitElement {
condition: Condition;
}
const preventDefault = (ev) => ev.preventDefault();
export const handleChangeEvent = (
element: ConditionElement,
ev: CustomEvent
@@ -91,6 +108,15 @@ export default class HaAutomationConditionRow extends LitElement {
@property({ type: Boolean }) public last?: boolean;
@property({ type: Boolean }) public narrow = false;
@state() private _collapsed = false;
@state() private _warnings?: string[];
@property({ type: Boolean, attribute: "sidebar" })
public optionsInSidebar = false;
@storage({
key: "automationClipboard",
state: false,
@@ -101,23 +127,202 @@ export default class HaAutomationConditionRow extends LitElement {
@state() private _yamlMode = false;
@state() private _warnings?: string[];
@state() private _testing = false;
@state() private _testingResult?: boolean;
@state() private _selected = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[];
@query("ha-automation-condition-editor")
public conditionEditor?: HaAutomationConditionEditor;
private _renderRow() {
return html`
<ha-svg-icon
slot="leading-icon"
class="condition-icon"
.path=${CONDITION_ICONS[this.condition.condition]}
></ha-svg-icon>
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
</h3>
<slot name="icons" slot="icons"></slot>
<ha-md-button-menu
slot="icons"
@click=${preventDefaultStopPropagation}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
>
</ha-icon-button>
<ha-md-menu-item .clickAction=${this._testCondition}>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.test"
)}
<ha-svg-icon slot="start" .path=${mdiFlask}></ha-svg-icon>
</ha-md-menu-item>
${!this.optionsInSidebar
? html`
<ha-md-menu-item
.clickAction=${this._renameCondition}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.rename"
)}
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
`
: nothing}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
.clickAction=${this._duplicateCondition}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
<ha-svg-icon slot="start" .path=${mdiContentDuplicate}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._copyCondition}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
)}
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._cutCondition}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
)}
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveUp}
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveDown}
.disabled=${this.disabled || this.last}
>
${this.hass.localize("ui.panel.config.automation.editor.move_down")}
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
${!this.optionsInSidebar
? html`<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${this._uiSupported(this.condition.condition) ||
!!this._warnings}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>`
: nothing}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
.clickAction=${this._onDisable}
.disabled=${this.disabled}
>
${this.condition.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
)
: this.hass.localize(
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="start"
.path=${this.condition.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
class="warning"
.clickAction=${this._onDelete}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
<ha-svg-icon
class="warning"
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
${!this.optionsInSidebar
? html`${this._warnings
? html`<ha-automation-editor-warning
.localize=${this.hass.localize}
.warnings=${this._warnings}
>
</ha-automation-editor-warning>`
: nothing}
<ha-automation-condition-editor
.hass=${this.hass}
.condition=${this.condition}
.disabled=${this.disabled}
.yamlMode=${this._yamlMode}
.uiSupported=${this._uiSupported(this.condition.condition)}
.narrow=${this.narrow}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-condition-editor>`
: nothing}
`;
}
protected render() {
if (!this.condition) {
return nothing;
}
return html`
<ha-card outlined>
<ha-card
outlined
class=${classMap({
selected: this._selected,
"building-block":
this.optionsInSidebar &&
CONDITION_BUILDING_BLOCKS.includes(this.condition.condition) &&
!this._collapsed,
})}
>
${this.condition.enabled === false
? html`
<div class="disabled-bar">
@@ -126,187 +331,27 @@ export default class HaAutomationConditionRow extends LitElement {
)}
</div>
`
: ""}
<ha-expansion-panel left-chevron>
<ha-svg-icon
slot="leading-icon"
class="condition-icon"
.path=${CONDITION_ICONS[this.condition.condition]}
></ha-svg-icon>
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
</h3>
<slot name="icons" slot="icons"></slot>
<ha-md-button-menu
slot="icons"
@click=${preventDefault}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
>
</ha-icon-button>
<ha-md-menu-item .clickAction=${this._testCondition}>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.test"
: nothing}
${this.optionsInSidebar
? html`<ha-automation-row
.disabled=${this.condition.enabled === false}
.leftChevron=${CONDITION_BUILDING_BLOCKS.includes(
this.condition.condition
)}
<ha-svg-icon slot="start" .path=${mdiFlask}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._renameCondition}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.rename"
.collapsed=${this._collapsed}
.selected=${this._selected}
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
.buildingBlock=${CONDITION_BUILDING_BLOCKS.includes(
this.condition.condition
)}
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
.clickAction=${this._duplicateCondition}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
<ha-svg-icon
slot="start"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._copyCondition}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
)}
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._cutCondition}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
)}
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveUp}
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveDown}
.disabled=${this.disabled || this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${this._warnings}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
.clickAction=${this._onDisable}
.disabled=${this.disabled}
>
${this.condition.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
)
: this.hass.localize(
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="start"
.path=${this.condition.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
class="warning"
.clickAction=${this._onDelete}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
<ha-svg-icon
class="warning"
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
<div
class=${classMap({
"card-content": true,
disabled: this.condition.enabled === false,
})}
>
${this._warnings
? html`<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.errors.config.editor_not_supported"
)}
>
${this._warnings!.length > 0 &&
this._warnings![0] !== undefined
? html` <ul>
${this._warnings!.map(
(warning) => html`<li>${warning}</li>`
)}
</ul>`
: ""}
${this.hass.localize(
"ui.errors.config.edit_in_yaml_supported"
)}
</ha-alert>`
: ""}
<ha-automation-condition-editor
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._handleChangeEvent}
.yamlMode=${this._yamlMode}
.disabled=${this.disabled}
.hass=${this.hass}
.condition=${this.condition}
></ha-automation-condition-editor>
</div>
</ha-expansion-panel>
>${this._renderRow()}</ha-automation-row
>`
: html`
<ha-expansion-panel left-chevron>
${this._renderRow()}
</ha-expansion-panel>
`}
<div
class="testing ${classMap({
active: this._testing,
@@ -323,21 +368,35 @@ export default class HaAutomationConditionRow extends LitElement {
)}
</div>
</ha-card>
${this.optionsInSidebar &&
CONDITION_BUILDING_BLOCKS.includes(this.condition.condition) &&
!this._collapsed
? html`<ha-automation-condition-editor
.hass=${this.hass}
.condition=${this.condition}
.disabled=${this.disabled}
.uiSupported=${this._uiSupported(this.condition.condition)}
indent
.selected=${this._selected}
.narrow=${this.narrow}
@value-changed=${this._onValueChange}
></ha-automation-condition-editor>`
: nothing}
`;
}
private _handleUiModeNotAvailable(ev: CustomEvent) {
// Prevent possible parent action-row from switching to yamlMode
ev.stopPropagation();
this._warnings = handleStructError(this.hass, ev.detail).warnings;
if (!this._yamlMode) {
this._yamlMode = true;
protected willUpdate(changedProperties) {
// on yaml toggle --> clear warnings
if (changedProperties.has("yamlMode")) {
this._warnings = undefined;
}
}
private _handleChangeEvent(ev: CustomEvent) {
if (ev.detail.yaml) {
this._warnings = undefined;
private _onValueChange(event: CustomEvent) {
// reload sidebar if sort, deleted,... happend
if (this._selected && this.optionsInSidebar) {
this.openSidebar(event.detail.value);
}
}
@@ -352,6 +411,11 @@ export default class HaAutomationConditionRow extends LitElement {
const enabled = !(this.condition.enabled ?? true);
const value = { ...this.condition, enabled };
fireEvent(this, "value-changed", { value });
this.openSidebar(value); // refresh sidebar
if (this._yamlMode && !this.optionsInSidebar) {
this.conditionEditor?.yamlEditor?.setValue(value);
}
};
private _onDelete = () => {
@@ -367,17 +431,18 @@ export default class HaAutomationConditionRow extends LitElement {
destructive: true,
confirm: () => {
fireEvent(this, "value-changed", { value: null });
if (this._selected) {
fireEvent(this, "close-sidebar");
}
},
});
};
private _switchUiMode() {
this._warnings = undefined;
this._yamlMode = false;
}
private _switchYamlMode() {
this._warnings = undefined;
this._yamlMode = true;
}
@@ -463,6 +528,12 @@ export default class HaAutomationConditionRow extends LitElement {
fireEvent(this, "value-changed", {
value,
});
if (this._selected && this.optionsInSidebar) {
this.openSidebar(value); // refresh sidebar
} else if (this._yamlMode) {
this.conditionEditor?.yamlEditor?.setValue(value);
}
}
};
@@ -477,6 +548,9 @@ export default class HaAutomationConditionRow extends LitElement {
private _cutCondition = () => {
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
if (this._selected) {
fireEvent(this, "close-sidebar");
}
};
private _moveUp = () => {
@@ -493,7 +567,10 @@ export default class HaAutomationConditionRow extends LitElement {
} else {
this._switchYamlMode();
}
this.expand();
if (!this.optionsInSidebar) {
this.expand();
}
};
public expand() {
@@ -502,52 +579,68 @@ export default class HaAutomationConditionRow extends LitElement {
});
}
private _handleUiModeNotAvailable(ev: CustomEvent) {
this._warnings = handleStructError(this.hass, ev.detail).warnings;
if (!this._yamlMode) {
this._yamlMode = true;
}
}
private _toggleSidebar(ev: Event) {
ev?.stopPropagation();
if (this._selected) {
this._selected = false;
fireEvent(this, "close-sidebar");
return;
}
this.openSidebar();
}
public openSidebar(condition?: Condition): void {
if (this.narrow) {
this.scrollIntoView();
}
const sidebarCondition = condition || this.condition;
fireEvent(this, "open-sidebar", {
save: (value) => {
fireEvent(this, "value-changed", { value });
},
close: () => {
this._selected = false;
fireEvent(this, "close-sidebar");
},
rename: () => {
this._renameCondition();
},
toggleYamlMode: () => {
this._toggleYamlMode();
return this._yamlMode;
},
disable: this._onDisable,
delete: this._onDelete,
config: sidebarCondition,
type: "condition",
uiSupported: this._uiSupported(sidebarCondition.condition),
yamlMode: this._yamlMode,
});
this._selected = true;
}
private _uiSupported = memoizeOne(
(type: string) =>
customElements.get(`ha-automation-condition-${type}`) !== undefined
);
private _toggleCollapse() {
this._collapsed = !this._collapsed;
}
static get styles(): CSSResultGroup {
return [
haStyle,
rowStyles,
css`
.disabled {
opacity: 0.5;
pointer-events: none;
}
ha-expansion-panel {
--expansion-panel-summary-padding: 0 0 0 8px;
--expansion-panel-content-padding: 0;
}
h3 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
.condition-icon {
display: none;
}
@media (min-width: 870px) {
.condition-icon {
display: inline-block;
color: var(--secondary-text-color);
opacity: 0.9;
}
}
.card-content {
padding: 16px;
}
.disabled-bar {
background: var(--divider-color, #e0e0e0);
text-align: center;
border-top-right-radius: calc(
var(--ha-card-border-radius, 12px) - var(
--ha-card-border-width,
1px
)
);
border-top-left-radius: calc(
var(--ha-card-border-radius, 12px) - var(
--ha-card-border-width,
1px
)
);
}
.testing {
position: absolute;
top: 0px;
@@ -562,17 +655,8 @@ export default class HaAutomationConditionRow extends LitElement {
overflow: hidden;
transition: max-height 0.3s;
text-align: center;
border-top-right-radius: calc(
var(--ha-card-border-radius, 12px) - var(
--ha-card-border-width,
1px
)
);
border-top-left-radius: calc(
var(--ha-card-border-radius, 12px) - var(
--ha-card-border-width,
1px
)
border-top-right-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
border-top-left-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
);
}
.testing.active {
@@ -584,15 +668,6 @@ export default class HaAutomationConditionRow extends LitElement {
.testing.pass {
background-color: var(--success-color);
}
ha-md-menu-item > ha-svg-icon {
--mdc-icon-size: 24px;
}
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`,
];
}

View File

@@ -23,6 +23,7 @@ import {
} from "../show-add-automation-element-dialog";
import "./ha-automation-condition-row";
import type HaAutomationConditionRow from "./ha-automation-condition-row";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
@customElement("ha-automation-condition")
export default class HaAutomationCondition extends LitElement {
@@ -34,8 +35,13 @@ export default class HaAutomationCondition extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean }) public root = false;
@property({ type: Boolean, attribute: "sidebar" }) public optionsInSidebar =
false;
@state() private _showReorder = false;
@state()
@@ -96,7 +102,15 @@ export default class HaAutomationCondition extends LitElement {
"ha-automation-condition-row:last-of-type"
)!;
row.updateComplete.then(() => {
row.expand();
// on new condition open the settings in the sidebar, except for building blocks
if (
this.optionsInSidebar &&
!CONDITION_BUILDING_BLOCKS.includes(row.condition.condition)
) {
row.openSidebar();
} else if (!this.optionsInSidebar) {
row.expand();
}
row.scrollIntoView();
row.focus();
});
@@ -140,12 +154,14 @@ export default class HaAutomationCondition extends LitElement {
.totalConditions=${this.conditions.length}
.condition=${cond}
.disabled=${this.disabled}
.narrow=${this.narrow}
@duplicate=${this._duplicateCondition}
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._conditionChanged}
.hass=${this.hass}
?highlight=${this.highlightedConditions?.includes(cond)}
.optionsInSidebar=${this.optionsInSidebar}
>
${this._showReorder && !this.disabled
? html`
@@ -292,6 +308,7 @@ export default class HaAutomationCondition extends LitElement {
// Ensure condition is removed even after update
const conditions = this.conditions.filter((c) => c !== condition);
fireEvent(this, "value-changed", { value: conditions });
fireEvent(this, "close-sidebar");
}
private _conditionChanged(ev: CustomEvent) {
@@ -325,15 +342,18 @@ export default class HaAutomationCondition extends LitElement {
static styles = css`
.conditions {
padding: 16px;
padding: 16px 0 16px 16px;
margin: -16px;
display: flex;
flex-direction: column;
gap: 16px;
}
:host([root]) .conditions {
padding-right: 8px;
}
.sortable-ghost {
background: none;
border-radius: var(--ha-card-border-radius, 12px);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
.sortable-drag {
background: none;
@@ -342,9 +362,6 @@ export default class HaAutomationCondition extends LitElement {
display: block;
scroll-margin-top: 48px;
}
.buttons {
order: 1;
}
.handle {
padding: 12px;
cursor: move; /* fallback if grab cursor is unsupported */
@@ -358,6 +375,7 @@ export default class HaAutomationCondition extends LitElement {
display: flex;
flex-wrap: wrap;
gap: 8px;
order: 1;
}
`;
}

View File

@@ -17,6 +17,11 @@ export abstract class HaLogicalCondition
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, attribute: "sidebar" }) public optionsInSidebar =
false;
protected render() {
return html`
<ha-automation-condition
@@ -24,6 +29,8 @@ export abstract class HaLogicalCondition
@value-changed=${this._valueChanged}
.hass=${this.hass}
.disabled=${this.disabled}
.optionsInSidebar=${this.optionsInSidebar}
.narrow=${this.narrow}
></ha-automation-condition>
`;
}

View File

@@ -232,6 +232,7 @@ class DialogNewAutomation extends LitElement implements HassDialog {
ha-dialog {
--dialog-content-padding: 0;
--mdc-dialog-max-height: 60vh;
--mdc-dialog-max-height: 60dvh;
}
@media all and (min-width: 550px) {
ha-dialog {

View File

@@ -0,0 +1,36 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-alert";
@customElement("ha-automation-editor-warning")
export class HaAutomationEditorWarning extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: "alert-title" }) public alertTitle?: string;
@property({ attribute: false }) public warnings: string[] = [];
protected render() {
return html`
<ha-alert
alert-type="warning"
.title=${this.alertTitle ||
this.localize("ui.errors.config.editor_not_supported")}
>
${this.warnings.length && this.warnings[0] !== undefined
? html`<ul>
${this.warnings.map((warning) => html`<li>${warning}</li>`)}
</ul>`
: nothing}
${this.localize("ui.errors.config.edit_in_yaml_supported")}
</ha-alert>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-editor-warning": HaAutomationEditorWarning;
}
}

View File

@@ -28,9 +28,9 @@ import { navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import { promiseTimeout } from "../../../common/util/promise-timeout";
import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/ha-button";
import "../../../components/ha-button-menu";
import "../../../components/ha-fab";
import "../../../components/ha-button";
import "../../../components/ha-fade-in";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
@@ -97,6 +97,7 @@ declare global {
"move-down": undefined;
"move-up": undefined;
duplicate: undefined;
"save-automation": undefined;
}
}
@@ -403,61 +404,65 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
</ha-list-item>
</ha-button-menu>
<div
class="content ${classMap({
"yaml-mode": this._mode === "yaml",
})}"
class=${this._mode === "yaml" ? "yaml-mode" : ""}
@subscribe-automation-config=${this._subscribeAutomationConfig}
>
${this._errors || stateObj?.state === UNAVAILABLE
? html`<ha-alert
alert-type="error"
.title=${stateObj?.state === UNAVAILABLE
? this.hass.localize(
"ui.panel.config.automation.editor.unavailable"
)
: undefined}
>
${this._errors || this._validationErrors}
${stateObj?.state === UNAVAILABLE
? html`<ha-svg-icon
slot="icon"
.path=${mdiRobotConfused}
></ha-svg-icon>`
: nothing}
</ha-alert>`
: ""}
${this._blueprintConfig
? html`<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.automation.editor.confirm_take_control"
)}
<div slot="action" style="display: flex;">
<ha-button appearance="plain" @click=${this._takeControlSave}
>${this.hass.localize("ui.common.yes")}</ha-button
>
<ha-button appearance="plain" @click=${this._revertBlueprint}
>${this.hass.localize("ui.common.no")}</ha-button
>
</div>
</ha-alert>`
: this._readOnly
? html`<ha-alert alert-type="warning" dismissable
>${this.hass.localize(
"ui.panel.config.automation.editor.read_only"
)}
<ha-button
appearance="filled"
size="small"
variant="warning"
slot="action"
@click=${this._duplicate}
>
${this.hass.localize(
"ui.panel.config.automation.editor.migrate"
)}
</ha-button>
<div class="error-wrapper">
${this._errors || stateObj?.state === UNAVAILABLE
? html`<ha-alert
alert-type="error"
.title=${stateObj?.state === UNAVAILABLE
? this.hass.localize(
"ui.panel.config.automation.editor.unavailable"
)
: undefined}
>
${this._errors || this._validationErrors}
${stateObj?.state === UNAVAILABLE
? html`<ha-svg-icon
slot="icon"
.path=${mdiRobotConfused}
></ha-svg-icon>`
: nothing}
</ha-alert>`
: nothing}
: ""}
${this._blueprintConfig
? html`<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.automation.editor.confirm_take_control"
)}
<div slot="action" style="display: flex;">
<ha-button
appearance="plain"
@click=${this._takeControlSave}
>${this.hass.localize("ui.common.yes")}</ha-button
>
<ha-button
appearance="plain"
@click=${this._revertBlueprint}
>${this.hass.localize("ui.common.no")}</ha-button
>
</div>
</ha-alert>`
: this._readOnly
? html`<ha-alert alert-type="warning" dismissable
>${this.hass.localize(
"ui.panel.config.automation.editor.read_only"
)}
<ha-button
appearance="filled"
size="small"
variant="warning"
slot="action"
@click=${this._duplicate}
>
${this.hass.localize(
"ui.panel.config.automation.editor.migrate"
)}
</ha-button>
</ha-alert>`
: nothing}
</div>
${this._mode === "gui"
? html`
<div
@@ -474,7 +479,10 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
.stateObj=${stateObj}
.config=${this._config}
.disabled=${Boolean(this._readOnly)}
.saving=${this._saving}
.dirty=${this._dirty}
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
></blueprint-automation-editor>
`
: html`
@@ -486,7 +494,9 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
.config=${this._config}
.disabled=${Boolean(this._readOnly)}
.dirty=${this._dirty}
.saving=${this._saving}
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
@editor-save=${this._handleSaveAutomation}
></manual-automation-editor>
`}
@@ -521,21 +531,24 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
@editor-save=${this._handleSaveAutomation}
.showErrors=${false}
disable-fullscreen
></ha-yaml-editor>`
></ha-yaml-editor>
<ha-fab
slot="fab"
class=${this._dirty ? "dirty" : ""}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.save"
)}
.disabled=${this._saving}
extended
@click=${this._saveAutomation}
>
<ha-svg-icon
slot="icon"
.path=${mdiContentSave}
></ha-svg-icon>
</ha-fab>`
: nothing}
</div>
<ha-fab
slot="fab"
class=${classMap({
dirty: !this._readOnly && this._dirty,
})}
.label=${this.hass.localize("ui.panel.config.automation.editor.save")}
.disabled=${this._saving}
extended
@click=${this._handleSaveAutomation}
>
<ha-svg-icon slot="icon" .path=${mdiContentSave}></ha-svg-icon>
</ha-fab>
</hass-subpage>
`;
}
@@ -1102,9 +1115,6 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
align-items: center;
height: 100%;
}
.content {
padding-bottom: 20px;
}
.yaml-mode {
height: 100%;
display: flex;
@@ -1112,13 +1122,34 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
padding-bottom: 0;
}
manual-automation-editor,
blueprint-automation-editor,
:not(.yaml-mode) > ha-alert {
blueprint-automation-editor {
margin: 0 auto;
max-width: 1040px;
padding: 28px 20px 0;
display: block;
}
:not(.yaml-mode) > .error-wrapper {
position: absolute;
top: 4px;
z-index: 3;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
:not(.yaml-mode) > .error-wrapper ha-alert {
background-color: var(--card-background-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
border-radius: var(--ha-border-radius-sm);
}
manual-automation-editor {
max-width: 1540px;
padding: 0 12px;
}
ha-yaml-editor {
flex-grow: 1;
--actions-border-radius: 0;
@@ -1135,14 +1166,6 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
margin-inline-end: 8px;
margin-inline-start: initial;
}
ha-fab {
position: relative;
bottom: calc(-80px - var(--safe-area-inset-bottom));
transition: bottom 0.3s;
}
ha-fab.dirty {
bottom: 0;
}
li[role="separator"] {
border-bottom-color: var(--divider-color);
}
@@ -1160,6 +1183,15 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
max-width: 1040px;
padding: 28px 20px 0;
}
ha-fab {
position: fixed;
right: 16px;
bottom: calc(-80px - var(--safe-area-inset-bottom));
transition: bottom 0.3s;
}
ha-fab.dirty {
bottom: 16px;
}
`,
];
}

View File

@@ -0,0 +1,412 @@
import {
mdiClose,
mdiDelete,
mdiDotsVertical,
mdiIdentifier,
mdiPlayCircleOutline,
mdiPlaylistEdit,
mdiRenameBox,
mdiStopCircleOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../common/dom/fire_event";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { handleStructError } from "../../../common/structs/handle-errors";
import type { LocalizeKeys } from "../../../common/translations/localize";
import "../../../components/ha-card";
import "../../../components/ha-dialog-header";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-button-menu";
import "../../../components/ha-md-divider";
import "../../../components/ha-md-menu-item";
import type { Condition, Trigger } from "../../../data/automation";
import type { Action, RepeatAction } from "../../../data/script";
import { isTriggerList } from "../../../data/trigger";
import type { HomeAssistant } from "../../../types";
import "./action/ha-automation-action-editor";
import { getAutomationActionType } from "./action/ha-automation-action-row";
import { getRepeatType } from "./action/types/ha-automation-action-repeat";
import "./condition/ha-automation-condition-editor";
import type HaAutomationConditionEditor from "./condition/ha-automation-condition-editor";
import "./ha-automation-editor-warning";
import "./trigger/ha-automation-trigger-editor";
import type HaAutomationTriggerEditor from "./trigger/ha-automation-trigger-editor";
import { ACTION_BUILDING_BLOCKS } from "../../../data/action";
import { CONDITION_BUILDING_BLOCKS } from "../../../data/condition";
export interface OpenSidebarConfig {
save: (config: Trigger | Condition | Action) => void;
close: () => void;
rename: () => void;
toggleYamlMode: () => boolean;
disable: () => void;
delete: () => void;
config: Trigger | Condition | Action;
type: "trigger" | "condition" | "action" | "option";
uiSupported: boolean;
yamlMode: boolean;
}
@customElement("ha-automation-sidebar")
export default class HaAutomationSidebar extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config?: OpenSidebarConfig;
@property({ type: Boolean, attribute: "wide" }) public isWide = false;
@property({ type: Boolean }) public disabled = false;
@state() private _yamlMode = false;
@state() private _requestShowId = false;
@state() private _warnings?: string[];
@query(".sidebar-editor")
public editor?: HaAutomationTriggerEditor | HaAutomationConditionEditor;
protected willUpdate(changedProperties) {
if (changedProperties.has("config")) {
this._requestShowId = false;
this._warnings = undefined;
if (this.config) {
this._yamlMode = this.config.yamlMode;
if (this._yamlMode) {
this.editor?.yamlEditor?.setValue(this.config.config);
}
}
}
}
protected render() {
if (!this.config) {
return nothing;
}
const disabled =
this.disabled ||
("enabled" in this.config.config && this.config.config.enabled === false);
let type = isTriggerList(this.config.config as Trigger)
? "list"
: this.config.type === "action"
? getAutomationActionType(this.config.config as Action)
: this.config.config[this.config.type];
if (this.config.type === "action" && type === "repeat") {
type = `repeat_${getRepeatType((this.config.config as RepeatAction).repeat)}`;
}
const isBuildingBlock = [
...CONDITION_BUILDING_BLOCKS,
...ACTION_BUILDING_BLOCKS,
].includes(type);
const subtitle = this.hass.localize(
(this.config.type === "option"
? "ui.panel.config.automation.editor.actions.type.choose.label"
: `ui.panel.config.automation.editor.${this.config.type}s.${this.config.type}`) as LocalizeKeys
);
const title =
this.hass.localize(
(this.config.type === "option"
? "ui.panel.config.automation.editor.actions.type.choose.option_label"
: `ui.panel.config.automation.editor.${this.config.type}s.type.${type}.label`) as LocalizeKeys
) || type;
const description =
isBuildingBlock || this.config.type === "option"
? this.hass.localize(
(this.config.type === "option"
? "ui.panel.config.automation.editor.actions.type.choose.option_description"
: `ui.panel.config.automation.editor.${this.config.type}s.type.${type}.description.picker`) as LocalizeKeys
)
: "";
return html`
<ha-card
outlined
class=${classMap({
mobile: !this.isWide,
yaml: this._yamlMode,
})}
>
<ha-dialog-header>
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this._closeSidebar}
></ha-icon-button>
<span slot="title">${title}</span>
<span slot="subtitle">${subtitle}</span>
<ha-md-button-menu
slot="actionItems"
@click=${this._openOverflowMenu}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item
.clickAction=${this.config.rename}
.disabled=${disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)}
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
${this.config.type === "trigger" &&
!this._yamlMode &&
!("id" in this.config.config) &&
!this._requestShowId
? html`<ha-md-menu-item
.clickAction=${this._showTriggerId}
.disabled=${disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<ha-svg-icon
slot="start"
.path=${mdiIdentifier}
></ha-svg-icon>
</ha-md-menu-item>`
: nothing}
${this.config.type !== "option"
? html`
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!this.config.uiSupported || !!this._warnings}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon
slot="start"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-md-menu-item>
`
: nothing}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
${this.config.type !== "option"
? html`
<ha-md-menu-item
.clickAction=${this.config.disable}
.disabled=${this.disabled || type === "list"}
>
${disabled
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
)
: this.hass.localize(
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="start"
.path=${disabled
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item
.clickAction=${this.config.delete}
class="warning"
.disabled=${this.disabled}
>
${this.hass.localize(
`ui.panel.config.automation.editor.actions.${this.config.type !== "option" ? "delete" : "type.choose.remove_option"}`
)}
<ha-svg-icon
class="warning"
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
</ha-dialog-header>
${this._warnings
? html`<ha-automation-editor-warning
.localize=${this.hass.localize}
.warnings=${this._warnings}
>
</ha-automation-editor-warning>`
: nothing}
<div class="card-content">
${this.config.type === "trigger"
? html`<ha-automation-trigger-editor
class="sidebar-editor"
.hass=${this.hass}
.trigger=${this.config.config as Trigger}
@value-changed=${this._valueChangedSidebar}
.uiSupported=${this.config.uiSupported}
.showId=${this._requestShowId}
.yamlMode=${this._yamlMode}
.disabled=${this.disabled}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-trigger-editor>`
: this.config.type === "condition" &&
(this._yamlMode || !CONDITION_BUILDING_BLOCKS.includes(type))
? html`
<ha-automation-condition-editor
class="sidebar-editor"
.hass=${this.hass}
.condition=${this.config.config as Condition}
.yamlMode=${this._yamlMode}
.uiSupported=${this.config.uiSupported}
@value-changed=${this._valueChangedSidebar}
.disabled=${this.disabled}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-condition-editor>
`
: this.config.type === "action" &&
(this._yamlMode || !ACTION_BUILDING_BLOCKS.includes(type))
? html`
<ha-automation-action-editor
class="sidebar-editor"
.hass=${this.hass}
.action=${this.config.config as Action}
.yamlMode=${this._yamlMode}
.uiSupported=${this.config.uiSupported}
@value-changed=${this._valueChangedSidebar}
sidebar
narrow
.disabled=${this.disabled}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-action-editor>
`
: description || nothing}
</div>
</ha-card>
`;
}
private _handleUiModeNotAvailable(ev: CustomEvent) {
this._warnings = handleStructError(this.hass, ev.detail).warnings;
if (!this._yamlMode) {
this._yamlMode = true;
}
}
private _valueChangedSidebar(ev: CustomEvent) {
ev.stopPropagation();
this.config?.save(ev.detail.value);
if (this.config) {
fireEvent(this, "value-changed", {
value: {
...this.config,
config: ev.detail.value,
},
});
}
}
private _closeSidebar() {
this.config?.close();
}
private _openOverflowMenu(ev: MouseEvent) {
ev.stopPropagation();
ev.preventDefault();
}
private _toggleYamlMode = () => {
this._yamlMode = this.config!.toggleYamlMode();
fireEvent(this, "value-changed", {
value: {
...this.config,
yamlMode: this._yamlMode,
},
});
};
private _showTriggerId = () => {
this._requestShowId = true;
};
static styles = css`
:host {
height: 100%;
--ha-card-border-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-2xl)
);
border-radius: var(--ha-card-border-radius);
}
ha-card {
height: 100%;
width: 100%;
border-color: var(--primary-color);
border-width: 2px;
display: block;
}
ha-card.mobile {
border-bottom-right-radius: var(--ha-border-radius-square);
border-bottom-left-radius: var(--ha-border-radius-square);
}
@media all and (max-width: 870px) {
ha-card.mobile {
max-height: 70vh;
max-height: 70dvh;
border-width: 2px 2px 0;
}
ha-card.mobile.yaml {
height: 70vh;
height: 70dvh;
}
}
ha-dialog-header {
border-radius: var(--ha-card-border-radius);
}
.sidebar-editor {
padding-top: 64px;
}
.card-content {
max-height: calc(100% - 80px);
overflow: auto;
}
@media (min-width: 450px) and (min-height: 500px) {
.card-content {
max-height: calc(100% - 104px);
overflow: auto;
}
}
@media all and (max-width: 870px) {
ha-card.mobile .card-content {
max-height: calc(
70vh - 88px - max(var(--safe-area-inset-bottom), 16px)
);
max-height: calc(
70dvh - 88px - max(var(--safe-area-inset-bottom), 16px)
);
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-sidebar": HaAutomationSidebar;
}
}

View File

@@ -1,9 +1,10 @@
import { mdiHelpCircle } from "@mdi/js";
import { mdiContentSave, mdiHelpCircle } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { load } from "js-yaml";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import {
any,
array,
@@ -23,7 +24,7 @@ import {
removeSearchParam,
} from "../../../common/url/search-params";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-fab";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
import type {
@@ -38,7 +39,6 @@ import {
normalizeAutomationConfig,
} from "../../../data/automation";
import { getActionType, type Action } from "../../../data/script";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
@@ -46,7 +46,10 @@ import "./action/ha-automation-action";
import type HaAutomationAction from "./action/ha-automation-action";
import "./condition/ha-automation-condition";
import type HaAutomationCondition from "./condition/ha-automation-condition";
import "./ha-automation-sidebar";
import type { OpenSidebarConfig } from "./ha-automation-sidebar";
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
import { saveFabStyles } from "./styles";
import "./trigger/ha-automation-trigger";
import type HaAutomationTrigger from "./trigger/ha-automation-trigger";
@@ -77,6 +80,8 @@ export class HaManualAutomationEditor extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public saving = false;
@property({ attribute: false }) public config!: ManualAutomationConfig;
@property({ attribute: false }) public stateObj?: HassEntity;
@@ -85,6 +90,8 @@ export class HaManualAutomationEditor extends LitElement {
@state() private _pastedConfig?: ManualAutomationConfig;
@state() private _sidebarConfig?: OpenSidebarConfig;
private _previousConfig?: ManualAutomationConfig;
public connectedCallback() {
@@ -122,7 +129,7 @@ export class HaManualAutomationEditor extends LitElement {
);
}
protected render() {
private _renderContent() {
return html`
${this.stateObj?.state === "off"
? html`
@@ -130,12 +137,7 @@ export class HaManualAutomationEditor extends LitElement {
${this.hass.localize(
"ui.panel.config.automation.editor.disabled"
)}
<ha-button
size="small"
appearance="filled"
slot="action"
@click=${this._enable}
>
<ha-button size="small" slot="action" @click=${this._enable}>
${this.hass.localize(
"ui.panel.config.automation.editor.enable"
)}
@@ -182,10 +184,14 @@ export class HaManualAutomationEditor extends LitElement {
aria-labelledby="triggers-heading"
.triggers=${this.config.triggers || []}
.highlightedTriggers=${this._pastedConfig?.triggers || []}
.path=${["triggers"]}
@value-changed=${this._triggerChanged}
.hass=${this.hass}
.disabled=${this.disabled}
.disabled=${this.disabled || this.saving}
.narrow=${this.narrow}
@open-sidebar=${this._openSidebar}
@close-sidebar=${this._handleCloseSidebar}
root
sidebar
></ha-automation-trigger>
<div class="header">
@@ -224,11 +230,14 @@ export class HaManualAutomationEditor extends LitElement {
aria-labelledby="conditions-heading"
.conditions=${this.config.conditions || []}
.highlightedConditions=${this._pastedConfig?.conditions || []}
.path=${["conditions"]}
@value-changed=${this._conditionChanged}
.hass=${this.hass}
.disabled=${this.disabled}
.disabled=${this.disabled || this.saving}
.narrow=${this.narrow}
@open-sidebar=${this._openSidebar}
@close-sidebar=${this._handleCloseSidebar}
root
sidebar
></ha-automation-condition>
<div class="header">
@@ -265,16 +274,82 @@ export class HaManualAutomationEditor extends LitElement {
aria-labelledby="actions-heading"
.actions=${this.config.actions || []}
.highlightedActions=${this._pastedConfig?.actions || []}
.path=${["actions"]}
@value-changed=${this._actionChanged}
@open-sidebar=${this._openSidebar}
@close-sidebar=${this._handleCloseSidebar}
.hass=${this.hass}
.narrow=${this.narrow}
.disabled=${this.disabled}
.disabled=${this.disabled || this.saving}
root
sidebar
></ha-automation-action>
`;
}
protected render() {
return html`
<div class="split-view">
<div class="content-wrapper">
<div class="content">${this._renderContent()}</div>
<ha-fab
slot="fab"
class=${this.dirty ? "dirty" : ""}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.save"
)}
.disabled=${this.saving}
extended
@click=${this._saveAutomation}
>
<ha-svg-icon slot="icon" .path=${mdiContentSave}></ha-svg-icon>
</ha-fab>
</div>
<ha-automation-sidebar
class=${classMap({
sidebar: true,
hidden: !this._sidebarConfig,
overlay: !this.isWide,
})}
.isWide=${this.isWide}
.hass=${this.hass}
.config=${this._sidebarConfig}
@value-changed=${this._sidebarConfigChanged}
.disabled=${this.disabled}
></ha-automation-sidebar>
</div>
`;
}
private _openSidebar(ev: CustomEvent<OpenSidebarConfig>) {
// deselect previous selected row
this._sidebarConfig?.close?.();
this._sidebarConfig = ev.detail;
}
private _sidebarConfigChanged(ev: CustomEvent<{ value: OpenSidebarConfig }>) {
ev.stopPropagation();
if (!this._sidebarConfig) {
return;
}
this._sidebarConfig = {
...this._sidebarConfig,
...ev.detail.value,
};
}
private _closeSidebar() {
if (this._sidebarConfig) {
const closeRow = this._sidebarConfig?.close;
this._sidebarConfig = undefined;
closeRow?.();
}
}
private _handleCloseSidebar() {
this._sidebarConfig = undefined;
}
private _triggerChanged(ev: CustomEvent): void {
ev.stopPropagation();
this.resetPastedConfig();
@@ -311,6 +386,11 @@ export class HaManualAutomationEditor extends LitElement {
});
}
private _saveAutomation() {
this._closeSidebar();
fireEvent(this, "save-automation");
}
private _handlePaste = async (ev: ClipboardEvent) => {
if (!canOverrideAlphanumericInput(ev.composedPath())) {
return;
@@ -523,14 +603,77 @@ export class HaManualAutomationEditor extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyle,
saveFabStyles,
css`
:host {
display: block;
}
ha-card {
overflow: hidden;
.split-view {
display: flex;
flex-direction: row;
height: 100%;
position: relative;
gap: 16px;
}
.content-wrapper {
position: relative;
flex: 6;
}
.content {
padding: 32px 16px 64px 0;
height: calc(100vh - 153px);
height: calc(100dvh - 153px);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar {
padding: 12px 0;
flex: 4;
height: calc(100vh - 81px);
height: calc(100dvh - 81px);
width: 40%;
}
.sidebar.hidden {
border-color: transparent;
border-width: 0;
overflow: hidden;
flex: 0;
visibility: hidden;
}
.sidebar.overlay {
position: fixed;
bottom: 0;
right: 0;
height: calc(100% - 64px);
padding: 0;
z-index: 5;
}
@media all and (max-width: 870px) {
.sidebar.overlay {
max-height: 70vh;
max-height: 70dvh;
height: auto;
width: 100%;
box-shadow: 0px -8px 16px rgba(0, 0, 0, 0.2);
}
}
@media all and (max-width: 870px) {
.sidebar.overlay.hidden {
height: 0;
}
}
.sidebar.overlay.hidden {
width: 0;
}
.description {
margin: 0;
}
@@ -559,6 +702,11 @@ export class HaManualAutomationEditor extends LitElement {
font-weight: var(--ha-font-weight-normal);
line-height: 0;
}
ha-alert {
display: block;
margin-bottom: 16px;
}
`,
];
}
@@ -568,4 +716,9 @@ declare global {
interface HTMLElementTagNameMap {
"manual-automation-editor": HaManualAutomationEditor;
}
interface HASSDomEvents {
"open-sidebar": OpenSidebarConfig;
"close-sidebar": undefined;
}
}

View File

@@ -1,6 +1,4 @@
import { consume } from "@lit/context";
import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import {
mdiArrowDown,
mdiArrowUp,
@@ -12,16 +10,19 @@ import {
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ensureArray } from "../../../../common/array/ensure-array";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefault } from "../../../../common/dom/prevent_default";
import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-automation-row";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-list-item";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-svg-icon";
import type { Condition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
import { fullEntitiesContext } from "../../../../data/context";
@@ -31,10 +32,10 @@ import {
showConfirmationDialog,
showPromptDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../action/ha-automation-action";
import "../condition/ha-automation-condition";
import { editorStyles, rowStyles } from "../styles";
@customElement("ha-automation-option-row")
export default class HaAutomationOptionRow extends LitElement {
@@ -52,8 +53,15 @@ export default class HaAutomationOptionRow extends LitElement {
@property({ type: Boolean }) public last = false;
@property({ type: Boolean, attribute: "sidebar" })
public optionsInSidebar = false;
@state() private _expanded = false;
@state() private _selected = false;
@state() private _collapsed = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[];
@@ -87,144 +95,175 @@ export default class HaAutomationOptionRow extends LitElement {
return str;
}
private _renderRow() {
return html`
<h3 slot="header">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option",
{ number: this.index + 1 }
)}:
${this.option.alias || (this._expanded ? "" : this._getDescription())}
</h3>
<slot name="icons" slot="icons"></slot>
<ha-md-button-menu
slot="icons"
@click=${preventDefaultStopPropagation}
@closed=${stopPropagation}
@keydown=${stopPropagation}
positioning="fixed"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${!this.optionsInSidebar
? html`
<ha-md-menu-item
@click=${this._renameOption}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.rename"
)}
<ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item
@click=${this._duplicateOption}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
@click=${this._moveUp}
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="graphic" .path=${mdiArrowUp}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
@click=${this._moveDown}
.disabled=${this.disabled || this.last}
>
${this.hass.localize("ui.panel.config.automation.editor.move_down")}
<ha-svg-icon slot="graphic" .path=${mdiArrowDown}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
@click=${this._removeOption}
class="warning"
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.remove_option"
)}
<ha-svg-icon
class="warning"
slot="graphic"
.path=${mdiDelete}
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
${!this.optionsInSidebar ? this._renderContent() : nothing}
`;
}
private _renderContent() {
return html`<div
class=${classMap({
"card-content": true,
indent: this.optionsInSidebar,
selected: this._selected,
})}
>
<h4>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.conditions"
)}:
</h4>
<ha-automation-condition
.conditions=${ensureArray<string | Condition>(this.option.conditions)}
.disabled=${this.disabled}
.hass=${this.hass}
.narrow=${this.narrow}
@value-changed=${this._conditionChanged}
.optionsInSidebar=${this.optionsInSidebar}
></ha-automation-condition>
<h4>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.sequence"
)}:
</h4>
<ha-automation-action
.actions=${ensureArray(this.option.sequence) || []}
.disabled=${this.disabled}
.hass=${this.hass}
.narrow=${this.narrow}
@value-changed=${this._actionChanged}
.optionsInSidebar=${this.optionsInSidebar}
></ha-automation-action>
</div>`;
}
protected render() {
if (!this.option) return nothing;
return html`
<ha-card outlined>
<ha-expansion-panel
left-chevron
@expanded-changed=${this._expandedChanged}
id="option"
>
<h3 slot="header">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option",
{ number: this.index + 1 }
)}:
${this.option.alias ||
(this._expanded ? "" : this._getDescription())}
</h3>
<slot name="icons" slot="icons"></slot>
<ha-button-menu
slot="icons"
@action=${this._handleAction}
@click=${preventDefault}
@closed=${stopPropagation}
fixed
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.rename"
)}
<ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon>
</ha-list-item>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-list-item>
<ha-list-item
graphic="icon"
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="graphic" .path=${mdiArrowUp}></ha-svg-icon>
</ha-list-item>
<ha-list-item
graphic="icon"
.disabled=${this.disabled || this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="graphic" .path=${mdiArrowDown}></ha-svg-icon>
</ha-list-item>
<ha-list-item
class="warning"
graphic="icon"
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.remove_option"
)}
<ha-svg-icon
class="warning"
slot="graphic"
.path=${mdiDelete}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
<div class="card-content">
<h4>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.conditions"
)}:
</h4>
<ha-automation-condition
.conditions=${ensureArray<string | Condition>(
this.option.conditions
)}
.disabled=${this.disabled}
.hass=${this.hass}
.narrow=${this.narrow}
@value-changed=${this._conditionChanged}
></ha-automation-condition>
<h4>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.sequence"
)}:
</h4>
<ha-automation-action
.actions=${ensureArray(this.option.sequence) || []}
.disabled=${this.disabled}
.hass=${this.hass}
.narrow=${this.narrow}
@value-changed=${this._actionChanged}
></ha-automation-action>
</div>
</ha-expansion-panel>
<ha-card outlined class=${this._selected ? "selected" : ""}>
${this.optionsInSidebar
? html`<ha-automation-row
left-chevron
.collapsed=${this._collapsed}
.selected=${this._selected}
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
>${this._renderRow()}</ha-automation-row
>`
: html`
<ha-expansion-panel
left-chevron
@expanded-changed=${this._expandedChanged}
id="option"
>
${this._renderRow()}
</ha-expansion-panel>
`}
</ha-card>
${this.optionsInSidebar && !this._collapsed
? this._renderContent()
: nothing}
`;
}
private async _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
await this._renameOption();
break;
case 1:
fireEvent(this, "duplicate");
break;
case 2:
fireEvent(this, "move-up");
break;
case 3:
fireEvent(this, "move-down");
break;
case 4:
this._removeOption();
break;
}
private _duplicateOption() {
fireEvent(this, "duplicate");
}
private _removeOption() {
private _moveUp() {
fireEvent(this, "move-up");
}
private _moveDown() {
fireEvent(this, "move-down");
}
private _removeOption = () => {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.delete_confirm_title"
@@ -235,14 +274,18 @@ export default class HaAutomationOptionRow extends LitElement {
dismissText: this.hass.localize("ui.common.cancel"),
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
confirm: () =>
confirm: () => {
fireEvent(this, "value-changed", {
value: null,
}),
});
if (this._selected) {
fireEvent(this, "close-sidebar");
}
},
});
}
};
private async _renameOption(): Promise<void> {
private _renameOption = async () => {
const alias = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.change_alias"
@@ -266,7 +309,7 @@ export default class HaAutomationOptionRow extends LitElement {
value,
});
}
}
};
private _conditionChanged(ev: CustomEvent) {
ev.stopPropagation();
@@ -286,46 +329,61 @@ export default class HaAutomationOptionRow extends LitElement {
});
}
private _toggleSidebar(ev: Event) {
ev?.stopPropagation();
if (this._selected) {
this._selected = false;
fireEvent(this, "close-sidebar");
return;
}
this.openSidebar();
}
public openSidebar(): void {
if (this.narrow) {
this.scrollIntoView();
}
fireEvent(this, "open-sidebar", {
save: () => {
// nothing to save for an option in the sidebar
},
close: () => {
this._selected = false;
fireEvent(this, "close-sidebar");
},
rename: () => {
this._renameOption();
},
toggleYamlMode: () => false, // no yaml mode for options
disable: () => {
// option cannot be disabled
},
delete: this._removeOption,
config: {},
type: "option",
uiSupported: true,
yamlMode: false,
});
this._selected = true;
}
public expand() {
this.updateComplete.then(() => {
this.shadowRoot!.querySelector("ha-expansion-panel")!.expanded = true;
});
}
private _toggleCollapse() {
this._collapsed = !this._collapsed;
}
static get styles(): CSSResultGroup {
return [
haStyle,
rowStyles,
editorStyles,
css`
ha-button-menu,
ha-icon-button {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
ha-expansion-panel {
--expansion-panel-summary-padding: 0 0 0 8px;
--expansion-panel-content-padding: 0;
}
h3 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
.card-content {
padding: 16px;
}
ha-list-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
ha-list-item.hidden {
display: none;
}
.warning ul {
margin: 4px 0;
}
li[role="separator"] {
border-bottom-color: var(--divider-color);
}

View File

@@ -27,6 +27,9 @@ export default class HaAutomationOption extends LitElement {
@property({ attribute: false }) public options!: Option[];
@property({ type: Boolean, attribute: "sidebar" }) public optionsInSidebar =
false;
@state() private _showReorder = false;
@state()
@@ -87,6 +90,7 @@ export default class HaAutomationOption extends LitElement {
@move-up=${this._moveUp}
@value-changed=${this._optionChanged}
.hass=${this.hass}
.optionsInSidebar=${this.optionsInSidebar}
>
${this._showReorder && !this.disabled
? html`
@@ -101,6 +105,7 @@ export default class HaAutomationOption extends LitElement {
<div class="buttons">
<ha-button
appearance="filled"
size="small"
.disabled=${this.disabled}
@click=${this._addOption}
>
@@ -125,7 +130,9 @@ export default class HaAutomationOption extends LitElement {
"ha-automation-option-row:last-of-type"
)!;
row.updateComplete.then(() => {
row.expand();
if (!this.optionsInSidebar) {
row.expand();
}
row.scrollIntoView();
row.focus();
});
@@ -238,7 +245,7 @@ export default class HaAutomationOption extends LitElement {
static styles = css`
.options {
padding: 16px;
padding: 16px 0 16px 16px;
margin: -16px;
display: flex;
flex-direction: column;
@@ -246,7 +253,7 @@ export default class HaAutomationOption extends LitElement {
}
.sortable-ghost {
background: none;
border-radius: var(--ha-card-border-radius, 12px);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
.sortable-drag {
background: none;

View File

@@ -1,7 +1,40 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { ACTION_GROUPS } from "../../../data/action";
import type { ActionType } from "../../../data/script";
export const PASTE_VALUE = "__paste__";
// These will be replaced with the correct action
export const VIRTUAL_ACTIONS: Record<
keyof (typeof ACTION_GROUPS)["building_blocks"]["members"],
ActionType
> = {
repeat_count: {
repeat: {
count: 2,
sequence: [],
},
},
repeat_while: {
repeat: {
while: [],
sequence: [],
},
},
repeat_until: {
repeat: {
until: [],
sequence: [],
},
},
repeat_for_each: {
repeat: {
for_each: {},
sequence: [],
},
},
} as const;
export interface AddAutomationElementDialogParams {
type: "trigger" | "condition" | "action";
add: (key: string) => void;

View File

@@ -0,0 +1,90 @@
import { css } from "lit";
export const rowStyles = css`
ha-icon-button {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
}
ha-expansion-panel {
--expansion-panel-summary-padding: 0 0 0 8px;
--expansion-panel-content-padding: 0;
}
h3 {
font-size: inherit;
font-weight: inherit;
}
ha-card {
transition: outline 0.2s;
}
.disabled-bar {
background: var(--divider-color, #e0e0e0);
text-align: center;
border-top-right-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
border-top-left-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
}
.warning ul {
margin: 4px 0;
}
ha-md-menu-item > ha-svg-icon {
--mdc-icon-size: 24px;
}
ha-tooltip {
cursor: default;
}
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`;
export const editorStyles = css`
.disabled {
opacity: 0.5;
pointer-events: none;
}
.card-content {
padding: 16px;
}
.card-content.yaml {
padding: 0 1px;
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
}
.card-content.indent {
margin-left: 12px;
margin-right: -4px;
padding: 12px 24px 16px 16px;
border-left: 2px solid var(--ha-color-border-neutral-quiet);
}
.card-content.indent.selected,
:host([selected]) .card-content.indent {
border-color: var(--primary-color);
background-color: var(--ha-color-fill-primary-quiet-resting);
border-top-right-radius: var(--ha-border-radius-xl);
border-bottom-right-radius: var(--ha-border-radius-xl);
}
`;
export const saveFabStyles = css`
:host {
overflow: hidden;
}
ha-fab {
position: absolute;
right: 16px;
bottom: calc(-80px - var(--safe-area-inset-bottom));
transition: bottom 0.3s;
}
ha-fab.dirty {
bottom: 16px;
}
`;

View File

@@ -0,0 +1,166 @@
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-textfield";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { Trigger } from "../../../../data/automation";
import { migrateAutomationTrigger } from "../../../../data/automation";
import { isTriggerList } from "../../../../data/trigger";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../ha-automation-editor-warning";
@customElement("ha-automation-trigger-editor")
export default class HaAutomationTriggerEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: Trigger;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean, attribute: "yaml" }) public yamlMode = false;
@property({ type: Boolean, attribute: "supported" }) public uiSupported =
false;
@property({ type: Boolean, attribute: "show-id" }) public showId = false;
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
protected render() {
const type = isTriggerList(this.trigger) ? "list" : this.trigger.trigger;
const yamlMode = this.yamlMode || !this.uiSupported;
const showId = "id" in this.trigger || this.showId;
return html`
<div
class=${classMap({
"card-content": true,
disabled:
this.disabled ||
("enabled" in this.trigger &&
this.trigger.enabled === false &&
!this.yamlMode),
yaml: yamlMode,
})}
>
${yamlMode
? html`
${!this.uiSupported
? html`
<ha-automation-editor-warning
.alertTitle=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.unsupported_platform",
{ platform: type }
)}
.localize=${this.hass.localize}
></ha-automation-editor-warning>
`
: nothing}
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${this.trigger}
.readOnly=${this.disabled}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>
`
: html`
${showId && !isTriggerList(this.trigger)
? html`
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.id"
)}
.value=${this.trigger.id || ""}
.disabled=${this.disabled}
@change=${this._idChanged}
>
</ha-textfield>
`
: ""}
<div @value-changed=${this._onUiChanged}>
${dynamicElement(`ha-automation-trigger-${type}`, {
hass: this.hass,
trigger: this.trigger,
disabled: this.disabled,
})}
</div>
`}
</div>
`;
}
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) {
return;
}
fireEvent(this, "value-changed", {
value: migrateAutomationTrigger(ev.detail.value),
});
}
private _onUiChanged(ev: CustomEvent) {
if (isTriggerList(this.trigger)) return;
ev.stopPropagation();
const value = {
...(this.trigger.alias ? { alias: this.trigger.alias } : {}),
...ev.detail.value,
};
fireEvent(this, "value-changed", { value });
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.disabled {
opacity: 0.5;
pointer-events: none;
}
.card-content {
padding: 16px;
}
.card-content.yaml {
padding: 0 1px;
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
}
ha-textfield {
display: block;
margin-bottom: 24px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-trigger-editor": HaAutomationTriggerEditor;
}
}

View File

@@ -18,28 +18,24 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { storage } from "../../../../common/decorators/storage";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefault } from "../../../../common/dom/prevent_default";
import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { handleStructError } from "../../../../common/structs/handle-errors";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-automation-row";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-textfield";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import type { AutomationClipboard, Trigger } from "../../../../data/automation";
import {
migrateAutomationTrigger,
subscribeTrigger,
} from "../../../../data/automation";
import { subscribeTrigger } from "../../../../data/automation";
import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
@@ -50,8 +46,11 @@ import {
showConfirmationDialog,
showPromptDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../ha-automation-editor-warning";
import { rowStyles } from "../styles";
import "./ha-automation-trigger-editor";
import type HaAutomationTriggerEditor from "./ha-automation-trigger-editor";
import "./types/ha-automation-trigger-calendar";
import "./types/ha-automation-trigger-conversation";
import "./types/ha-automation-trigger-device";
@@ -109,17 +108,25 @@ export default class HaAutomationTriggerRow extends LitElement {
@property({ type: Boolean }) public last?: boolean;
@state() private _warnings?: string[];
@property({ type: Boolean, attribute: "sidebar" })
public optionsInSidebar = false;
@state() private _yamlMode = false;
@state() private _requestShowId = false;
@state() private _triggered?: Record<string, unknown>;
@state() private _triggerColor = false;
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
@state() private _selected = false;
@state() private _requestShowId = false;
@state() private _warnings?: string[];
@property({ type: Boolean }) public narrow = false;
@query("ha-automation-trigger-editor")
public triggerEditor?: HaAutomationTriggerEditor;
@storage({
key: "automationClipboard",
@@ -135,19 +142,186 @@ export default class HaAutomationTriggerRow extends LitElement {
private _triggerUnsub?: Promise<UnsubscribeFunc>;
private _renderRow() {
const type = this._getType(this.trigger);
const supported = this._uiSupported(type);
const yamlMode = this._yamlMode || !supported;
return html`
<ha-svg-icon
slot="leading-icon"
class="trigger-icon"
.path=${TRIGGER_ICONS[type]}
></ha-svg-icon>
<h3 slot="header">
${describeTrigger(this.trigger, this.hass, this._entityReg)}
</h3>
<slot name="icons" slot="icons"></slot>
<ha-md-button-menu
slot="icons"
@click=${preventDefaultStopPropagation}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${!this.optionsInSidebar
? html` <ha-md-menu-item
.clickAction=${this._renameTrigger}
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)}
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._showTriggerId}
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<ha-svg-icon slot="start" .path=${mdiIdentifier}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>`
: nothing}
<ha-md-menu-item
.clickAction=${this._duplicateTrigger}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.duplicate"
)}
<ha-svg-icon slot="start" .path=${mdiContentDuplicate}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._copyTrigger}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
)}
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._cutTrigger}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
)}
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveUp}
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveDown}
.disabled=${this.disabled || this.last}
>
${this.hass.localize("ui.panel.config.automation.editor.move_down")}
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
${!this.optionsInSidebar
? html`
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!supported || !!this._warnings}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon
slot="start"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-md-menu-item>
`
: nothing}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
.clickAction=${this._onDisable}
.disabled=${this.disabled || type === "list"}
>
${"enabled" in this.trigger && this.trigger.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
)
: this.hass.localize(
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="start"
.path=${"enabled" in this.trigger && this.trigger.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._onDelete}
class="warning"
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
<ha-svg-icon
class="warning"
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
${!this.optionsInSidebar
? html`${this._warnings
? html`<ha-automation-editor-warning
.localize=${this.hass.localize}
.warnings=${this._warnings}
>
</ha-automation-editor-warning>`
: nothing}
<ha-automation-trigger-editor
.hass=${this.hass}
.trigger=${this.trigger}
.disabled=${this.disabled}
.yamlMode=${this._yamlMode}
.showId=${this._requestShowId}
.uiSupported=${supported}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-trigger-editor>`
: nothing}
`;
}
protected render() {
if (!this.trigger) return nothing;
const type = isTriggerList(this.trigger) ? "list" : this.trigger.trigger;
const supported =
customElements.get(`ha-automation-trigger-${type}`) !== undefined;
const yamlMode = this._yamlMode || !supported;
const showId = "id" in this.trigger || this._requestShowId;
return html`
<ha-card outlined>
<ha-card outlined class=${this._selected ? "selected" : ""}>
${"enabled" in this.trigger && this.trigger.enabled === false
? html`
<div class="disabled-bar">
@@ -157,223 +331,21 @@ export default class HaAutomationTriggerRow extends LitElement {
</div>
`
: nothing}
<ha-expansion-panel left-chevron>
<ha-svg-icon
slot="leading-icon"
class="trigger-icon"
.path=${TRIGGER_ICONS[type]}
></ha-svg-icon>
<h3 slot="header">
${describeTrigger(this.trigger, this.hass, this._entityReg)}
</h3>
<slot name="icons" slot="icons"></slot>
<ha-md-button-menu
slot="icons"
@click=${preventDefault}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item
.clickAction=${this._renameTrigger}
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)}
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._showTriggerId}
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<ha-svg-icon slot="start" .path=${mdiIdentifier}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
.clickAction=${this._duplicateTrigger}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.duplicate"
)}
<ha-svg-icon
slot="start"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._copyTrigger}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
)}
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._cutTrigger}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
)}
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveUp}
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveDown}
.disabled=${this.disabled || this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!supported}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
.clickAction=${this._onDisable}
.disabled=${this.disabled || type === "list"}
>
${"enabled" in this.trigger && this.trigger.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
)
: this.hass.localize(
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="start"
.path=${"enabled" in this.trigger &&
this.trigger.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._onDelete}
class="warning"
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
<ha-svg-icon
class="warning"
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
<div
class=${classMap({
"card-content": true,
disabled:
"enabled" in this.trigger && this.trigger.enabled === false,
})}
>
${this._warnings
? html`<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.errors.config.editor_not_supported"
)}
>
${this._warnings.length && this._warnings[0] !== undefined
? html` <ul>
${this._warnings.map(
(warning) => html`<li>${warning}</li>`
)}
</ul>`
: ""}
${this.hass.localize(
"ui.errors.config.edit_in_yaml_supported"
)}
</ha-alert>`
: ""}
${yamlMode
? html`
${!supported
? html`
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.unsupported_platform",
{ platform: type }
)}
`
: ""}
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${this.trigger}
.readOnly=${this.disabled}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>
`
: html`
${showId && !isTriggerList(this.trigger)
? html`
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.id"
)}
.value=${this.trigger.id || ""}
.disabled=${this.disabled}
@change=${this._idChanged}
>
</ha-textfield>
`
: ""}
<div
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._onUiChanged}
>
${dynamicElement(`ha-automation-trigger-${type}`, {
hass: this.hass,
trigger: this.trigger,
disabled: this.disabled,
})}
</div>
`}
</div>
</ha-expansion-panel>
${this.optionsInSidebar
? html`<ha-automation-row
.disabled=${"enabled" in this.trigger &&
this.trigger.enabled === false}
@click=${this._toggleSidebar}
.selected=${this._selected}
>${this._selected
? "selected"
: nothing}${this._renderRow()}</ha-automation-row
>`
: html`
<ha-expansion-panel left-chevron>
${this._renderRow()}
</ha-expansion-panel>
`}
<div
class="triggered ${classMap({
active: this._triggered !== undefined,
@@ -389,6 +361,13 @@ export default class HaAutomationTriggerRow extends LitElement {
`;
}
protected willUpdate(changedProperties) {
// on yaml toggle --> clear warnings
if (changedProperties.has("yamlMode")) {
this._warnings = undefined;
}
}
protected override updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
if (changedProps.has("trigger")) {
@@ -474,6 +453,46 @@ export default class HaAutomationTriggerRow extends LitElement {
}
}
private _toggleSidebar(ev: Event) {
ev?.stopPropagation();
if (this._selected) {
this._selected = false;
fireEvent(this, "close-sidebar");
return;
}
this.openSidebar();
}
public openSidebar(trigger?: Trigger): void {
if (this.narrow) {
this.scrollIntoView();
}
fireEvent(this, "open-sidebar", {
save: (value) => {
fireEvent(this, "value-changed", { value });
},
close: () => {
this._selected = false;
fireEvent(this, "close-sidebar");
},
rename: () => {
this._renameTrigger();
},
toggleYamlMode: () => {
this._toggleYamlMode();
return this._yamlMode;
},
disable: this._onDisable,
delete: this._onDelete,
config: trigger || this.trigger,
type: "trigger",
uiSupported: this._uiSupported(this._getType(trigger || this.trigger)),
yamlMode: this._yamlMode,
});
this._selected = true;
}
private _setClipboard() {
this._clipboard = {
...this._clipboard,
@@ -494,6 +513,10 @@ export default class HaAutomationTriggerRow extends LitElement {
destructive: true,
confirm: () => {
fireEvent(this, "value-changed", { value: null });
if (this._selected) {
fireEvent(this, "close-sidebar");
}
},
});
};
@@ -503,58 +526,18 @@ export default class HaAutomationTriggerRow extends LitElement {
const enabled = !(this.trigger.enabled ?? true);
const value = { ...this.trigger, enabled };
fireEvent(this, "value-changed", { value });
if (this._yamlMode) {
this._yamlEditor?.setValue(value);
this.openSidebar(value); // refresh sidebar
if (this._yamlMode && !this.optionsInSidebar) {
this.triggerEditor?.yamlEditor?.setValue(value);
}
};
private _idChanged(ev: CustomEvent) {
if (isTriggerList(this.trigger)) return;
const newId = (ev.target as any).value;
if (newId === (this.trigger.id ?? "")) {
return;
}
this._requestShowId = true;
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) {
return;
}
this._warnings = undefined;
fireEvent(this, "value-changed", {
value: migrateAutomationTrigger(ev.detail.value),
});
}
private _onUiChanged(ev: CustomEvent) {
if (isTriggerList(this.trigger)) return;
ev.stopPropagation();
const value = {
...(this.trigger.alias ? { alias: this.trigger.alias } : {}),
...ev.detail.value,
};
fireEvent(this, "value-changed", { value });
}
private _switchUiMode() {
this._warnings = undefined;
this._yamlMode = false;
}
private _switchYamlMode() {
this._warnings = undefined;
this._yamlMode = true;
}
@@ -601,15 +584,21 @@ export default class HaAutomationTriggerRow extends LitElement {
fireEvent(this, "value-changed", {
value,
});
if (this._yamlMode) {
this._yamlEditor?.setValue(value);
if (this._selected && this.optionsInSidebar) {
this.openSidebar(value); // refresh sidebar
} else if (this._yamlMode) {
this.triggerEditor?.yamlEditor?.setValue(value);
}
}
};
private _showTriggerId = () => {
this._requestShowId = true;
this.expand();
if (!this.optionsInSidebar) {
this.expand();
}
};
private _duplicateTrigger = () => {
@@ -623,6 +612,9 @@ export default class HaAutomationTriggerRow extends LitElement {
private _cutTrigger = () => {
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
if (this._selected) {
fireEvent(this, "close-sidebar");
}
};
private _moveUp = () => {
@@ -639,7 +631,10 @@ export default class HaAutomationTriggerRow extends LitElement {
} else {
this._switchYamlMode();
}
this.expand();
if (!this.optionsInSidebar) {
this.expand();
}
};
public expand() {
@@ -648,52 +643,19 @@ export default class HaAutomationTriggerRow extends LitElement {
});
}
private _getType = memoizeOne((trigger: Trigger) =>
isTriggerList(trigger) ? "list" : trigger.trigger
);
private _uiSupported = memoizeOne(
(type: string) =>
customElements.get(`ha-automation-trigger-${type}`) !== undefined
);
static get styles(): CSSResultGroup {
return [
haStyle,
rowStyles,
css`
.disabled {
opacity: 0.5;
pointer-events: none;
}
ha-expansion-panel {
--expansion-panel-summary-padding: 0 0 0 8px;
--expansion-panel-content-padding: 0;
}
h3 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
.trigger-icon {
display: none;
}
@media (min-width: 870px) {
.trigger-icon {
display: inline-block;
color: var(--secondary-text-color);
opacity: 0.9;
}
}
.card-content {
padding: 16px;
}
.disabled-bar {
background: var(--divider-color, #e0e0e0);
text-align: center;
border-top-right-radius: calc(
var(--ha-card-border-radius, 12px) - var(
--ha-card-border-width,
1px
)
);
border-top-left-radius: calc(
var(--ha-card-border-radius, 12px) - var(
--ha-card-border-width,
1px
)
);
}
.triggered {
cursor: pointer;
position: absolute;
@@ -709,17 +671,13 @@ export default class HaAutomationTriggerRow extends LitElement {
overflow: hidden;
transition: max-height 0.3s;
text-align: center;
border-top-right-radius: calc(
var(--ha-card-border-radius, 12px) - var(
--ha-card-border-width,
1px
)
border-top-right-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
border-top-left-radius: calc(
var(--ha-card-border-radius, 12px) - var(
--ha-card-border-width,
1px
)
border-top-left-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
}
.triggered.active {
@@ -732,19 +690,6 @@ export default class HaAutomationTriggerRow extends LitElement {
background-color: var(--accent-color);
color: var(--text-accent-color, var(--text-primary-color));
}
ha-textfield {
display: block;
margin-bottom: 24px;
}
ha-md-menu-item > ha-svg-icon {
--mdc-icon-size: 24px;
}
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`,
];
}

View File

@@ -36,6 +36,13 @@ export default class HaAutomationTrigger extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, attribute: "sidebar" }) public optionsInSidebar =
false;
@property({ type: Boolean }) public root = false;
@state() private _showReorder = false;
@state()
@@ -95,7 +102,9 @@ export default class HaAutomationTrigger extends LitElement {
@value-changed=${this._triggerChanged}
.hass=${this.hass}
.disabled=${this.disabled}
.narrow=${this.narrow}
?highlight=${this.highlightedTriggers?.includes(trg)}
.optionsInSidebar=${this.optionsInSidebar}
>
${this._showReorder && !this.disabled
? html`
@@ -111,6 +120,8 @@ export default class HaAutomationTrigger extends LitElement {
<ha-button
.disabled=${this.disabled}
@click=${this._addTriggerDialog}
.appearance=${this.root ? "accent" : "filled"}
.size=${this.root ? "medium" : "small"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.add"
@@ -164,7 +175,11 @@ export default class HaAutomationTrigger extends LitElement {
"ha-automation-trigger-row:last-of-type"
)!;
row.updateComplete.then(() => {
row.expand();
if (this.optionsInSidebar) {
row.openSidebar();
} else {
row.expand();
}
row.scrollIntoView();
row.focus();
});
@@ -279,15 +294,18 @@ export default class HaAutomationTrigger extends LitElement {
static styles = css`
.triggers {
padding: 16px;
padding: 16px 0 16px 16px;
margin: -16px;
display: flex;
flex-direction: column;
gap: 16px;
}
:host([root]) .triggers {
padding-right: 8px;
}
.sortable-ghost {
background: none;
border-radius: var(--ha-card-border-radius, 12px);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
.sortable-drag {
background: none;

View File

@@ -1,4 +1,3 @@
import "@material/mwc-button";
import { mdiHelpCircle, mdiStarFourPoints } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";

View File

@@ -46,6 +46,16 @@ const STRATEGIES = [
description:
"ui.panel.config.lovelace.dashboards.dialog_new.strategy.areas.description",
},
{
type: "overview",
images: {
light: "/static/images/dashboard-options/light/icon-dashboard-areas.svg",
dark: "/static/images/dashboard-options/dark/icon-dashboard-areas.svg",
},
name: "ui.panel.config.lovelace.dashboards.dialog_new.strategy.overview.title",
description:
"ui.panel.config.lovelace.dashboards.dialog_new.strategy.overview.description",
},
{
type: "map",
images: {

View File

@@ -755,7 +755,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
iconTrailing
autocapitalize="none"
autocomplete="off"
autocorrect="off"
.autocorrect=${false}
input-spellcheck="false"
>
<div class="layout horizontal" slot="trailingIcon">

View File

@@ -324,7 +324,7 @@ class HaConfigInfo extends LitElement {
.ohf {
text-align: center;
padding-bottom: 0;
padding-bottom: 5px;
}
.ohf img {

View File

@@ -476,7 +476,13 @@ class HaConfigEntryRow extends LitElement {
private async _fetchSubEntries() {
this._subEntries = this.entry.num_subentries
? await getSubEntries(this.hass, this.entry.entry_id)
? (await getSubEntries(this.hass, this.entry.entry_id)).sort((a, b) =>
caseInsensitiveStringCompare(
a.title,
b.title,
this.hass.locale.language
)
)
: undefined;
}

View File

@@ -111,7 +111,7 @@ class ZWaveJSCapabilityMultiLevelSwitch extends LitElement {
).checked;
const startLevel = Number(
(this.shadowRoot!.getElementById("start_level") as HaTextField).value
(this.shadowRoot!.getElementById("start_level")! as HaTextField).value
);
const options = {

View File

@@ -153,7 +153,7 @@ class HaPanelDevState extends LitElement {
required
autocapitalize="none"
autocomplete="off"
autocorrect="off"
.autocorrect=${false}
input-spellcheck="false"
.value=${this._state}
@change=${this._stateChanged}

View File

@@ -3,6 +3,7 @@ import { LitElement, css, html, nothing } from "lit";
import { mdiPencil, mdiDownload } from "@mdi/js";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-menu-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-list-item";
import "../../components/ha-top-app-bar-fixed";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
@@ -49,6 +50,8 @@ class PanelEnergy extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _searchParms = new URLSearchParams(window.location.search);
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
@@ -65,15 +68,29 @@ class PanelEnergy extends LitElement {
}
}
private _back(ev) {
ev.stopPropagation();
history.back();
}
protected render(): TemplateResult {
return html`
<div class="header">
<div class="toolbar">
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
${!this.narrow
? html`<div class="main-title">
${this.hass.localize("panel.energy")}

View File

@@ -426,13 +426,7 @@ class HaPanelHistory extends LitElement {
private _dateRangeChanged(ev) {
this._startDate = ev.detail.value.startDate;
const endDate = ev.detail.value.endDate;
if (endDate.getHours() === 0 && endDate.getMinutes() === 0) {
endDate.setDate(endDate.getDate() + 1);
endDate.setMilliseconds(endDate.getMilliseconds() - 1);
}
this._endDate = endDate;
this._endDate = ev.detail.value.endDate;
this._updatePath();
}

View File

@@ -236,10 +236,6 @@ export class HaPanelLogbook extends LitElement {
private _dateRangeChanged(ev) {
const startDate = ev.detail.value.startDate;
const endDate = ev.detail.value.endDate;
if (endDate.getHours() === 0 && endDate.getMinutes() === 0) {
endDate.setDate(endDate.getDate() + 1);
endDate.setMilliseconds(endDate.getMilliseconds() - 1);
}
this._time = {
range: [startDate, endDate],
};

View File

@@ -0,0 +1,151 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-attribute-icon";
import "../../../components/ha-control-select";
import type { ControlSelectOption } from "../../../components/ha-control-select";
import { UNAVAILABLE } from "../../../data/entity";
import type { FanEntity, FanDirection } from "../../../data/fan";
import { FanEntityFeature } from "../../../data/fan";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
FanDirectionCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
export const supportsFanDirectionCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return (
domain === "fan" && supportsFeature(stateObj, FanEntityFeature.DIRECTION)
);
};
@customElement("hui-fan-direction-card-feature")
class HuiFanDirectionCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: FanDirectionCardFeatureConfig;
@state() _currentDirection?: FanDirection;
private get _stateObj() {
if (!this.hass || !this.context || !this.context.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id!] as FanEntity | undefined;
}
static getStubConfig(): FanDirectionCardFeatureConfig {
return {
type: "fan-direction",
};
}
public setConfig(config: FanDirectionCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected willUpdate(changedProp: PropertyValues): void {
if (
(changedProp.has("hass") || changedProp.has("context")) &&
this._stateObj
) {
const oldHass = changedProp.get("hass") as HomeAssistant | undefined;
const oldStateObj = oldHass?.states[this.context!.entity_id!];
if (oldStateObj !== this._stateObj) {
this._currentDirection = this._stateObj.attributes
.direction as FanDirection;
}
}
}
private async _valueChanged(ev: CustomEvent) {
const newDirection = (ev.detail as any).value as FanDirection;
if (newDirection === this._stateObj!.attributes.direction) return;
const oldDirection = this._stateObj!.attributes.direction as FanDirection;
this._currentDirection = newDirection;
try {
await this._setDirection(newDirection);
} catch (_err) {
this._currentDirection = oldDirection;
}
}
private async _setDirection(direction: string) {
await this.hass!.callService("fan", "set_direction", {
entity_id: this._stateObj!.entity_id,
direction: direction,
});
}
protected render(): TemplateResult | null {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._stateObj ||
!supportsFanDirectionCardFeature(this.hass, this.context)
) {
return null;
}
const stateObj = this._stateObj;
const FAN_DIRECTION_MAP: FanDirection[] = ["forward", "reverse"];
const options = FAN_DIRECTION_MAP.map<ControlSelectOption>((direction) => ({
value: direction,
label: this.hass!.localize(`ui.card.fan.${direction}`),
icon: html`<ha-attribute-icon
slot="graphic"
.hass=${this.hass}
.stateObj=${stateObj}
attribute="direction"
.attributeValue=${direction}
></ha-attribute-icon>`,
}));
return html`
<ha-control-select
.options=${options}
.value=${this._currentDirection}
@value-changed=${this._valueChanged}
hide-option-label
.label=${this.hass!.formatEntityAttributeName(stateObj, "direction")}
.disabled=${this._stateObj!.state === UNAVAILABLE}
>
</ha-control-select>
`;
}
static get styles() {
return cardFeatureStyles;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-fan-direction-card-feature": HuiFanDirectionCardFeature;
}
}

View File

@@ -0,0 +1,225 @@
import { mdiStop, mdiValveClosed, mdiValveOpen } from "@mdi/js";
import { html, LitElement, nothing, css } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { computeDomain } from "../../../common/entity/compute_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { stateColorCss } from "../../../common/entity/state_color";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-svg-icon";
import {
canClose,
canOpen,
canStop,
ValveEntityFeature,
type ValveEntity,
} from "../../../data/valve";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
ValveOpenCloseCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
import "../../../components/ha-control-switch";
export const supportsValveOpenCloseCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return (
domain === "valve" &&
(supportsFeature(stateObj, ValveEntityFeature.OPEN) ||
supportsFeature(stateObj, ValveEntityFeature.CLOSE))
);
};
@customElement("hui-valve-open-close-card-feature")
class HuiValveOpenCloseCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: ValveOpenCloseCardFeatureConfig;
private get _stateObj() {
if (!this.hass || !this.context || !this.context.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id!] as ValveEntity | undefined;
}
static getStubConfig(): ValveOpenCloseCardFeatureConfig {
return {
type: "valve-open-close",
};
}
public setConfig(config: ValveOpenCloseCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
private _onOpenValve(): void {
this.hass!.callService("valve", "open_valve", {
entity_id: this._stateObj!.entity_id,
});
}
private _onCloseValve(): void {
this.hass!.callService("valve", "close_valve", {
entity_id: this._stateObj!.entity_id,
});
}
private _onOpenTap(ev): void {
ev.stopPropagation();
this._onOpenValve();
}
private _onCloseTap(ev): void {
ev.stopPropagation();
this._onCloseValve();
}
private _onStopTap(ev): void {
ev.stopPropagation();
this.hass!.callService("valve", "stop_valve", {
entity_id: this._stateObj!.entity_id,
});
}
private _valueChanged(ev): void {
ev.stopPropagation();
const checked = ev.target.checked as boolean;
if (checked) {
this._onOpenValve();
} else {
this._onCloseValve();
}
}
protected render() {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._stateObj ||
!supportsValveOpenCloseCardFeature(this.hass, this.context)
) {
return nothing;
}
// Determine colors and active states for toggle-style UI
const openColor = stateColorCss(this._stateObj, "open");
const closedColor = stateColorCss(this._stateObj, "closed");
const openIcon = mdiValveOpen;
const closedIcon = mdiValveClosed;
const isOpen =
this._stateObj.state === "open" ||
this._stateObj.state === "closing" ||
this._stateObj.state === "opening";
const isClosed = this._stateObj.state === "closed";
if (
this._stateObj.attributes.assumed_state ||
this._stateObj.state === UNKNOWN
) {
return html`
<ha-control-button-group>
${supportsFeature(this._stateObj, ValveEntityFeature.CLOSE)
? html`
<ha-control-button
.label=${this.hass.localize("ui.card.valve.close_valve")}
@click=${this._onCloseTap}
.disabled=${!canClose(this._stateObj)}
class=${classMap({
active: isClosed,
})}
style=${styleMap({
"--color": closedColor,
})}
>
<ha-svg-icon .path=${mdiValveClosed}></ha-svg-icon>
</ha-control-button>
`
: nothing}
${supportsFeature(this._stateObj, ValveEntityFeature.STOP)
? html`
<ha-control-button
.label=${this.hass.localize("ui.card.valve.stop_valve")}
@click=${this._onStopTap}
.disabled=${!canStop(this._stateObj)}
>
<ha-svg-icon .path=${mdiStop}></ha-svg-icon>
</ha-control-button>
`
: nothing}
${supportsFeature(this._stateObj, ValveEntityFeature.OPEN)
? html`
<ha-control-button
.label=${this.hass.localize("ui.card.valve.open_valve")}
@click=${this._onOpenTap}
.disabled=${!canOpen(this._stateObj)}
class=${classMap({
active: isOpen,
})}
style=${styleMap({
"--color": openColor,
})}
>
<ha-svg-icon .path=${mdiValveOpen}></ha-svg-icon>
</ha-control-button>
`
: nothing}
</ha-control-button-group>
`;
}
return html`
<ha-control-switch
.pathOn=${openIcon}
.pathOff=${closedIcon}
.checked=${isOpen}
@change=${this._valueChanged}
.label=${this.hass.localize("ui.card.common.toggle")}
.disabled=${this._stateObj.state === UNAVAILABLE}
>
</ha-control-switch>
`;
}
static get styles() {
return [
cardFeatureStyles,
css`
ha-control-button.active {
--control-button-icon-color: white;
--control-button-background-color: var(--color);
--control-button-background-opacity: 1;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-valve-open-close-card-feature": HuiValveOpenCloseCardFeature;
}
}

View File

@@ -0,0 +1,141 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { computeAttributeNameDisplay } from "../../../common/entity/compute_attribute_display";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateActive } from "../../../common/entity/state_active";
import { stateColorCss } from "../../../common/entity/state_color";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-control-slider";
import { ValveEntityFeature, type ValveEntity } from "../../../data/valve";
import { UNAVAILABLE } from "../../../data/entity";
import { DOMAIN_ATTRIBUTES_UNITS } from "../../../data/entity_attributes";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
ValvePositionCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
export const supportsValvePositionCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return (
domain === "valve" &&
supportsFeature(stateObj, ValveEntityFeature.SET_POSITION)
);
};
@customElement("hui-valve-position-card-feature")
class HuiValvePositionCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@property({ attribute: false }) public color?: string;
@state() private _config?: ValvePositionCardFeatureConfig;
private get _stateObj() {
if (!this.hass || !this.context || !this.context.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id!] as ValveEntity | undefined;
}
static getStubConfig(): ValvePositionCardFeatureConfig {
return {
type: "valve-position",
};
}
public setConfig(config: ValvePositionCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected render() {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._stateObj ||
!supportsValvePositionCardFeature(this.hass, this.context)
) {
return nothing;
}
const percentage = stateActive(this._stateObj)
? (this._stateObj.attributes.current_position ?? 0)
: 0;
const value = Math.max(Math.round(percentage), 0);
const openColor = stateColorCss(this._stateObj, "open");
const color = this.color
? computeCssColor(this.color)
: stateColorCss(this._stateObj);
const style = {
"--feature-color": color,
// Use open color for inactive state to avoid grey slider that looks disabled
"--state-valve-inactive-color": openColor,
};
return html`
<ha-control-slider
style=${styleMap(style)}
.value=${value}
min="0"
max="100"
step="1"
inverted
show-handle
@value-changed=${this._valueChanged}
.label=${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj,
this.hass.entities,
"current_position"
)}
.disabled=${this._stateObj!.state === UNAVAILABLE}
.unit=${DOMAIN_ATTRIBUTES_UNITS.valve.current_position}
.locale=${this.hass.locale}
></ha-control-slider>
`;
}
private _valueChanged(ev: CustomEvent) {
const value = (ev.detail as any).value;
if (isNaN(value)) return;
this.hass!.callService("valve", "set_valve_position", {
entity_id: this._stateObj!.entity_id,
position: value,
});
}
static get styles() {
return cardFeatureStyles;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-valve-position-card-feature": HuiValvePositionCardFeature;
}
}

View File

@@ -43,6 +43,10 @@ export interface MediaPlayerVolumeSliderCardFeatureConfig {
type: "media-player-volume-slider";
}
export interface FanDirectionCardFeatureConfig {
type: "fan-direction";
}
export interface FanPresetModesCardFeatureConfig {
type: "fan-preset-modes";
style?: "dropdown" | "icons";
@@ -149,6 +153,14 @@ export interface VacuumCommandsCardFeatureConfig {
commands?: VacuumCommand[];
}
export interface ValveOpenCloseCardFeatureConfig {
type: "valve-open-close";
}
export interface ValvePositionCardFeatureConfig {
type: "valve-position";
}
export const LAWN_MOWER_COMMANDS = ["start_pause", "dock"] as const;
export type LawnMowerCommand = (typeof LAWN_MOWER_COMMANDS)[number];
@@ -201,6 +213,7 @@ export type LovelaceCardFeatureConfig =
| CoverPositionCardFeatureConfig
| CoverTiltPositionCardFeatureConfig
| CoverTiltCardFeatureConfig
| FanDirectionCardFeatureConfig
| FanPresetModesCardFeatureConfig
| FanSpeedCardFeatureConfig
| HumidifierToggleCardFeatureConfig
@@ -218,6 +231,8 @@ export type LovelaceCardFeatureConfig =
| ToggleCardFeatureConfig
| UpdateActionsCardFeatureConfig
| VacuumCommandsCardFeatureConfig
| ValveOpenCloseCardFeatureConfig
| ValvePositionCardFeatureConfig
| WaterHeaterOperationModesCardFeatureConfig
| AreaControlsCardFeatureConfig;

View File

@@ -6,6 +6,7 @@ import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
energySourcesByType,
getEnergyDataCollection,
getSummedData,
@@ -92,6 +93,10 @@ class HuiEnergySankeyCard
const prefs = this._data.prefs;
const types = energySourcesByType(prefs);
const { summedData, compareSummedData: _ } = getSummedData(this._data);
const { consumption, compareConsumption: __ } = computeConsumptionData(
summedData,
undefined
);
const computedStyle = getComputedStyle(this);
@@ -103,12 +108,60 @@ class HuiEnergySankeyCard
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.home"
),
value: 0,
value: Math.max(0, consumption.total.used_total),
color: computedStyle.getPropertyValue("--primary-color"),
index: 1,
};
nodes.push(homeNode);
if (types.battery) {
const totalBatteryOut = summedData.total.from_battery ?? 0;
const totalBatteryIn = summedData.total.to_battery ?? 0;
// Add battery source
nodes.push({
id: "battery",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.battery"
),
value: totalBatteryOut,
tooltip: `${formatNumber(totalBatteryOut, this.hass.locale)} kWh`,
color: computedStyle.getPropertyValue("--energy-battery-out-color"),
index: 0,
});
links.push({
source: "battery",
target: "home",
value: consumption.total.used_battery,
});
// Add battery sink
nodes.push({
id: "battery_in",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.battery"
),
value: totalBatteryIn,
tooltip: `${formatNumber(totalBatteryIn, this.hass.locale)} kWh`,
color: computedStyle.getPropertyValue("--energy-battery-in-color"),
index: 1,
});
if (consumption.total.grid_to_battery > 0) {
links.push({
source: "grid",
target: "battery_in",
value: consumption.total.grid_to_battery,
});
}
if (consumption.total.solar_to_battery > 0) {
links.push({
source: "solar",
target: "battery_in",
value: consumption.total.solar_to_battery,
});
}
}
if (types.grid) {
const totalFromGrid = summedData.total.from_grid ?? 0;
@@ -128,6 +181,7 @@ class HuiEnergySankeyCard
links.push({
source: "grid",
target: "home",
value: consumption.total.used_grid,
});
}
@@ -149,57 +203,7 @@ class HuiEnergySankeyCard
links.push({
source: "solar",
target: "home",
});
}
// Calculate total home consumption from all producers
homeNode.value = nodes
.filter((node) => node.index === 0)
.reduce((sum, node) => sum + (node.value || 0), 0);
if (types.battery) {
// Add battery source
const totalBatteryOut = summedData.total.from_battery ?? 0;
const totalBatteryIn = summedData.total.to_battery ?? 0;
const netBattery = totalBatteryOut - totalBatteryIn;
const netBatteryOut = Math.max(netBattery, 0);
const netBatteryIn = Math.max(-netBattery, 0);
homeNode.value += netBattery;
nodes.push({
id: "battery",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.battery"
),
value: netBatteryOut,
tooltip: `${formatNumber(netBatteryOut, this.hass.locale)} kWh`,
color: computedStyle.getPropertyValue("--energy-battery-out-color"),
index: 0,
});
links.push({
source: "battery",
target: "home",
});
// Add battery sink
nodes.push({
id: "battery_in",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.battery"
),
value: netBatteryIn,
tooltip: `${formatNumber(netBatteryIn, this.hass.locale)} kWh`,
color: computedStyle.getPropertyValue("--energy-battery-in-color"),
index: 1,
});
nodes.forEach((node) => {
// Link all sources to battery_in
if (node.index === 0) {
links.push({
source: node.id,
target: "battery_in",
});
}
value: consumption.total.used_solar,
});
}
@@ -217,17 +221,20 @@ class HuiEnergySankeyCard
color: computedStyle.getPropertyValue("--energy-grid-return-color"),
index: 1,
});
nodes.forEach((node) => {
// Link all non-grid sources to grid_return
if (node.index === 0 && node.id !== "grid") {
links.push({
source: node.id,
target: "grid_return",
});
}
});
homeNode.value -= totalToGrid;
if (consumption.total.battery_to_grid > 0) {
links.push({
source: "battery",
target: "grid",
value: consumption.total.battery_to_grid,
});
}
if (consumption.total.solar_to_grid > 0) {
links.push({
source: "solar",
target: "grid_return",
value: consumption.total.solar_to_grid,
});
}
}
let untrackedConsumption = homeNode.value;
@@ -370,9 +377,6 @@ class HuiEnergySankeyCard
target: "untracked",
value: untrackedConsumption,
});
} else if (untrackedConsumption < 0) {
// if untracked consumption is negative, then the sources are not enough
homeNode.value -= untrackedConsumption;
}
homeNode.tooltip = `${formatNumber(homeNode.value, this.hass.locale)} kWh`;

View File

@@ -2,6 +2,8 @@ import {
mdiAccount,
mdiAmpersand,
mdiGateOr,
mdiMapMarker,
mdiNotEqualVariant,
mdiNumeric,
mdiResponsive,
mdiStateMachine,
@@ -9,10 +11,12 @@ import {
import type { Condition } from "./validate-condition";
export const ICON_CONDITION: Record<Condition["condition"], string> = {
location: mdiMapMarker,
numeric_state: mdiNumeric,
state: mdiStateMachine,
screen: mdiResponsive,
user: mdiAccount,
and: mdiAmpersand,
not: mdiNotEqualVariant,
or: mdiGateOr,
};

View File

@@ -1,17 +1,21 @@
import { ensureArray } from "../../../common/array/ensure-array";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import { listenMediaQuery } from "../../../common/dom/media_query";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import { UNKNOWN } from "../../../data/entity";
import { getUserPerson } from "../../../data/person";
import type { HomeAssistant } from "../../../types";
export type Condition =
| LocationCondition
| NumericStateCondition
| StateCondition
| ScreenCondition
| UserCondition
| OrCondition
| AndCondition;
| AndCondition
| NotCondition;
// Legacy conditional card condition
export interface LegacyCondition {
@@ -24,6 +28,11 @@ interface BaseCondition {
condition: string;
}
export interface LocationCondition extends BaseCondition {
condition: "location";
locations?: string[];
}
export interface NumericStateCondition extends BaseCondition {
condition: "numeric_state";
entity?: string;
@@ -58,6 +67,11 @@ export interface AndCondition extends BaseCondition {
conditions?: Condition[];
}
export interface NotCondition extends BaseCondition {
condition: "not";
conditions?: Condition[];
}
function getValueFromEntityId(
hass: HomeAssistant,
value: string
@@ -138,6 +152,17 @@ function checkScreenCondition(condition: ScreenCondition, _: HomeAssistant) {
: false;
}
function checkLocationCondition(
condition: LocationCondition,
hass: HomeAssistant
) {
const stateObj = getUserPerson(hass);
if (!stateObj) {
return false;
}
return condition.locations?.includes(stateObj.state);
}
function checkUserCondition(condition: UserCondition, hass: HomeAssistant) {
return condition.users && hass.user?.id
? condition.users.includes(hass.user.id)
@@ -149,6 +174,11 @@ function checkAndCondition(condition: AndCondition, hass: HomeAssistant) {
return checkConditionsMet(condition.conditions, hass);
}
function checkNotCondition(condition: NotCondition, hass: HomeAssistant) {
if (!condition.conditions) return true;
return !checkConditionsMet(condition.conditions, hass);
}
function checkOrCondition(condition: OrCondition, hass: HomeAssistant) {
if (!condition.conditions) return true;
return condition.conditions.some((c) => checkConditionsMet([c], hass));
@@ -171,10 +201,14 @@ export function checkConditionsMet(
return checkScreenCondition(c, hass);
case "user":
return checkUserCondition(c, hass);
case "location":
return checkLocationCondition(c, hass);
case "numeric_state":
return checkStateNumericCondition(c, hass);
case "and":
return checkAndCondition(c, hass);
case "not":
return checkNotCondition(c, hass);
case "or":
return checkOrCondition(c, hass);
default:
@@ -243,10 +277,18 @@ function validateUserCondition(condition: UserCondition) {
return condition.users != null;
}
function validateLocationCondition(condition: LocationCondition) {
return condition.locations != null;
}
function validateAndCondition(condition: AndCondition) {
return condition.conditions != null;
}
function validateNotCondition(condition: NotCondition) {
return condition.conditions != null;
}
function validateOrCondition(condition: OrCondition) {
return condition.conditions != null;
}
@@ -272,10 +314,14 @@ export function validateConditionalConfig(
return validateScreenCondition(c);
case "user":
return validateUserCondition(c);
case "location":
return validateLocationCondition(c);
case "numeric_state":
return validateNumericStateCondition(c);
case "and":
return validateAndCondition(c);
case "not":
return validateNotCondition(c);
case "or":
return validateOrCondition(c);
default:

View File

@@ -10,6 +10,7 @@ import "../card-features/hui-cover-open-close-card-feature";
import "../card-features/hui-cover-position-card-feature";
import "../card-features/hui-cover-tilt-card-feature";
import "../card-features/hui-cover-tilt-position-card-feature";
import "../card-features/hui-fan-direction-card-feature";
import "../card-features/hui-fan-preset-modes-card-feature";
import "../card-features/hui-fan-speed-card-feature";
import "../card-features/hui-humidifier-modes-card-feature";
@@ -27,6 +28,8 @@ import "../card-features/hui-target-temperature-card-feature";
import "../card-features/hui-toggle-card-feature";
import "../card-features/hui-update-actions-card-feature";
import "../card-features/hui-vacuum-commands-card-feature";
import "../card-features/hui-valve-open-close-card-feature";
import "../card-features/hui-valve-position-card-feature";
import "../card-features/hui-water-heater-operation-modes-card-feature";
import "../card-features/hui-area-controls-card-feature";
@@ -50,6 +53,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"cover-position",
"cover-tilt-position",
"cover-tilt",
"fan-direction",
"fan-preset-modes",
"fan-speed",
"humidifier-modes",
@@ -67,6 +71,8 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"toggle",
"update-actions",
"vacuum-commands",
"valve-open-close",
"valve-position",
"water-heater-operation-modes",
]);

View File

@@ -18,6 +18,8 @@ import "./ha-card-condition-editor";
import type { HaCardConditionEditor } from "./ha-card-condition-editor";
import type { LovelaceConditionEditorConstructor } from "./types";
import "./types/ha-card-condition-and";
import "./types/ha-card-condition-location";
import "./types/ha-card-condition-not";
import "./types/ha-card-condition-numeric_state";
import "./types/ha-card-condition-or";
import "./types/ha-card-condition-screen";
@@ -25,11 +27,13 @@ import "./types/ha-card-condition-state";
import "./types/ha-card-condition-user";
const UI_CONDITION = [
"location",
"numeric_state",
"state",
"screen",
"user",
"and",
"not",
"or",
] as const satisfies readonly Condition["condition"][];

View File

@@ -0,0 +1,105 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { array, assert, literal, object, string } from "superstruct";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-check-list-item";
import "../../../../../components/ha-switch";
import "../../../../../components/ha-list";
import type { HomeAssistant } from "../../../../../types";
import type { LocationCondition } from "../../../common/validate-condition";
import "../../../../../components/ha-form/ha-form";
const locationConditionStruct = object({
condition: literal("location"),
locations: array(string()),
});
const SCHEMA = [
{
name: "locations",
selector: {
state: {
entity_id: "person.whomever",
hide_states: ["unavailable", "unknown"],
multiple: true,
},
},
},
];
@customElement("ha-card-condition-location")
export class HaCardConditionLocation extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: LocationCondition;
@property({ type: Boolean }) public disabled = false;
public static get defaultConfig(): LocationCondition {
return { condition: "location", locations: [] };
}
protected static validateUIConfig(condition: LocationCondition) {
return assert(condition, locationConditionStruct);
}
protected render() {
return html`
<ha-form
.hass=${this.hass}
.data=${this.condition}
.schema=${SCHEMA}
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
></ha-form>
`;
}
private _valueChanged(ev) {
ev.stopPropagation();
const locations = ev.detail.value.locations;
const condition: LocationCondition = {
...this.condition,
locations,
};
fireEvent(this, "value-changed", { value: condition });
}
private _computeLabelCallback = (schema): string => {
switch (schema.name) {
case "locations":
return this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.condition.location.locations"
);
default:
return "";
}
};
private _computeHelperCallback = (schema): string => {
switch (schema.name) {
case "locations":
return this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.condition.location.locations_helper"
);
default:
return "";
}
};
static styles = css`
:host {
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-card-condition-location": HaCardConditionLocation;
}
}

View File

@@ -0,0 +1,62 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { any, array, assert, literal, object, optional } from "superstruct";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../../../../types";
import type {
NotCondition,
Condition,
StateCondition,
} from "../../../common/validate-condition";
import "../ha-card-conditions-editor";
const notConditionStruct = object({
condition: literal("not"),
conditions: optional(array(any())),
});
@customElement("ha-card-condition-not")
export class HaCardConditionNot extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: NotCondition;
@property({ type: Boolean }) public disabled = false;
public static get defaultConfig(): NotCondition {
return { condition: "not", conditions: [] };
}
protected static validateUIConfig(condition: StateCondition) {
return assert(condition, notConditionStruct);
}
protected render() {
return html`
<ha-card-conditions-editor
nested
.hass=${this.hass}
.conditions=${this.condition.conditions}
@value-changed=${this._valueChanged}
>
</ha-card-conditions-editor>
`;
}
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
const conditions = ev.detail.value as Condition[];
const condition = {
...this.condition,
conditions,
};
fireEvent(this, "value-changed", { value: condition });
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-card-condition-not": HaCardConditionNot;
}
}

View File

@@ -113,7 +113,7 @@ export class HuiAlarmModesCardFeatureEditor
: undefined;
if (customize_modes && !config.modes) {
config.modes = stateObj ? supportedAlarmModes(stateObj) : [];
config.modes = stateObj ? supportedAlarmModes(stateObj).reverse() : [];
}
if (!customize_modes && config.modes) {
delete config.modes;

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