Compare commits

..

136 Commits

Author SHA1 Message Date
Bram Kragten
64f54d9aaa 20240424.1 (#20609) 2024-04-24 14:45:22 +02:00
Bram Kragten
fbda9ca418 Bumped version to 20240424.1 2024-04-24 14:30:36 +02:00
Paul Bottein
4e97e3763e Use theme mode property for ha-map (#20606)
* Use theme mode property for ha-map

* Use theme mode in ha-location-editor

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2024-04-24 14:28:33 +02:00
Bram Kragten
4c9c52d27d Add filter for domains to entity settings (#20605) 2024-04-24 13:53:14 +02:00
Bram Kragten
87bcd3e471 Add multiselect area to automation/scene/script (#20604) 2024-04-24 10:10:43 +00:00
Paul Bottein
7e9b01b56d Fix text alignment for open lock card feature (#20603)
Fix text aligment for open lock card feature
2024-04-24 09:36:17 +00:00
karwosts
713763fc21 Fix visual map issues (#20541)
* Fix visual map issues (#15587)

- hover background color of zoom control in dark mode
- map light mode when dark theme is used
- background color of zoom control with map dark mode when light theme is used

* Fix breaking change (#15587)

* Change theme mode selection to use dropdown (#15587)

- Additionally fixed unpleasant horizontal scrollbar in map editor

* Add yaml migration, fix force light/dark options

---------

Co-authored-by: Christoph Wen <wen.christoph@gmail.com>
2024-04-24 11:35:37 +02:00
renovate[bot]
5b7ab1bfcb Update dependency cropperjs to v1.6.2 (#20600)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-24 11:28:58 +02:00
Bram Kragten
8712adbf8d 20240424.0 (#20602) 2024-04-24 11:21:24 +02:00
Bram Kragten
4b0d19b615 Fix duplicate imports from merge 2024-04-24 11:14:55 +02:00
Bram Kragten
90e5d259af Merge branch 'master' into dev 2024-04-24 11:13:34 +02:00
Bram Kragten
af3a331f57 Bumped version to 20240424.0 2024-04-24 11:09:48 +02:00
Bram Kragten
67c60a4aa8 Add strict connection cloud settings (#20585)
* Add strict connection cloud settings

* Change static page to guard page

* saving files helps...

* Add create link button

* use correct service
2024-04-24 11:06:15 +02:00
Bram Kragten
62de16bb8e Implement storing sorting and grouping for all tables (#20594) 2024-04-24 11:06:00 +02:00
Marc Geurts
d9b71e754d Add lock features for tile card (#20539)
* Added lock features for tile card

* Change success label
2024-04-24 10:30:34 +02:00
Matthias Alphart
5fc950f09f Fix ha-filter-states clear filter behaviour (#20599)
Fix ha-filter-states clear filter behaviour
2024-04-24 10:28:17 +02:00
J. Nick Koston
0725c7b160 Restore getHassTranslationsPre109 (#20597)
* Revert "Remove legacy state translations (#20536)"

This reverts commit e376efc579.

* keep
2024-04-24 10:26:24 +02:00
Steve Repsher
469dbbcccc Rollback gulp to version 4 (#20598) 2024-04-24 10:25:52 +02:00
Bram Kragten
ffdd661b1f Make fallback translations work (#20596) 2024-04-24 07:31:29 +02:00
Bram Kragten
81922f5a3e Save and restore collapsed groups (#20591) 2024-04-23 21:00:17 +02:00
renovate[bot]
7e25366897 Update dependency @types/chromecast-caf-receiver to v6.0.14 (#20589)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-23 18:04:44 +02:00
Bram Kragten
8ab61b5468 Add title when text in datatable doesnt fit (#20590) 2024-04-23 13:50:44 +02:00
Bram Kragten
8239f6dd60 Update hass-tabs-subpage-data-table.ts 2024-04-22 20:28:21 +02:00
Bram Kragten
45dce18e4d typo 2024-04-22 20:15:52 +02:00
Bram Kragten
a428ad0655 Add bulk area assignment to device dashboard (#20581)
* Add bulk area assignment to device dashboard

* Update ha-config-devices-dashboard.ts
2024-04-22 18:35:58 +02:00
Bram Kragten
1b54d51e4a Add option for custom group order to data table (#20582) 2024-04-22 18:28:50 +02:00
Bram Kragten
eb1354d229 Allow groups in data table to be collapsed (#20579) 2024-04-22 18:27:46 +02:00
Bram Kragten
4d21f9e80c Allow to group entities by domain (#20580)
Allow group entities by domain
2024-04-22 18:27:13 +02:00
Paul Bottein
62f46baacf Fix more info slider in iOS (#20586) 2024-04-22 18:26:50 +02:00
Bram Kragten
a3090796d2 Store grouping and sorting for device table (#20583) 2024-04-22 18:13:44 +02:00
renovate[bot]
c34c5d64f9 Update dependency @codemirror/commands to v6.5.0 (#20587)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-22 18:09:46 +02:00
Bram Kragten
66228f5858 Add new timestamp state domains (#20584) 2024-04-22 16:33:32 +02:00
Bram Kragten
ac378cfe6d Update external barcode scanning API (#20470) 2024-04-22 12:52:59 +02:00
Nicooow
7ecf8b755e Add css var for meta theme-color attribute (#20558)
* add app-theme-color var

* Fix Prettier format
2024-04-22 09:51:21 +02:00
Matthias Alphart
141107f1f3 Apply initial values to forms of device trigger extra fields (#20555)
* Apply default values of device trigger extra fields

* use computeInitialHaFormData
2024-04-22 09:48:50 +02:00
karwosts
b5277dee53 When munging statistics to history, assume always numeric (#20544) 2024-04-22 09:41:12 +02:00
Steve Repsher
4b593c1c96 Enhance webpack transform async plugin to use babel runtime (#20543) 2024-04-22 09:40:20 +02:00
dependabot[bot]
50ce1b94c8 Bump actions/upload-artifact from 4.3.1 to 4.3.2 (#20575)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-22 09:22:38 +02:00
dependabot[bot]
8bf27a83ec Bump actions/checkout from 4.1.2 to 4.1.3 (#20576)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-22 09:21:14 +02:00
renovate[bot]
389f0d3d23 Update dependency marked to v12.0.2 (#20577)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-22 09:19:52 +02:00
karwosts
b966601e6a Hide beta toggle when unsupervised (#20573) 2024-04-21 09:50:14 +02:00
renovate[bot]
f2a0881821 Update dependency @types/tar to v6.1.13 (#20572)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-21 09:36:09 +02:00
renovate[bot]
50a49eae43 Update dependency date-fns-tz to v3.1.3 (#20552)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-20 14:23:57 -04:00
renovate[bot]
1c04561004 Update dependency @codemirror/commands to v6.4.0 (#20569)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-20 19:39:54 +02:00
renovate[bot]
b80d94d260 Update dependency magic-string to v0.30.10 (#20568)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-20 19:39:42 +02:00
karwosts
87012e23e7 Wrap unbreakable area names in ha-card header (#20566) 2024-04-20 19:39:24 +02:00
Simon Lamon
f39758b103 Only create sortable when not disabled (#20565) 2024-04-20 14:32:40 +02:00
G Johansson
697bbf428e Add translation to integration setup failures (#19128) 2024-04-20 09:16:36 +02:00
renovate[bot]
c7444a2605 Update dependency @octokit/auth-oauth-device to v7.1.1 (#20560)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-20 09:03:01 +02:00
karwosts
3a5f4d33d2 Fix stopped_unknown_reason localization message (#20557)
* Fix stopped_unknown_reason localization message
2024-04-20 08:45:05 +02:00
renovate[bot]
c3dc62523b Update dependency core-js to v3.37.0 (#20559) 2024-04-19 20:45:00 -04:00
renovate[bot]
424622061a Lock file maintenance (#20553) 2024-04-19 00:47:42 -04:00
renovate[bot]
a3b021b11d Update dependency @material/web to v1.4.1 (#20551) 2024-04-19 00:12:18 -04:00
renovate[bot]
b60ad8b143 Update typescript-eslint monorepo to v7.7.0 (#20549) 2024-04-18 18:55:17 -04:00
J. Nick Koston
e376efc579 Remove legacy state translations (#20536)
* Remove legacy state translations

https://github.com/home-assistant/core/pull/112023
2024-04-18 06:33:07 +02:00
renovate[bot]
382035a1d4 Update dependency @types/color-name to v1.1.4 (#20546)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-17 18:55:59 -04:00
renovate[bot]
542e22fe0e Update dependency tar to v7.0.1 (#20547)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-17 22:54:57 +00:00
karwosts
af37d57779 Fix entity picker delete entity (#20542) 2024-04-17 19:48:25 +02:00
renovate[bot]
fbef0b0186 Update dependency gulp to v5 (#20305)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-17 12:31:10 -04:00
renovate[bot]
9e67d6add8 Update dependency @types/leaflet to v1.9.11 (#20538)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-17 18:21:59 +02:00
Simon Lamon
25c702ad2b Don't display keyboard shortcut hints in quickbar if keyboard shortcuts are disabled (#20527)
Fix forgotten hint shortcut tip
2024-04-15 20:20:45 +02:00
Bram Kragten
6516597c93 Fix zones on mobile, align mobile and non mobile view (#20525)
* Fix zones on mobile, align mobile and non mobile view

* Fix CI

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2024-04-15 20:20:14 +02:00
renovate[bot]
1df9c38a8c Update dependency tar to v7 (#20513)
* Update dependency tar to v7

* Update fetch-nightly-translations.js

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2024-04-15 13:40:38 +00:00
renovate[bot]
bd7217145a Update dependency element-internals-polyfill to v1.3.11 (#20512)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-15 15:07:09 +02:00
renovate[bot]
569fef38a4 Update vaadinWebComponents monorepo to v24.3.11 (#20523)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-15 15:03:46 +02:00
Thomas Steiner
f21c89cf1a Make allow attribute configurable in iframe panel (#19087)
* Make allow attribute configurable in iframe panel

* Delete .vscode/settings.json

* Update ha-panel-iframe.ts

* Don't quote

* Update src/panels/iframe/ha-panel-iframe.ts

Co-authored-by: Quentame <polletquentin74@me.com>

* Make `allow` configurable for `hui-iframe-card`

* Update src/panels/iframe/ha-panel-iframe.ts

Co-authored-by: Quentame <polletquentin74@me.com>

* Update src/panels/lovelace/cards/hui-iframe-card.ts

Co-authored-by: Quentame <polletquentin74@me.com>

* Update src/panels/iframe/ha-panel-iframe.ts

Co-authored-by: Quentame <polletquentin74@me.com>

* Update src/panels/lovelace/cards/hui-iframe-card.ts

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

* Delete src/panels/iframe/ha-panel-iframe.ts

* Restore dev

* Update ha-panel-iframe.ts

* Prettier

---------

Co-authored-by: Quentame <polletquentin74@me.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2024-04-15 14:48:57 +02:00
renovate[bot]
02cc418969 Update CodeMirror (#20520)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-15 09:21:07 +00:00
Cougar
4faba159c0 ZHA (Zigbee) visualization enhancement (#20511) 2024-04-15 11:09:16 +02:00
Adam Kapos
29816e6c5e Fix malformed CSS in dialog surface (#20519) 2024-04-15 11:06:07 +02:00
karwosts
5317a11c39 Sort custom cards in card picker (#20517) 2024-04-14 22:32:35 +02:00
Simon Lamon
27c53b3241 Replace paper-listbox in zone area (#19955)
* ha-config-zone

* Fixes

* add selected event back

* Fixes

* remove leftover paper-item css rule

* Fixup merge conflict
2024-04-14 13:46:38 +02:00
renovate[bot]
919befa961 Update dependency typescript to v5.4.5 (#20510) 2024-04-13 20:43:27 -04:00
renovate[bot]
f9c02ed099 Update dependency date-fns-tz to v3.0.1 (#20509) 2024-04-13 20:38:12 -04:00
karwosts
b35c325f43 Rebuild stack card when a child card rebuilds (#19861) 2024-04-12 20:53:04 +02:00
karwosts
b82f1128fe Location/zone editor updates (#19994) 2024-04-12 20:51:23 +02:00
karwosts
178feb7330 Create helpers from automation editor (#19287)
* Create helpers from automation editor

* support multiple createDomains

* localization

* fix lint

* Move multi domain to entity picker

* Update dialog-helper-detail.ts

* Update ha-config-helpers.ts

* optimize a little

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2024-04-12 18:38:39 +00:00
karwosts
0118a5bf4c Allow customizing display name for energy device (#20033)
* Allow customizing display name for energy device

* use display name in device settings list
2024-04-12 20:31:59 +02:00
renovate[bot]
e0087bd142 Update dependency @material/web to v1.4.0 (#20177)
* Update dependency @material/web to v1.4.0

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2024-04-12 15:15:33 +00:00
renovate[bot]
c2d3e7900e Update date-fns to v3 (major) (#20504)
* Update date-fns to v3

* update imports

* breaking changes

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2024-04-12 17:03:30 +02:00
Charles Garwood
fb8312110b Add support for setting label description (#20421)
* Add support for setting label description
2024-04-12 15:49:07 +02:00
renovate[bot]
16de57342e Update dependency eslint-plugin-wc to v2.1.0 (#20500)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-12 08:23:48 -04:00
renovate[bot]
ad6e041c04 Update dependency @codemirror/view to v6.26.2 (#20505)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-12 13:10:15 +02:00
Steve Repsher
e22e3e88a0 Speed up and simplify translations build (#19988)
* Speed up and simplify translations build

- Remove use of gulp-flatmap for merges (wasted input) and just loop over translation files.
- Parse and buffer master only once for all merges.
- Remove lokalise key reference transform from non-English files. This is already done by Lokalise when they are downloaded.
- Remove tabs from merged output to minimize buffer sizes.
- Pipe merges to a hashing stream, removing extra tasks and intermediate file I/O.
- Pipe hashed files to a single custom asynchronous transform stream to fragmentize the files. It expands the stream to push a new file for each fragment.
- Incorporate flattening into fragmentization.
- Delete entire ui.panel key for base translation (instead of leaving an empty object).
- Optimize flatten method to stop copying output over and over.
- Convert empty and test filters to JSON.parse() revivers for simplicity and better performance.
- Incorporate supervisor builds into main tasks using a simple toggle (i.e. remove duplicate code).
- Funcify local tasks and simplify exported tasks.
- Incorporate test metadata task into a simplified metadata task.

* Fix Lokalise key reference link

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2024-04-12 12:49:18 +02:00
Steve Repsher
dc8a50965c Group date-fns packages (#20499) 2024-04-12 12:22:18 +02:00
Bram Kragten
1914de7ddf Bumped version to 20240404.2 2024-04-12 11:21:05 +02:00
Simon Lamon
2e505cfb1f Add spacing between icon and name in entity button bar (#20492)
* Fix width between icon and name

* Remove no-text
2024-04-12 11:20:15 +02:00
Bram Kragten
ab49aca815 Handle errors in multi select (#20494) 2024-04-12 11:19:57 +02:00
Bram Kragten
c96968e476 Fix issues with application credentials (#20495) 2024-04-12 11:19:31 +02:00
Simon Lamon
8f050516ec Fix missing argument in voice assistant expose search label (#20491) 2024-04-12 11:18:47 +02:00
Simon Lamon
27d2b244a4 Add spacing between icon and name in entity button bar (#20492)
* Fix width between icon and name

* Remove no-text
2024-04-12 11:17:51 +02:00
Bram Kragten
be2f2c6271 Handle errors in multi select (#20494) 2024-04-12 11:15:15 +02:00
Bram Kragten
8dc2797b16 Fix issues with application credentials (#20495) 2024-04-12 11:10:23 +02:00
renovate[bot]
7ca8dabc44 Update typescript-eslint monorepo to v7.6.0 (#20497)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-11 16:32:52 -04:00
Bram Kragten
baeb55e217 Merge branch 'master' into dev 2024-04-11 11:16:42 +02:00
Simon Lamon
a8502fcc11 Fix missing argument in voice assistant expose search label (#20491) 2024-04-11 11:10:02 +02:00
Paulus Schoutsen
9f5bc5b196 Match on correct entity ID of HA conversation agent (#20484) 2024-04-10 11:41:12 +02:00
Bruno Pantaleão Gonçalves
7556ab9506 Add css var for header webkit backdrop filter (#20473)
* Add CSS var for header -webkit-backdrop-filter

* Add backdrop to energy and developer tools
2024-04-10 11:40:44 +02:00
Adam Kapos
bf176ac314 Make it possible for themes to blur backgrounds (#20447)
* Make it possible for themes to blur card backgrounds

* Make it possible for themes to blur dialog backgrounds

* Add ha prefix

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

* Rename dialog-backdrop-filter to ha-dialog-scrim-backdrop-filter

With backwards compatibility

* Run prettier

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2024-04-10 11:37:35 +02:00
Adam Kapos
9903e22eaa Change tile to display "last triggered" in relative time (#20463) 2024-04-09 14:11:58 +02:00
Simon Lamon
1e0f7d9629 Don't display keyboard shortcut hints if keyboard shortcuts are disabled (#20456)
* Shortcut hints

* Prettier
2024-04-09 14:10:59 +02:00
Charles Garwood
e8a140af44 Don't duplicate the label as the description in Z-Wave Config (#20454)
Don'tduplicate the label as the description
2024-04-09 14:09:21 +02:00
Bram Kragten
b091d4f298 Write log on translation error (#20430)
* Write log on translation error

* Update ha-config-devices-dashboard.ts

---------

Co-authored-by: Franck Nijhof <git@frenck.dev>
2024-04-09 13:25:27 +02:00
renovate[bot]
35cf3063cb Update dependency @lokalise/node-api to v12.4.0 (#20252) 2024-04-08 20:49:48 -04:00
renovate[bot]
7141ef17be Update dependency @codemirror/legacy-modes to v6.4.0 (#20475) 2024-04-08 20:36:26 -04:00
renovate[bot]
be2c68c0bb Update octokit monorepo (#20453) 2024-04-08 20:32:57 -04:00
renovate[bot]
7c944d3767 Update dependency mocha to v10.4.0 (#20279) 2024-04-08 20:29:48 -04:00
renovate[bot]
1d4f02df2e Update dependency glob to v10.3.12 (#20298) 2024-04-08 20:28:30 -04:00
renovate[bot]
2007a74a20 Update babel monorepo to v7.24.4 (#20451) 2024-04-08 20:23:53 -04:00
renovate[bot]
8c0839ad57 Update dependency @types/tar to v6.1.12 (#20457) 2024-04-08 20:22:31 -04:00
renovate[bot]
516b9a54c4 Update dependency typescript to v5.4.4 (#20468) 2024-04-08 20:21:33 -04:00
renovate[bot]
0d3e730c9c Update dependency magic-string to v0.30.9 (#20465)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-08 09:33:26 +02:00
renovate[bot]
c7a87d02b2 Update dependency @types/leaflet to v1.9.9 (#20452) 2024-04-07 20:37:42 +02:00
Bram Kragten
dd082c204b Remove unused type (#20429) 2024-04-05 12:22:47 +02:00
renovate[bot]
c4af3d1579 Update typescript-eslint monorepo to v7.5.0 (#20426)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-05 11:36:28 +02:00
Bram Kragten
10eadbcbbb Merge branch 'dev' 2024-04-04 21:31:51 +02:00
Bram Kragten
17141824f7 Bumped version to 20240404.1 2024-04-04 21:31:30 +02:00
Bram Kragten
4cfd6c010f Fix overflow on submenus (#20415) 2024-04-04 19:54:36 +02:00
Bram Kragten
daa9024bff Make pages full height 2024-04-04 17:16:36 +02:00
Bram Kragten
e96aca90fe 20240404.0 (#20414) 2024-04-04 16:23:24 +02:00
Bram Kragten
0580a31961 Bumped version to 20240404.0 2024-04-04 16:16:30 +02:00
Bram Kragten
5c42c5130c Add count of items (#20410)
* Add count of items

* Adjust layout, correct filter count
2024-04-04 16:15:13 +02:00
Bram Kragten
72d1e37a23 Fix integration filter search (#20408) 2024-04-04 13:26:26 +02:00
Bram Kragten
61c9072a08 Fix icons in entity settings (#20405) 2024-04-04 13:00:14 +02:00
Bram Kragten
08b25f9c2a Add floor and label support to describe action (#20403) 2024-04-04 13:00:05 +02:00
Samuel Schultze
1a03b49700 Fix calendar range selection (#20394)
fix: calendar range selector
2024-04-04 12:59:54 +02:00
Paul Bottein
2d4a8e2e45 Fix search input outlined background color and margin (#20407) 2024-04-04 12:53:03 +02:00
Bram Kragten
8486377604 Fix z-index create category dialog (#20406) 2024-04-04 10:51:04 +00:00
Bram Kragten
4326519a3f 20240403.1 (#20380) 2024-04-03 16:58:47 +02:00
Bram Kragten
962b30adb9 20240403.0 (#20370) 2024-04-03 14:50:16 +02:00
Bram Kragten
29eb73176a 20240402.2 (#20348) 2024-04-02 23:34:17 +02:00
Bram Kragten
4f1cf1110f 20240402.1 (#20326) 2024-04-02 16:41:12 +02:00
Bram Kragten
d3bf0da289 20240402.0 (#20314) 2024-04-02 11:44:05 +02:00
Paul Bottein
fd06d434f2 20240329.1 (#20280) 2024-03-29 21:25:10 +01:00
Paul Bottein
d24d29e42f 20240329.0 (#20277) 2024-03-29 19:10:34 +01:00
Paul Bottein
e02a47a16a 20240328.0 (#20250) 2024-03-28 16:49:01 +01:00
Bram Kragten
795c16a941 20240327.0 (#20210) 2024-03-27 17:52:08 +01:00
158 changed files with 4630 additions and 2196 deletions

View File

@@ -21,7 +21,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
with:
ref: dev
@@ -57,7 +57,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
with:
ref: master

View File

@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
- name: Setup Node
uses: actions/setup-node@v4.0.2
with:
@@ -58,7 +58,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
- name: Setup Node
uses: actions/setup-node@v4.0.2
with:
@@ -76,7 +76,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
- name: Setup Node
uses: actions/setup-node@v4.0.2
with:
@@ -89,7 +89,7 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@v4.3.1
uses: actions/upload-artifact@v4.3.2
with:
name: frontend-bundle-stats
path: build/stats/*.json
@@ -100,7 +100,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
- name: Setup Node
uses: actions/setup-node@v4.0.2
with:
@@ -113,7 +113,7 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@v4.3.1
uses: actions/upload-artifact@v4.3.2
with:
name: supervisor-bundle-stats
path: build/stats/*.json

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.

View File

@@ -22,7 +22,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
with:
ref: dev
@@ -58,7 +58,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
with:
ref: master

View File

@@ -16,7 +16,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
- name: Setup Node
uses: actions/setup-node@v4.0.2

View File

@@ -21,7 +21,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
- name: Setup Node
uses: actions/setup-node@v4.0.2

View File

@@ -20,7 +20,7 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5
@@ -57,14 +57,14 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts
uses: actions/upload-artifact@v4.3.1
uses: actions/upload-artifact@v4.3.2
with:
name: wheels
path: dist/home_assistant_frontend*.whl
if-no-files-found: error
- name: Upload translations
uses: actions/upload-artifact@v4.3.1
uses: actions/upload-artifact@v4.3.2
with:
name: translations
path: translations.tar.gz

View File

@@ -23,7 +23,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@master

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v4.1.2
uses: actions/checkout@v4.1.3
- name: Upload Translations
run: |

View File

@@ -9,7 +9,7 @@ import gulp from "gulp";
import jszip from "jszip";
import path from "path";
import process from "process";
import tar from "tar";
import { extract } from "tar";
const MAX_AGE = 24; // hours
const OWNER = "home-assistant";
@@ -156,7 +156,7 @@ gulp.task("fetch-nightly-translations", async function () {
console.log("Unpacking downloaded translations...");
const zip = await jszip.loadAsync(downloadResponse.data);
await deleteCurrent;
const extractStream = zip.file(/.*/)[0].nodeStream().pipe(tar.extract());
const extractStream = zip.file(/.*/)[0].nodeStream().pipe(extract());
await new Promise((resolve, reject) => {
extractStream.on("close", resolve).on("error", reject);
});

View File

@@ -1,92 +1,76 @@
import { createHash } from "crypto";
import { deleteSync } from "del";
import { mkdirSync, readdirSync, readFileSync, renameSync } from "fs";
import { writeFile } from "node:fs/promises";
import { deleteAsync } from "del";
import { glob } from "glob";
import gulp from "gulp";
import flatmap from "gulp-flatmap";
import transform from "gulp-json-transform";
import merge from "gulp-merge-json";
import rename from "gulp-rename";
import path from "path";
import vinylBuffer from "vinyl-buffer";
import source from "vinyl-source-stream";
import { createHash } from "node:crypto";
import { mkdir, readFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { Transform } from "node:stream";
import { finished } from "node:stream/promises";
import env from "../env.cjs";
import paths from "../paths.cjs";
import { mapFiles } from "../util.cjs";
import "./fetch-nightly-translations.js";
const inFrontendDir = "translations/frontend";
const inBackendDir = "translations/backend";
const workDir = "build/translations";
const fullDir = workDir + "/full";
const coreDir = workDir + "/core";
const outDir = workDir + "/output";
const outDir = join(workDir, "output");
const EN_SRC = join(paths.translations_src, "en.json");
let mergeBackend = false;
gulp.task(
"translations-enable-merge-backend",
gulp.parallel((done) => {
gulp.parallel(async () => {
mergeBackend = true;
done();
}, "allow-setup-fetch-nightly-translations")
);
// Panel translations which should be split from the core translations.
const TRANSLATION_FRAGMENTS = Object.keys(
JSON.parse(
readFileSync(
path.resolve(paths.polymer_dir, "src/translations/en.json"),
"utf-8"
)
).ui.panel
);
// Transform stream to apply a function on Vinyl JSON files (buffer mode only).
// The provided function can either return a new object, or an array of
// [object, subdirectory] pairs for fragmentizing the JSON.
class CustomJSON extends Transform {
constructor(func, reviver = null) {
super({ objectMode: true });
this._func = func;
this._reviver = reviver;
}
function recursiveFlatten(prefix, data) {
let output = {};
Object.keys(data).forEach((key) => {
if (typeof data[key] === "object") {
output = {
...output,
...recursiveFlatten(prefix + key + ".", data[key]),
};
async _transform(file, _, callback) {
try {
let obj = JSON.parse(file.contents.toString(), this._reviver);
if (this._func) obj = this._func(obj, file.path);
for (const [outObj, dir] of Array.isArray(obj) ? obj : [[obj, ""]]) {
const outFile = file.clone({ contents: false });
outFile.contents = Buffer.from(JSON.stringify(outObj));
outFile.dirname += `/${dir}`;
this.push(outFile);
}
callback(null);
} catch (err) {
callback(err);
}
}
}
// Utility to flatten object keys to single level using separator
const flatten = (data, prefix = "", sep = ".") => {
const output = {};
for (const [key, value] of Object.entries(data)) {
if (typeof value === "object") {
Object.assign(output, flatten(value, prefix + key + sep, sep));
} else {
output[prefix + key] = data[key];
output[prefix + key] = value;
}
});
}
return output;
}
};
function flatten(data) {
return recursiveFlatten("", data);
}
function emptyFilter(data) {
const newData = {};
Object.keys(data).forEach((key) => {
if (data[key]) {
if (typeof data[key] === "object") {
newData[key] = emptyFilter(data[key]);
} else {
newData[key] = data[key];
}
}
});
return newData;
}
function recursiveEmpty(data) {
const newData = {};
Object.keys(data).forEach((key) => {
if (data[key]) {
if (typeof data[key] === "object") {
newData[key] = recursiveEmpty(data[key]);
} else {
newData[key] = "TRANSLATED";
}
}
});
return newData;
}
// Filter functions that can be passed directly to JSON.parse()
const emptyReviver = (_key, value) => value || undefined;
const testReviver = (_key, value) =>
value && typeof value === "string" ? "TRANSLATED" : value;
/**
* Replace Lokalise key placeholders with their actual values.
@@ -95,60 +79,44 @@ function recursiveEmpty(data) {
* be included in src/translations/en.json, but still be usable while
* developing locally.
*
* @link https://docs.lokalise.co/article/KO5SZWLLsy-key-referencing
* @link https://docs.lokalise.com/en/articles/1400528-key-referencing
*/
const re_key_reference = /\[%key:([^%]+)%\]/;
function lokaliseTransform(data, original, file) {
const KEY_REFERENCE = /\[%key:([^%]+)%\]/;
const lokaliseTransform = (data, path, original = data) => {
const output = {};
Object.entries(data).forEach(([key, value]) => {
if (value instanceof Object) {
output[key] = lokaliseTransform(value, original, file);
for (const [key, value] of Object.entries(data)) {
if (typeof value === "object") {
output[key] = lokaliseTransform(value, path, original);
} else {
output[key] = value.replace(re_key_reference, (_match, lokalise_key) => {
output[key] = value.replace(KEY_REFERENCE, (_match, lokalise_key) => {
const replace = lokalise_key.split("::").reduce((tr, k) => {
if (!tr) {
throw Error(
`Invalid key placeholder ${lokalise_key} in ${file.path}`
);
throw Error(`Invalid key placeholder ${lokalise_key} in ${path}`);
}
return tr[k];
}, original);
if (typeof replace !== "string") {
throw Error(
`Invalid key placeholder ${lokalise_key} in ${file.path}`
);
throw Error(`Invalid key placeholder ${lokalise_key} in ${path}`);
}
return replace;
});
}
});
}
return output;
}
};
gulp.task("clean-translations", async () => deleteSync([workDir]));
gulp.task("clean-translations", () => deleteAsync([workDir]));
gulp.task("ensure-translations-build-dir", async () => {
mkdirSync(workDir, { recursive: true });
});
const makeWorkDir = () => mkdir(workDir, { recursive: true });
gulp.task("create-test-metadata", () =>
env.isProdBuild()
? Promise.resolve()
: writeFile(
workDir + "/testMetadata.json",
JSON.stringify({ test: { nativeName: "Test" } })
)
);
gulp.task("create-test-translation", () =>
const createTestTranslation = () =>
env.isProdBuild()
? Promise.resolve()
: gulp
.src(path.join(paths.translations_src, "en.json"))
.pipe(transform((data, _file) => recursiveEmpty(data)))
.src(EN_SRC)
.pipe(new CustomJSON(null, testReviver))
.pipe(rename("test.json"))
.pipe(gulp.dest(workDir))
);
.pipe(gulp.dest(workDir));
/**
* This task will build a master translation file, to be used as the base for
@@ -159,279 +127,171 @@ gulp.task("create-test-translation", () =>
* project is buildable immediately after merging new translation keys, since
* the Lokalise update to translations/en.json will not happen immediately.
*/
gulp.task("build-master-translation", () => {
const src = [path.join(paths.translations_src, "en.json")];
if (mergeBackend) {
src.push(path.join(inBackendDir, "en.json"));
}
return gulp
.src(src)
.pipe(transform((data, file) => lokaliseTransform(data, data, file)))
const createMasterTranslation = () =>
gulp
.src([EN_SRC, ...(mergeBackend ? [`${inBackendDir}/en.json`] : [])])
.pipe(new CustomJSON(lokaliseTransform))
.pipe(
merge({
fileName: "en.json",
jsonSpace: undefined,
})
)
.pipe(gulp.dest(fullDir));
});
.pipe(gulp.dest(workDir));
gulp.task("build-merged-translations", () =>
gulp
.src([
inFrontendDir + "/*.json",
"!" + inFrontendDir + "/en.json",
...(env.isProdBuild() ? [] : [workDir + "/test.json"]),
])
.pipe(transform((data, file) => lokaliseTransform(data, data, file)))
.pipe(
flatmap((stream, file) => {
// For each language generate a merged json file. It begins with the master
// translation as a failsafe for untranslated strings, and merges all parent
// tags into one file for each specific subtag
//
// TODO: This is a naive interpretation of BCP47 that should be improved.
// Will be OK for now as long as we don't have anything more complicated
// than a base translation + region.
const tr = path.basename(file.history[0], ".json");
const subtags = tr.split("-");
const src = [fullDir + "/en.json"];
for (let i = 1; i <= subtags.length; i++) {
const lang = subtags.slice(0, i).join("-");
if (lang === "test") {
src.push(workDir + "/test.json");
} else if (lang !== "en") {
src.push(inFrontendDir + "/" + lang + ".json");
if (mergeBackend) {
src.push(inBackendDir + "/" + lang + ".json");
}
}
}
return gulp
.src(src, { allowEmpty: true })
.pipe(transform((data) => emptyFilter(data)))
.pipe(
merge({
fileName: tr + ".json",
})
)
.pipe(gulp.dest(fullDir));
})
)
);
const FRAGMENTS = ["base"];
let taskName;
const toggleSupervisorFragment = async () => {
FRAGMENTS[0] = "supervisor";
};
const splitTasks = [];
TRANSLATION_FRAGMENTS.forEach((fragment) => {
taskName = "build-translation-fragment-" + fragment;
gulp.task(taskName, () =>
// Return only the translations for this fragment.
gulp
.src(fullDir + "/*.json")
.pipe(
transform((data) => ({
ui: {
panel: {
[fragment]: data.ui.panel[fragment],
},
},
}))
)
.pipe(gulp.dest(workDir + "/" + fragment))
);
splitTasks.push(taskName);
});
const panelFragment = (fragment) =>
fragment !== "base" && fragment !== "supervisor";
taskName = "build-translation-core";
gulp.task(taskName, () =>
// Remove the fragment translations from the core translation.
gulp
.src(fullDir + "/*.json")
.pipe(
transform((data, _file) => {
TRANSLATION_FRAGMENTS.forEach((fragment) => {
delete data.ui.panel[fragment];
});
delete data.supervisor;
return data;
})
)
.pipe(gulp.dest(coreDir))
);
const HASHES = new Map();
splitTasks.push(taskName);
gulp.task("build-flattened-translations", () =>
// Flatten the split versions of our translations, and move them into outDir
gulp
.src(
TRANSLATION_FRAGMENTS.map(
(fragment) => workDir + "/" + fragment + "/*.json"
).concat(coreDir + "/*.json"),
{ base: workDir }
)
.pipe(
transform((data) =>
// Polymer.AppLocalizeBehavior requires flattened json
flatten(data)
)
)
.pipe(
rename((filePath) => {
if (filePath.dirname === "core") {
filePath.dirname = "";
}
// In dev we create the file with the fake hash in the filename
if (!env.isProdBuild()) {
filePath.basename += "-dev";
}
})
)
.pipe(gulp.dest(outDir))
);
const fingerprints = {};
gulp.task("build-translation-fingerprints", () => {
// Fingerprint full file of each language
const files = readdirSync(fullDir);
for (let i = 0; i < files.length; i++) {
fingerprints[files[i].split(".")[0]] = {
// In dev we create fake hashes
hash: env.isProdBuild()
? createHash("md5")
.update(readFileSync(path.join(fullDir, files[i]), "utf-8"))
.digest("hex")
: "dev",
};
const createTranslations = async () => {
// Parse and store the master to avoid repeating this for each locale, then
// add the panel fragments when processing the app.
const enMaster = JSON.parse(await readFile(`${workDir}/en.json`, "utf-8"));
if (FRAGMENTS[0] === "base") {
FRAGMENTS.push(...Object.keys(enMaster.ui.panel));
}
// In dev we create the file with the fake hash in the filename
if (env.isProdBuild()) {
mapFiles(outDir, ".json", (filename) => {
const parsed = path.parse(filename);
// The downstream pipeline is setup first. It hashes the merged data for
// each locale, then fragmentizes and flattens the data for final output.
const translationFiles = await glob([
`${inFrontendDir}/!(en).json`,
...(env.isProdBuild() ? [] : [`${workDir}/test.json`]),
]);
const hashStream = new Transform({
objectMode: true,
transform: async (file, _, callback) => {
const hash = env.isProdBuild()
? createHash("md5").update(file.contents).digest("hex")
: "dev";
HASHES.set(file.stem, hash);
file.stem += `-${hash}`;
callback(null, file);
},
}).setMaxListeners(translationFiles.length + 1);
const fragmentsStream = hashStream
.pipe(
new CustomJSON((data) =>
FRAGMENTS.map((fragment) => {
switch (fragment) {
case "base":
// Remove the panels and supervisor to create the base translations
return [
flatten({
...data,
ui: { ...data.ui, panel: undefined },
supervisor: undefined,
}),
"",
];
case "supervisor":
// Supervisor key is at the top level
return [flatten(data.supervisor), ""];
default:
// Create a fragment with only the given panel
return [
flatten(data.ui.panel[fragment], `ui.panel.${fragment}.`),
fragment,
];
}
})
)
)
.pipe(gulp.dest(outDir));
// nl.json -> nl-<hash>.json
if (!(parsed.name in fingerprints)) {
throw new Error(`Unable to find hash for ${filename}`);
// Send the English master downstream first, then for each other locale
// generate merged JSON data to continue piping. It begins with the master
// translation as a failsafe for untranslated strings, and merges all parent
// tags into one file for each specific subtag
//
// TODO: This is a naive interpretation of BCP47 that should be improved.
// Will be OK for now as long as we don't have anything more complicated
// than a base translation + region.
gulp.src(`${workDir}/en.json`).pipe(hashStream, { end: false });
const mergesFinished = [];
for (const translationFile of translationFiles) {
const locale = basename(translationFile, ".json");
const subtags = locale.split("-");
const mergeFiles = [];
for (let i = 1; i <= subtags.length; i++) {
const lang = subtags.slice(0, i).join("-");
if (lang === "test") {
mergeFiles.push(`${workDir}/test.json`);
} else if (lang !== "en") {
mergeFiles.push(`${inFrontendDir}/${lang}.json`);
if (mergeBackend) {
mergeFiles.push(`${inBackendDir}/${lang}.json`);
}
}
renameSync(
filename,
`${parsed.dir}/${parsed.name}-${fingerprints[parsed.name].hash}${
parsed.ext
}`
);
});
}
const mergeStream = gulp.src(mergeFiles, { allowEmpty: true }).pipe(
merge({
fileName: `${locale}.json`,
startObj: enMaster,
jsonReviver: emptyReviver,
jsonSpace: undefined,
})
);
mergesFinished.push(finished(mergeStream));
mergeStream.pipe(hashStream, { end: false });
}
const stream = source("translationFingerprints.json");
stream.write(JSON.stringify(fingerprints));
process.nextTick(() => stream.end());
return stream.pipe(vinylBuffer()).pipe(gulp.dest(workDir));
});
// Wait for all merges to finish, then it's safe to end writing to the
// downstream pipeline and wait for all fragments to finish writing.
await Promise.all(mergesFinished);
hashStream.end();
await finished(fragmentsStream);
};
gulp.task("build-translation-fragment-supervisor", () =>
const writeTranslationMetaData = () =>
gulp
.src(fullDir + "/*.json")
.pipe(transform((data) => data.supervisor))
.src([`${paths.translations_src}/translationMetadata.json`])
.pipe(
rename((filePath) => {
// In dev we create the file with the fake hash in the filename
new CustomJSON((meta) => {
// Add the test translation in development.
if (!env.isProdBuild()) {
filePath.basename += "-dev";
meta.test = { nativeName: "Test" };
}
})
)
.pipe(gulp.dest(workDir + "/supervisor"))
);
gulp.task("build-translation-flatten-supervisor", () =>
gulp
.src(workDir + "/supervisor/*.json")
.pipe(
transform((data) =>
// Polymer.AppLocalizeBehavior requires flattened json
flatten(data)
)
)
.pipe(gulp.dest(outDir))
);
gulp.task("build-translation-write-metadata", () =>
gulp
.src([
path.join(paths.translations_src, "translationMetadata.json"),
...(env.isProdBuild() ? [] : [workDir + "/testMetadata.json"]),
workDir + "/translationFingerprints.json",
])
.pipe(merge({}))
.pipe(
transform((data) => {
const newData = {};
Object.entries(data).forEach(([key, value]) => {
// Filter out translations without native name.
if (value.nativeName) {
newData[key] = value;
} else {
// Filter out locales without a native name, and add the hashes.
for (const locale of Object.keys(meta)) {
if (!meta[locale].nativeName) {
meta[locale] = undefined;
console.warn(
`Skipping language ${key}. Native name was not translated.`
`Skipping locale ${locale} because native name is not translated.`
);
} else {
meta[locale].hash = HASHES.get(locale);
}
});
return newData;
}
return {
fragments: FRAGMENTS.filter(panelFragment),
translations: meta,
};
})
)
.pipe(
transform((data) => ({
fragments: TRANSLATION_FRAGMENTS,
translations: data,
}))
)
.pipe(rename("translationMetadata.json"))
.pipe(gulp.dest(workDir))
);
gulp.task(
"create-translations",
gulp.series(
gulp.parallel("create-test-metadata", "create-test-translation"),
"build-master-translation",
"build-merged-translations",
gulp.parallel(...splitTasks),
"build-flattened-translations"
)
);
.pipe(gulp.dest(workDir));
gulp.task(
"build-translations",
gulp.series(
gulp.parallel(
"fetch-nightly-translations",
gulp.series("clean-translations", "ensure-translations-build-dir")
gulp.series("clean-translations", makeWorkDir)
),
"create-translations",
"build-translation-fingerprints",
"build-translation-write-metadata"
createTestTranslation,
createMasterTranslation,
createTranslations,
writeTranslationMetaData
)
);
gulp.task(
"build-supervisor-translations",
gulp.series(
gulp.parallel(
"fetch-nightly-translations",
gulp.series("clean-translations", "ensure-translations-build-dir")
),
gulp.parallel("create-test-metadata", "create-test-translation"),
"build-master-translation",
"build-merged-translations",
"build-translation-fragment-supervisor",
"build-translation-flatten-supervisor",
"build-translation-fingerprints",
"build-translation-write-metadata"
)
gulp.series(toggleSupervisorFragment, "build-translations")
);

View File

@@ -99,7 +99,7 @@ gulp.task("webpack-watch-app", () => {
).watch({ poll: isWsl }, doneHandler());
gulp.watch(
path.join(paths.translations_src, "en.json"),
gulp.series("create-translations", "copy-translations-app")
gulp.series("build-translations", "copy-translations-app")
);
});

View File

@@ -1,16 +0,0 @@
const path = require("path");
const fs = require("fs");
// Helper function to map recursively over files in a folder and it's subfolders
module.exports.mapFiles = function mapFiles(startPath, filter, mapFunc) {
const files = fs.readdirSync(startPath);
for (let i = 0; i < files.length; i++) {
const filename = path.join(startPath, files[i]);
const stat = fs.lstatSync(filename);
if (stat.isDirectory()) {
mapFiles(filename, filter, mapFunc);
} else if (filename.indexOf(filter) >= 0) {
mapFunc(filename);
}
}
};

View File

@@ -10,6 +10,7 @@ const WebpackBar = require("webpackbar");
const {
TransformAsyncModulesPlugin,
} = require("transform-async-modules-webpack-plugin");
const { dependencies } = require("../package.json");
const paths = require("./paths.cjs");
const bundle = require("./bundle.cjs");
@@ -156,11 +157,15 @@ const createWebpackConfig = ({
transform: (stats) => JSON.stringify(filterStats(stats)),
}),
!latestBuild &&
new TransformAsyncModulesPlugin({ browserslistEnv: "legacy" }),
new TransformAsyncModulesPlugin({
browserslistEnv: "legacy",
runtime: { version: dependencies["@babel/runtime"] },
}),
].filter(Boolean),
resolve: {
extensions: [".ts", ".js", ".json"],
alias: {
"lit/static-html$": "lit/static-html.js",
"lit/decorators$": "lit/decorators.js",
"lit/directive$": "lit/directive.js",
"lit/directives/until$": "lit/directives/until.js",

View File

@@ -1,4 +1,4 @@
import { format, startOfToday, startOfTomorrow } from "date-fns/esm";
import { format, startOfToday, startOfTomorrow } from "date-fns";
import {
EnergyInfo,
EnergyPreferences,

View File

@@ -136,7 +136,7 @@ export class DemoAutomationDescribeAction extends LitElement {
<div class="action">
<span>
${this._action
? describeAction(this.hass, [], this._action)
? describeAction(this.hass, [], [], [], this._action)
: "<invalid YAML>"}
</span>
<ha-yaml-editor
@@ -149,7 +149,7 @@ export class DemoAutomationDescribeAction extends LitElement {
${ACTIONS.map(
(conf) => html`
<div class="action">
<span>${describeAction(this.hass, [], conf as any)}</span>
<span>${describeAction(this.hass, [], [], [], conf as any)}</span>
<pre>${dump(conf)}</pre>
</div>
`

View File

@@ -161,12 +161,14 @@ const LABELS: LabelRegistryEntry[] = [
name: "Energy",
icon: null,
color: "yellow",
description: null,
},
{
label_id: "entertainment",
name: "Entertainment",
icon: "mdi:popcorn",
color: "blue",
description: null,
},
];

View File

@@ -2,6 +2,7 @@ import { html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, query } from "lit/decorators";
import { CoverEntityFeature } from "../../../../src/data/cover";
import { LightColorMode } from "../../../../src/data/light";
import { LockEntityFeature } from "../../../../src/data/lock";
import { VacuumEntityFeature } from "../../../../src/data/vacuum";
import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass";
@@ -20,6 +21,11 @@ const ENTITIES = [
getEntity("light", "unavailable", "unavailable", {
friendly_name: "Unavailable entity",
}),
getEntity("lock", "front_door", "locked", {
friendly_name: "Front Door Lock",
device_class: "lock",
supported_features: LockEntityFeature.OPEN,
}),
getEntity("climate", "thermostat", "heat", {
current_temperature: 73,
min_temp: 45,
@@ -138,6 +144,24 @@ const CONFIGS = [
- type: "color-temp"
`,
},
{
heading: "Lock commands feature",
config: `
- type: tile
entity: lock.front_door
features:
- type: "lock-commands"
`,
},
{
heading: "Lock open door feature",
config: `
- type: tile
entity: lock.front_door
features:
- type: "lock-open-door"
`,
},
{
heading: "Vacuum commands feature",
config: `

View File

@@ -36,6 +36,8 @@ const createConfigEntry = (
pref_disable_new_entities: false,
pref_disable_polling: false,
reason: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
...override,
});

View File

@@ -1,4 +1,7 @@
import { globIterate } from "glob";
import { availableParallelism } from "node:os";
process.env.UV_THREADPOOL_SIZE = availableParallelism();
const gulpImports = [];

View File

@@ -25,15 +25,15 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.24.1",
"@babel/runtime": "7.24.4",
"@braintree/sanitize-url": "7.0.1",
"@codemirror/autocomplete": "6.15.0",
"@codemirror/commands": "6.3.3",
"@codemirror/autocomplete": "6.16.0",
"@codemirror/commands": "6.5.0",
"@codemirror/language": "6.10.1",
"@codemirror/legacy-modes": "6.3.3",
"@codemirror/legacy-modes": "6.4.0",
"@codemirror/search": "6.5.6",
"@codemirror/state": "6.4.1",
"@codemirror/view": "6.26.1",
"@codemirror/view": "6.26.3",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.12.3",
"@formatjs/intl-displaynames": "6.6.6",
@@ -81,7 +81,7 @@
"@material/mwc-top-app-bar": "0.27.0",
"@material/mwc-top-app-bar-fixed": "0.27.0",
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
"@material/web": "=1.3.0",
"@material/web": "1.4.1",
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@polymer/paper-item": "3.0.1",
@@ -89,8 +89,8 @@
"@polymer/paper-tabs": "3.1.0",
"@polymer/polymer": "3.5.1",
"@thomasloven/round-slider": "0.6.0",
"@vaadin/combo-box": "24.3.10",
"@vaadin/vaadin-themable-mixin": "24.3.10",
"@vaadin/combo-box": "24.3.11",
"@vaadin/vaadin-themable-mixin": "24.3.11",
"@vibrant/color": "3.2.1-alpha.1",
"@vibrant/core": "3.2.1-alpha.1",
"@vibrant/quantizer-mmcq": "3.2.1-alpha.1",
@@ -101,17 +101,17 @@
"chart.js": "4.4.2",
"color-name": "2.0.0",
"comlink": "4.4.1",
"core-js": "3.36.1",
"cropperjs": "1.6.1",
"date-fns": "2.30.0",
"date-fns-tz": "2.0.1",
"core-js": "3.37.0",
"cropperjs": "1.6.2",
"date-fns": "3.6.0",
"date-fns-tz": "3.1.3",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"element-internals-polyfill": "1.3.10",
"element-internals-polyfill": "1.3.11",
"fuse.js": "7.0.0",
"google-timezones-json": "1.2.0",
"hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch",
"home-assistant-js-websocket": "9.2.1",
"home-assistant-js-websocket": "9.3.0",
"idb-keyval": "6.2.1",
"intl-messageformat": "10.5.11",
"js-yaml": "4.1.0",
@@ -119,7 +119,7 @@
"leaflet-draw": "1.0.4",
"lit": "2.8.0",
"luxon": "3.4.4",
"marked": "12.0.1",
"marked": "12.0.2",
"memoize-one": "6.0.0",
"node-vibrant": "3.2.1-alpha.1",
"proxy-polyfill": "0.3.2",
@@ -150,18 +150,18 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.24.3",
"@babel/core": "7.24.4",
"@babel/helper-define-polyfill-provider": "0.6.1",
"@babel/plugin-proposal-decorators": "7.24.1",
"@babel/plugin-transform-runtime": "7.24.3",
"@babel/preset-env": "7.24.3",
"@babel/preset-env": "7.24.4",
"@babel/preset-typescript": "7.24.1",
"@bundle-stats/plugin-webpack-filter": "4.12.2",
"@koa/cors": "5.0.0",
"@lokalise/node-api": "12.3.0",
"@octokit/auth-oauth-device": "7.0.1",
"@octokit/plugin-retry": "7.0.3",
"@octokit/rest": "20.0.2",
"@lokalise/node-api": "12.4.0",
"@octokit/auth-oauth-device": "7.1.1",
"@octokit/plugin-retry": "7.1.0",
"@octokit/rest": "20.1.0",
"@open-wc/dev-server-hmr": "0.1.4",
"@rollup/plugin-babel": "6.0.4",
"@rollup/plugin-commonjs": "25.0.7",
@@ -169,24 +169,24 @@
"@rollup/plugin-node-resolve": "15.2.3",
"@rollup/plugin-replace": "5.0.5",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.13",
"@types/chromecast-caf-receiver": "6.0.14",
"@types/chromecast-caf-sender": "1.0.9",
"@types/color-name": "1.1.3",
"@types/color-name": "1.1.4",
"@types/glob": "8.1.0",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.8",
"@types/leaflet": "1.9.11",
"@types/leaflet-draw": "1.0.11",
"@types/luxon": "3.4.2",
"@types/mocha": "10.0.6",
"@types/qrcode": "1.5.5",
"@types/serve-handler": "6.1.4",
"@types/sortablejs": "1.15.8",
"@types/tar": "6.1.11",
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@typescript-eslint/eslint-plugin": "7.4.0",
"@typescript-eslint/parser": "7.4.0",
"@typescript-eslint/eslint-plugin": "7.7.0",
"@typescript-eslint/parser": "7.7.0",
"@web/dev-server": "0.1.38",
"@web/dev-server-rollup": "0.4.1",
"babel-loader": "9.1.3",
@@ -203,12 +203,11 @@
"eslint-plugin-lit": "1.11.0",
"eslint-plugin-lit-a11y": "4.1.2",
"eslint-plugin-unused-imports": "3.1.0",
"eslint-plugin-wc": "2.0.4",
"eslint-plugin-wc": "2.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.2.0",
"glob": "10.3.10",
"glob": "10.3.12",
"gulp": "4.0.2",
"gulp-flatmap": "1.0.2",
"gulp-json-transform": "0.5.0",
"gulp-merge-json": "2.2.1",
"gulp-rename": "2.0.0",
@@ -220,9 +219,9 @@
"lint-staged": "15.2.2",
"lit-analyzer": "2.0.3",
"lodash.template": "4.5.0",
"magic-string": "0.30.8",
"magic-string": "0.30.10",
"map-stream": "0.0.7",
"mocha": "10.3.0",
"mocha": "10.4.0",
"object-hash": "3.0.0",
"open": "10.1.0",
"pinst": "3.0.0",
@@ -235,13 +234,11 @@
"sinon": "17.0.1",
"source-map-url": "0.4.1",
"systemjs": "6.14.3",
"tar": "6.2.1",
"tar": "7.0.1",
"terser-webpack-plugin": "5.3.10",
"transform-async-modules-webpack-plugin": "1.0.4",
"transform-async-modules-webpack-plugin": "1.1.0",
"ts-lit-plugin": "2.0.2",
"typescript": "5.4.3",
"vinyl-buffer": "1.0.1",
"vinyl-source-stream": "2.0.0",
"typescript": "5.4.5",
"webpack": "5.91.0",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.0.4",

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20240403.1"
version = "20240424.1"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
readme = "README.md"

View File

@@ -40,6 +40,11 @@
"matchPackageNames": ["tsparticles-engine"],
"matchPackagePrefixes": ["tsparticles-preset-"]
},
{
"description": "Group date-fns with dependent timezone package",
"groupName": "date-fns",
"matchPackageNames": ["date-fns", "date-fns-tz"]
},
{
"description": "Group and temporarily disable WDS packages",
"groupName": "Web Dev Server",

View File

@@ -1,4 +1,4 @@
import { utcToZonedTime, zonedTimeToUtc } from "date-fns-tz";
import { toZonedTime, fromZonedTime } from "date-fns-tz";
import { HassConfig } from "home-assistant-js-websocket";
import { FrontendLocaleData, TimeZone } from "../../data/translation";
@@ -8,10 +8,10 @@ const calcZonedDate = (
fn: (date: Date, options?: any) => Date | number | boolean,
options?
) => {
const inputZoned = utcToZonedTime(date, tz);
const inputZoned = toZonedTime(date, tz);
const fnZoned = fn(inputZoned, options);
if (fnZoned instanceof Date) {
return zonedTimeToUtc(fnZoned, tz) as Date;
return fromZonedTime(fnZoned, tz) as Date;
}
return fnZoned;
};
@@ -51,6 +51,6 @@ export const calcDateDifferenceProperty = (
locale,
config,
locale.time_zone === TimeZone.server
? utcToZonedTime(startDate, config.time_zone)
? toZonedTime(startDate, config.time_zone)
: startDate
);

View File

@@ -187,11 +187,14 @@ export const computeStateDisplayFromEntityAttributes = (
if (
[
"button",
"conversation",
"event",
"image",
"input_button",
"notify",
"scene",
"stt",
"tag",
"tts",
"wake_word",
].includes(domain) ||

View File

@@ -2,6 +2,7 @@ import IntlMessageFormat from "intl-messageformat";
import type { HTMLTemplateResult } from "lit";
import { polyfillLocaleData } from "../../resources/locale-data-polyfill";
import { Resources, TranslationDict } from "../../types";
import { fireEvent } from "../dom/fire_event";
// Exclude some patterns from key type checking for now
// These are intended to be removed as errors are fixed
@@ -81,7 +82,9 @@ export interface FormatsType {
*/
export const computeLocalize = async <Keys extends string = LocalizeKeys>(
cache: any,
cache: HTMLElement & {
_localizationCache?: Record<string, IntlMessageFormat>;
},
language: string,
resources: Resources,
formats?: FormatsType
@@ -107,7 +110,7 @@ export const computeLocalize = async <Keys extends string = LocalizeKeys>(
}
const messageKey = key + translatedValue;
let translatedMessage = cache._localizationCache[messageKey] as
let translatedMessage = cache._localizationCache![messageKey] as
| IntlMessageFormat
| undefined;
@@ -121,7 +124,7 @@ export const computeLocalize = async <Keys extends string = LocalizeKeys>(
} catch (err: any) {
return "Translation error: " + err.message;
}
cache._localizationCache[messageKey] = translatedMessage;
cache._localizationCache![messageKey] = translatedMessage;
}
let argObject = {};
@@ -137,6 +140,12 @@ export const computeLocalize = async <Keys extends string = LocalizeKeys>(
try {
return translatedMessage.format<string>(argObject) as string;
} catch (err: any) {
// eslint-disable-next-line no-console
console.error("Translation error", key, language, err);
fireEvent(cache, "write_log", {
level: "error",
message: `Failed to format translation for key '${key}' in language '${language}'. ${err}`,
});
return "Translation " + err;
}
};

View File

@@ -0,0 +1,9 @@
export const hasRejectedItems = <T = any>(results: PromiseSettledResult<T>[]) =>
results.some((result) => result.status === "rejected");
export const rejectedItems = <T = any>(
results: PromiseSettledResult<T>[]
): PromiseRejectedResult[] =>
results.filter(
(result) => result.status === "rejected"
) as PromiseRejectedResult[];

View File

@@ -1,4 +1,4 @@
import { differenceInDays, differenceInWeeks, startOfWeek } from "date-fns/esm";
import { differenceInDays, differenceInWeeks, startOfWeek } from "date-fns";
import { FrontendLocaleData } from "../../data/translation";
import { firstWeekdayIndex } from "../datetime/first_weekday";

View File

@@ -34,7 +34,7 @@ import {
endOfMonth,
endOfQuarter,
endOfYear,
} from "date-fns/esm";
} from "date-fns";
import {
formatDate,
formatDateMonth,

View File

@@ -1,13 +1,13 @@
import { mdiArrowDown, mdiArrowUp } from "@mdi/js";
import { mdiArrowDown, mdiArrowUp, mdiChevronDown } from "@mdi/js";
import deepClone from "deep-clone-simple";
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
TemplateResult,
css,
html,
nothing,
} from "lit";
import {
customElement,
@@ -22,7 +22,9 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import { debounce } from "../../common/util/debounce";
import { groupBy } from "../../common/util/group-by";
import { nextRender } from "../../common/util/render-status";
import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
@@ -32,17 +34,6 @@ import type { HaCheckbox } from "../ha-checkbox";
import "../ha-svg-icon";
import "../search-input";
import { filterData, sortData } from "./sort-filter";
import { groupBy } from "../../common/util/group-by";
import { stringCompare } from "../../common/string/compare";
declare global {
// for fire event
interface HASSDomEvents {
"selection-changed": SelectionChangedEvent;
"row-click": RowClickedEvent;
"sorting-changed": SortingChangedEvent;
}
}
export interface RowClickedEvent {
id: string;
@@ -52,6 +43,10 @@ export interface SelectionChangedEvent {
value: string[];
}
export interface CollapsedChangedEvent {
value: string[];
}
export interface SortingChangedEvent {
column: string;
direction: SortingDirection;
@@ -142,10 +137,14 @@ export class HaDataTable extends LitElement {
@property() public groupColumn?: string;
@property({ attribute: false }) public groupOrder?: string[];
@property() public sortColumn?: string;
@property() public sortDirection: SortingDirection = null;
@property({ attribute: false }) public initialCollapsedGroups?: string[];
@state() private _filterable = false;
@state() private _filter = "";
@@ -158,6 +157,8 @@ export class HaDataTable extends LitElement {
@state() private _items: DataTableRowData[] = [];
@state() private _collapsedGroups: string[] = [];
private _checkableRowsCount?: number;
private _checkedRows: string[] = [];
@@ -213,17 +214,19 @@ export class HaDataTable extends LitElement {
(column) => column.filterable
);
for (const columnId in this.columns) {
if (this.columns[columnId].direction) {
this.sortDirection = this.columns[columnId].direction!;
this.sortColumn = columnId;
if (!this.sortColumn) {
for (const columnId in this.columns) {
if (this.columns[columnId].direction) {
this.sortDirection = this.columns[columnId].direction!;
this.sortColumn = columnId;
fireEvent(this, "sorting-changed", {
column: columnId,
direction: this.sortDirection,
});
fireEvent(this, "sorting-changed", {
column: columnId,
direction: this.sortDirection,
});
break;
break;
}
}
}
@@ -248,13 +251,23 @@ export class HaDataTable extends LitElement {
).length;
}
if (!this.hasUpdated && this.initialCollapsedGroups) {
this._collapsedGroups = this.initialCollapsedGroups;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
} else if (properties.has("groupColumn")) {
this._collapsedGroups = [];
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
}
if (
properties.has("data") ||
properties.has("columns") ||
properties.has("_filter") ||
properties.has("sortColumn") ||
properties.has("sortDirection") ||
properties.has("groupColumn")
properties.has("groupColumn") ||
properties.has("groupOrder") ||
properties.has("_collapsedGroups")
) {
this._sortFilterData();
}
@@ -447,6 +460,8 @@ export class HaDataTable extends LitElement {
}
return html`
<div
@mouseover=${this._setTitle}
@focus=${this._setTitle}
role=${column.main ? "rowheader" : "cell"}
class="mdc-data-table__cell ${classMap({
"mdc-data-table__cell--flex": column.type === "flex",
@@ -514,11 +529,7 @@ export class HaDataTable extends LitElement {
}
if (this.appendRow || this.hasFab || this.groupColumn) {
const items = [...data];
if (this.appendRow) {
items.push({ append: true, content: this.appendRow });
}
let items = [...data];
if (this.groupColumn) {
const grouped = groupBy(items, (item) => item[this.groupColumn!]);
@@ -530,13 +541,24 @@ export class HaDataTable extends LitElement {
const sorted: {
[key: string]: DataTableRowData[];
} = Object.keys(grouped)
.sort((a, b) =>
stringCompare(
.sort((a, b) => {
const orderA = this.groupOrder?.indexOf(a) ?? -1;
const orderB = this.groupOrder?.indexOf(b) ?? -1;
if (orderA !== orderB) {
if (orderA === -1) {
return 1;
}
if (orderB === -1) {
return -1;
}
return orderA - orderB;
}
return stringCompare(
["", "-", "—"].includes(a) ? "zzz" : a,
["", "-", "—"].includes(b) ? "zzz" : b,
this.hass.locale.language
)
)
);
})
.reduce((obj, key) => {
obj[key] = grouped[key];
return obj;
@@ -552,23 +574,39 @@ export class HaDataTable extends LitElement {
content: html`<div
class="mdc-data-table__cell group-header"
role="cell"
.group=${groupName}
@click=${this._collapseGroup}
>
${groupName === UNDEFINED_GROUP_KEY ? "" : groupName || ""}
<ha-icon-button
.path=${mdiChevronDown}
class=${this._collapsedGroups.includes(groupName)
? "collapsed"
: ""}
>
</ha-icon-button>
${groupName === UNDEFINED_GROUP_KEY
? this.hass.localize("ui.components.data-table.ungrouped")
: groupName || ""}
</div>`,
});
}
groupedItems.push(...rows);
if (!this._collapsedGroups.includes(groupName)) {
groupedItems.push(...rows);
}
});
this._items = groupedItems;
} else {
this._items = items;
items = groupedItems;
}
if (this.appendRow) {
items.push({ append: true, content: this.appendRow });
}
if (this.hasFab) {
this._items = [...this._items, { empty: true }];
items.push({ empty: true });
}
this._items = items;
} else {
this._items = data;
}
@@ -649,6 +687,13 @@ export class HaDataTable extends LitElement {
fireEvent(this, "row-click", { id: rowId }, { bubbles: false });
};
private _setTitle(ev: Event) {
const target = ev.currentTarget as HTMLElement;
if (target.scrollWidth > target.offsetWidth) {
target.setAttribute("title", target.innerText);
}
}
private _checkedRowsChanged() {
// force scroller to update, change it's items
if (this._items.length) {
@@ -679,6 +724,18 @@ export class HaDataTable extends LitElement {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
private _collapseGroup = (ev: Event) => {
const groupName = (ev.currentTarget as any).group;
if (this._collapsedGroups.includes(groupName)) {
this._collapsedGroups = this._collapsedGroups.filter(
(grp) => grp !== groupName
);
} else {
this._collapsedGroups = [...this._collapsedGroups, groupName];
}
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
};
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
@@ -931,8 +988,21 @@ export class HaDataTable extends LitElement {
.group-header {
padding-top: 12px;
padding-left: 12px;
padding-inline-start: 12px;
width: 100%;
font-weight: 500;
display: flex;
align-items: center;
cursor: pointer;
}
.group-header ha-icon-button {
transition: transform 0.2s ease;
}
.group-header ha-icon-button.collapsed {
transform: rotate(180deg);
}
:host {
@@ -1031,4 +1101,12 @@ declare global {
interface HTMLElementTagNameMap {
"ha-data-table": HaDataTable;
}
// for fire event
interface HASSDomEvents {
"selection-changed": SelectionChangedEvent;
"row-click": RowClickedEvent;
"sorting-changed": SortingChangedEvent;
"collapsed-changed": CollapsedChangedEvent;
}
}

View File

@@ -11,10 +11,10 @@ import {
} from "../common/datetime/localize_date";
import { mainWindow } from "../common/dom/get_main_window";
// Set the current date to the left picker instead of the right picker because the right is hidden
const CustomDateRangePicker = Vue.extend({
mixins: [DateRangePicker],
methods: {
// Set the current date to the left picker instead of the right picker because the right is hidden
selectMonthDate() {
const dt: Date = this.end || new Date();
// @ts-ignore
@@ -23,6 +23,33 @@ const CustomDateRangePicker = Vue.extend({
month: dt.getMonth() + 1,
});
},
// Fix the start/end date calculation when selecting a date range. The
// original code keeps track of the first clicked date (in_selection) but it
// never sets it to either the start or end date variables, so if the
// in_selection date is between the start and end date that were set by the
// hover the selection will enter a broken state that's counter-intuitive
// when hovering between weeks and leads to a random date when selecting a
// range across months. This bug doesn't seem to be present on v0.6.7 of the
// lib
hoverDate(value: Date) {
if (this.readonly) return;
if (this.in_selection) {
const pickA = this.in_selection as Date;
const pickB = value;
this.start = this.normalizeDatetime(
Math.min(pickA.valueOf(), pickB.valueOf()),
this.start
);
this.end = this.normalizeDatetime(
Math.max(pickA.valueOf(), pickB.valueOf()),
this.end
);
}
this.$emit("hover-date", value);
},
},
});

View File

@@ -18,6 +18,12 @@ import "../ha-icon-button";
import "../ha-svg-icon";
import "./state-badge";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
import { domainToName } from "../../data/integration";
import {
isHelperDomain,
HelperDomain,
} from "../../panels/config/helpers/const";
interface HassEntityWithCachedName extends HassEntity, ScorableTextItem {
friendly_name: string;
@@ -25,6 +31,8 @@ interface HassEntityWithCachedName extends HassEntity, ScorableTextItem {
export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean;
const CREATE_ID = "___create-new-entity___";
@customElement("ha-entity-picker")
export class HaEntityPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -44,6 +52,8 @@ export class HaEntityPicker extends LitElement {
@property() public helper?: string;
@property({ type: Array }) public createDomains?: string[];
/**
* Show entities from specific domains.
* @type {Array}
@@ -130,7 +140,11 @@ export class HaEntityPicker extends LitElement {
></state-badge>`
: ""}
<span>${item.friendly_name}</span>
<span slot="secondary">${item.entity_id}</span>
<span slot="secondary"
>${item.entity_id.startsWith(CREATE_ID)
? this.hass.localize("ui.components.entity.entity-picker.new_entity")
: item.entity_id}</span
>
</ha-list-item>`;
private _getStates = memoizeOne(
@@ -143,7 +157,8 @@ export class HaEntityPicker extends LitElement {
includeDeviceClasses: this["includeDeviceClasses"],
includeUnitOfMeasurement: this["includeUnitOfMeasurement"],
includeEntities: this["includeEntities"],
excludeEntities: this["excludeEntities"]
excludeEntities: this["excludeEntities"],
createDomains: this["createDomains"]
): HassEntityWithCachedName[] => {
let states: HassEntityWithCachedName[] = [];
@@ -152,6 +167,34 @@ export class HaEntityPicker extends LitElement {
}
let entityIds = Object.keys(hass.states);
const createItems = createDomains?.length
? createDomains.map((domain) => {
const newFriendlyName = hass.localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? hass.localize(
`ui.panel.config.helpers.types.${domain as HelperDomain}`
)
: domainToName(hass.localize, domain),
}
);
return {
entity_id: CREATE_ID + domain,
state: "on",
last_changed: "",
last_updated: "",
context: { id: "", user_id: null, parent_id: null },
friendly_name: newFriendlyName,
attributes: {
icon: "mdi:plus",
},
strings: [domain, newFriendlyName],
};
})
: [];
if (!entityIds.length) {
return [
{
@@ -171,6 +214,7 @@ export class HaEntityPicker extends LitElement {
},
strings: [],
},
...createItems,
];
}
@@ -281,9 +325,14 @@ export class HaEntityPicker extends LitElement {
},
strings: [],
},
...createItems,
];
}
if (createItems?.length) {
states.push(...createItems);
}
return states;
}
);
@@ -310,13 +359,18 @@ export class HaEntityPicker extends LitElement {
this.includeDeviceClasses,
this.includeUnitOfMeasurement,
this.includeEntities,
this.excludeEntities
this.excludeEntities,
this.createDomains
);
if (this._initedStates) {
this.comboBox.filteredItems = this._states;
}
this._initedStates = true;
}
if (changedProps.has("createDomains") && this.createDomains?.length) {
this.hass.loadFragmentTranslation("config");
}
}
protected render(): TemplateResult {
@@ -354,6 +408,18 @@ export class HaEntityPicker extends LitElement {
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;
if (newValue && newValue.startsWith(CREATE_ID)) {
const domain = newValue.substring(CREATE_ID.length);
showHelperDetailDialog(this, {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) this._setValue(item.entityId);
},
});
return;
}
if (newValue !== this._value) {
this._setValue(newValue);
}

View File

@@ -14,6 +14,8 @@ export class HaCard extends LitElement {
--ha-card-background,
var(--card-background-color, white)
);
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
backdrop-filter: var(--ha-card-backdrop-filter, none);
box-shadow: var(--ha-card-box-shadow, none);
box-sizing: border-box;
border-radius: var(--ha-card-border-radius, 12px);

View File

@@ -1,6 +1,6 @@
import "element-internals-polyfill";
import { MdCircularProgress } from "@material/web/progress/circular-progress";
import { CSSResult, PropertyValues, css } from "lit";
import { PropertyValues, css } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-circular-progress")
@@ -32,17 +32,15 @@ export class HaCircularProgress extends MdCircularProgress {
}
}
static get styles(): CSSResult[] {
return [
...super.styles,
css`
:host {
--md-sys-color-primary: var(--primary-color);
--md-circular-progress-size: 48px;
}
`,
];
}
static override styles = [
...super.styles,
css`
:host {
--md-sys-color-primary: var(--primary-color);
--md-circular-progress-size: 48px;
}
`,
];
}
declare global {

View File

@@ -67,6 +67,9 @@ export class HaControlSlider extends LitElement {
@property({ attribute: "tooltip-mode" })
public tooltipMode: TooltipMode = "interaction";
@property({ attribute: "touch-action" })
public touchAction?: string;
@property({ type: Number })
public value?: number;
@@ -152,7 +155,7 @@ export class HaControlSlider extends LitElement {
setupListeners() {
if (this.slider && !this._mc) {
this._mc = new Manager(this.slider, {
touchAction: this.vertical ? "pan-x" : "pan-y",
touchAction: this.touchAction ?? (this.vertical ? "pan-x" : "pan-y"),
});
this._mc.add(
new Pan({

View File

@@ -33,6 +33,9 @@ export class HaControlSwitch extends LitElement {
// SVG icon path (if you need a non SVG icon instead, use the provided off icon slot to pass an <ha-icon slot="icon-off"> in)
@property({ type: String }) pathOff?: string;
@property({ attribute: "touch-action" })
public touchAction?: string;
private _mc?: HammerManager;
protected firstUpdated(changedProperties: PropertyValues): void {
@@ -73,7 +76,7 @@ export class HaControlSwitch extends LitElement {
setupListeners() {
if (this.switch && !this._mc) {
this._mc = new Manager(this.switch, {
touchAction: this.vertical ? "pan-x" : "pan-y",
touchAction: this.touchAction ?? (this.vertical ? "pan-x" : "pan-y"),
});
this._mc.add(
new Swipe({

View File

@@ -75,8 +75,14 @@ export class HaDialog extends DialogBase {
var(--divider-color)
);
z-index: var(--dialog-z-index, 8);
-webkit-backdrop-filter: var(--dialog-backdrop-filter, none);
backdrop-filter: var(--dialog-backdrop-filter, none);
-webkit-backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
);
backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
);
--mdc-dialog-box-shadow: var(--dialog-box-shadow, none);
--mdc-typography-headline6-font-weight: 400;
--mdc-typography-headline6-font-size: 1.574rem;
@@ -119,6 +125,8 @@ export class HaDialog extends DialogBase {
margin-top: var(--dialog-surface-margin-top);
min-height: var(--mdc-dialog-min-height, auto);
border-radius: var(--ha-dialog-border-radius, 28px);
-webkit-backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
}
:host([flexContent]) .mdc-dialog .mdc-dialog__content {
display: flex;

View File

@@ -69,7 +69,7 @@ export class HaFilterDevices extends LitElement {
@value-changed=${this._handleSearchChange}
>
</search-input-outlined>
<mwc-list class="ha-scrollbar">
<mwc-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._devices(
this.hass.devices,
@@ -94,7 +94,7 @@ export class HaFilterDevices extends LitElement {
? nothing
: html`<ha-check-list-item
.value=${device.id}
.selected=${this.value?.includes(device.id)}
.selected=${this.value?.includes(device.id) ?? false}
>
${computeDeviceName(device, this.hass)}
</ha-check-list-item>`;

View File

@@ -0,0 +1,198 @@
import { mdiFilterVariantRemove } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import { domainToName } from "../data/integration";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-domain-icon";
import "./search-input-outlined";
import { computeDomain } from "../common/entity/compute_domain";
@customElement("ha-filter-domains")
export class HaFilterDomains extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize(
"ui.panel.config.entities.picker.headers.domain"
)}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing}
</div>
${this._shouldRender
? html`<search-input-outlined
.hass=${this.hass}
.filter=${this._filter}
@value-changed=${this._handleSearchChange}
>
</search-input-outlined>
<mwc-list
class="ha-scrollbar"
@click=${this._handleItemClick}
multi
>
${repeat(
this._domains(this.hass.states, this._filter),
(i) => i,
(domain) =>
html`<ha-check-list-item
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.hass=${this.hass}
.domain=${domain}
brandFallback
></ha-domain-icon>
${domainToName(this.hass.localize, domain)}
</ha-check-list-item>`
)}
</mwc-list> `
: nothing}
</ha-expansion-panel>
`;
}
private _domains = memoizeOne((states, filter) => {
const domains = new Set<string>();
Object.keys(states).forEach((entityId) => {
domains.add(computeDomain(entityId));
});
return Array.from(domains)
.filter((domain) => !filter || domain.toLowerCase().includes(filter))
.sort((a, b) => stringCompare(a, b, this.hass.locale.language));
});
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49 - 32}px`; // 32px is the height of the search input
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value.includes(value);
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: undefined,
});
}
private _clearFilter(ev) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value.toLowerCase();
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--primary-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
search-input-outlined {
display: block;
padding: 0 8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-domains": HaFilterDomains;
}
}

View File

@@ -71,7 +71,7 @@ export class HaFilterEntities extends LitElement {
@value-changed=${this._handleSearchChange}
>
</search-input-outlined>
<mwc-list class="ha-scrollbar">
<mwc-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._entities(
this.hass.states,
@@ -108,7 +108,7 @@ export class HaFilterEntities extends LitElement {
? nothing
: html`<ha-check-list-item
.value=${entity.entity_id}
.selected=${this.value?.includes(entity.entity_id)}
.selected=${this.value?.includes(entity.entity_id) ?? false}
graphic="icon"
>
<ha-state-icon

View File

@@ -1,4 +1,3 @@
import { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -57,9 +56,9 @@ export class HaFilterIntegrations extends LitElement {
>
</search-input-outlined>
<mwc-list
@selected=${this._integrationsSelected}
multi
class="ha-scrollbar"
@click=${this._handleItemClick}
multi
>
${repeat(
this._integrations(this._manifests, this._filter, this.value),
@@ -131,34 +130,21 @@ export class HaFilterIntegrations extends LitElement {
)
);
private async _integrationsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
) {
const integrations = this._integrations(
this._manifests!,
this._filter,
this.value
);
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const domain = integrations[index].domain;
value.push(domain);
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
this.value = value;
listItem.selected = this.value?.includes(value);
fireEvent(this, "data-table-filter-changed", {
value,
value: this.value,
items: undefined,
});
}

View File

@@ -62,8 +62,8 @@ export class HaFilterStates extends LitElement {
(item) =>
html`<ha-check-list-item
.value=${item.value}
.selected=${this.value?.includes(item.value)}
.graphic=${hasIcon ? "icon" : undefined}
.selected=${this.value?.includes(item.value) ?? false}
.graphic=${hasIcon ? "icon" : null}
>
${item.icon
? html`<ha-icon

View File

@@ -302,6 +302,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
name: this.hass.localize("ui.components.label-picker.no_match"),
icon: null,
color: null,
description: null,
},
];
}
@@ -315,6 +316,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
name: this.hass.localize("ui.components.label-picker.add_new"),
icon: "mdi:plus",
color: null,
description: null,
},
];
}

View File

@@ -1,25 +1,23 @@
import { customElement } from "lit/decorators";
import "element-internals-polyfill";
import { MdListItem } from "@material/web/list/list-item";
import { CSSResult, css } from "lit";
import { css } from "lit";
@customElement("ha-list-item-new")
export class HaListItemNew extends MdListItem {
static get styles(): CSSResult[] {
return [
...MdListItem.styles,
css`
:host {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-secondary: var(--secondary-text-color);
--md-sys-color-surface: var(--card-background-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color);
}
`,
];
}
static override styles = [
...super.styles,
css`
:host {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-secondary: var(--secondary-text-color);
--md-sys-color-surface: var(--card-background-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color);
}
`,
];
}
declare global {

View File

@@ -1,20 +1,18 @@
import { customElement } from "lit/decorators";
import "element-internals-polyfill";
import { MdList } from "@material/web/list/list";
import { CSSResult, css } from "lit";
import { css } from "lit";
@customElement("ha-list-new")
export class HaListNew extends MdList {
static get styles(): CSSResult[] {
return [
...MdList.styles,
css`
:host {
--md-sys-color-surface: var(--card-background-color);
}
`,
];
}
static override styles = [
...super.styles,
css`
:host {
--md-sys-color-surface: var(--card-background-color);
}
`,
];
}
declare global {

View File

@@ -1,12 +1,12 @@
import { MdMenuItem } from "@material/web/menu/menu-item";
import "element-internals-polyfill";
import { CSSResult, css } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-menu-item")
export class HaMenuItem extends MdMenuItem {
static override styles: CSSResult[] = [
...MdMenuItem.styles,
static override styles = [
...super.styles,
css`
:host {
--ha-icon-display: block;

View File

@@ -1,12 +1,12 @@
import { customElement } from "lit/decorators";
import "element-internals-polyfill";
import { CSSResult, css } from "lit";
import { css } from "lit";
import { MdMenu } from "@material/web/menu/menu";
@customElement("ha-menu")
export class HaMenu extends MdMenu {
static override styles: CSSResult[] = [
...MdMenu.styles,
static override styles = [
...super.styles,
css`
:host {
--md-sys-color-surface-container: var(--card-background-color);

View File

@@ -0,0 +1,40 @@
import { MdOutlinedField } from "@material/web/field/outlined-field";
import "element-internals-polyfill";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { literal } from "lit/static-html";
@customElement("ha-outlined-field")
export class HaOutlinedField extends MdOutlinedField {
protected readonly fieldTag = literal`ha-outlined-field`;
static override styles = [
...super.styles,
css`
.container::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--ha-outlined-field-container-color, transparent);
opacity: var(--ha-outlined-field-container-opacity, 1);
border-start-start-radius: var(--_container-shape-start-start);
border-start-end-radius: var(--_container-shape-start-end);
border-end-start-radius: var(--_container-shape-end-start);
border-end-end-radius: var(--_container-shape-end-end);
}
.with-start .start {
margin-inline-end: var(--ha-outlined-field-start-margin, 4px);
}
.with-end .end {
margin-inline-start: var(--ha-outlined-field-end-margin, 4px);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-outlined-field": HaOutlinedField;
}
}

View File

@@ -2,9 +2,13 @@ import { MdOutlinedTextField } from "@material/web/textfield/outlined-text-field
import "element-internals-polyfill";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { literal } from "lit/static-html";
import "./ha-outlined-field";
@customElement("ha-outlined-text-field")
export class HaOutlinedTextField extends MdOutlinedTextField {
protected readonly fieldTag = literal`ha-outlined-field`;
static override styles = [
...super.styles,
css`
@@ -25,16 +29,10 @@ export class HaOutlinedTextField extends MdOutlinedTextField {
--md-outlined-field-container-shape-end-end: 10px;
--md-outlined-field-container-shape-end-start: 10px;
--md-outlined-field-focus-outline-width: 1px;
--ha-outlined-field-start-margin: -4px;
--ha-outlined-field-end-margin: -4px;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
}
md-outlined-field {
background: var(--ha-outlined-text-field-container-color, transparent);
opacity: var(--ha-outlined-text-field-container-opacity, 1);
border-start-start-radius: var(--_container-shape-start-start);
border-start-end-radius: var(--_container-shape-start-end);
border-end-end-radius: var(--_container-shape-end-end);
border-end-start-radius: var(--_container-shape-end-start);
}
.input {
font-family: Roboto, sans-serif;
}

View File

@@ -7,8 +7,10 @@ import type {
LocationSelectorValue,
} from "../../data/selector";
import type { HomeAssistant } from "../../types";
import type { SchemaUnion } from "../ha-form/types";
import type { MarkerLocation } from "../map/ha-locations-editor";
import "../map/ha-locations-editor";
import "../ha-form/ha-form";
@customElement("ha-selector-location")
export class HaLocationSelector extends LitElement {
@@ -24,6 +26,49 @@ export class HaLocationSelector extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false;
private _schema = memoizeOne(
(radius?: boolean, radius_readonly?: boolean) =>
[
{
name: "",
type: "grid",
schema: [
{
name: "latitude",
required: true,
selector: { number: { step: "any" } },
},
{
name: "longitude",
required: true,
selector: { number: { step: "any" } },
},
],
},
...(radius
? [
{
name: "radius",
required: true,
default: 1000,
disabled: !!radius_readonly,
selector: { number: { min: 0, step: 1, mode: "box" } as const },
} as const,
]
: []),
] as const
);
protected willUpdate() {
if (!this.value) {
this.value = {
latitude: this.hass.config.latitude,
longitude: this.hass.config.longitude,
radius: this.selector.location?.radius ? 1000 : undefined,
};
}
}
protected render() {
return html`
<p>${this.label ? this.label : ""}</p>
@@ -35,6 +80,17 @@ export class HaLocationSelector extends LitElement {
@location-updated=${this._locationChanged}
@radius-updated=${this._radiusChanged}
></ha-locations-editor>
<ha-form
.hass=${this.hass}
.schema=${this._schema(
this.selector.location?.radius,
this.selector.location?.radius_readonly
)}
.data=${this.value}
.computeLabel=${this._computeLabel}
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
@@ -66,7 +122,8 @@ export class HaLocationSelector extends LitElement {
? "mdi:map-marker-radius"
: "mdi:map-marker",
location_editable: true,
radius_editable: true,
radius_editable:
!!selector.location?.radius && !selector.location?.radius_readonly,
},
];
}
@@ -80,14 +137,39 @@ export class HaLocationSelector extends LitElement {
}
private _radiusChanged(ev: CustomEvent) {
const radius = ev.detail.radius;
const radius = Math.round(ev.detail.radius);
fireEvent(this, "value-changed", { value: { ...this.value, radius } });
}
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value;
const radius = Math.round(ev.detail.value.radius);
fireEvent(this, "value-changed", {
value: {
latitude: value.latitude,
longitude: value.longitude,
...(this.selector.location?.radius &&
!this.selector.location?.radius_readonly
? {
radius,
}
: {}),
},
});
}
private _computeLabel = (
entry: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(`ui.components.selectors.location.${entry.name}`);
static styles = css`
ha-locations-editor {
display: block;
height: 400px;
margin-bottom: 16px;
}
p {
margin-top: 0;

View File

@@ -82,6 +82,7 @@ export class HaTargetSelector extends LitElement {
.deviceFilter=${this._filterDevices}
.entityFilter=${this._filterEntities}
.disabled=${this.disabled}
.createDomains=${this.selector.target?.create_domains}
></ha-target-picker>`;
}

View File

@@ -33,6 +33,7 @@ import {
expandFloorTarget,
expandLabelTarget,
Selector,
TargetSelector,
} from "../data/selector";
import { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url";
@@ -43,6 +44,7 @@ import "./ha-service-picker";
import "./ha-settings-row";
import "./ha-yaml-editor";
import type { HaYamlEditor } from "./ha-yaml-editor";
import { isHelperDomain } from "../panels/config/helpers/const";
const attributeFilter = (values: any[], attribute: any) => {
if (typeof attribute === "object") {
@@ -363,6 +365,15 @@ export class HaServiceControl extends LitElement {
return false;
}
private _targetSelector = memoizeOne(
(targetSelector: TargetSelector | null | undefined, domain?: string) => {
const create_domains = isHelperDomain(domain) ? [domain] : undefined;
return targetSelector
? { target: { ...targetSelector, create_domains } }
: { target: { create_domains } };
}
);
protected render() {
const serviceData = this._getServiceInfo(
this._value?.service,
@@ -401,157 +412,152 @@ export class HaServiceControl extends LitElement {
)) ||
serviceData?.description;
return html`
${this.hidePicker
? nothing
: html`<ha-service-picker
return html`${this.hidePicker
? nothing
: html`<ha-service-picker
.hass=${this.hass}
.value=${this._value?.service}
.disabled=${this.disabled}
@value-changed=${this._serviceChanged}
></ha-service-picker>`}
${this.hideDescription
? nothing
: html`
<div class="description">
${description ? html`<p>${description}</p>` : ""}
${this._manifest
? html` <a
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
class="help-icon"
></ha-icon-button>
</a>`
: nothing}
</div>
`}
${serviceData && "target" in serviceData
? html`<ha-settings-row .narrow=${this.narrow}>
${hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""}
<span slot="heading"
>${this.hass.localize("ui.components.service-control.target")}</span
>
<span slot="description"
>${this.hass.localize(
"ui.components.service-control.target_description"
)}</span
><ha-selector
.hass=${this.hass}
.value=${this._value?.service}
.selector=${this._targetSelector(
serviceData.target as TargetSelector,
domain
)}
.disabled=${this.disabled}
@value-changed=${this._serviceChanged}
></ha-service-picker>`}
${this.hideDescription
? nothing
: html`
<div class="description">
${description ? html`<p>${description}</p>` : ""}
${this._manifest
? html` <a
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
class="help-icon"
></ha-icon-button>
</a>`
: nothing}
</div>
`}
${serviceData && "target" in serviceData
? html`<ha-settings-row .narrow=${this.narrow}>
${hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""}
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.components.service-control.target_description"
)}</span
><ha-selector
.hass=${this.hass}
.selector=${serviceData.target
? { target: serviceData.target }
: { target: {} }}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector
></ha-settings-row>`
: entityId
? html`<ha-entity-picker
.hass=${this.hass}
.disabled=${this.disabled}
.value=${this._value?.data?.entity_id}
.label=${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.entity_id.description`
) || entityId.description}
@value-changed=${this._entityPicked}
allow-custom-entity
></ha-entity-picker>`
: ""}
${shouldRenderServiceDataYaml
? html`<ha-yaml-editor
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector
></ha-settings-row>`
: entityId
? html`<ha-entity-picker
.hass=${this.hass}
.label=${this.hass.localize("ui.components.service-control.data")}
.name=${"data"}
.readOnly=${this.disabled}
.defaultValue=${this._value?.data}
@value-changed=${this._dataChanged}
></ha-yaml-editor>`
: filteredFields?.map((dataField) => {
const selector = dataField?.selector ?? { text: undefined };
const type = Object.keys(selector)[0];
const enhancedSelector = [
"action",
"condition",
"trigger",
].includes(type)
? {
[type]: {
...selector[type],
path: [dataField.key],
},
}
: selector;
.disabled=${this.disabled}
.value=${this._value?.data?.entity_id}
.label=${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.entity_id.description`
) || entityId.description}
@value-changed=${this._entityPicked}
allow-custom-entity
></ha-entity-picker>`
: ""}
${shouldRenderServiceDataYaml
? html`<ha-yaml-editor
.hass=${this.hass}
.label=${this.hass.localize("ui.components.service-control.data")}
.name=${"data"}
.readOnly=${this.disabled}
.defaultValue=${this._value?.data}
@value-changed=${this._dataChanged}
></ha-yaml-editor>`
: filteredFields?.map((dataField) => {
const selector = dataField?.selector ?? { text: undefined };
const type = Object.keys(selector)[0];
const enhancedSelector = ["action", "condition", "trigger"].includes(
type
)
? {
[type]: {
...selector[type],
path: [dataField.key],
},
}
: selector;
const showOptional = showOptionalToggle(dataField);
const showOptional = showOptionalToggle(dataField);
return dataField.selector &&
(!dataField.advanced ||
this.showAdvanced ||
(this._value?.data &&
this._value.data[dataField.key] !== undefined))
? html`<ha-settings-row .narrow=${this.narrow}>
${!showOptional
? hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""
: html`<ha-checkbox
.key=${dataField.key}
.checked=${this._checkedKeys.has(dataField.key) ||
(this._value?.data &&
this._value.data[dataField.key] !== undefined)}
.disabled=${this.disabled}
@change=${this._checkboxChanged}
slot="prefix"
></ha-checkbox>`}
<span slot="heading"
>${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.name`
) ||
dataField.name ||
dataField.key}</span
>
<span slot="description"
>${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description`
) || dataField?.description}</span
>
<ha-selector
.disabled=${this.disabled ||
(showOptional &&
!this._checkedKeys.has(dataField.key) &&
(!this._value?.data ||
this._value.data[dataField.key] === undefined))}
.hass=${this.hass}
.selector=${enhancedSelector}
.key=${dataField.key}
@value-changed=${this._serviceDataChanged}
.value=${this._value?.data
? this._value.data[dataField.key]
: undefined}
.placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback}
@item-moved=${this._itemMoved}
></ha-selector>
</ha-settings-row>`
: "";
})}
`;
return dataField.selector &&
(!dataField.advanced ||
this.showAdvanced ||
(this._value?.data &&
this._value.data[dataField.key] !== undefined))
? html`<ha-settings-row .narrow=${this.narrow}>
${!showOptional
? hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""
: html`<ha-checkbox
.key=${dataField.key}
.checked=${this._checkedKeys.has(dataField.key) ||
(this._value?.data &&
this._value.data[dataField.key] !== undefined)}
.disabled=${this.disabled}
@change=${this._checkboxChanged}
slot="prefix"
></ha-checkbox>`}
<span slot="heading"
>${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.name`
) ||
dataField.name ||
dataField.key}</span
>
<span slot="description"
>${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description`
) || dataField?.description}</span
>
<ha-selector
.disabled=${this.disabled ||
(showOptional &&
!this._checkedKeys.has(dataField.key) &&
(!this._value?.data ||
this._value.data[dataField.key] === undefined))}
.hass=${this.hass}
.selector=${enhancedSelector}
.key=${dataField.key}
@value-changed=${this._serviceDataChanged}
.value=${this._value?.data
? this._value.data[dataField.key]
: undefined}
.placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback}
@item-moved=${this._itemMoved}
></ha-selector>
</ha-settings-row>`
: "";
})} `;
}
private _localizeValueCallback = (key: string) => {

View File

@@ -1,7 +1,7 @@
import { customElement } from "lit/decorators";
import "element-internals-polyfill";
import { MdSlider } from "@material/web/slider/slider";
import { CSSResult, css } from "lit";
import { css } from "lit";
import { mainWindow } from "../common/dom/get_main_window";
@customElement("ha-slider")
@@ -11,8 +11,8 @@ export class HaSlider extends MdSlider {
this.dir = mainWindow.document.dir;
}
static override styles: CSSResult[] = [
...MdSlider.styles,
static override styles = [
...super.styles,
css`
:host {
--md-sys-color-primary: var(--primary-color);

View File

@@ -82,7 +82,7 @@ export class HaSortable extends LitElement {
public connectedCallback() {
super.connectedCallback();
this._shouldBeDestroy = false;
if (this.hasUpdated) {
if (this.hasUpdated && !this.disabled) {
this._createSortable();
}
}

View File

@@ -1,13 +1,17 @@
import { customElement } from "lit/decorators";
import "element-internals-polyfill";
import { CSSResult, css } from "lit";
import { css } from "lit";
import { MdSubMenu } from "@material/web/menu/sub-menu";
@customElement("ha-sub-menu")
// @ts-expect-error
export class HaSubMenu extends MdSubMenu {
static override styles: CSSResult[] = [
MdSubMenu.styles,
async show() {
super.show();
this.menu.hasOverflow = false;
}
static override styles = [
...super.styles,
css`
:host {
--ha-icon-display: block;

View File

@@ -65,6 +65,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property() public helper?: string;
@property({ type: Array }) public createDomains?: string[];
/**
* Show only targets with entities from specific domains.
* @type {Array}
@@ -468,6 +470,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains}
.excludeEntities=${ensureArray(this.value?.entity_id)}
.createDomains=${this.createDomains}
@value-changed=${this._targetPicked}
@click=${this._preventDefault}
allow-custom-entity

View File

@@ -19,7 +19,7 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, ThemeMode } from "../../types";
import "../ha-input-helper-text";
import "./ha-map";
import type { HaMap } from "./ha-map";
@@ -61,7 +61,8 @@ export class HaLocationsEditor extends LitElement {
@property({ type: Number }) public zoom = 16;
@property({ type: Boolean }) public darkMode = false;
@property({ attribute: "theme-mode", type: String })
public themeMode: ThemeMode = "auto";
@state() private _locationMarkers?: Record<string, Marker | Circle>;
@@ -133,7 +134,7 @@ export class HaLocationsEditor extends LitElement {
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
?darkMode=${this.darkMode}
.themeMode=${this.themeMode}
></ha-map>
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`

View File

@@ -1,32 +1,32 @@
import { isToday } from "date-fns";
import type {
Circle,
CircleMarker,
LatLngTuple,
LatLngExpression,
LatLngTuple,
Layer,
Map,
Marker,
Polyline,
} from "leaflet";
import { isToday } from "date-fns";
import { css, CSSResultGroup, PropertyValues, ReactiveElement } from "lit";
import { CSSResultGroup, PropertyValues, ReactiveElement, css } from "lit";
import { customElement, property, state } from "lit/decorators";
import { formatDateTime } from "../../common/datetime/format_date_time";
import {
formatTimeWeekday,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
import {
LeafletModuleType,
setupLeafletMap,
} from "../../common/dom/setup-leaflet-map";
import {
formatTimeWithSeconds,
formatTimeWeekday,
} from "../../common/datetime/format_time";
import { formatDateTime } from "../../common/datetime/format_date_time";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { loadPolyfillIfNeeded } from "../../resources/resize-observer.polyfill";
import { HomeAssistant } from "../../types";
import { HomeAssistant, ThemeMode } from "../../types";
import { isTouch } from "../../util/is_touch";
import "../ha-icon-button";
import "./ha-entity-marker";
import { isTouch } from "../../util/is_touch";
const getEntityId = (entity: string | HaMapEntity): string =>
typeof entity === "string" ? entity : entity.entity_id;
@@ -69,7 +69,8 @@ export class HaMap extends ReactiveElement {
@property({ type: Boolean }) public fitZones = false;
@property({ type: Boolean }) public darkMode = false;
@property({ attribute: "theme-mode", type: String })
public themeMode: ThemeMode = "auto";
@property({ type: Number }) public zoom = 14;
@@ -154,7 +155,7 @@ export class HaMap extends ReactiveElement {
}
if (
!changedProps.has("darkMode") &&
!changedProps.has("themeMode") &&
(!changedProps.has("hass") ||
(oldHass && oldHass.themes?.darkMode === this.hass.themes?.darkMode))
) {
@@ -163,12 +164,18 @@ export class HaMap extends ReactiveElement {
this._updateMapStyle();
}
private get _darkMode() {
return (
this.themeMode === "dark" ||
(this.themeMode === "auto" && Boolean(this.hass.themes.darkMode))
);
}
private _updateMapStyle(): void {
const darkMode = this.darkMode || (this.hass.themes.darkMode ?? false);
const forcedDark = this.darkMode;
const map = this.renderRoot.querySelector("#map");
map!.classList.toggle("dark", darkMode);
map!.classList.toggle("forced-dark", forcedDark);
map!.classList.toggle("dark", this._darkMode);
map!.classList.toggle("forced-dark", this.themeMode === "dark");
map!.classList.toggle("forced-light", this.themeMode === "light");
}
private async _loadMap(): Promise<void> {
@@ -398,8 +405,7 @@ export class HaMap extends ReactiveElement {
"--dark-primary-color"
);
const className =
this.darkMode || this.hass.themes.darkMode ? "dark" : "light";
const className = this._darkMode ? "dark" : "light";
for (const entity of this.entities) {
const stateObj = hass.states[getEntityId(entity)];
@@ -543,27 +549,30 @@ export class HaMap extends ReactiveElement {
background: #090909;
}
#map.forced-dark {
color: #ffffff;
--map-filter: invert(0.9) hue-rotate(170deg) brightness(1.5)
contrast(1.2) saturate(0.3);
}
#map.forced-light {
background: #ffffff;
color: #000000;
--map-filter: invert(0);
}
#map:active {
cursor: grabbing;
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
.light {
color: #000000;
}
.dark {
color: #ffffff;
}
.leaflet-tile-pane {
filter: var(--map-filter);
}
.dark .leaflet-bar a {
background-color: var(--card-background-color, #1c1c1c);
background-color: #1c1c1c;
color: #ffffff;
}
.dark .leaflet-bar a:hover {
background-color: #313131;
}
.leaflet-marker-draggable {
cursor: move !important;
}

View File

@@ -97,7 +97,7 @@ class SearchInputOutlined extends LitElement {
ha-outlined-text-field {
display: block;
width: 100%;
--ha-outlined-text-field-container-color: var(--card-background-color);
--ha-outlined-field-container-color: var(--card-background-color);
}
ha-svg-icon,
ha-icon-button {

View File

@@ -1,3 +1,4 @@
import { consume } from "@lit-labs/context";
import {
mdiAlertCircle,
mdiCircle,
@@ -6,14 +7,13 @@ import {
mdiProgressWrench,
mdiRecordCircleOutline,
} from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
css,
html,
nothing,
} from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -23,27 +23,31 @@ import { relativeTime } from "../../common/datetime/relative_time";
import { fireEvent } from "../../common/dom/fire_event";
import { toggleAttribute } from "../../common/dom/toggle_attribute";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../data/entity_registry";
floorsContext,
fullEntitiesContext,
labelsContext,
} from "../../data/context";
import { EntityRegistryEntry } from "../../data/entity_registry";
import { FloorRegistryEntry } from "../../data/floor_registry";
import { LabelRegistryEntry } from "../../data/label_registry";
import { LogbookEntry } from "../../data/logbook";
import {
ChooseAction,
ChooseActionChoice,
getActionType,
IfAction,
ParallelAction,
RepeatAction,
getActionType,
} from "../../data/script";
import { describeAction } from "../../data/script_i18n";
import {
ActionTraceStep,
AutomationTraceExtended,
ChooseActionTraceStep,
getDataFromPath,
IfActionTraceStep,
isTriggerPath,
TriggerTraceStep,
getDataFromPath,
isTriggerPath,
} from "../../data/trace";
import { HomeAssistant } from "../../types";
import "./ha-timeline";
@@ -200,6 +204,8 @@ class ActionRenderer {
constructor(
private hass: HomeAssistant,
private entityReg: EntityRegistryEntry[],
private labelReg: LabelRegistryEntry[],
private floorReg: FloorRegistryEntry[],
private entries: TemplateResult[],
private trace: AutomationTraceExtended,
private logbookRenderer: LogbookRenderer,
@@ -310,7 +316,14 @@ class ActionRenderer {
this._renderEntry(
path,
describeAction(this.hass, this.entityReg, data, actionType),
describeAction(
this.hass,
this.entityReg,
this.labelReg,
this.floorReg,
data,
actionType
),
undefined,
data.enabled === false
);
@@ -475,7 +488,13 @@ class ActionRenderer {
const name =
repeatConfig.alias ||
describeAction(this.hass, this.entityReg, repeatConfig);
describeAction(
this.hass,
this.entityReg,
this.labelReg,
this.floorReg,
repeatConfig
);
this._renderEntry(repeatPath, name, undefined, disabled);
@@ -631,15 +650,17 @@ export class HaAutomationTracer extends LitElement {
@property({ type: Boolean }) public allowPick = false;
@state() private _entityReg: EntityRegistryEntry[] = [];
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[];
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeEntityRegistry(this.hass.connection!, (entities) => {
this._entityReg = entities;
}),
];
}
@state()
@consume({ context: labelsContext, subscribe: true })
_labelReg!: LabelRegistryEntry[];
@state()
@consume({ context: floorsContext, subscribe: true })
_floorReg!: FloorRegistryEntry[];
protected render() {
if (!this.trace) {
@@ -657,6 +678,8 @@ export class HaAutomationTracer extends LitElement {
const actionRenderer = new ActionRenderer(
this.hass,
this._entityReg,
this._labelReg,
this._floorReg,
entries,
this.trace,
logbookRenderer,
@@ -774,6 +797,7 @@ export class HaAutomationTracer extends LitElement {
description: html`${this.hass.localize(
`ui.panel.config.automation.trace.messages.${message}`,
{
reason: this.trace.script_execution,
time: renderFinishedAt(),
executiontime: renderRuntime(),
}

View File

@@ -1,6 +1,8 @@
import { EntityFilter } from "../common/entity/entity_filter";
import { HomeAssistant } from "../types";
type StrictConnectionMode = "disabled" | "guard_page" | "drop_connection";
interface CloudStatusNotLoggedIn {
logged_in: false;
cloud: "disconnected" | "connecting" | "connected";
@@ -19,6 +21,7 @@ export interface CloudPreferences {
alexa_enabled: boolean;
remote_enabled: boolean;
remote_allow_remote_enable: boolean;
strict_connection: StrictConnectionMode;
google_secure_devices_pin: string | undefined;
cloudhooks: { [webhookId: string]: CloudWebhook };
alexa_report_state: boolean;
@@ -141,6 +144,7 @@ export const updateCloudPref = (
google_secure_devices_pin?: CloudPreferences["google_secure_devices_pin"];
tts_default_voice?: CloudPreferences["tts_default_voice"];
remote_allow_remote_enable?: CloudPreferences["remote_allow_remote_enable"];
strict_connection?: CloudPreferences["strict_connection"];
}
) =>
hass.callWS({

View File

@@ -23,6 +23,8 @@ export interface ConfigEntry {
pref_disable_polling: boolean;
disabled_by: "user" | null;
reason: string | null;
error_reason_translation_key: string | null;
error_reason_translation_placeholders: Record<string, string> | null;
}
export type ConfigEntryMutableParams = Partial<

View File

@@ -2,6 +2,8 @@ import { createContext } from "@lit-labs/context";
import { HassConfig } from "home-assistant-js-websocket";
import { HomeAssistant } from "../types";
import { EntityRegistryEntry } from "./entity_registry";
import { FloorRegistryEntry } from "./floor_registry";
import { LabelRegistryEntry } from "./label_registry";
export const connectionContext =
createContext<HomeAssistant["connection"]>("connection");
@@ -25,3 +27,7 @@ export const panelsContext = createContext<HomeAssistant["panels"]>("panels");
export const fullEntitiesContext =
createContext<EntityRegistryEntry[]>("extendedEntities");
export const floorsContext = createContext<FloorRegistryEntry[]>("floors");
export const labelsContext = createContext<LabelRegistryEntry[]>("labels");

View File

@@ -9,7 +9,7 @@ import {
startOfDay,
isFirstDayOfMonth,
isLastDayOfMonth,
} from "date-fns/esm";
} from "date-fns";
import { Collection, getCollection } from "home-assistant-js-websocket";
import {
calcDate,
@@ -95,6 +95,7 @@ export type EnergySolarForecasts = {
export interface DeviceConsumptionEnergyPreference {
// This is an ever increasing value
stat_consumption: string;
name?: string;
}
export interface FlowFromGridSourceEnergyPreference {

View File

@@ -422,7 +422,8 @@ export const computeHistory = (
entityIds: string[],
localize: LocalizeFunc,
sensorNumericalDeviceClasses: string[],
splitDeviceClasses = false
splitDeviceClasses = false,
forceNumeric = false
): HistoryResult => {
const lineChartDevices: { [unit: string]: HistoryStates } = {};
const timelineDevices: TimelineEntity[] = [];
@@ -468,6 +469,7 @@ export const computeHistory = (
let unit: string | undefined;
const isNumeric =
forceNumeric ||
isNumericFromDomain(domain) ||
(currentState != null &&
isNumericFromAttributes(currentState.attributes)) ||

View File

@@ -9,12 +9,14 @@ export interface LabelRegistryEntry {
name: string;
icon: string | null;
color: string | null;
description: string | null;
}
export interface LabelRegistryEntryMutableParams {
name: string;
icon?: string | null;
color?: string | null;
description?: string | null;
}
export const fetchLabelRegistry = (conn: Connection) =>

View File

@@ -5,9 +5,7 @@ import {
import { getExtendedEntityRegistryEntry } from "./entity_registry";
import { showEnterCodeDialog } from "../dialogs/enter-code/show-enter-code-dialog";
import { HomeAssistant } from "../types";
export const FORMAT_TEXT = "text";
export const FORMAT_NUMBER = "number";
import { UNAVAILABLE } from "./entity";
export const enum LockEntityFeature {
OPEN = 1,
@@ -24,6 +22,33 @@ export interface LockEntity extends HassEntityBase {
type ProtectedLockService = "lock" | "unlock" | "open";
export function isLocked(stateObj: LockEntity) {
return stateObj.state === "locked";
}
export function isUnlocking(stateObj: LockEntity) {
return stateObj.state === "unlocking";
}
export function isLocking(stateObj: LockEntity) {
return stateObj.state === "locking";
}
export function isJammed(stateObj: LockEntity) {
return stateObj.state === "jammed";
}
export function isAvailable(stateObj: LockEntity) {
if (stateObj.state === UNAVAILABLE) {
return false;
}
const assumedState = stateObj.attributes.assumed_state === true;
return (
assumedState ||
(!isLocking(stateObj) && !isUnlocking(stateObj) && !isJammed(stateObj))
);
}
export const callProtectedLockService = async (
element: HTMLElement,
hass: HomeAssistant,

View File

@@ -14,7 +14,9 @@ import {
computeEntityRegistryName,
entityRegistryById,
} from "./entity_registry";
import { FloorRegistryEntry } from "./floor_registry";
import { domainToName } from "./integration";
import { LabelRegistryEntry } from "./label_registry";
import {
ActionType,
ActionTypes,
@@ -40,6 +42,8 @@ const actionTranslationBaseKey =
export const describeAction = <T extends ActionType>(
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
labelRegistry: LabelRegistryEntry[],
floorRegistry: FloorRegistryEntry[],
action: ActionTypes[T],
actionType?: T,
ignoreAlias = false
@@ -48,6 +52,8 @@ export const describeAction = <T extends ActionType>(
return tryDescribeAction(
hass,
entityRegistry,
labelRegistry,
floorRegistry,
action,
actionType,
ignoreAlias
@@ -66,6 +72,8 @@ export const describeAction = <T extends ActionType>(
const tryDescribeAction = <T extends ActionType>(
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
labelRegistry: LabelRegistryEntry[],
floorRegistry: FloorRegistryEntry[],
action: ActionTypes[T],
actionType?: T,
ignoreAlias = false
@@ -82,10 +90,12 @@ const tryDescribeAction = <T extends ActionType>(
const targets: string[] = [];
if (config.target) {
for (const [key, label] of Object.entries({
for (const [key, name] of Object.entries({
area_id: "areas",
device_id: "devices",
entity_id: "entities",
floor_id: "floors",
label_id: "labels",
})) {
if (!(key in config.target)) {
continue;
@@ -99,7 +109,7 @@ const tryDescribeAction = <T extends ActionType>(
targets.push(
hass.localize(
`${actionTranslationBaseKey}.service.description.target_template`,
{ name: label }
{ name }
)
);
break;
@@ -147,6 +157,32 @@ const tryDescribeAction = <T extends ActionType>(
)
);
}
} else if (key === "floor_id") {
const floor = floorRegistry.find(
(flr) => flr.floor_id === targetThing
);
if (floor?.name) {
targets.push(floor.name);
} else {
targets.push(
hass.localize(
`${actionTranslationBaseKey}.service.description.target_unknown_floor`
)
);
}
} else if (key === "label_id") {
const label = labelRegistry.find(
(lbl) => lbl.label_id === targetThing
);
if (label?.name) {
targets.push(label.name);
} else {
targets.push(
hass.localize(
`${actionTranslationBaseKey}.service.description.target_unknown_label`
)
);
}
} else {
targets.push(targetThing);
}

View File

@@ -270,7 +270,11 @@ export interface LanguageSelector {
}
export interface LocationSelector {
location: { radius?: boolean; icon?: string } | null;
location: {
radius?: boolean;
radius_readonly?: boolean;
icon?: string;
} | null;
}
export interface LocationSelectorValue {
@@ -401,6 +405,7 @@ export interface TargetSelector {
target: {
entity?: EntitySelectorFilter | readonly EntitySelectorFilter[];
device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[];
create_domains?: string[];
} | null;
}

View File

@@ -11,6 +11,11 @@ export interface Zone {
radius?: number;
}
export interface HomeZoneMutableParams {
latitude: number;
longitude: number;
}
export interface ZoneMutableParams {
name: string;
icon?: string;

View File

@@ -78,6 +78,7 @@ class LightColorTempPicker extends LitElement {
return html`
<ha-control-slider
touch-action="none"
inverted
vertical
.value=${this._ctPickerValue}

View File

@@ -9,11 +9,12 @@ import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-outlined-icon-button";
import "../../../components/ha-state-icon";
import { UNAVAILABLE } from "../../../data/entity";
import {
LockEntity,
LockEntityFeature,
callProtectedLockService,
isAvailable,
isJammed,
} from "../../../data/lock";
import "../../../state-control/lock/ha-state-control-lock-toggle";
import type { HomeAssistant } from "../../../types";
@@ -85,15 +86,13 @@ class MoreInfoLock extends LitElement {
"--state-color": color,
};
const isJammed = this.stateObj.state === "jammed";
return html`
<ha-more-info-state-header
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-more-info-state-header>
<div class="controls" style=${styleMap(style)}>
${this.stateObj.state === "jammed"
${isJammed(this.stateObj)
? html`
<div class="status">
<span></span>
@@ -125,7 +124,7 @@ class MoreInfoLock extends LitElement {
`
: html`
<ha-control-button
.disabled=${this.stateObj.state === UNAVAILABLE}
.disabled=${!isAvailable(this.stateObj)}
class="open-button ${this._buttonState}"
@click=${this._open}
>
@@ -139,7 +138,7 @@ class MoreInfoLock extends LitElement {
: nothing}
</div>
<div>
${isJammed
${isJammed(this.stateObj)
? html`
<ha-control-button-group class="jammed">
<ha-control-button @click=${this._unlock}>

View File

@@ -1,4 +1,4 @@
import { startOfYesterday, subHours } from "date-fns/esm";
import { startOfYesterday, subHours } from "date-fns";
import { LitElement, PropertyValues, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";

View File

@@ -1,4 +1,4 @@
import { startOfYesterday } from "date-fns/esm";
import { startOfYesterday } from "date-fns";
import { css, html, LitElement, PropertyValues, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";

View File

@@ -9,7 +9,19 @@ import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { showAutomationEditor } from "../data/automation";
import { HomeAssistantMain } from "../layouts/home-assistant-main";
import type { EMIncomingMessageCommands } from "./external_messaging";
import type {
EMIncomingMessageBarCodeScanAborted,
EMIncomingMessageBarCodeScanResult,
EMIncomingMessageCommands,
} from "./external_messaging";
const barCodeListeners = new Set<
(
msg:
| EMIncomingMessageBarCodeScanResult
| EMIncomingMessageBarCodeScanAborted
) => boolean
>();
export const attachExternalToApp = (hassMainEl: HomeAssistantMain) => {
window.addEventListener("haptic", (ev) =>
@@ -24,6 +36,19 @@ export const attachExternalToApp = (hassMainEl: HomeAssistantMain) => {
);
};
export const addExternalBarCodeListener = (
listener: (
msg:
| EMIncomingMessageBarCodeScanResult
| EMIncomingMessageBarCodeScanAborted
) => boolean
) => {
barCodeListeners.add(listener);
return () => {
barCodeListeners.delete(listener);
};
};
const handleExternalMessage = (
hassMainEl: HomeAssistantMain,
msg: EMIncomingMessageCommands
@@ -88,6 +113,22 @@ const handleExternalMessage = (
success: true,
result: null,
});
} else if (msg.command === "bar_code/scan_result") {
barCodeListeners.forEach((listener) => listener(msg));
bus.fireMessage({
id: msg.id,
type: "result",
success: true,
result: null,
});
} else if (msg.command === "bar_code/aborted") {
barCodeListeners.forEach((listener) => listener(msg));
bus.fireMessage({
id: msg.id,
type: "result",
success: true,
result: null,
});
} else {
return false;
}

View File

@@ -37,9 +37,11 @@ interface EMOutgoingMessageConfigGet extends EMMessage {
interface EMOutgoingMessageBarCodeScan extends EMMessage {
type: "bar_code/scan";
title: string;
description: string;
alternative_option_label?: string;
payload: {
title: string;
description: string;
alternative_option_label?: string;
};
}
interface EMOutgoingMessageBarCodeClose extends EMMessage {
@@ -48,7 +50,9 @@ interface EMOutgoingMessageBarCodeClose extends EMMessage {
interface EMOutgoingMessageBarCodeNotify extends EMMessage {
type: "bar_code/notify";
message: string;
payload: {
message: string;
};
}
interface EMOutgoingMessageMatterCommission extends EMMessage {

View File

@@ -41,14 +41,6 @@ import type { HomeAssistant, Route } from "../types";
import "./hass-tabs-subpage";
import type { PageNavigation } from "./hass-tabs-subpage";
declare global {
// for fire event
interface HASSDomEvents {
"search-changed": { value: string };
"clear-filter": undefined;
}
}
@customElement("hass-tabs-subpage-data-table")
export class HaTabsSubpageDataTable extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -63,6 +55,8 @@ export class HaTabsSubpageDataTable extends LitElement {
@property({ type: Boolean, attribute: "main-page" }) public mainPage = false;
@property({ attribute: false }) public initialCollapsedGroups: string[] = [];
/**
* Object with the columns.
* @type {Object}
@@ -166,8 +160,15 @@ export class HaTabsSubpageDataTable extends LitElement {
@property({ type: Boolean }) public showFilters = false;
@property({ attribute: false }) public initialSorting?: {
column: string;
direction: SortingDirection;
};
@property() public initialGroupColumn?: string;
@property({ attribute: false }) public groupOrder?: string[];
@state() private _sortColumn?: string;
@state() private _sortDirection: SortingDirection = null;
@@ -190,9 +191,16 @@ export class HaTabsSubpageDataTable extends LitElement {
this._dataTable.clearSelection();
}
protected firstUpdated() {
protected willUpdate() {
if (this.hasUpdated) {
return;
}
if (this.initialGroupColumn) {
this._groupColumn = this.initialGroupColumn;
this._setGroupColumn(this.initialGroupColumn);
}
if (this.initialSorting) {
this._sortColumn = this.initialSorting.column;
this._sortDirection = this.initialSorting.direction;
}
}
@@ -418,6 +426,8 @@ export class HaTabsSubpageDataTable extends LitElement {
.sortColumn=${this._sortColumn}
.sortDirection=${this._sortDirection}
.groupColumn=${this._groupColumn}
.groupOrder=${this.groupOrder}
.initialCollapsedGroups=${this.initialCollapsedGroups}
>
${!this.narrow
? html`
@@ -496,7 +506,6 @@ export class HaTabsSubpageDataTable extends LitElement {
${this.showFilters && !showPane
? html`<ha-dialog
open
hideActions
.heading=${localize("ui.components.subpage-data-table.filters")}
>
<ha-dialog-header slot="heading">
@@ -523,8 +532,17 @@ export class HaTabsSubpageDataTable extends LitElement {
: nothing}
</ha-dialog-header>
<div class="filter-dialog-content">
<slot name="filter-pane"></slot></div
></ha-dialog>`
<slot name="filter-pane"></slot>
</div>
<div slot="primaryAction">
<ha-button @click=${this._toggleFilters}>
${this.hass.localize(
"ui.components.subpage-data-table.show_results",
{ number: this.data.length }
)}
</ha-button>
</div>
</ha-dialog>`
: nothing}
`;
}
@@ -552,10 +570,20 @@ export class HaTabsSubpageDataTable extends LitElement {
this._sortDirection = null;
}
this._sortColumn = this._sortDirection === null ? undefined : columnId;
fireEvent(this, "sorting-changed", {
column: columnId,
direction: this._sortDirection,
});
}
private _handleGroupBy(ev) {
this._groupColumn = ev.currentTarget.value;
this._setGroupColumn(ev.currentTarget.value);
}
private _setGroupColumn(columnId: string) {
this._groupColumn = columnId;
fireEvent(this, "grouping-changed", { value: columnId });
}
private _enableSelectMode() {
@@ -779,7 +807,6 @@ export class HaTabsSubpageDataTable extends LitElement {
}
ha-dialog {
--dialog-z-index: 100;
--mdc-dialog-min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left)
);
@@ -794,7 +821,7 @@ export class HaTabsSubpageDataTable extends LitElement {
}
.filter-dialog-content {
height: calc(100vh - 1px - var(--header-height));
height: calc(100vh - 1px - 61px - var(--header-height));
display: flex;
flex-direction: column;
}
@@ -812,4 +839,11 @@ declare global {
interface HTMLElementTagNameMap {
"hass-tabs-subpage-data-table": HaTabsSubpageDataTable;
}
// for fire event
interface HASSDomEvents {
"search-changed": { value: string };
"grouping-changed": { value: string };
"clear-filter": undefined;
}
}

View File

@@ -169,10 +169,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// @ts-ignore
this._loadHassTranslations(this.hass!.language, "entity");
// Backwards compatibility for custom integrations
// @ts-ignore
this._loadHassTranslations(this.hass!.language, "state");
document.addEventListener(
"visibilitychange",
() => this._checkVisibility(),

View File

@@ -41,7 +41,7 @@ import type { HomeAssistant } from "../types";
import { onBoardingStyles } from "./styles";
const AMSTERDAM: [number, number] = [52.3731339, 4.8903147];
const mql = matchMedia("(prefers-color-scheme: dark)");
const darkMql = matchMedia("(prefers-color-scheme: dark)");
const LOCATION_MARKER_ID = "location";
@customElement("onboarding-location")
@@ -199,7 +199,7 @@ class OnboardingLocation extends LitElement {
this._highlightedMarker
)}
zoom="14"
.darkMode=${mql.matches}
.themeMode=${darkMql.matches ? "dark" : "light"}
.disabled=${this._working}
@location-updated=${this._locationChanged}
@marker-clicked=${this._markerClicked}

View File

@@ -1,7 +1,7 @@
import "@material/mwc-button";
import { mdiCalendarClock } from "@mdi/js";
import { toDate } from "date-fns-tz";
import { addDays, isSameDay } from "date-fns/esm";
import { addDays, isSameDay } from "date-fns";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { property, state } from "lit/decorators";
import { formatDate } from "../../common/datetime/format_date";

View File

@@ -6,7 +6,7 @@ import {
addMilliseconds,
differenceInMilliseconds,
startOfHour,
} from "date-fns/esm";
} from "date-fns";
import { HassEntity } from "home-assistant-js-websocket";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";

View File

@@ -1,14 +1,6 @@
import { mdiDelete, mdiPlus } from "@mdi/js";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
nothing,
} from "lit";
import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { LocalizeFunc } from "../../../common/translations/localize";
@@ -59,27 +51,24 @@ export class HaConfigApplicationCredentials extends LitElement {
title: localize(
"ui.panel.config.application_credentials.picker.headers.name"
),
sortable: true,
direction: "asc",
grows: true,
template: (entry) => html`${entry.name}`,
},
client_id: {
title: localize(
"ui.panel.config.application_credentials.picker.headers.client_id"
),
width: "30%",
direction: "asc",
hidden: narrow,
template: (entry) => html`${entry.client_id}`,
},
application: {
localizedDomain: {
title: localize(
"ui.panel.config.application_credentials.picker.headers.application"
),
sortable: true,
width: "30%",
direction: "asc",
template: (entry) => html`${domainToName(localize, entry.domain)}`,
},
};
@@ -87,6 +76,14 @@ export class HaConfigApplicationCredentials extends LitElement {
}
);
private _getApplicationCredentials = memoizeOne(
(applicationCredentials: ApplicationCredential[], localize: LocalizeFunc) =>
applicationCredentials.map((credential) => ({
...credential,
localizedDomain: domainToName(localize, credential.domain),
}))
);
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this._loadTranslations();
@@ -102,56 +99,40 @@ export class HaConfigApplicationCredentials extends LitElement {
backPath="/config"
.tabs=${configSections.devices}
.columns=${this._columns(this.narrow, this.hass.localize)}
.data=${this._applicationCredentials}
.data=${this._getApplicationCredentials(
this._applicationCredentials,
this.hass.localize
)}
hasFab
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
>
${this._selected.length
? html`
<div
class=${classMap({
"header-toolbar": this.narrow,
"table-header": !this.narrow,
})}
slot="header"
>
<p class="selected-txt">
${this.hass.localize(
"ui.panel.config.application_credentials.picker.selected",
{ number: this._selected.length }
<div class="header-btns" slot="selection-bar">
${!this.narrow
? html`
<mwc-button @click=${this._removeSelected} class="warning"
>${this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.button"
)}</mwc-button
>
`
: html`
<ha-icon-button
class="warning"
id="remove-btn"
@click=${this._removeSelected}
.path=${mdiDelete}
.label=${this.hass.localize("ui.common.remove")}
></ha-icon-button>
<ha-help-tooltip
.label=${this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.button"
)}
</p>
<div class="header-btns">
${!this.narrow
? html`
<mwc-button
@click=${this._removeSelected}
class="warning"
>${this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.button"
)}</mwc-button
>
`
: html`
<ha-icon-button
class="warning"
id="remove-btn"
@click=${this._removeSelected}
.path=${mdiDelete}
.label=${this.hass.localize("ui.common.remove")}
></ha-icon-button>
<ha-help-tooltip
.label=${this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.button"
)}
>
</ha-help-tooltip>
`}
</div>
</div>
`
: nothing}
>
</ha-help-tooltip>
`}
</div>
<ha-fab
slot="fab"
.label=${this.hass.localize(

View File

@@ -511,6 +511,7 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
display: flex;
justify-content: space-between;
align-items: center;
overflow-wrap: anywhere;
}
.warning {
color: var(--error-color);

View File

@@ -42,8 +42,14 @@ import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { ACTION_ICONS, YAML_ONLY_ACTION_TYPES } from "../../../../data/action";
import { AutomationClipboard } from "../../../../data/automation";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import {
floorsContext,
fullEntitiesContext,
labelsContext,
} from "../../../../data/context";
import { EntityRegistryEntry } from "../../../../data/entity_registry";
import { FloorRegistryEntry } from "../../../../data/floor_registry";
import { LabelRegistryEntry } from "../../../../data/label_registry";
import {
Action,
NonConditionAction,
@@ -146,6 +152,14 @@ export default class HaAutomationActionRow extends LitElement {
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[];
@state()
@consume({ context: labelsContext, subscribe: true })
_labelReg!: LabelRegistryEntry[];
@state()
@consume({ context: floorsContext, subscribe: true })
_floorReg!: FloorRegistryEntry[];
@state() private _warnings?: string[];
@state() private _uiModeAvailable = true;
@@ -210,7 +224,13 @@ export default class HaAutomationActionRow extends LitElement {
.path=${ACTION_ICONS[type!]}
></ha-svg-icon>`}
${capitalizeFirstLetter(
describeAction(this.hass, this._entityReg, this.action)
describeAction(
this.hass,
this._entityReg,
this._labelReg,
this._floorReg,
this.action
)
)}
</h3>
@@ -573,7 +593,15 @@ export default class HaAutomationActionRow extends LitElement {
),
inputType: "string",
placeholder: capitalizeFirstLetter(
describeAction(this.hass, this._entityReg, this.action, undefined, true)
describeAction(
this.hass,
this._entityReg,
this._labelReg,
this._floorReg,
this.action,
undefined,
true
)
),
defaultValue: this.action.alias,
confirmText: this.hass.localize("ui.common.submit"),

View File

@@ -15,11 +15,12 @@ import {
mdiPlus,
mdiRobotHappy,
mdiTag,
mdiTextureBox,
mdiToggleSwitch,
mdiToggleSwitchOffOutline,
mdiTransitConnection,
} from "@mdi/js";
import { differenceInDays } from "date-fns/esm";
import { differenceInDays } from "date-fns";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import {
CSSResultGroup,
@@ -37,15 +38,21 @@ import { computeCssColor } from "../../../common/color/compute-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatShortDateTime } from "../../../common/datetime/format_date_time";
import { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage";
import { HASSDomEvent, fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { navigate } from "../../../common/navigate";
import { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
import "../../../components/chips/ha-assist-chip";
import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/entity/ha-entity-toggle";
@@ -63,6 +70,7 @@ import type { HaMenu } from "../../../components/ha-menu";
import "../../../components/ha-menu-item";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import { createAreaRegistryEntry } from "../../../data/area_registry";
import {
AutomationEntity,
deleteAutomation,
@@ -100,6 +108,7 @@ import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route, ServiceCallResponse } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { turnOnOffEntity } from "../../lovelace/common/entity/turn-on-off-entity";
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import { configSections } from "../ha-panel-config";
@@ -152,6 +161,19 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
@state() private _overflowAutomation?: AutomationItem;
@storage({ key: "automation-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "automation-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({
key: "automation-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed?: string;
@query("#overflow-menu") private _overflowMenu!: HaMenu;
private _sizeController = new ResizeController(this, {
@@ -196,6 +218,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
labels: (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
),
selectable: entityRegEntry !== undefined,
};
});
}
@@ -383,6 +406,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
${this.hass.localize("ui.panel.config.category.editor.add")}
</div>
</ha-menu-item>`;
const labelItems = html`${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((entityId) =>
@@ -419,9 +443,54 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
${this.hass.localize("ui.panel.config.labels.add_label")}
</div></ha-menu-item
>`;
const labelsInOverflow =
(this._sizeController.value && this._sizeController.value < 700) ||
const areaItems = html`${Object.values(this.hass.areas).map(
(area) =>
html`<ha-menu-item
.value=${area.area_id}
@click=${this._handleBulkArea}
>
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<div slot="headline">${area.name}</div>
</ha-menu-item>`
)}
<ha-menu-item .value=${null} @click=${this._handleBulkArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.no_area"
)}
</div>
</ha-menu-item>
<md-divider role="separator" tabindex="-1"></md-divider>
<ha-menu-item @click=${this._bulkCreateArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.add_area"
)}
</div>
</ha-menu-item>`;
const areasInOverflow =
(this._sizeController.value && this._sizeController.value < 900) ||
(!this._sizeController.value && this.hass.dockedSidebar === "docked");
const labelsInOverflow =
areasInOverflow &&
(!this._sizeController.value || this._sizeController.value < 700);
const automations = this._automations(
this.automations,
this._entityReg,
this.hass.areas,
this._categories,
this._labels,
this._filteredAutomations
);
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
@@ -432,28 +501,36 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
id="entity_id"
.route=${this.route}
.tabs=${configSections.automations}
.searchLabel=${this.hass.localize(
"ui.panel.config.automation.picker.search",
{ number: automations.length }
)}
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
hasFilters
.filters=${
Object.values(this._filters).filter((filter) => filter.value?.length)
.length
Object.values(this._filters).filter((filter) =>
Array.isArray(filter.value)
? filter.value.length
: filter.value &&
Object.values(filter.value).some((val) =>
Array.isArray(val) ? val.length : val
)
).length
}
.columns=${this._columns(
this.narrow,
this.hass.localize,
this.hass.locale
)}
initialGroupColumn="category"
.data=${this._automations(
this.automations,
this._entityReg,
this.hass.areas,
this._categories,
this._labels,
this._filteredAutomations
)}
.initialGroupColumn=${this._activeGrouping || "category"}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
.data=${automations}
.empty=${!this.automations.length}
@row-click=${this._handleRowClicked}
.noDataText=${this.hass.localize(
@@ -560,6 +637,22 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
></ha-svg-icon>
</ha-assist-chip>
${labelItems}
</ha-button-menu-new>`}
${areasInOverflow
? nothing
: html`<ha-button-menu-new slot="selection-bar">
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${areaItems}
</ha-button-menu-new>`}`
: nothing
}
@@ -624,6 +717,24 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
</ha-sub-menu>`
: nothing
}
${
this.narrow || areasInOverflow
? html`<ha-sub-menu>
<ha-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
</div>
<ha-svg-icon
slot="end"
.path=${mdiChevronRight}
></ha-svg-icon>
</ha-menu-item>
<ha-menu slot="menu">${areaItems}</ha-menu>
</ha-sub-menu>`
: nothing
}
<ha-menu-item @click=${this._handleBulkEnable}>
<ha-svg-icon slot="start" .path=${mdiToggleSwitch}></ha-svg-icon>
<div slot="headline">
@@ -1101,7 +1212,20 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
})
);
});
await Promise.all(promises);
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private async _handleBulkLabel(ev) {
@@ -1124,7 +1248,60 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
})
);
});
await Promise.all(promises);
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private async _handleBulkArea(ev) {
const area = ev.currentTarget.value;
this._bulkAddArea(area);
}
private async _bulkAddArea(area: string) {
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
this._selected.forEach((entityId) => {
promises.push(
updateEntityRegistryEntry(this.hass, entityId, {
area_id: area,
})
);
});
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private async _bulkCreateArea() {
showAreaRegistryDetailDialog(this, {
createEntry: async (values) => {
const area = await createAreaRegistryEntry(this.hass, values);
this._bulkAddArea(area.area_id);
return area;
},
});
}
private async _handleBulkEnable() {
@@ -1132,7 +1309,20 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
this._selected.forEach((entityId) => {
promises.push(turnOnOffEntity(this.hass, entityId, true));
});
await Promise.all(promises);
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private async _handleBulkDisable() {
@@ -1140,7 +1330,20 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
this._selected.forEach((entityId) => {
promises.push(turnOnOffEntity(this.hass, entityId, false));
});
await Promise.all(promises);
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private async _bulkCreateCategory() {
@@ -1168,12 +1371,25 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
});
}
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
height: 100%;
}
hass-tabs-subpage-data-table {
--data-table-row-height: 60px;

View File

@@ -6,6 +6,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/device/ha-device-picker";
import "../../../../../components/device/ha-device-trigger-picker";
import "../../../../../components/ha-form/ha-form";
import { computeInitialHaFormData } from "../../../../../components/ha-form/compute-initial-ha-form-data";
import { fullEntitiesContext } from "../../../../../data/context";
import {
deviceAutomationsEqual,
@@ -44,7 +45,9 @@ export class HaDeviceTrigger extends LitElement {
private _extraFieldsData = memoizeOne(
(trigger: DeviceTrigger, capabilities: DeviceCapabilities) => {
const extraFieldsData: Record<string, any> = {};
const extraFieldsData = computeInitialHaFormData(
capabilities.extra_fields
);
capabilities.extra_fields.forEach((item) => {
if (trigger[item.name] !== undefined) {
extraFieldsData![item.name] = trigger[item.name];

View File

@@ -24,6 +24,7 @@ import { extractSearchParam } from "../../../common/url/search-params";
import {
DataTableColumnContainer,
RowClickedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/entity/ha-entity-toggle";
import "../../../components/ha-button";
@@ -54,6 +55,7 @@ import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import { configSections } from "../ha-panel-config";
import { showAddBlueprintDialog } from "./show-dialog-import-blueprint";
import { storage } from "../../../common/decorators/storage";
type BlueprintMetaDataPath = BlueprintMetaData & {
path: string;
@@ -92,8 +94,24 @@ class HaBlueprintOverview extends LitElement {
Blueprints
>;
@storage({ key: "blueprint-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "blueprint-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({
key: "blueprint-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed?: string;
private _processedBlueprints = memoizeOne(
(blueprints: Record<string, Blueprints>): BlueprintMetaDataPath[] => {
(
blueprints: Record<string, Blueprints>,
localize: LocalizeFunc
): BlueprintMetaDataPath[] => {
const result: any[] = [];
Object.entries(blueprints).forEach(([type, typeBlueprints]) =>
Object.entries(typeBlueprints).forEach(([path, blueprint]) => {
@@ -101,6 +119,9 @@ class HaBlueprintOverview extends LitElement {
result.push({
name: blueprint.error,
type,
translated_type: localize(
`ui.panel.config.blueprint.overview.types.${type as "automation" | "script"}`
),
error: true,
path,
fullpath: `${type}/${path}`,
@@ -109,6 +130,9 @@ class HaBlueprintOverview extends LitElement {
result.push({
...blueprint.metadata,
type,
translated_type: localize(
`ui.panel.config.blueprint.overview.types.${type as "automation" | "script"}`
),
error: false,
path,
fullpath: `${type}/${path}`,
@@ -140,14 +164,11 @@ class HaBlueprintOverview extends LitElement {
`
: undefined,
},
type: {
translated_type: {
title: localize("ui.panel.config.blueprint.overview.headers.type"),
template: (blueprint) =>
html`${this.hass.localize(
`ui.panel.config.blueprint.overview.types.${blueprint.type}`
)}`,
sortable: true,
filterable: true,
groupable: true,
hidden: narrow,
direction: "asc",
width: "10%",
@@ -256,7 +277,7 @@ class HaBlueprintOverview extends LitElement {
this.hass.language,
this.hass.localize
)}
.data=${this._processedBlueprints(this.blueprints)}
.data=${this._processedBlueprints(this.blueprints, this.hass.localize)}
id="fullpath"
.noDataText=${this.hass.localize(
"ui.panel.config.blueprint.overview.no_blueprints"
@@ -281,6 +302,12 @@ class HaBlueprintOverview extends LitElement {
>
</a>
</div>`}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
>
<ha-icon-button
slot="toolbar-icon"
@@ -341,9 +368,10 @@ class HaBlueprintOverview extends LitElement {
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const blueprint = this._processedBlueprints(this.blueprints).find(
(b) => b.fullpath === ev.detail.id
)!;
const blueprint = this._processedBlueprints(
this.blueprints,
this.hass.localize
).find((b) => b.fullpath === ev.detail.id)!;
if (blueprint.error) {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.blueprint.overview.error", {
@@ -502,6 +530,18 @@ class HaBlueprintOverview extends LitElement {
fireEvent(this, "reload-blueprints");
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
static get styles(): CSSResultGroup {
return haStyle;
}

View File

@@ -21,6 +21,7 @@ import {
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
@customElement("cloud-remote-pref")
export class CloudRemotePref extends LitElement {
@@ -33,7 +34,7 @@ export class CloudRemotePref extends LitElement {
return nothing;
}
const { remote_enabled, remote_allow_remote_enable } =
const { remote_enabled, remote_allow_remote_enable, strict_connection } =
this.cloudStatus.prefs;
const {
@@ -153,6 +154,61 @@ export class CloudRemotePref extends LitElement {
@change=${this._toggleAllowRemoteEnabledChanged}
></ha-switch>
</ha-settings-row>
<ha-settings-row>
<span slot="heading"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_secondary"
)}</span
>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_mode"
)}
@selected=${this._setStrictConnectionMode}
naturalMenuWidth
.value=${strict_connection}
>
<ha-list-item value="disabled">
${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_modes.disabled"
)}
</ha-list-item>
<ha-list-item value="guard_page">
${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_modes.guard_page"
)}
</ha-list-item>
<ha-list-item value="drop_connection">
${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_modes.drop_connection"
)}
</ha-list-item>
</ha-select>
</ha-settings-row>
${strict_connection !== "disabled"
? html` <ha-settings-row>
<span slot="heading"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_link"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_link_secondary"
)}</span
>
<ha-button @click=${this._createLoginUrl}
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_create_link"
)}</ha-button
>
</ha-settings-row>`
: nothing}
<ha-settings-row>
<span slot="heading"
>${this.hass.localize(
@@ -223,6 +279,18 @@ export class CloudRemotePref extends LitElement {
}
}
private async _setStrictConnectionMode(ev) {
const mode = ev.target.value;
try {
await updateCloudPref(this.hass, {
strict_connection: mode,
});
fireEvent(this, "ha-refresh-cloud-status");
} catch (err: any) {
alert(err.message);
}
}
private async _copyURL(ev): Promise<void> {
const url = ev.currentTarget.url;
await copyToClipboard(url);
@@ -231,6 +299,40 @@ export class CloudRemotePref extends LitElement {
});
}
private async _createLoginUrl() {
try {
const result = await this.hass.callService(
"cloud",
"create_temporary_strict_connection_url",
undefined,
undefined,
false,
true
);
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_link"
),
text: html`${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_link_created_message"
)}
<pre>${result.response.url}</pre>
<ha-button
.url=${result.response.url}
@click=${this._copyURL}
unelevated
>
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_copy_link"
)}
</ha-button>`,
});
} catch (err: any) {
showAlertDialog(this, { text: err.message });
}
}
static get styles(): CSSResultGroup {
return css`
.preparing {

View File

@@ -1,7 +1,6 @@
import "@material/mwc-list/mwc-list-item";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { UNIT_C } from "../../../common/const";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { navigate } from "../../../common/navigate";
@@ -22,8 +21,6 @@ import "../../../components/ha-settings-row";
import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield";
import "../../../components/ha-timezone-picker";
import "../../../components/map/ha-locations-editor";
import type { MarkerLocation } from "../../../components/map/ha-locations-editor";
import { ConfigUpdateValues, saveCoreConfig } from "../../../data/core";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
@@ -242,35 +239,22 @@ class HaConfigSectionGeneral extends LitElement {
>
</ha-language-picker>
</div>
${this.narrow
? html`
<ha-locations-editor
.hass=${this.hass}
.locations=${this._markerLocation(
this.hass.config.latitude,
this.hass.config.longitude,
this._location
)}
@location-updated=${this._locationChanged}
></ha-locations-editor>
`
: html`
<ha-settings-row>
<div slot="heading">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_location"
)}
</div>
<div slot="description" class="secondary">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_location_description"
)}
</div>
<mwc-button @click=${this._editLocation}
>${this.hass.localize("ui.common.edit")}</mwc-button
>
</ha-settings-row>
`}
<ha-settings-row>
<div slot="heading">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_location"
)}
</div>
<div slot="description" class="secondary">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_location_description"
)}
</div>
<mwc-button @click=${this._editLocation}
>${this.hass.localize("ui.common.edit")}</mwc-button
>
</ha-settings-row>
<div class="card-actions">
<ha-progress-button @click=${this._updateEntry}>
${this.hass!.localize("ui.panel.config.zone.detail.update")}
@@ -319,10 +303,6 @@ class HaConfigSectionGeneral extends LitElement {
this._updateUnits = (ev.target as HaCheckbox).checked;
}
private _locationChanged(ev: CustomEvent) {
this._location = ev.detail.location;
}
private async _updateEntry(ev: CustomEvent) {
const button = ev.target as HaProgressButton;
if (button.progress) {
@@ -381,21 +361,6 @@ class HaConfigSectionGeneral extends LitElement {
}
}
private _markerLocation = memoizeOne(
(
lat: number,
lng: number,
location?: [number, number]
): MarkerLocation[] => [
{
id: "location",
latitude: location ? location[0] : lat,
longitude: location ? location[1] : lng,
location_editable: true,
},
]
);
private _editLocation() {
navigate("/config/zone/edit/zone.home");
}
@@ -441,11 +406,6 @@ class HaConfigSectionGeneral extends LitElement {
margin-top: 8px;
display: inline-block;
}
ha-locations-editor {
display: block;
height: 400px;
padding: 16px;
}
`,
];
}

View File

@@ -82,11 +82,11 @@ class HaConfigSectionUpdates extends LitElement {
>
${this.hass.localize("ui.panel.config.updates.show_skipped")}
</ha-check-list-item>
${this._supervisorInfo?.channel !== "dev"
${this._supervisorInfo && this._supervisorInfo.channel !== "dev"
? html`
<li divider role="separator"></li>
<mwc-list-item @request-selected=${this._toggleBeta}>
${this._supervisorInfo?.channel === "stable"
${this._supervisorInfo.channel === "stable"
? this.hass.localize("ui.panel.config.updates.join_beta")
: this.hass.localize(
"ui.panel.config.updates.leave_beta"

View File

@@ -93,10 +93,19 @@ const randomTip = (hass: HomeAssistant, narrow: boolean) => {
weight: 2,
narrow: true,
},
{ content: hass.localize("ui.tips.key_c_hint"), weight: 1, narrow: false },
{ content: hass.localize("ui.tips.key_m_hint"), weight: 1, narrow: false },
];
if (hass?.enableShortcuts) {
tips.push(
{
content: hass.localize("ui.tips.key_c_hint"),
weight: 1,
narrow: false,
},
{ content: hass.localize("ui.tips.key_m_hint"), weight: 1, narrow: false }
);
}
if (narrow) {
tips = tips.filter((tip) => tip.narrow);
}
@@ -310,7 +319,9 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
private _showQuickBar(): void {
showQuickBar(this, {
commandMode: true,
hint: this.hass.localize("ui.dialogs.quick-bar.key_c_hint"),
hint: this.hass.enableShortcuts
? this.hass.localize("ui.dialogs.quick-bar.key_c_hint")
: undefined,
});
}

View File

@@ -1,6 +1,12 @@
import { consume } from "@lit-labs/context";
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import { mdiChevronRight, mdiMenuDown, mdiPlus } from "@mdi/js";
import {
mdiChevronRight,
mdiDotsVertical,
mdiMenuDown,
mdiPlus,
mdiTextureBox,
} from "@mdi/js";
import {
CSSResultGroup,
LitElement,
@@ -10,10 +16,12 @@ import {
nothing,
} from "lit";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { storage } from "../../../common/decorators/storage";
import { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import {
@@ -22,10 +30,15 @@ import {
} from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate";
import { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
import {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/entity/ha-battery-icon";
@@ -41,6 +54,7 @@ import "../../../components/ha-filter-states";
import "../../../components/ha-icon-button";
import "../../../components/ha-menu-item";
import "../../../components/ha-sub-menu";
import { createAreaRegistryEntry } from "../../../data/area_registry";
import { ConfigEntry, sortConfigEntries } from "../../../data/config_entries";
import { fullEntitiesContext } from "../../../data/context";
import {
@@ -65,10 +79,12 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
import { configSections } from "../ha-panel-config";
import "../integrations/ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
interface DeviceRowData extends DeviceRegistryEntry {
device?: DeviceRowData;
@@ -112,6 +128,19 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
@state()
_labels!: LabelRegistryEntry[];
@storage({ key: "devices-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "devices-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({ key: "devices-table-collapsed", state: false, subscribe: false })
private _activeCollapsed?: string;
private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width,
});
private _ignoreLocationChange = false;
public connectedCallback() {
@@ -544,6 +573,41 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
this._labels
);
const areasInOverflow =
(this._sizeController.value && this._sizeController.value < 700) ||
(!this._sizeController.value && this.hass.dockedSidebar === "docked");
const areaItems = html`${Object.values(this.hass.areas).map(
(area) =>
html`<ha-menu-item
.value=${area.area_id}
@click=${this._handleBulkArea}
>
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<div slot="headline">${area.name}</div>
</ha-menu-item>`
)}
<ha-menu-item .value=${null} @click=${this._handleBulkArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.no_area"
)}
</div>
</ha-menu-item>
<md-divider role="separator" tabindex="-1"></md-divider>
<ha-menu-item @click=${this._bulkCreateArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.add_area"
)}
</div>
</ha-menu-item>`;
const labelItems = html`${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((deviceId) =>
@@ -591,7 +655,8 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
.tabs=${configSections.devices}
.route=${this.route}
.searchLabel=${this.hass.localize(
"ui.panel.config.devices.picker.search"
"ui.panel.config.devices.picker.search",
{ number: devicesOutput.length }
)}
.columns=${this._columns(this.hass.localize, this.narrow)}
.data=${devicesOutput}
@@ -600,11 +665,22 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
@selection-changed=${this._handleSelectionChanged}
.filter=${this._filter}
hasFilters
.filters=${Object.values(this._filters).filter(
(filter) => filter.value?.length
.filters=${Object.values(this._filters).filter((filter) =>
Array.isArray(filter.value)
? filter.value.length
: filter.value &&
Object.values(filter.value).some((val) =>
Array.isArray(val) ? val.length : val
)
).length}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@clear-filter=${this._clearFilter}
@search-changed=${this._handleSearchChange}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@row-click=${this._handleRowClicked}
clickable
hasFab
@@ -673,36 +749,77 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
${!this.narrow
? html`<ha-button-menu-new slot="selection-bar">
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label"
)}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${labelItems}
</ha-button-menu-new>`
: html` <ha-button-menu-new has-overflow slot="selection-bar"
><ha-assist-chip
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_action"
)}
slot="trigger"
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label"
)}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${labelItems}
</ha-button-menu-new>
${areasInOverflow
? nothing
: html`<ha-button-menu-new slot="selection-bar">
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${areaItems}
</ha-button-menu-new>`}`
: nothing}
${this.narrow || areasInOverflow
? html`<ha-button-menu-new has-overflow slot="selection-bar">
${this.narrow
? html`<ha-assist-chip
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_action"
)}
slot="trigger"
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>`
: html`<ha-icon-button
.path=${mdiDotsVertical}
.label=${"ui.panel.config.automation.picker.bulk_action"}
slot="trigger"
></ha-icon-button>`}
${this.narrow
? html` <ha-sub-menu>
<ha-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label"
)}
</div>
<ha-svg-icon
slot="end"
.path=${mdiChevronRight}
></ha-svg-icon>
</ha-menu-item>
<ha-menu slot="menu">${labelItems}</ha-menu>
</ha-sub-menu>`
: nothing}
<ha-sub-menu>
<ha-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label"
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
</div>
<ha-svg-icon
@@ -710,9 +827,10 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
.path=${mdiChevronRight}
></ha-svg-icon>
</ha-menu-item>
<ha-menu slot="menu">${labelItems}</ha-menu>
<ha-menu slot="menu">${areaItems}</ha-menu>
</ha-sub-menu>
</ha-button-menu-new>`}
</ha-button-menu-new>`
: nothing}
</hass-tabs-subpage-data-table>
`;
}
@@ -798,6 +916,46 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
this._selected = ev.detail.value;
}
private async _handleBulkArea(ev) {
const area = ev.currentTarget.value;
this._bulkAddArea(area);
}
private async _bulkAddArea(area: string) {
const promises: Promise<DeviceRegistryEntry>[] = [];
this._selected.forEach((deviceId) => {
promises.push(
updateDeviceRegistryEntry(this.hass, deviceId, {
area_id: area,
})
);
});
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private async _bulkCreateArea() {
showAreaRegistryDetailDialog(this, {
createEntry: async (values) => {
const area = await createAreaRegistryEntry(this.hass, values);
this._bulkAddArea(area.area_id);
return area;
},
});
}
private async _handleBulkLabel(ev) {
const label = ev.currentTarget.value;
const action = ev.currentTarget.action;
@@ -818,7 +976,20 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
})
);
});
await Promise.all(promises);
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private _bulkCreateLabel() {
@@ -831,9 +1002,24 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
});
}
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
static get styles(): CSSResultGroup {
return [
css`
:host {
display: block;
}
hass-tabs-subpage-data-table {
--data-table-row-height: 60px;
}

View File

@@ -1,5 +1,5 @@
import "@material/mwc-button/mwc-button";
import { mdiDelete, mdiDevices } from "@mdi/js";
import { mdiDelete, mdiDevices, mdiPencil } from "@mdi/js";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
@@ -83,18 +83,24 @@ export class EnergyDeviceSettings extends LitElement {
${this.preferences.device_consumption.map((device) => {
const entityState = this.hass.states[device.stat_consumption];
return html`
<div class="row">
<div class="row" .device=${device}>
<ha-state-icon
.hass=${this.hass}
.stateObj=${entityState}
></ha-state-icon>
<span class="content"
>${getStatisticLabel(
>${device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption]
)}</span
>
<ha-icon-button
.label=${this.hass.localize("ui.common.edit")}
@click=${this._editDevice}
.path=${mdiPencil}
></ha-icon-button>
<ha-icon-button
.label=${this.hass.localize("ui.common.delete")}
@click=${this._deleteDevice}
@@ -117,6 +123,24 @@ export class EnergyDeviceSettings extends LitElement {
`;
}
private _editDevice(ev) {
const origDevice: DeviceConsumptionEnergyPreference =
ev.currentTarget.closest(".row").device;
showEnergySettingsDeviceDialog(this, {
device: { ...origDevice },
device_consumptions: this.preferences
.device_consumption as DeviceConsumptionEnergyPreference[],
saveCallback: async (newDevice) => {
await this._savePreferences({
...this.preferences,
device_consumption: this.preferences.device_consumption.map((d) =>
d === origDevice ? newDevice : d
),
});
},
});
}
private _addDevice() {
showEnergySettingsDeviceDialog(this, {
device_consumptions: this.preferences

View File

@@ -44,9 +44,10 @@ export class DialogEnergyDeviceSettings
this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
).units;
this._excludeList = this._params.device_consumptions.map(
(entry) => entry.stat_consumption
);
this._device = this._params.device;
this._excludeList = this._params.device_consumptions
.map((entry) => entry.stat_consumption)
.filter((id) => id !== this._device?.stat_consumption);
}
public closeDialog(): void {
@@ -88,6 +89,7 @@ export class DialogEnergyDeviceSettings
.hass=${this.hass}
.helpMissingEntityUrl=${energyStatisticHelpUrl}
.includeUnitClass=${energyUnitClasses}
.value=${this._device?.stat_consumption}
.label=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.device_consumption_energy"
)}
@@ -96,6 +98,17 @@ export class DialogEnergyDeviceSettings
dialogInitialFocus
></ha-statistic-picker>
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.display_name"
)}
type="text"
.disabled=${!this._device}
.value=${this._device?.name || ""}
@change=${this._nameChanged}
>
</ha-textfield>
<mwc-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass.localize("ui.common.cancel")}
</mwc-button>
@@ -118,6 +131,17 @@ export class DialogEnergyDeviceSettings
this._device = { stat_consumption: ev.detail.value };
}
private _nameChanged(ev) {
const newDevice = {
...this._device!,
name: ev.target!.value,
} as DeviceConsumptionEnergyPreference;
if (!newDevice.name) {
delete newDevice.name;
}
this._device = newDevice;
}
private async _save() {
try {
await this._params!.saveCallback(this._device!);
@@ -134,6 +158,10 @@ export class DialogEnergyDeviceSettings
ha-statistic-picker {
width: 100%;
}
ha-textfield {
margin-top: 16px;
width: 100%;
}
`,
];
}

View File

@@ -70,6 +70,7 @@ export interface EnergySettingsWaterDialogParams {
}
export interface EnergySettingsDeviceDialogParams {
device?: DeviceConsumptionEnergyPreference;
device_consumptions: DeviceConsumptionEnergyPreference[];
saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise<void>;
}

View File

@@ -27,9 +27,9 @@ import {
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import memoize from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
@@ -38,16 +38,22 @@ import {
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/ha-alert";
import "../../../components/ha-button-menu";
import "../../../components/ha-check-list-item";
import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-domains";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-integrations";
import "../../../components/ha-filter-labels";
@@ -67,7 +73,11 @@ import {
removeEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../../data/entity_registry";
import { entryIcon } from "../../../data/icons";
import {
EntitySources,
fetchEntitySourcesWithCache,
} from "../../../data/entity_sources";
import { domainToName } from "../../../data/integration";
import {
LabelRegistryEntry,
createLabelRegistryEntry,
@@ -88,10 +98,6 @@ import { configSections } from "../ha-panel-config";
import "../integrations/ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
EntitySources,
fetchEntitySourcesWithCache,
} from "../../../data/entity_sources";
export interface StateEntity
extends Omit<EntityRegistryEntry, "id" | "unique_id"> {
@@ -108,6 +114,7 @@ export interface EntityRow extends StateEntity {
status: string | undefined;
area?: string;
localized_platform: string;
domain: string;
label_entries: LabelRegistryEntry[];
}
@@ -147,6 +154,19 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
@state() private _entitySources?: EntitySources;
@storage({ key: "entities-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "entities-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({
key: "entities-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed?: string;
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
@@ -207,21 +227,19 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
type: "icon",
template: (entry) =>
entry.icon
? html`
<ha-state-icon
title=${ifDefined(entry.entity?.state)}
slot="item-icon"
.hass=${this.hass}
.stateObj=${entry.entity}
></ha-state-icon>
`
: html`
<ha-icon
icon=${until(
entryIcon(this.hass, entry as EntityRegistryEntry)
)}
></ha-icon>
`,
? html`<ha-icon .icon=${entry.icon}></ha-icon>`
: entry.entity
? html`
<ha-state-icon
title=${ifDefined(entry.entity?.state)}
slot="item-icon"
.hass=${this.hass}
.stateObj=${entry.entity}
></ha-state-icon>
`
: html`<ha-domain-icon
.domain=${computeDomain(entry.entity_id)}
></ha-domain-icon>`,
},
name: {
main: true,
@@ -261,6 +279,13 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
filterable: true,
width: "20%",
},
domain: {
title: localize("ui.panel.config.entities.picker.headers.domain"),
sortable: false,
hidden: true,
filterable: true,
groupable: true,
},
area: {
title: localize("ui.panel.config.entities.picker.headers.area"),
sortable: true,
@@ -419,6 +444,10 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
entryIds.includes(entity.config_entry_id))
);
filter.value!.forEach((domain) => filteredDomains.add(domain));
} else if (key === "ha-filter-domains" && filter.value?.length) {
filteredEntities = filteredEntities.filter((entity) =>
filter.value?.includes(computeDomain(entity.entity_id))
);
} else if (key === "ha-filter-labels" && filter.value?.length) {
filteredEntities = filteredEntities.filter((entity) =>
entity.labels.some((lbl) => filter.value!.includes(lbl))
@@ -467,9 +496,9 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
),
unavailable,
restored,
localized_platform:
localize(`component.${entry.platform}.title`) || entry.platform,
localized_platform: domainToName(localize, entry.platform),
area: area ? area.name : "—",
domain: domainToName(localize, computeDomain(entry.entity_id)),
status: restored
? localize("ui.panel.config.entities.picker.status.restored")
: unavailable
@@ -577,16 +606,29 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
)}
.data=${filteredEntities}
.searchLabel=${this.hass.localize(
"ui.panel.config.entities.picker.search"
"ui.panel.config.entities.picker.search",
{ number: filteredEntities.length }
)}
hasFilters
.filters=${
Object.values(this._filters).filter((filter) => filter.value?.length)
.length
Object.values(this._filters).filter((filter) =>
Array.isArray(filter.value)
? filter.value.length
: filter.value &&
Object.values(filter.value).some((val) =>
Array.isArray(val) ? val.length : val
)
).length
}
.filter=${this._filter}
selectable
.selected=${this._selected.length}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@selection-changed=${this._handleSelectionChanged}
clickable
@clear-filter=${this._clearFilter}
@@ -745,6 +787,15 @@ ${
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-devices>
<ha-filter-domains
.hass=${this.hass}
.value=${this._filters["ha-filter-domains"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-domains"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-domains>
<ha-filter-integrations
.hass=${this.hass}
.value=${this._filters["ha-filter-integrations"]?.value}
@@ -954,19 +1005,41 @@ ${
confirm: async () => {
let require_restart = false;
let reload_delay = 0;
await Promise.all(
const result = await Promise.allSettled(
this._selected.map(async (entity) => {
const result = await updateEntityRegistryEntry(this.hass, entity, {
disabled_by: null,
});
if (result.require_restart) {
const updateResult = await updateEntityRegistryEntry(
this.hass,
entity,
{
disabled_by: null,
}
);
if (updateResult.require_restart) {
require_restart = true;
}
if (result.reload_delay) {
reload_delay = Math.max(reload_delay, result.reload_delay);
if (updateResult.reload_delay) {
reload_delay = Math.max(reload_delay, updateResult.reload_delay);
}
})
);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.common.multiselect.failed",
{
number: rejected.length,
}
),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
this._clearSelection();
// If restart is required by any entity, show a dialog.
// Otherwise, show a dialog explaining that some patience is needed
@@ -1065,7 +1138,20 @@ ${
})
);
});
await Promise.all(promises);
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private _bulkCreateLabel() {
@@ -1154,6 +1240,18 @@ ${
});
}
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
static get styles(): CSSResultGroup {
return [
haStyle,

View File

@@ -35,7 +35,11 @@ import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { listenMediaQuery } from "../../common/dom/media_query";
import { CloudStatus, fetchCloudStatus } from "../../data/cloud";
import { fullEntitiesContext } from "../../data/context";
import {
floorsContext,
fullEntitiesContext,
labelsContext,
} from "../../data/context";
import {
entityRegistryByEntityId,
entityRegistryById,
@@ -45,6 +49,8 @@ import { HassRouterPage, RouterOptions } from "../../layouts/hass-router-page";
import { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import { HomeAssistant, Route } from "../../types";
import { subscribeLabelRegistry } from "../../data/label_registry";
import { subscribeFloorRegistry } from "../../data/floor_registry";
declare global {
// for fire event
@@ -379,11 +385,27 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) {
initialValue: [],
});
private _labelsContext = new ContextProvider(this, {
context: labelsContext,
initialValue: [],
});
private _floorsContext = new ContextProvider(this, {
context: floorsContext,
initialValue: [],
});
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeEntityRegistry(this.hass.connection!, (entities) => {
this._entitiesContext.setValue(entities);
}),
subscribeLabelRegistry(this.hass.connection!, (labels) => {
this._labelsContext.setValue(labels);
}),
subscribeFloorRegistry(this.hass.connection!, (floors) => {
this._floorsContext.setValue(floors);
}),
];
}

View File

@@ -32,7 +32,7 @@ import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-c
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { Helper, HelperDomain } from "./const";
import { Helper, HelperDomain, isHelperDomain } from "./const";
import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail";
type HelperCreators = {
@@ -96,7 +96,7 @@ export class DialogHelperDetail extends LitElement {
@state() private _opened = false;
@state() private _domain?: HelperDomain;
@state() private _domain?: string;
@state() private _error?: string;
@@ -114,8 +114,12 @@ export class DialogHelperDetail extends LitElement {
this._params = params;
this._domain = params.domain;
this._item = undefined;
if (this._domain && this._domain in HELPERS) {
await HELPERS[this._domain].import();
}
this._opened = true;
await this.updateComplete;
this.hass.loadFragmentTranslation("config");
Promise.all([
getConfigFlowHandlers(this.hass, ["helper"]),
// Ensure the titles are loaded before we render the flows.
@@ -141,7 +145,7 @@ export class DialogHelperDetail extends LitElement {
if (this._domain) {
content = html`
<div class="form" @value-changed=${this._valueChanged}>
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
${this._error ? html`<div class="error">${this._error}</div>` : ""}
${dynamicElement(`ha-${this._domain}-form`, {
hass: this.hass,
item: this._item,
@@ -155,13 +159,15 @@ export class DialogHelperDetail extends LitElement {
>
${this.hass!.localize("ui.panel.config.helpers.dialog.create")}
</mwc-button>
<mwc-button
slot="secondaryAction"
@click=${this._goBack}
.disabled=${this._submitting}
>
${this.hass!.localize("ui.common.back")}
</mwc-button>
${this._params?.domain
? nothing
: html`<mwc-button
slot="secondaryAction"
@click=${this._goBack}
.disabled=${this._submitting}
>
${this.hass!.localize("ui.common.back")}
</mwc-button>`}
`;
} else if (this._loading || this._helperFlows === undefined) {
content = html`<ha-circular-progress
@@ -253,9 +259,13 @@ export class DialogHelperDetail extends LitElement {
"ui.panel.config.helpers.dialog.create_platform",
{
platform:
this.hass.localize(
`ui.panel.config.helpers.types.${this._domain}`
) || this._domain,
(isHelperDomain(this._domain) &&
this.hass.localize(
`ui.panel.config.helpers.types.${
this._domain as HelperDomain
}`
)) ||
this._domain,
}
)
: this.hass.localize("ui.panel.config.helpers.dialog.create_helper")
@@ -277,7 +287,16 @@ export class DialogHelperDetail extends LitElement {
this._submitting = true;
this._error = "";
try {
await HELPERS[this._domain].create(this.hass, this._item);
const createdEntity = await HELPERS[this._domain].create(
this.hass,
this._item
);
if (this._params?.dialogClosedCallback && createdEntity.id) {
this._params.dialogClosedCallback({
flowFinished: true,
entityId: `${this._domain}.${createdEntity.id}`,
});
}
this.closeDialog();
} catch (err: any) {
this._error = err.message || "Unknown error";

View File

@@ -2,7 +2,7 @@ import { Calendar, CalendarOptions } from "@fullcalendar/core";
import allLocales from "@fullcalendar/core/locales-all";
import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import { addDays, isSameDay, isSameWeek, nextDay } from "date-fns";
import { Day, addDays, isSameDay, isSameWeek, nextDay } from "date-fns";
import {
CSSResultGroup,
LitElement,

View File

@@ -24,6 +24,7 @@ import {
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { storage } from "../../../common/decorators/storage";
import { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { navigate } from "../../../common/navigate";
@@ -32,10 +33,15 @@ import {
LocalizeKeys,
} from "../../../common/translations/localize";
import { extractSearchParam } from "../../../common/url/search-params";
import {
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
import {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/ha-fab";
@@ -135,6 +141,19 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public route!: Route;
@storage({ key: "helpers-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "helpers-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({
key: "helpers-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed?: string;
@state() private _stateItems: HassEntity[] = [];
@state() private _entityEntries?: Record<string, EntityRegistryEntry>;
@@ -486,6 +505,16 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
const labelsInOverflow =
(this._sizeController.value && this._sizeController.value < 700) ||
(!this._sizeController.value && this.hass.dockedSidebar === "docked");
const helpers = this._getItems(
this.hass.localize,
this._stateItems,
this._entityEntries,
this._configEntries,
this._entityReg,
this._categories,
this._labels,
this._filteredStateItems
);
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
@@ -493,25 +522,30 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
.searchLabel=${this.hass.localize(
"ui.panel.config.helpers.picker.search",
{ number: helpers.length }
)}
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
hasFilters
.filters=${Object.values(this._filters).filter(
(filter) => filter.value?.length
.filters=${Object.values(this._filters).filter((filter) =>
Array.isArray(filter.value)
? filter.value.length
: filter.value &&
Object.values(filter.value).some((val) =>
Array.isArray(val) ? val.length : val
)
).length}
.columns=${this._columns(this.narrow, this.hass.localize)}
.data=${this._getItems(
this.hass.localize,
this._stateItems,
this._entityEntries,
this._configEntries,
this._entityReg,
this._categories,
this._labels,
this._filteredStateItems
)}
initialGroupColumn="category"
.data=${helpers}
.initialGroupColumn=${this._activeGrouping || "category"}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
.activeFilters=${this._activeFilters}
@clear-filter=${this._clearFilter}
@row-click=${this._openEditDialog}
@@ -791,7 +825,20 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
})
);
});
await Promise.all(promises);
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private async _handleBulkLabel(ev) {
@@ -814,7 +861,20 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
})
);
});
await Promise.all(promises);
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private _handleSelectionChanged(
@@ -980,6 +1040,18 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
});
}
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
static get styles(): CSSResultGroup {
return [
haStyle,

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