Compare commits

..

206 Commits

Author SHA1 Message Date
Paul Bottein
7144b7802e Invert order 2025-07-08 15:06:59 +02:00
Paul Bottein
ca315b88ce Add sections dialog 2025-07-08 12:35:51 +02:00
renovate[bot]
a6304d6284 Update rspack monorepo to v1.4.4 (#26105)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 08:50:51 +03:00
Paul Bottein
8e866e86d6 Use query params instead of path for media browser navigate ids (#26099) 2025-07-08 08:50:28 +03:00
Kevin Lakotko
2e8203f666 Sort groups if same as sort column (#26010)
* fix(grouping): if sorted by column sort group

* chore: use props to group for memoization
2025-07-07 19:23:27 +03:00
Paulus Schoutsen
b60f2e3201 Add extra margin AI Task pref (#26096)
Add extra margin AI Task
2025-07-07 12:11:35 +03:00
renovate[bot]
c5f57f436c Update rspack monorepo to v1.4.3 (#26093)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 10:17:53 +03:00
steinmn
3bb930b906 Fix flickering Edit sidebar dialog by locking content padding (#26084)
Fix flickering Edit sidebar dialog
2025-07-07 05:29:25 +00:00
Ezra Freedman
e75331e159 Weather card smallest width is not set correctly (#26082)
set result.width, not result.height
2025-07-06 10:12:59 +02:00
renovate[bot]
d6b66a7145 Update dependency @rsdoctor/rspack-plugin to v1.1.7 (#26087)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-06 08:07:11 +00:00
Yosi Levy
5c346798c8 RTL fixes for 7-25 (#26074) 2025-07-06 10:04:09 +02:00
renovate[bot]
5ffe37407a Update dependency hls.js to v1.6.6 (#26085)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-06 09:58:49 +02:00
renovate[bot]
2b056c0434 Update dependency @lokalise/node-api to v14.9.1 (#26081)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-06 09:58:06 +02:00
Paulus Schoutsen
27b36707e5 Automation save dialog to suggest name, description and labels (#26071)
* AI Task structure

* Suggest description and labels too
2025-07-06 09:57:16 +02:00
renovate[bot]
5760614b65 Update babel monorepo to v7.28.0 (#26079)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-05 12:17:24 +02:00
renovate[bot]
3835912b01 Update dependency @rsdoctor/rspack-plugin to v1.1.6 (#26078)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-05 12:14:01 +02:00
Petar Petrov
a385655c85 Remove deprecated dependency @types/glob (#26075) 2025-07-05 08:27:41 +02:00
karwosts
e177012108 Fix default range icon (#26069) 2025-07-04 23:34:38 +02:00
renovate[bot]
cc3234ad8f Update dependency eslint to v9.30.1 (#26072)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 22:47:44 +02:00
karwosts
4d932f0b4a Support translating number selector UoM (#26070) 2025-07-04 21:06:49 +03:00
renovate[bot]
257769cdc7 Update dependency globals to v16.3.0 (#26068)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 18:42:09 +02:00
renovate[bot]
6619f064eb Update dependency @lokalise/node-api to v14.9.0 (#26067)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 15:12:03 +02:00
renovate[bot]
382a47a082 Update rspack monorepo to v1.4.2 (#26066)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 15:11:40 +02:00
renovate[bot]
ad4be75fe1 Update dependency typescript-eslint to v8.35.1 (#26058)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 10:16:33 +03:00
Ezra Freedman
d605b67b41 Prevent uncaught TypeError on HuiWeatherForecastCard render (#26038) 2025-07-03 21:19:48 +02:00
renovate[bot]
dba6a3c756 Update fullcalendar monorepo to v6.1.18 (#26047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-03 17:14:49 +03:00
c0ffeeca7
002e9ad071 Terminology: change controller to adapter (#26051)
* Terminology: change controller to adapter

* Update src/translations/en.json

Co-authored-by: AlCalzone <d.griesel@gmx.net>

* Apply suggestions from code review

---------

Co-authored-by: AlCalzone <d.griesel@gmx.net>
2025-07-03 15:51:35 +02:00
Paul Bottein
6e7874c2c9 Fix play media action (#26035) 2025-07-02 19:30:06 +02:00
Paul Bottein
978f9b0f83 Reduce media selector size (#26033) 2025-07-02 18:08:31 +02:00
renovate[bot]
2b88669a72 Update dependency eslint-plugin-lit-a11y to v5.1.0 (#26020)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 17:20:13 +02:00
renovate[bot]
252fd2bb6c Update dependency @bundle-stats/plugin-webpack-filter to v4.21.0 (#26032)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 17:19:41 +02:00
Paul Bottein
cc68a087a2 Fix zoom in statistic chart (#26034) 2025-07-02 17:18:39 +02:00
karwosts
3e1341a731 Fix glitchy 'show' checkboxes on integration page (#26021) 2025-07-02 13:43:06 +02:00
Bram Kragten
6be25270fd Dont fetch device actions on first updated (#26028) 2025-07-02 13:42:21 +02:00
Bram Kragten
ce929aea46 Disable fullscreen in trigger detail dialog (#26030) 2025-07-02 13:41:52 +02:00
Paul Bottein
8853bf6ea2 Improve styling of the code editor in fullscreen mode (#26029) 2025-07-02 13:41:26 +02:00
Paul Bottein
2241807745 Fix UI jump when using drag and drop in areas strategy editor (#26026) 2025-07-02 09:03:21 +00:00
Paul Bottein
50d705c943 Add missing domain icon import in area controls (#26023) 2025-07-01 21:47:20 +02:00
Paul Bottein
eb111d3c32 Add missing area helper (#26022) 2025-07-01 21:46:58 +02:00
Paul Bottein
1e59f9f4be Increase target area in tile card and area card (#26017) 2025-07-01 14:34:19 +02:00
Paul Bottein
523eb9522f Add dashboard title to strategy editor (#26015) 2025-07-01 14:33:36 +02:00
Paul Bottein
f6cb322819 Avoid selector to take to much space in action calls (#26014) 2025-07-01 14:32:54 +02:00
Paul Bottein
4f97756f4e Force narrow style for action, condition and trigger in blueprint (#26018) 2025-07-01 15:22:55 +03:00
Ezra Freedman
8644dd5271 Fix translation in the integration page for entities (#26009)
add call to localize
2025-07-01 06:48:13 +00:00
renovate[bot]
26d842f432 Update dependency @babel/helper-define-polyfill-provider to v0.6.5 (#26008)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 08:34:48 +02:00
renovate[bot]
ad4f14ffaf Update dependency eslint to v9.30.0 (#26012)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 08:31:30 +02:00
Paul Bottein
948c858e78 Fix object selector not displayed (#26007) 2025-06-30 16:15:37 +00:00
Paul Bottein
49099223d3 Do not display quality scale for custom integrations (#26006) 2025-06-30 16:10:08 +00:00
Paul Bottein
0fbd430594 Allow to re-order floors in areas dashboard (#26002)
* Allow to re-order floors in areas dashboard

* Move drag handle to right

* Improve typings

* Only show drag handle if there is at least 2 floors
2025-06-30 16:09:42 +00:00
Kevin Lakotko
8cc762d839 Fix use of numeric option for collator (#25917)
* fix(string): use numeric option for collator

* test: add natural sort comparison tests
2025-06-30 18:00:45 +02:00
Paul Bottein
89d9dd2893 Improve device row in integration page (#26005)
Improve device row in config entry page
2025-06-30 17:44:50 +02:00
renovate[bot]
b7d1ce1c37 Update rspack monorepo to v1.4.1 (#26001)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 17:22:39 +02:00
renovate[bot]
869d10ca3f Update CodeMirror (#26003)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 17:22:09 +02:00
Simon Lamon
f338089148 Pass area control service calls through hass (#25986)
Connection logging
2025-06-30 14:59:15 +02:00
renovate[bot]
06b0f9fcaf Update dependency @rsdoctor/rspack-plugin to v1.1.5 (#26000)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 13:49:05 +02:00
Simon Lamon
7ad07e4c55 Fix fullscreen yaml editor (transparency background) (#25989)
Fix fullscreen editor (transparency background)
2025-06-30 14:16:59 +03:00
renovate[bot]
ad65600d11 Update dependency marked to v16 (#25997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 14:15:16 +03:00
renovate[bot]
e91d907e56 Update dependency prettier to v3.6.2 (#25996) 2025-06-30 08:26:28 +02:00
renovate[bot]
b35a1fc9e0 Update dependency @babel/core to v7.27.7 (#25992)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 20:54:40 +03:00
renovate[bot]
dd18ad96f3 Update dependency gulp-rename to v2.1.0 (#25985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 17:16:40 +02:00
Norbert Rittel
62eec56e5f Fix grammar of Light, Sensor and Tile card descriptions (#25988)
* Fix grammar of Light, Sensor and Entity card descriptions

* Capitalize "Tile card" as a name

* Apply same change to Entity badge description
2025-06-29 17:16:09 +02:00
renovate[bot]
7187e25cad Update rspack monorepo to v1.4.0 (#25987)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 11:06:34 +02:00
Norbert Rittel
6d9e6a616d Fix sentence-casing, spelling and grammar issues (#25981)
* Fix sentence-casing, spelling and grammar issues

* Add "IP information" to the list

* More sentence-casing issues
2025-06-29 09:25:29 +02:00
renovate[bot]
44d87e3c66 Update dependency barcode-detector to v3.0.5 (#25980)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-28 17:41:15 +02:00
renovate[bot]
085e2460bc Update dependency prettier to v3.6.1 (#25978)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-28 17:40:47 +02:00
karwosts
1f8a9e4caf Improve settings page accessibility (No. 2) (#25965) 2025-06-27 19:38:11 +02:00
Franck Nijhof
f08877437e Add initial instructions file for GitHub Copilot and Claude Code (#25967) 2025-06-27 18:06:23 +02:00
renovate[bot]
6690d1ef22 Update dependency @types/leaflet to v1.9.19 (#25974)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-27 17:54:52 +02:00
Paul Bottein
9d8a5b366e Use entity format state if only one entity for that domain in the area card (#25964)
Use entity format state if only one entity is area card
2025-06-27 17:41:58 +02:00
Franck Nijhof
22c798c9d6 Add Claude to gitignore (#25966) 2025-06-27 15:59:14 +02:00
Norbert Rittel
8aabb1f32f Dev Tools: Remove excessive space from "Input date times" (#25973)
Remove excessive space from "input date times"
2025-06-27 15:57:52 +02:00
renovate[bot]
33d5cecc85 Update dependency ua-parser-js to v2.0.4 (#25968)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-27 15:12:14 +03:00
Paul Bottein
7693a4dc24 Use areas dashboard name in the top bar (#25969) 2025-06-27 10:40:15 +02:00
Paul Bottein
9ec38c7dd9 Bump vaadin to 24.7.9 (#25963) 2025-06-26 21:22:06 +00:00
Bram Kragten
e8cb85f7ff Disable fullscreen editor for editors that are already fullscreen (#25959)
* Disabled fullscreen editor for editors that are already fullscreen

* Update ha-code-editor.ts
2025-06-26 23:17:30 +02:00
renovate[bot]
ef964a2717 Update dependency typescript-eslint to v8.35.0 (#25956) 2025-06-26 19:32:39 +02:00
Paul Bottein
369881f8a6 Fix expand icon for entries and sub entries (#25955) 2025-06-26 19:21:15 +02:00
Bram Kragten
68e22d23f1 Fix filtering on device in entities config panel (#25948)
* Fix filtering on device in entities config panel

* fix

* set filters from url twice to catch race...
2025-06-26 16:42:11 +02:00
Paul Bottein
696ba69a9e Revert vaadin to 24.7.7 (#25953) 2025-06-26 14:41:58 +00:00
Paul Bottein
e2ab52e10e Don't limit combo-box dropdown size (#25952) 2025-06-26 14:12:07 +00:00
Bram Kragten
b154bc1502 Load title when fetching flow (#25951) 2025-06-26 14:07:46 +00:00
Paul Bottein
a952b880d8 Disable escape key to close edit card dialog (#25947) 2025-06-26 15:25:26 +02:00
Bram Kragten
018aceb542 Add label to version number (#25942)
Add label
2025-06-26 15:38:33 +03:00
Bram Kragten
2fb86f118e make sure header is always shown in data entry flow (#25941) 2025-06-26 11:07:08 +00:00
Bram Kragten
6c8caccfec Use different icon for services (#25939) 2025-06-26 13:05:28 +02:00
Paul Bottein
3dd3a80054 Remove alert classes and only use slot sensors for areas dashboard (#25937)
* Remove alert classes and only used slot sensors for areas dashboard

* Rename group to sensors

* Rename group to sensors
2025-06-26 13:05:05 +02:00
Bram Kragten
675310afdf add version number to integration page (#25940)
* add version number to integration page

* Update ha-config-integration-page.ts
2025-06-26 11:04:13 +00:00
Paul Bottein
f5df91d4c7 Better handle case when no floors in areas dashboard (#25933) 2025-06-26 12:18:34 +02:00
Bram Kragten
d8ab9b73ba Prevent overflow of ripple on device row on integration page (#25922) 2025-06-26 12:17:00 +02:00
renovate[bot]
89ab0b4a3d Update dependency prettier to v3.6.0 (#25930)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-26 10:18:03 +02:00
Eric Stern
dd4cb1df72 Fix logbook stream subscription (#25927) 2025-06-26 09:42:43 +02:00
Paulus Schoutsen
b81cd37776 Make the config entry row section wider on mobile (#25924) 2025-06-26 09:41:39 +02:00
Paulus Schoutsen
34112e7446 Fix wrapping of add subentry buttons (#25925) 2025-06-26 09:41:10 +02:00
Bram Kragten
2dee45b465 Update confirm disable messages (#25919) 2025-06-26 08:57:16 +03:00
Norbert Rittel
10eb0a8b87 Deduplicate weekdays in time conditions (#25915)
Replace weekdays conditions with references to common ui strings
2025-06-26 08:55:46 +03:00
Bram Kragten
551035238f Dont show internal quality scale (#25921)
dont show internal quality scale
2025-06-25 17:34:12 -04:00
Bram Kragten
eb9359e9e1 Only show own devices when there are devices... (#25920)
only show own devices when there are devices...
2025-06-25 17:31:43 -04:00
Bram Kragten
df2523a6a2 Merge branch 'rc' into dev 2025-06-25 17:25:23 +02:00
Bram Kragten
edb1e1bba7 Bumped version to 20250625.0 2025-06-25 17:24:02 +02:00
Bram Kragten
e5bc234ab3 make debug mode better visible, improve disabling device (#25910) 2025-06-25 17:23:22 +02:00
ildar170975
6006e926a7 fix hui-panel-view for a "warning_multiple_cards" (#25899) 2025-06-25 17:20:14 +02:00
Petar Petrov
93df473ad2 Translate select options in config flows (#25911) 2025-06-25 17:18:29 +02:00
Paul Bottein
af149dcfab Move exclude entities config to area card (#25909) 2025-06-25 16:18:47 +02:00
karwosts
3ab6a02994 Improve settings page accessibility (#25885) 2025-06-25 13:35:14 +00:00
Leon
e7a04eb3d2 Short-format negative and small numbers in energy-distribution-card (#25862) 2025-06-25 13:22:21 +00:00
karwosts
2dfe5f50a6 Support templates in action target (#25656) 2025-06-25 15:20:53 +02:00
Paul Bottein
174d54396f Add cover controls to area card and improve areas dashboard (#25892) 2025-06-25 13:14:41 +00:00
karwosts
5c1a8029bf Fix entity selector slicing value on load (#25854) 2025-06-25 15:11:23 +02:00
Franck Nijhof
884341656f Add full-screen button to code editors (#25903) 2025-06-25 13:07:31 +00:00
Bram Kragten
fcbc8de95a Update device row in integration page (#25907)
* Update ha-config-entry-device-row.ts

* Update src/panels/config/integrations/ha-config-entry-device-row.ts

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-06-25 12:16:11 +00:00
Paul Bottein
f90eb4fee0 Exclude entities in controls for areas dashboard (#25906) 2025-06-25 13:50:21 +03:00
Paulus Schoutsen
9afc4260c9 Move AI task prefs to system -> general (#25904)
Move AI task prefs
2025-06-25 08:27:09 +03:00
Franck Nijhof
3aafa47f6d Improve Entity ID auto-complete in YAML mode (#25901) 2025-06-24 16:22:31 -04:00
Paulus Schoutsen
35ba2fffda Also show sub entry services when sub entry expanded (#25900) 2025-06-24 19:46:47 +02:00
Paul Bottein
31bc708725 Improve alerts padding in area card (#25897) 2025-06-24 16:09:56 +00:00
Franck Nijhof
caa60e4e8c Fix warnings raised when migrating incomplete automation configuration (#25898) 2025-06-24 18:02:45 +02:00
Paul Bottein
edcca81acc Group area per floor in the areas strategy editor (#25895) 2025-06-24 15:28:46 +02:00
Stefan Agner
508e451f94 Add container arch to system health data (#25896) 2025-06-24 15:27:52 +02:00
Bram Kragten
bfcc36bb40 Update integration page layout (#25880) 2025-06-24 15:27:07 +02:00
Petar Petrov
976bf7c512 Update buttons in Z-Wave firmware update dialog (#25894)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-06-24 15:20:30 +02:00
Petar Petrov
fb28c8971b Update Z-Wave text (#25893) 2025-06-24 14:31:26 +02:00
Paul Bottein
641a2eb77c Use area card in area strategy (#25879)
* Bring area card to area strategy

* Add device classes

* Use subview
2025-06-24 14:46:12 +03:00
Bram Kragten
47b90feffa Bumped version to 20250531.4 2025-06-24 09:58:11 +02:00
Paul Bottein
45e66d8b6e Don't send double card updated event when rendering the card (#25883) 2025-06-24 09:56:53 +02:00
Petar Petrov
73cd1e8e9d Fix duplicated requests in statistics-graph (#25878) 2025-06-24 09:56:52 +02:00
Petar Petrov
1a8ff83e2d Another fix for history chart axis rounding (#25852) 2025-06-24 09:56:51 +02:00
Petar Petrov
e6fbe0d538 Round chart limits with fit_y_data (#25851) 2025-06-24 09:56:51 +02:00
Paul Bottein
5ac6781f7d Remove debug type in secondary line in statistic picker (#25835) 2025-06-24 09:56:50 +02:00
Petar Petrov
7f7e693547 Fix bar chart data order when using the legend (#25832)
* Fix bar chart data order when using the legend

* type fix
2025-06-24 09:56:49 +02:00
Paul Bottein
51246f119c Fix disabled color in dark mode in production (#25818) 2025-06-24 09:56:48 +02:00
Anthony Relle
d764187e8c Update ElectricityMaps URL in Energy Dashboard (#25816)
fix: update electricitymap domain
2025-06-24 09:56:47 +02:00
Petar Petrov
6738b7d708 Fix sankey total calculation to account for included_in_stat (#25805) 2025-06-24 09:56:47 +02:00
Petar Petrov
77c458a0e5 Reduce reset-zoom button size on timeline charts (#25796) 2025-06-24 09:56:46 +02:00
ildar170975
876e36b4e0 hui-graph-footer-editor: add margin to ha-switch to prevent a scrollbar (#25645)
* add padding-bottom to card-config to prevent a scrollbar

* revert padding for card-config

* add margin to ha-switch

* revert margin for ha-switch

* set margin for ha-switch
2025-06-24 08:27:16 +03:00
Paul Bottein
dd64fa228c Add color options to area card (#25881)
* Add color options to area card

* Color all controls with the same color

* Clean area card
2025-06-24 08:23:58 +03:00
renovate[bot]
b3f5eb256f Update dependency eslint-plugin-import to v2.32.0 (#25890)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 06:25:30 +02:00
ildar170975
a5005c0840 Device page: sort related automations, scenes, scripts (#24742)
* sort related items

* use memoize for sorted _related

* fix for "_entities" & "_getEntitiesSorted"
2025-06-24 06:25:09 +02:00
Paul Bottein
c73122d7ee Don't send double card updated event when rendering the card (#25883) 2025-06-23 14:10:27 +02:00
Petar Petrov
82da36825e Fix duplicated requests in statistics-graph (#25878) 2025-06-23 11:25:46 +02:00
Petar Petrov
3e2a5bff4d Zwave delete device button on device page (#25766)
* Handle ZWaveJS device delete in frontend according to status

* finishing touches

* Fix custom value selected when clicking item in combo box (#25734)

* Assist Chat: handle intent progress delta not always being there (#25730)

handle intent progress data type change

* More support for no-grid energy dashboard (#25644)

* More support for no-grid energy dashboard

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

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* lint

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Z-Wave: apply sentence-style capitalization (#25739)

* Display full error for card preview mode (#25747)

* Fix edit card not working in chrome after editing (#25751)

* Change backup type order (#25759)

* Fix alerts refresh on device page (#25748)

* Fix alerts refresh on device page

* don't reset actions periodically

* reset stuff only on deviceId change

* Ensure grid options always return an object (#25760)

Assist Chat: handle intent progress delta not always being there (#25730)

handle intent progress data type change

More support for no-grid energy dashboard (#25644)

* More support for no-grid energy dashboard

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

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* lint

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

Z-Wave: apply sentence-style capitalization (#25739)

Display full error for card preview mode (#25747)

Fix edit card not working in chrome after editing (#25751)

Change backup type order (#25759)

Fix alerts refresh on device page (#25748)

* Fix alerts refresh on device page

* don't reset actions periodically

* reset stuff only on deviceId change

Ensure grid options always return an object (#25760)

* fix merge issue

* lint

* Update text and buttons

* Update src/translations/en.json

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

* update text

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: karwosts <32912880+karwosts@users.noreply.github.com>
Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-06-23 11:07:19 +03:00
Paulus Schoutsen
1349d9d8e3 Hide AI Task from default dashboard (#25877) 2025-06-23 09:40:16 +02:00
Petar Petrov
f6f2cb0fce Round chart limits with fit_y_data (#25851) 2025-06-23 08:42:54 +03:00
Paulus Schoutsen
589fa75b17 Add support for accept keyword in media selector (#25808) 2025-06-22 14:02:39 -04:00
renovate[bot]
fdd6ccf379 Update dependency @rsdoctor/rspack-plugin to v1.1.4 (#25868)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-22 18:48:32 +03:00
Petar Petrov
f50d5d79a4 Another fix for history chart axis rounding (#25852) 2025-06-22 12:11:29 +02:00
iluvdata
ad589b32c9 allow previews in config_subentries_flow (#25859) 2025-06-21 08:09:13 +00:00
renovate[bot]
1990472970 Lock file maintenance (#25838)
* Lock file maintenance
2025-06-20 20:43:30 +00:00
Norbert Rittel
299713fd5e Fix inconsistently spelled occurrences of "add-on" (#25858)
* Fix inconsistently spelled occurrences of "add-on"

- add the missing hyphen on three occurrences of "addon"
- change one occurrence of "Add-on" to lowercase

* Also capitalize one inconsistent occurrence of "Ingress"
2025-06-20 22:09:09 +02:00
renovate[bot]
a13dae4745 Update vitest monorepo to v3.2.4 (#25857)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-20 18:30:39 +00:00
Paulus Schoutsen
8324c23618 Update people dashboard intro (#25856) 2025-06-20 20:30:25 +02:00
Norbert Rittel
9c8d6a939b Fix spelling of "to log in", "to set up" and more (#25855)
- add missing space to "log in" where it's the verb
- add missing space to "set up" where it's the verb
- fix sentence-casing of "Create area"
- fix spelling of "ID" as abbreviation
2025-06-20 20:20:00 +02:00
Paulus Schoutsen
1059b519af Add LLM Task to suggest automation name (#25778)
* Add AI Task to suggest automation name

* Use state and update styling
2025-06-20 12:07:47 -04:00
Paulus Schoutsen
52a02093e3 Allow changing LLM Task preferences (#25779)
* Allow changing LLM Task preferences

* value-changed
2025-06-20 12:07:29 -04:00
Paul Bottein
b608bd949b Add fields and multiple support to object selector (#25843)
* Add schema and multiple for object selector

* Add selector to gallery

* Fix description

* Improve formatting

* Add ellipsis

* Update fields instead of schema

* Update gallery

* Update format

* Fix format value

* Fix dialog size
2025-06-20 15:48:59 +02:00
Paul Bottein
f87e20cae9 Redesign area card (#25802)
* Use entity filter to get device classes in editor

* Add name and sensor states to area card

* Fix area type

* Add basic controls

* Fix editor

* Add image

* Add image type

* Add translation key for area controls

* Improve editor

* Fix unknown entity id in area

* Fix default feature position

* Add alert badge

* Add helper

* Display all alerts when using big card

* Disable covers and re-enable switches

* Filter compatible controls

* Use state icon for alerts

* Rename to display type

* Delete deprecated show camera

* Fix aspect ratio

* Improve helper

* Undo domain icon changes

* Undo domain icon changes

* Update types

* Fix translation cases

* Fix card size

* Feedback

* Don't fallback to compact

* Use plural form

* Refactor active color
2025-06-20 15:33:26 +02:00
renovate[bot]
d58186fec9 Update dependency typescript-eslint to v8.34.1 (#25848)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-20 15:01:44 +03:00
Bastian
f47336392c Fix/dhcp config network sort (#25799)
* Add ip sort method to compare helper

* Add ip sort functionality to dhcp config panel datatable

* Add type ip to DataTableColumnData

* Change ip sorting to padStart method for better readablity

* Rename ip compare method to clarify ipv4

* Enhance IP compare method to include ipv6

* Add compare IP test
2025-06-20 15:01:02 +03:00
karwosts
e9272b9a27 Remove gray colors from chart color set (#25844) 2025-06-20 13:50:12 +02:00
Paul Bottein
de42714505 20250531.3 (#25764) 2025-06-12 14:23:03 +02:00
Paul Bottein
359460b570 Bumped version to 20250531.3 2025-06-12 14:22:11 +02:00
Paul Bottein
38545a01dd Ensure grid options always return an object (#25760) 2025-06-12 14:21:51 +02:00
Paul Bottein
8cead75087 Change backup type order (#25759) 2025-06-12 14:21:50 +02:00
Petar Petrov
7f68447a4f Fix alerts refresh on device page (#25748)
* Fix alerts refresh on device page

* don't reset actions periodically

* reset stuff only on deviceId change
2025-06-12 14:21:49 +02:00
Paul Bottein
380f760f79 20250531.2 (#25752) 2025-06-11 16:27:54 +02:00
Paul Bottein
bffe38c827 Bumped version to 20250531.2 2025-06-11 16:27:06 +02:00
Paul Bottein
89d0746c7c Fix edit card not working in chrome after editing (#25751) 2025-06-11 16:25:20 +02:00
Paul Bottein
7e2059e836 20250531.1 (#25749) 2025-06-11 14:42:10 +02:00
Paul Bottein
0d97962578 Bumped version to 20250531.1 2025-06-11 14:30:59 +02:00
Paul Bottein
4c5015e178 Display full error for card preview mode (#25747) 2025-06-11 14:29:55 +02:00
Paul Bottein
28214aebc5 Reduce keypad gap and margin in alarm panel card (#25735) 2025-06-11 14:29:54 +02:00
Paul Bottein
a26ca7d065 Fix custom value selected when clicking item in combo box (#25734) 2025-06-11 14:29:54 +02:00
Paul Bottein
2ab20aef69 Allow to open more info using query params (#25733) 2025-06-11 14:29:53 +02:00
Petar Petrov
8149ee60cb Fix period boundaries in Energy dashboard (#25728) 2025-06-11 14:29:52 +02:00
Petar Petrov
89ce6870f6 Handle tiny values in a log chart (#25727) 2025-06-11 14:29:51 +02:00
Mathieu
4307e2350f Adjust tooltip positioning in ha-sidebar for not first lis… (#25696)
fix(tooltip): fix tooltip positioning in ha-sidebar for not first listbox
2025-06-11 14:29:50 +02:00
karwosts
7dc4d555ae Hoist integration card tooltips (#25679) 2025-06-11 14:29:50 +02:00
Bram Kragten
1483e8e38f Set answers to yes and no for cloud pipeline confirm (#25674) 2025-06-11 14:29:49 +02:00
Simon Lamon
5daec6bbc5 Calendar add event button gap alignment (#25662)
Calendar gap alignment
2025-06-11 14:29:48 +02:00
Bram Kragten
77eae6044d Bumped version to 20250531.0 2025-05-31 16:15:54 +02:00
Bram Kragten
84ac0cd41e Add css variables for start and end padding of tabs (#25654) 2025-05-31 16:15:28 +02:00
Bram Kragten
74f9c1551e Add label to collapse button in data table groups (#25653) 2025-05-31 16:15:27 +02:00
Bram Kragten
aebd6350c0 fix line height entity card (#25652) 2025-05-31 16:15:27 +02:00
ildar170975
4c78eb4797 Add cyrilic letters to slugify() (#25647)
* add cyrilic

* Update src/common/string/slugify.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-05-31 16:15:26 +02:00
ildar170975
560e3890b9 Revert #25027 "more-info-camera: disable download_snapshot if idle" (#25643)
remove a check for "idle"
2025-05-31 16:15:25 +02:00
Ville Skyttä
e448268feb Spelling fixes (#25638) 2025-05-31 16:15:24 +02:00
Ville Skyttä
7ceba218fa Fix Zigbee capitalization in manage device button (#25637) 2025-05-31 16:15:24 +02:00
Petar Petrov
cd61725cf5 Fix Z-WaveJS device count in dashboard (#25635) 2025-05-31 16:15:23 +02:00
Petar Petrov
228860a1ee Use theme variables for network graph labels (#25634) 2025-05-31 16:15:22 +02:00
karwosts
9f69347e1d Cleanup some styling on disabled entity picker (#25632) 2025-05-31 16:15:21 +02:00
Bram Kragten
46e05f10d1 Bumped version to 20250528.0 2025-05-28 22:35:50 +02:00
Paul Bottein
a1819d6189 Improve search in add automation element dialog (#25626)
* Improve search in add automation element dialog

* Remove unexpected import

* Take min character search into account
2025-05-28 22:35:43 +02:00
Paul Bottein
48a3e1fd63 Put item at the top of picker result if there is an exact match with entity id (#25625) 2025-05-28 22:35:43 +02:00
Paul Bottein
139c8b3702 Fix picker field height (#25623) 2025-05-28 22:35:42 +02:00
Paul Bottein
9458946dcc Fix missing helper for entity picker (#25622) 2025-05-28 22:35:41 +02:00
Paul Bottein
b907dbefad Force narrow style for action, condition and trigger in flows (#25619) 2025-05-28 22:35:40 +02:00
Wendelin
5371fd649c Set markdown code line-height (#25618) 2025-05-28 22:35:40 +02:00
Paul Bottein
61d9b0d2a3 Improve action picker UI and search (#25525) 2025-05-28 22:35:39 +02:00
Bram Kragten
06270c771f Bumped version to 20250527.0 2025-05-27 21:43:42 +02:00
Norbert Rittel
ae49de8e71 Fix typo in restore_entity_id_selected::confirm_text (#25615) 2025-05-27 21:42:52 +02:00
Petar Petrov
6abdeeae20 Fix for history graph with tiny values (#25612) 2025-05-27 21:42:51 +02:00
Petar Petrov
116716c51d Fix duplicate legend items when comparing energy data (#25610) 2025-05-27 21:42:51 +02:00
Wendelin
77ee69b64d Fix font settings for button card (#25607) 2025-05-27 21:42:50 +02:00
Wendelin
1a57eeddde Fix sidebar loading and demo (#25606) 2025-05-27 21:42:49 +02:00
Petar Petrov
9131bf6dfd Fix double history graphs for a disabled entity (#25604) 2025-05-27 21:42:48 +02:00
Paul Bottein
1611423ca5 Fix duplicated items in strategy editor (#25600) 2025-05-27 21:42:48 +02:00
Bram Kragten
de56c3376e Bumped version to 20250526.0 2025-05-26 18:32:32 +02:00
146 changed files with 8971 additions and 4722 deletions

592
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,592 @@
# GitHub Copilot & Claude Code Instructions
You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control.
## Table of Contents
- [Quick Reference](#quick-reference)
- [Core Architecture](#core-architecture)
- [Development Standards](#development-standards)
- [Component Library](#component-library)
- [Common Patterns](#common-patterns)
- [Text and Copy Guidelines](#text-and-copy-guidelines)
- [Development Workflow](#development-workflow)
- [Review Guidelines](#review-guidelines)
## Quick Reference
### Essential Commands
```bash
yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler
yarn test # Vitest
script/develop # Development server
```
### Component Prefixes
- `ha-` - Home Assistant components
- `hui-` - Lovelace UI components
- `dialog-` - Dialog components
### Import Patterns
```typescript
import type { HomeAssistant } from "../types";
import { fireEvent } from "../common/dom/fire_event";
import { showAlertDialog } from "../dialogs/generic/show-alert-dialog";
```
## Core Architecture
The Home Assistant frontend is a modern web application that:
- Uses Web Components (custom elements) built with Lit framework
- Is written entirely in TypeScript with strict type checking
- Communicates with the backend via WebSocket API
- Provides comprehensive theming and internationalization
## Development Standards
### Code Quality Requirements
**Linting and Formatting (Enforced by Tools)**
- ESLint config extends Airbnb, TypeScript strict, Lit, Web Components, Accessibility
- Prettier with ES5 trailing commas enforced
- No console statements (`no-console: "error"`) - use proper logging
- Import organization: No unused imports, consistent type imports
**Naming Conventions**
- PascalCase for types and classes
- camelCase for variables, methods
- Private methods require leading underscore
- Public methods forbid leading underscore
### TypeScript Usage
- **Always use strict TypeScript**: Enable all strict flags, avoid `any` types
- **Proper type imports**: Use `import type` for type-only imports
- **Define interfaces**: Create proper interfaces for data structures
- **Type component properties**: All Lit properties must be properly typed
- **No unused variables**: Prefix with `_` if intentionally unused
- **Consistent imports**: Use `@typescript-eslint/consistent-type-imports`
```typescript
// Good
import type { HomeAssistant } from "../types";
interface EntityConfig {
entity: string;
name?: string;
}
@property({ type: Object })
hass!: HomeAssistant;
// Bad
@property()
hass: any;
```
### Web Components with Lit
- **Use Lit 3.x patterns**: Follow modern Lit practices
- **Extend appropriate base classes**: Use `LitElement`, `SubscribeMixin`, or other mixins as needed
- **Define custom element names**: Use `ha-` prefix for components
```typescript
@customElement("ha-my-component")
export class HaMyComponent extends LitElement {
@property({ attribute: false })
hass!: HomeAssistant;
@state()
private _config?: MyComponentConfig;
static get styles() {
return css`
:host {
display: block;
}
`;
}
render() {
return html`<div>Content</div>`;
}
}
```
### Component Guidelines
- **Use composition**: Prefer composition over inheritance
- **Lazy load panels**: Heavy panels should be dynamically imported
- **Optimize renders**: Use `@state()` for internal state, `@property()` for public API
- **Handle loading states**: Always show appropriate loading indicators
- **Support themes**: Use CSS custom properties from theme
### Data Management
- **Use WebSocket API**: All backend communication via home-assistant-js-websocket
- **Cache appropriately**: Use collections and caching for frequently accessed data
- **Handle errors gracefully**: All API calls should have error handling
- **Update real-time**: Subscribe to state changes for live updates
```typescript
// Good
try {
const result = await fetchEntityRegistry(this.hass.connection);
this._processResult(result);
} catch (err) {
showAlertDialog(this, {
text: `Failed to load: ${err.message}`,
});
}
```
### Styling Guidelines
- **Use CSS custom properties**: Leverage the theme system
- **Mobile-first responsive**: Design for mobile, enhance for desktop
- **Follow Material Design**: Use Material Web Components where appropriate
- **Support RTL**: Ensure all layouts work in RTL languages
```typescript
static get styles() {
return css`
:host {
--spacing: 16px;
padding: var(--spacing);
color: var(--primary-text-color);
background-color: var(--card-background-color);
}
@media (max-width: 600px) {
:host {
--spacing: 8px;
}
}
`;
}
```
### Performance Best Practices
- **Code split**: Split code at the panel/dialog level
- **Lazy load**: Use dynamic imports for heavy components
- **Optimize bundle**: Keep initial bundle size minimal
- **Use virtual scrolling**: For long lists, implement virtual scrolling
- **Memoize computations**: Cache expensive calculations
### Testing Requirements
- **Write tests**: Add tests for data processing and utilities
- **Test with Vitest**: Use the established test framework
- **Mock appropriately**: Mock WebSocket connections and API calls
- **Test accessibility**: Ensure components are accessible
## Component Library
### Dialog Components
**Available Dialog Types:**
- `ha-md-dialog` - Preferred for new code (Material Design 3)
- `ha-dialog` - Legacy component still widely used
**Opening Dialogs (Fire Event Pattern - Recommended):**
```typescript
fireEvent(this, "show-dialog", {
dialogTag: "dialog-example",
dialogImport: () => import("./dialog-example"),
dialogParams: { title: "Example", data: someData },
});
```
**Dialog Implementation Requirements:**
- Implement `HassDialog<T>` interface
- Use `createCloseHeading()` for standard headers
- Import `haStyleDialog` for consistent styling
- Return `nothing` when no params (loading state)
- Fire `dialog-closed` event when closing
- Add `dialogInitialFocus` for accessibility
````
### Form Component (ha-form)
- Schema-driven using `HaFormSchema[]`
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
- Built-in validation with error display
- Use `dialogInitialFocus` in dialogs
- Use `computeLabel`, `computeError`, `computeHelper` for translations
```typescript
<ha-form
.hass=${this.hass}
.data=${this._data}
.schema=${this._schema}
.error=${this._errors}
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
@value-changed=${this._valueChanged}
></ha-form>
````
### Alert Component (ha-alert)
- Types: `error`, `warning`, `info`, `success`
- Properties: `title`, `alert-type`, `dismissable`, `icon`, `action`, `rtl`
- Content announced by screen readers when dynamically displayed
```html
<ha-alert alert-type="error">Error message</ha-alert>
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
```
## Common Patterns
### Creating a Panel
```typescript
@customElement("ha-panel-myfeature")
export class HaPanelMyFeature extends SubscribeMixin(LitElement) {
@property({ attribute: false })
hass!: HomeAssistant;
@property({ type: Boolean, reflect: true })
narrow!: boolean;
@property()
route!: Route;
hassSubscribe() {
return [
subscribeEntityRegistry(this.hass.connection, (entities) => {
this._entities = entities;
}),
];
}
}
```
### Creating a Dialog
```typescript
@customElement("dialog-my-feature")
export class DialogMyFeature
extends LitElement
implements HassDialog<MyDialogParams>
{
@property({ attribute: false })
hass!: HomeAssistant;
@state()
private _params?: MyDialogParams;
public async showDialog(params: MyDialogParams): Promise<void> {
this._params = params;
}
public closeDialog(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(this.hass, this._params.title)}
>
<!-- Dialog content -->
<ha-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._submit} slot="primaryAction">
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog>
`;
}
static styles = [haStyleDialog, css``];
}
```
### Dialog Design Guidelines
- Max width: 560px (Alert/confirmation: 320px fixed width)
- Close X-icon on top left (all screen sizes)
- Submit button grouped with cancel at bottom right
- Keep button labels short: "Save", "Delete", "Enable"
- Destructive actions use red warning button
- Always use a title (best practice)
- Strive for minimalism
#### Creating a Lovelace Card
**Purpose**: Cards allow users to tell different stories about their house (based on gallery)
```typescript
@customElement("hui-my-card")
export class HuiMyCard extends LitElement implements LovelaceCard {
@property({ attribute: false })
hass!: HomeAssistant;
@state()
private _config?: MyCardConfig;
public setConfig(config: MyCardConfig): void {
if (!config.entity) {
throw new Error("Entity required");
}
this._config = config;
}
public getCardSize(): number {
return 3; // Height in grid units
}
// Optional: Editor for card configuration
public static getConfigElement(): LovelaceCardEditor {
return document.createElement("hui-my-card-editor");
}
// Optional: Stub config for card picker
public static getStubConfig(): object {
return { entity: "" };
}
}
```
**Card Guidelines:**
- Cards are highly customizable for different households
- Implement `LovelaceCard` interface with `setConfig()` and `getCardSize()`
- Use proper error handling in `setConfig()`
- Consider all possible states (loading, error, unavailable)
- Support different entity types and states
- Follow responsive design principles
- Add configuration editor when needed
### Internationalization
- **Use localize**: Always use the localization system
- **Add translation keys**: Add keys to src/translations/en.json
- **Support placeholders**: Use proper placeholder syntax
```typescript
this.hass.localize("ui.panel.config.updates.update_available", {
count: 5,
});
```
### Accessibility
- **ARIA labels**: Add appropriate ARIA labels
- **Keyboard navigation**: Ensure all interactions work with keyboard
- **Screen reader support**: Test with screen readers
- **Color contrast**: Meet WCAG AA standards
## Development Workflow
### Setup and Commands
1. **Setup**: `script/setup` - Install dependencies
2. **Develop**: `script/develop` - Development server
3. **Lint**: `yarn lint` - Run all linting before committing
4. **Test**: `yarn test` - Add and run tests
5. **Build**: `script/build_frontend` - Test production build
### Common Pitfalls to Avoid
- Don't use `querySelector` - Use refs or component properties
- Don't manipulate DOM directly - Let Lit handle rendering
- Don't use global styles - Scope styles to components
- Don't block the main thread - Use web workers for heavy computation
- Don't ignore TypeScript errors - Fix all type issues
### Security Best Practices
- Sanitize HTML - Never use `unsafeHTML` with user content
- Validate inputs - Always validate user inputs
- Use HTTPS - All external resources must use HTTPS
- CSP compliance - Ensure code works with Content Security Policy
### Text and Copy Guidelines
#### Terminology Standards
**Delete vs Remove** (Based on gallery/src/pages/Text/remove-delete-add-create.markdown)
- **Use "Remove"** for actions that can be restored or reapplied:
- Removing a user's permission
- Removing a user from a group
- Removing links between items
- Removing a widget from dashboard
- Removing an item from a cart
- **Use "Delete"** for permanent, non-recoverable actions:
- Deleting a field
- Deleting a value in a field
- Deleting a task
- Deleting a group
- Deleting a permission
- Deleting a calendar event
**Create vs Add** (Create pairs with Delete, Add pairs with Remove)
- **Use "Add"** for already-existing items:
- Adding a permission to a user
- Adding a user to a group
- Adding links between items
- Adding a widget to dashboard
- Adding an item to a cart
- **Use "Create"** for something made from scratch:
- Creating a new field
- Creating a new task
- Creating a new group
- Creating a new permission
- Creating a new calendar event
#### Writing Style (Consistent with Home Assistant Documentation)
- **Use American English**: Standard spelling and terminology
- **Friendly, informational tone**: Be inspiring, personal, comforting, engaging
- **Address users directly**: Use "you" and "your"
- **Be inclusive**: Objective, non-discriminatory language
- **Be concise**: Use clear, direct language
- **Be consistent**: Follow established terminology patterns
- **Use active voice**: "Delete the automation" not "The automation should be deleted"
- **Avoid jargon**: Use terms familiar to home automation users
#### Language Standards
- **Always use "Home Assistant"** in full, never "HA" or "HASS"
- **Avoid abbreviations**: Spell out terms when possible
- **Use sentence case everywhere**: Titles, headings, buttons, labels, UI elements
- ✅ "Create new automation"
- ❌ "Create New Automation"
- ✅ "Device settings"
- ❌ "Device Settings"
- **Oxford comma**: Use in lists (item 1, item 2, and item 3)
- **Replace Latin terms**: Use "like" instead of "e.g.", "for example" instead of "i.e."
- **Avoid CAPS for emphasis**: Use bold or italics instead
- **Write for all skill levels**: Both technical and non-technical users
#### Key Terminology
- **"add-on"** (hyphenated, not "addon")
- **"integration"** (preferred over "component")
- **Technical terms**: Use lowercase (automation, entity, device, service)
#### Translation Considerations
- **Add translation keys**: All user-facing text must be translatable
- **Use placeholders**: Support dynamic content in translations
- **Keep context**: Provide enough context for translators
```typescript
// Good
this.hass.localize("ui.panel.config.automation.delete_confirm", {
name: automation.alias,
});
// Bad - hardcoded text
("Are you sure you want to delete this automation?");
```
### Common Review Issues (From PR Analysis)
#### User Experience and Accessibility
- **Form validation**: Always provide proper field labels and validation feedback
- **Form accessibility**: Prevent password managers from incorrectly identifying fields
- **Loading states**: Show clear progress indicators during async operations
- **Error handling**: Display meaningful error messages when operations fail
- **Mobile responsiveness**: Ensure components work well on small screens
- **Hit targets**: Make clickable areas large enough for touch interaction
- **Visual feedback**: Provide clear indication of interactive states
#### Dialog and Modal Patterns
- **Dialog width constraints**: Respect minimum and maximum width requirements
- **Interview progress**: Show clear progress for multi-step operations
- **State persistence**: Handle dialog state properly during background operations
- **Cancel behavior**: Ensure cancel/close buttons work consistently
- **Form prefilling**: Use smart defaults but allow user override
#### Component Design Patterns
- **Terminology consistency**: Use "Join"/"Apply" instead of "Group" when appropriate
- **Visual hierarchy**: Ensure proper font sizes and spacing ratios
- **Grid alignment**: Components should align to the design grid system
- **Badge placement**: Position badges and indicators consistently
- **Color theming**: Respect theme variables and design system colors
#### Code Quality Issues
- **Null checking**: Always check if entities exist before accessing properties
- **TypeScript safety**: Handle potentially undefined array/object access
- **Import organization**: Remove unused imports and use proper type imports
- **Event handling**: Properly subscribe and unsubscribe from events
- **Memory leaks**: Clean up subscriptions and event listeners
#### Configuration and Props
- **Optional parameters**: Make configuration fields optional when sensible
- **Smart defaults**: Provide reasonable default values
- **Future extensibility**: Design APIs that can be extended later
- **Validation**: Validate configuration before applying changes
## Review Guidelines
### Core Requirements Checklist
- [ ] TypeScript strict mode passes (`yarn lint:types`)
- [ ] No ESLint errors or warnings (`yarn lint:eslint`)
- [ ] Prettier formatting applied (`yarn lint:prettier`)
- [ ] Lit analyzer passes (`yarn lint:lit`)
- [ ] Component follows Lit best practices
- [ ] Proper error handling implemented
- [ ] Loading states handled
- [ ] Mobile responsive
- [ ] Theme variables used
- [ ] Translations added
- [ ] Accessible to screen readers
- [ ] Tests added (where applicable)
- [ ] No console statements (use proper logging)
- [ ] Unused imports removed
- [ ] Proper naming conventions
### Text and Copy Checklist
- [ ] Follows terminology guidelines (Delete vs Remove, Create vs Add)
- [ ] Localization keys added for all user-facing text
- [ ] Uses "Home Assistant" (never "HA" or "HASS")
- [ ] Sentence case for ALL text (titles, headings, buttons, labels)
- [ ] American English spelling
- [ ] Friendly, informational tone
- [ ] Avoids abbreviations and jargon
- [ ] Correct terminology (add-on not addon, integration not component)
### Component-Specific Checks
- [ ] Dialogs implement HassDialog interface
- [ ] Dialog styling uses haStyleDialog
- [ ] Dialog accessibility includes dialogInitialFocus
- [ ] ha-alert used correctly for messages
- [ ] ha-form uses proper schema structure
- [ ] Components handle all states (loading, error, unavailable)
- [ ] Entity existence checked before property access
- [ ] Event subscriptions properly cleaned up

4
.gitignore vendored
View File

@@ -53,3 +53,7 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# AI tooling
.claude

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
.github/copilot-instructions.md

View File

@@ -416,6 +416,34 @@ const SCHEMAS: {
},
},
},
items: {
name: "Items",
selector: {
object: {
label_field: "name",
description_field: "value",
multiple: true,
fields: {
name: {
label: "Name",
selector: { text: {} },
required: true,
},
value: {
label: "Value",
selector: {
number: {
mode: "slider",
min: 0,
max: 100,
unit_of_measurement: "%",
},
},
},
},
},
},
},
},
},
];

View File

@@ -30,11 +30,11 @@
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.18.6",
"@codemirror/commands": "6.8.1",
"@codemirror/language": "6.11.1",
"@codemirror/language": "6.11.2",
"@codemirror/legacy-modes": "6.5.1",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.37.2",
"@codemirror/view": "6.38.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.0",
"@formatjs/intl-displaynames": "6.8.11",
@@ -45,12 +45,12 @@
"@formatjs/intl-numberformat": "8.15.4",
"@formatjs/intl-pluralrules": "5.4.4",
"@formatjs/intl-relativetimeformat": "11.4.11",
"@fullcalendar/core": "6.1.17",
"@fullcalendar/daygrid": "6.1.17",
"@fullcalendar/interaction": "6.1.17",
"@fullcalendar/list": "6.1.17",
"@fullcalendar/luxon3": "6.1.17",
"@fullcalendar/timegrid": "6.1.17",
"@fullcalendar/core": "6.1.18",
"@fullcalendar/daygrid": "6.1.18",
"@fullcalendar/interaction": "6.1.18",
"@fullcalendar/list": "6.1.18",
"@fullcalendar/luxon3": "6.1.18",
"@fullcalendar/timegrid": "6.1.18",
"@lezer/highlight": "1.2.1",
"@lit-labs/motion": "1.0.8",
"@lit-labs/observers": "2.0.5",
@@ -89,14 +89,14 @@
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.8.1",
"@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.8.0",
"@vaadin/vaadin-themable-mixin": "24.8.0",
"@vaadin/combo-box": "24.7.9",
"@vaadin/vaadin-themable-mixin": "24.7.9",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1",
"barcode-detector": "3.0.4",
"barcode-detector": "3.0.5",
"color-name": "2.0.0",
"comlink": "4.4.2",
"core-js": "3.43.0",
@@ -111,7 +111,7 @@
"fuse.js": "7.1.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.5",
"hls.js": "1.6.6",
"home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "10.7.16",
@@ -122,7 +122,7 @@
"lit": "3.3.0",
"lit-html": "3.3.0",
"luxon": "3.6.1",
"marked": "15.0.12",
"marked": "16.0.0",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.3",
"object-hash": "3.0.0",
@@ -135,7 +135,7 @@
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.3",
"ua-parser-js": "2.0.4",
"vis-data": "7.1.9",
"vue": "2.7.16",
"vue2-daterange-picker": "0.6.8",
@@ -149,26 +149,25 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.27.4",
"@babel/helper-define-polyfill-provider": "0.6.4",
"@babel/plugin-transform-runtime": "7.27.4",
"@babel/preset-env": "7.27.2",
"@bundle-stats/plugin-webpack-filter": "4.20.2",
"@lokalise/node-api": "14.8.0",
"@babel/core": "7.28.0",
"@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.0",
"@babel/preset-env": "7.28.0",
"@bundle-stats/plugin-webpack-filter": "4.21.0",
"@lokalise/node-api": "14.9.1",
"@octokit/auth-oauth-device": "8.0.1",
"@octokit/plugin-retry": "8.0.1",
"@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.1.3",
"@rspack/cli": "1.3.12",
"@rspack/core": "1.3.12",
"@rsdoctor/rspack-plugin": "1.1.7",
"@rspack/cli": "1.4.4",
"@rspack/core": "1.4.4",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/glob": "8.1.0",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.18",
"@types/leaflet": "1.9.19",
"@types/leaflet-draw": "1.0.12",
"@types/leaflet.markercluster": "1.5.5",
"@types/lodash.merge": "4.6.9",
@@ -179,18 +178,18 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "3.2.3",
"@vitest/coverage-v8": "3.2.4",
"babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0",
"eslint": "9.29.0",
"eslint": "9.30.1",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.5",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-lit": "2.1.1",
"eslint-plugin-lit-a11y": "5.0.1",
"eslint-plugin-lit-a11y": "5.1.0",
"eslint-plugin-unused-imports": "4.1.4",
"eslint-plugin-wc": "3.0.1",
"fancy-log": "2.0.0",
@@ -199,7 +198,7 @@
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.0.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "26.1.0",
@@ -210,7 +209,7 @@
"lodash.template": "4.5.0",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.5.3",
"prettier": "3.6.2",
"rspack-manifest-plugin": "5.0.3",
"serve": "14.2.4",
"sinon": "21.0.0",
@@ -218,9 +217,9 @@
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.8.3",
"typescript-eslint": "8.34.0",
"typescript-eslint": "8.35.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.3",
"vitest": "3.2.4",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -231,8 +230,8 @@
"lit-html": "3.3.0",
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.0",
"@fullcalendar/daygrid": "6.1.17",
"globals": "16.2.0",
"@fullcalendar/daygrid": "6.1.18",
"globals": "16.3.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},

View File

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

View File

@@ -11,7 +11,6 @@ export const COLORS = [
"#9c6b4e",
"#97bbf5",
"#01ab63",
"#9498a0",
"#094bad",
"#c99000",
"#d84f3e",
@@ -21,7 +20,6 @@ export const COLORS = [
"#8043ce",
"#7599d1",
"#7a4c31",
"#74787f",
"#6989f4",
"#ffd444",
"#ff957c",
@@ -31,7 +29,6 @@ export const COLORS = [
"#c884ff",
"#badeff",
"#bf8b6d",
"#b6bac2",
"#927acc",
"#97ee3f",
"#bf3947",
@@ -44,7 +41,6 @@ export const COLORS = [
"#d9b100",
"#9d7a00",
"#698cff",
"#d9d9d9",
"#00d27e",
"#d06800",
"#009f82",

View File

@@ -77,7 +77,7 @@ export const formatDateNumeric = (
const month = parts.find((value) => value.type === "month")?.value;
const year = parts.find((value) => value.type === "year")?.value;
const lastPart = parts.at(parts.length - 1);
const lastPart = parts[parts.length - 1];
let lastLiteral = lastPart?.type === "literal" ? lastPart?.value : "";
if (locale.language === "bg" && locale.date_format === DateFormat.YMD) {

View File

@@ -202,7 +202,6 @@ export function storage(options: {
// Don't set the initial value if we have a value in localStorage
if (this.__initialized || getValue() === undefined) {
setValue(this, value);
this.requestUpdate(propertyKey, undefined);
}
},
configurable: true,
@@ -212,11 +211,13 @@ export function storage(options: {
const oldSetter = descriptor.set;
newDescriptor = {
...descriptor,
get(this: ReactiveStorageElement) {
return getValue();
},
set(this: ReactiveStorageElement, value) {
// Don't set the initial value if we have a value in localStorage
if (this.__initialized || getValue() === undefined) {
setValue(this, value);
this.requestUpdate(propertyKey, undefined);
}
oldSetter?.call(this, value);
},

View File

@@ -0,0 +1,68 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeStateDomain } from "./compute_state_domain";
import { isUnavailableState, UNAVAILABLE } from "../../data/entity";
import type { HomeAssistant } from "../../types";
export const computeGroupEntitiesState = (states: HassEntity[]): string => {
if (!states.length) {
return UNAVAILABLE;
}
const validState = states.filter((stateObj) => isUnavailableState(stateObj));
if (!validState) {
return UNAVAILABLE;
}
// Use the first state to determine the domain
// This assumes all states in the group have the same domain
const domain = computeStateDomain(states[0]);
if (domain === "cover") {
for (const s of ["opening", "closing", "open"]) {
if (states.some((stateObj) => stateObj.state === s)) {
return s;
}
}
return "closed";
}
if (states.some((stateObj) => stateObj.state === "on")) {
return "on";
}
return "off";
};
export const toggleGroupEntities = (
hass: HomeAssistant,
states: HassEntity[]
) => {
if (!states.length) {
return;
}
// Use the first state to determine the domain
// This assumes all states in the group have the same domain
const domain = computeStateDomain(states[0]);
const state = computeGroupEntitiesState(states);
const isOn = state === "on" || state === "open";
let service = isOn ? "turn_off" : "turn_on";
if (domain === "cover") {
if (state === "opening" || state === "closing") {
// If the cover is opening or closing, we toggle it to stop it
service = "stop_cover";
} else {
// For covers, we use the open/close service
service = isOn ? "close_cover" : "open_cover";
}
}
const entitiesIds = states.map((stateObj) => stateObj.entity_id);
hass.callService(domain, service, {
entity_id: entitiesIds,
});
};

View File

@@ -64,15 +64,27 @@ export const domainStateColorProperties = (
const compareState = state !== undefined ? state : stateObj.state;
const active = stateActive(stateObj, state);
return domainColorProperties(
domain,
stateObj.attributes.device_class,
compareState,
active
);
};
export const domainColorProperties = (
domain: string,
deviceClass: string | undefined,
state: string,
active: boolean
) => {
const properties: string[] = [];
const stateKey = slugify(compareState, "_");
const stateKey = slugify(state, "_");
const activeKey = active ? "active" : "inactive";
const dc = stateObj.attributes.device_class;
if (dc) {
properties.push(`--state-${domain}-${dc}-${stateKey}-color`);
if (deviceClass) {
properties.push(`--state-${domain}-${deviceClass}-${stateKey}-color`);
}
properties.push(

View File

@@ -1,12 +1,14 @@
import memoizeOne from "memoize-one";
import { isIPAddress } from "./is_ip_address";
const collator = memoizeOne(
(language: string | undefined) => new Intl.Collator(language)
(language: string | undefined) =>
new Intl.Collator(language, { numeric: true })
);
const caseInsensitiveCollator = memoizeOne(
(language: string | undefined) =>
new Intl.Collator(language, { sensitivity: "accent" })
new Intl.Collator(language, { sensitivity: "accent", numeric: true })
);
const fallbackStringCompare = (a: string, b: string) => {
@@ -33,6 +35,19 @@ export const stringCompare = (
return fallbackStringCompare(a, b);
};
export const ipCompare = (a: string, b: string) => {
const aIsIpV4 = isIPAddress(a);
const bIsIpV4 = isIPAddress(b);
if (aIsIpV4 && bIsIpV4) {
return ipv4Compare(a, b);
}
if (!aIsIpV4 && !bIsIpV4) {
return ipV6Compare(a, b);
}
return aIsIpV4 ? -1 : 1;
};
export const caseInsensitiveStringCompare = (
a: string,
b: string,
@@ -64,3 +79,42 @@ export const orderCompare = (order: string[]) => (a: string, b: string) => {
return idxA - idxB;
};
function ipv4Compare(a: string, b: string) {
const num1 = Number(
a
.split(".")
.map((num) => num.padStart(3, "0"))
.join("")
);
const num2 = Number(
b
.split(".")
.map((num) => num.padStart(3, "0"))
.join("")
);
return num1 - num2;
}
function ipV6Compare(a: string, b: string) {
const ipv6a = normalizeIPv6(a)
.split(":")
.map((part) => part.padStart(4, "0"))
.join("");
const ipv6b = normalizeIPv6(b)
.split(":")
.map((part) => part.padStart(4, "0"))
.join("");
return ipv6a.localeCompare(ipv6b);
}
function normalizeIPv6(ip) {
const parts = ip.split("::");
const head = parts[0].split(":");
const tail = parts[1] ? parts[1].split(":") : [];
const totalParts = 8;
const missing = totalParts - (head.length + tail.length);
const zeros = new Array(missing).fill("0");
return [...head, ...zeros, ...tail].join(":");
}

View File

@@ -390,6 +390,7 @@ export class HaChartBase extends LitElement {
type: "inside",
orient: "horizontal",
filterMode: "none",
xAxisIndex: 0,
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
zoomLock: !this._isTouchDevice && !this._modifierPressed,

View File

@@ -226,22 +226,24 @@ export class StateHistoryChartLine extends LitElement {
this.maxYAxis;
if (typeof minYAxis === "number") {
if (this.fitYData) {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
minYAxis = ({ min }) =>
Math.min(this._roundYAxis(min, Math.floor), this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => {
const value = min > 0 ? min * 0.95 : min * 1.05;
return Math.abs(value) < 1 ? value : Math.floor(value);
return this._roundYAxis(value, Math.floor);
};
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
maxYAxis = ({ max }) =>
Math.max(this._roundYAxis(max, Math.ceil), this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => {
const value = max > 0 ? max * 1.05 : max * 0.95;
return Math.abs(value) < 1 ? value : Math.ceil(value);
return this._roundYAxis(value, Math.ceil);
};
}
this._chartOptions = {
@@ -729,20 +731,17 @@ export class StateHistoryChartLine extends LitElement {
}
private _formatYAxisLabel = (value: number) => {
const formatOptions =
value >= 1 || value <= -1
? undefined
: {
// show the first significant digit for tiny values
maximumFractionDigits: Math.max(
2,
// use the difference to the previous value to determine the number of significant digits #25526
-Math.floor(
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
)
),
};
const label = formatNumber(value, this.hass.locale, formatOptions);
// show the first significant digit for tiny values
const maximumFractionDigits = Math.max(
1,
// use the difference to the previous value to determine the number of significant digits #25526
-Math.floor(
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
)
);
const label = formatNumber(value, this.hass.locale, {
maximumFractionDigits,
});
const width = measureTextWidth(label, 12) + 5;
if (width > this._yWidth) {
this._yWidth = width;
@@ -767,6 +766,10 @@ export class StateHistoryChartLine extends LitElement {
}
return value;
}
private _roundYAxis(value: number, roundingFn: (value: number) => number) {
return Math.abs(value) < 1 ? value : roundingFn(value);
}
}
customElements.define("state-history-chart-line", StateHistoryChartLine);

View File

@@ -238,22 +238,24 @@ export class StatisticsChart extends LitElement {
this.maxYAxis;
if (typeof minYAxis === "number") {
if (this.fitYData) {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
minYAxis = ({ min }) =>
Math.min(this._roundYAxis(min, Math.floor), this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => {
const value = min > 0 ? min * 0.95 : min * 1.05;
return Math.abs(value) < 1 ? value : Math.floor(value);
return this._roundYAxis(value, Math.floor);
};
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
maxYAxis = ({ max }) =>
Math.max(this._roundYAxis(max, Math.ceil), this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => {
const value = max > 0 ? max * 1.05 : max * 0.95;
return Math.abs(value) < 1 ? value : Math.ceil(value);
return this._roundYAxis(value, Math.ceil);
};
}
const endTime = this.endTime ?? new Date();
@@ -634,6 +636,10 @@ export class StatisticsChart extends LitElement {
return value;
}
private _roundYAxis(value: number, roundingFn: (value: number) => number) {
return Math.abs(value) < 1 ? value : roundingFn(value);
}
static styles = css`
:host {
display: block;

View File

@@ -72,6 +72,7 @@ export interface DataTableColumnData<T = any> extends DataTableSortColumnData {
label?: TemplateResult | string;
type?:
| "numeric"
| "ip"
| "icon"
| "icon-button"
| "overflow"
@@ -506,7 +507,9 @@ export class HaDataTable extends LitElement {
this.hasFab,
this.groupColumn,
this.groupOrder,
this._collapsedGroups
this._collapsedGroups,
this.sortColumn,
this.sortDirection
)}
.keyFunction=${this._keyFunction}
.renderItem=${renderRow}
@@ -701,22 +704,37 @@ export class HaDataTable extends LitElement {
hasFab: boolean,
groupColumn: string | undefined,
groupOrder: string[] | undefined,
collapsedGroups: string[]
collapsedGroups: string[],
sortColumn: string | undefined,
sortDirection: SortingDirection
) => {
if (appendRow || hasFab || groupColumn) {
let items = [...data];
if (groupColumn) {
const isGroupSortColumn = sortColumn === groupColumn;
const grouped = groupBy(items, (item) => item[groupColumn]);
if (grouped.undefined) {
// make sure ungrouped items are at the bottom
grouped[UNDEFINED_GROUP_KEY] = grouped.undefined;
delete grouped.undefined;
}
const sorted: Record<string, DataTableRowData[]> = Object.keys(
const sortedEntries: [string, DataTableRowData[]][] = Object.keys(
grouped
)
.sort((a, b) => {
if (!groupOrder && isGroupSortColumn) {
const comparison = stringCompare(
a,
b,
this.hass.locale.language
);
if (sortDirection === "asc") {
return comparison;
}
return comparison * -1;
}
const orderA = groupOrder?.indexOf(a) ?? -1;
const orderB = groupOrder?.indexOf(b) ?? -1;
if (orderA !== orderB) {
@@ -734,12 +752,18 @@ export class HaDataTable extends LitElement {
this.hass.locale.language
);
})
.reduce((obj, key) => {
obj[key] = grouped[key];
return obj;
}, {});
.reduce(
(entries, key) => {
const entry: [string, DataTableRowData[]] = [key, grouped[key]];
entries.push(entry);
return entries;
},
[] as [string, DataTableRowData[]][]
);
const groupedItems: DataTableRowData[] = [];
Object.entries(sorted).forEach(([groupName, rows]) => {
sortedEntries.forEach(([groupName, rows]) => {
const collapsed = collapsedGroups.includes(groupName);
groupedItems.push({
append: true,
@@ -835,7 +859,9 @@ export class HaDataTable extends LitElement {
this.hasFab,
this.groupColumn,
this.groupOrder,
this._collapsedGroups
this._collapsedGroups,
this.sortColumn,
this.sortDirection
);
if (

View File

@@ -1,5 +1,5 @@
import { expose } from "comlink";
import { stringCompare } from "../../common/string/compare";
import { stringCompare, ipCompare } from "../../common/string/compare";
import { stripDiacritics } from "../../common/string/strip-diacritics";
import type {
ClonedDataTableColumnData,
@@ -57,6 +57,8 @@ const sortData = (
if (column.type === "numeric") {
valA = isNaN(valA) ? undefined : Number(valA);
valB = isNaN(valB) ? undefined : Number(valB);
} else if (column.type === "ip") {
return sort * ipCompare(valA, valB);
} else if (typeof valA === "string" && typeof valB === "string") {
return sort * stringCompare(valA, valB, language);
}

View File

@@ -366,6 +366,7 @@ export class HaAreaPicker extends LitElement {
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.helper=${this.helper}
.notFoundLabel=${this.hass.localize(
"ui.components.area-picker.no_match"
)}

View File

@@ -0,0 +1,282 @@
import { mdiDrag, mdiTextureBox } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { areaCompare } from "../data/area_registry";
import type { FloorRegistryEntry } from "../data/floor_registry";
import { getFloors } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-floor-icon";
import "./ha-items-display-editor";
import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
import "./ha-svg-icon";
import "./ha-textfield";
export interface AreasFloorsDisplayValue {
areas_display?: {
hidden?: string[];
order?: string[];
};
floors_display?: {
order?: string[];
};
}
const UNASSIGNED_FLOOR = "__unassigned__";
@customElement("ha-areas-floors-display-editor")
export class HaAreasFloorsDisplayEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: AreasFloorsDisplayValue;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property({ type: Boolean, attribute: "show-navigation-button" })
public showNavigationButton = false;
protected render(): TemplateResult {
const groupedAreasItems = this._groupedAreasItems(
this.hass.areas,
this.hass.floors
);
const filteredFloors = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
).filter(
(floor) =>
// Only include floors that have areas assigned to them
groupedAreasItems[floor.floor_id]?.length > 0
);
const value: DisplayValue = {
order: this.value?.areas_display?.order ?? [],
hidden: this.value?.areas_display?.hidden ?? [],
};
const canReorderFloors =
filteredFloors.filter((floor) => floor.floor_id !== UNASSIGNED_FLOOR)
.length > 1;
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-sortable
draggable-selector=".draggable"
handle-selector=".handle"
@item-moved=${this._floorMoved}
.disabled=${this.disabled || !canReorderFloors}
invert-swap
>
<div>
${repeat(
filteredFloors,
(floor) => floor.floor_id,
(floor: FloorRegistryEntry) => html`
<ha-expansion-panel
outlined
.header=${computeFloorName(floor)}
left-chevron
class=${floor.floor_id === UNASSIGNED_FLOOR ? "" : "draggable"}
>
<ha-floor-icon
slot="leading-icon"
.floor=${floor}
></ha-floor-icon>
${floor.floor_id === UNASSIGNED_FLOOR || !canReorderFloors
? nothing
: html`
<ha-svg-icon
class="handle"
slot="icons"
.path=${mdiDrag}
></ha-svg-icon>
`}
<ha-items-display-editor
.hass=${this.hass}
.items=${groupedAreasItems[floor.floor_id]}
.value=${value}
.floorId=${floor.floor_id}
@value-changed=${this._areaDisplayChanged}
.showNavigationButton=${this.showNavigationButton}
></ha-items-display-editor>
</ha-expansion-panel>
`
)}
</div>
</ha-sortable>
`;
}
private _groupedAreasItems = memoizeOne(
(
hassAreas: HomeAssistant["areas"],
// update items if floors change
_hassFloors: HomeAssistant["floors"]
): Record<string, DisplayItem[]> => {
const compare = areaCompare(hassAreas);
const areas = Object.values(hassAreas).sort((areaA, areaB) =>
compare(areaA.area_id, areaB.area_id)
);
const groupedItems: Record<string, DisplayItem[]> = areas.reduce(
(acc, area) => {
const { floor } = getAreaContext(area, this.hass!);
const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR;
if (!acc[floorId]) {
acc[floorId] = [];
}
acc[floorId].push({
value: area.area_id,
label: area.name,
icon: area.icon ?? undefined,
iconPath: mdiTextureBox,
});
return acc;
},
{} as Record<string, DisplayItem[]>
);
return groupedItems;
}
);
private _sortedFloors = memoizeOne(
(
hassFloors: HomeAssistant["floors"],
order: string[] | undefined
): FloorRegistryEntry[] => {
const floors = getFloors(hassFloors, order);
const noFloors = floors.length === 0;
floors.push({
floor_id: UNASSIGNED_FLOOR,
name: noFloors
? this.hass.localize("ui.panel.lovelace.strategy.areas.areas")
: this.hass.localize("ui.panel.lovelace.strategy.areas.other_areas"),
icon: null,
level: null,
aliases: [],
created_at: 0,
modified_at: 0,
});
return floors;
}
);
private _floorMoved(ev: CustomEvent<HASSDomEvents["item-moved"]>) {
ev.stopPropagation();
const newIndex = ev.detail.newIndex;
const oldIndex = ev.detail.oldIndex;
const floorIds = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
).map((floor) => floor.floor_id);
const newOrder = [...floorIds];
const movedFloorId = newOrder.splice(oldIndex, 1)[0];
newOrder.splice(newIndex, 0, movedFloorId);
const newValue: AreasFloorsDisplayValue = {
areas_display: this.value?.areas_display,
floors_display: {
order: newOrder,
},
};
if (newValue.floors_display?.order?.length === 0) {
delete newValue.floors_display.order;
}
fireEvent(this, "value-changed", { value: newValue });
}
private async _areaDisplayChanged(ev: CustomEvent<{ value: DisplayValue }>) {
ev.stopPropagation();
const value = ev.detail.value;
const currentFloorId = (ev.currentTarget as any).floorId;
const floorIds = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
).map((floor) => floor.floor_id);
const oldAreaDisplay = this.value?.areas_display ?? {};
const oldHidden = oldAreaDisplay?.hidden ?? [];
const oldOrder = oldAreaDisplay?.order ?? [];
const newHidden: string[] = [];
const newOrder: string[] = [];
for (const floorId of floorIds) {
if ((currentFloorId ?? UNASSIGNED_FLOOR) === floorId) {
newHidden.push(...(value.hidden ?? []));
newOrder.push(...(value.order ?? []));
continue;
}
const hidden = oldHidden.filter((areaId) => {
const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
if (hidden?.length) {
newHidden.push(...hidden);
}
const order = oldOrder.filter((areaId) => {
const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
if (order?.length) {
newOrder.push(...order);
}
}
const newValue: AreasFloorsDisplayValue = {
areas_display: {
hidden: newHidden,
order: newOrder,
},
floors_display: this.value?.floors_display,
};
if (newValue.areas_display?.hidden?.length === 0) {
delete newValue.areas_display.hidden;
}
if (newValue.areas_display?.order?.length === 0) {
delete newValue.areas_display.order;
}
if (newValue.floors_display?.order?.length === 0) {
delete newValue.floors_display.order;
}
fireEvent(this, "value-changed", { value: newValue });
}
static styles = css`
ha-expansion-panel {
margin-bottom: 8px;
--expansion-panel-summary-padding: 0 16px;
}
ha-expansion-panel [slot="leading-icon"] {
margin-inline-end: 16px;
}
label {
display: block;
font-weight: var(--ha-font-weight-bold);
margin-bottom: 8px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-areas-floors-display-editor": HaAreasFloorsDisplayEditor;
}
}

View File

@@ -0,0 +1,61 @@
import { css, html, LitElement, type PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import parseAspectRatio from "../common/util/parse-aspect-ratio";
const DEFAULT_ASPECT_RATIO = "16:9";
@customElement("ha-aspect-ratio")
export class HaAspectRatio extends LitElement {
@property({ type: String, attribute: "aspect-ratio" })
public aspectRatio?: string;
private _ratio: {
w: number;
h: number;
} | null = null;
public willUpdate(changedProps: PropertyValues) {
if (changedProps.has("aspect_ratio") || this._ratio === null) {
this._ratio = this.aspectRatio
? parseAspectRatio(this.aspectRatio)
: null;
if (this._ratio === null || this._ratio.w <= 0 || this._ratio.h <= 0) {
this._ratio = parseAspectRatio(DEFAULT_ASPECT_RATIO);
}
}
}
protected render(): unknown {
if (!this.aspectRatio) {
return html`<slot></slot>`;
}
return html`
<div
class="ratio"
style=${styleMap({
paddingBottom: `${((100 * this._ratio!.h) / this._ratio!.w).toFixed(2)}%`,
})}
>
<slot></slot>
</div>
`;
}
static styles = css`
.ratio ::slotted(*) {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-aspect-ratio": HaAspectRatio;
}
}

View File

@@ -1,138 +0,0 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "./ha-svg-icon";
@customElement("ha-automation-row")
export class HaAutomationRow extends LitElement {
@property() header?: string;
@property() secondary?: string;
protected render(): TemplateResult {
return html`
<div class="top">
<div
id="summary"
@click=${this._toggleContainer}
@keydown=${this._toggleContainer}
role="button"
>
<slot name="leading-icon"></slot>
<slot name="header">
<div class="header">
${this.header}
<slot class="secondary" name="secondary">${this.secondary}</slot>
</div>
</slot>
<slot name="icons"></slot>
</div>
</div>
`;
}
private async _toggleContainer(ev): Promise<void> {
if (ev.defaultPrevented) {
return;
}
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") {
return;
}
ev.preventDefault();
this.click();
}
static styles = css`
:host {
display: block;
}
.top {
display: flex;
align-items: center;
border-radius: var(--ha-card-border-radius, 12px);
}
.top.expanded {
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
.top.focused {
background: var(--input-fill-color);
}
:host([outlined]) {
box-shadow: none;
border-width: 1px;
border-style: solid;
border-color: var(--outline-color);
border-radius: var(--ha-card-border-radius, 12px);
}
.summary-icon {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
direction: var(--direction);
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
}
:host([left-chevron]) .summary-icon,
::slotted([slot="leading-icon"]) {
margin-left: 0;
margin-right: 8px;
margin-inline-start: 0;
margin-inline-end: 8px;
}
#summary {
flex: 1;
display: flex;
padding: var(--expansion-panel-summary-padding, 0 8px);
min-height: 48px;
align-items: center;
cursor: pointer;
overflow: hidden;
font-weight: var(--ha-font-weight-medium);
outline: none;
}
#summary.noCollapse {
cursor: default;
}
.summary-icon.expanded {
transform: rotate(180deg);
}
.header,
::slotted([slot="header"]) {
flex: 1;
overflow-wrap: anywhere;
}
.container {
padding: var(--expansion-panel-content-padding, 0 8px);
overflow: hidden;
transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1);
height: 0px;
}
.container.expanded {
height: auto;
}
.secondary {
display: block;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-row": HaAutomationRow;
}
}

View File

@@ -6,6 +6,7 @@ import type {
} from "@codemirror/autocomplete";
import type { Extension, TransactionSpec } from "@codemirror/state";
import type { EditorView, KeyBinding, ViewUpdate } from "@codemirror/view";
import { mdiArrowExpand, mdiArrowCollapse } from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, ReactiveElement } from "lit";
@@ -15,6 +16,7 @@ import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-icon-button";
declare global {
interface HASSDomEvents {
@@ -59,8 +61,13 @@ export class HaCodeEditor extends ReactiveElement {
@property({ type: Boolean }) public error = false;
@property({ type: Boolean, attribute: "disable-fullscreen" })
public disableFullscreen = false;
@state() private _value = "";
@state() private _isFullscreen = false;
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
private _loadedCodeMirror?: typeof import("../resources/codemirror");
@@ -92,6 +99,7 @@ export class HaCodeEditor extends ReactiveElement {
this.requestUpdate();
}
this.addEventListener("keydown", stopPropagation);
this.addEventListener("keydown", this._handleKeyDown);
// This is unreachable as editor will not exist yet,
// but focus should not behave like this for good a11y.
// (@steverep to fix in autofocus PR)
@@ -106,6 +114,10 @@ export class HaCodeEditor extends ReactiveElement {
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("keydown", stopPropagation);
this.removeEventListener("keydown", this._handleKeyDown);
if (this._isFullscreen) {
this._toggleFullscreen();
}
this.updateComplete.then(() => {
this.codemirror!.destroy();
delete this.codemirror;
@@ -164,6 +176,12 @@ export class HaCodeEditor extends ReactiveElement {
if (changedProps.has("error")) {
this.classList.toggle("error-state", this.error);
}
if (changedProps.has("_isFullscreen")) {
this.classList.toggle("fullscreen", this._isFullscreen);
}
if (changedProps.has("disableFullscreen")) {
this._updateFullscreenButton();
}
}
private get _mode() {
@@ -238,8 +256,74 @@ export class HaCodeEditor extends ReactiveElement {
}),
parent: this.renderRoot,
});
this._updateFullscreenButton();
}
private _updateFullscreenButton() {
const existingButton = this.renderRoot.querySelector(".fullscreen-button");
if (this.disableFullscreen) {
// Remove button if it exists and fullscreen is disabled
if (existingButton) {
existingButton.remove();
}
// Exit fullscreen if currently in fullscreen mode
if (this._isFullscreen) {
this._isFullscreen = false;
}
return;
}
// Create button if it doesn't exist
if (!existingButton) {
const button = document.createElement("ha-icon-button");
(button as any).path = this._isFullscreen
? mdiArrowCollapse
: mdiArrowExpand;
button.setAttribute(
"label",
this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen"
);
button.classList.add("fullscreen-button");
// Use bound method to ensure proper this context
button.addEventListener("click", this._handleFullscreenClick);
this.renderRoot.appendChild(button);
} else {
// Update existing button
(existingButton as any).path = this._isFullscreen
? mdiArrowCollapse
: mdiArrowExpand;
existingButton.setAttribute(
"label",
this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen"
);
}
}
private _handleFullscreenClick = (e: Event) => {
e.preventDefault();
e.stopPropagation();
this._toggleFullscreen();
};
private _toggleFullscreen() {
this._isFullscreen = !this._isFullscreen;
this._updateFullscreenButton();
}
private _handleKeyDown = (e: KeyboardEvent) => {
if (this._isFullscreen && e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
this._toggleFullscreen();
} else if (e.key === "F11" && !this.disableFullscreen) {
e.preventDefault();
e.stopPropagation();
this._toggleFullscreen();
}
};
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
if (!states) {
return [];
@@ -257,6 +341,126 @@ export class HaCodeEditor extends ReactiveElement {
private _entityCompletions(
context: CompletionContext
): CompletionResult | null | Promise<CompletionResult | null> {
// Check for YAML mode and entity-related fields
if (this.mode === "yaml") {
const currentLine = context.state.doc.lineAt(context.pos);
const lineText = currentLine.text;
// Properties that commonly contain entity IDs
const entityProperties = [
"entity_id",
"entity",
"entities",
"badges",
"devices",
"lights",
"light",
"group_members",
"scene",
"zone",
"zones",
];
// Create regex pattern for all entity properties
const propertyPattern = entityProperties.join("|");
const entityFieldRegex = new RegExp(
`^\\s*(-\\s+)?(${propertyPattern}):\\s*`
);
// Check if we're in an entity field (single entity or list item)
const entityFieldMatch = lineText.match(entityFieldRegex);
const listItemMatch = lineText.match(/^\s*-\s+/);
if (entityFieldMatch) {
// Calculate the position after the entity field
const afterField = currentLine.from + entityFieldMatch[0].length;
// If cursor is after the entity field, show all entities
if (context.pos >= afterField) {
const states = this._getStates(this.hass!.states);
if (!states || !states.length) {
return null;
}
// Find what's already typed after the field
const typedText = context.state.sliceDoc(afterField, context.pos);
// Filter states based on what's typed
const filteredStates = typedText
? states.filter((entityState) =>
entityState.label
.toLowerCase()
.startsWith(typedText.toLowerCase())
)
: states;
return {
from: afterField,
options: filteredStates,
validFor: /^[a-z_]*\.?\w*$/,
};
}
} else if (listItemMatch) {
// Check if this is a list item under an entity_id field
const lineNumber = currentLine.number;
// Look at previous lines to check if we're under an entity_id field
for (let i = lineNumber - 1; i > 0 && i >= lineNumber - 10; i--) {
const prevLine = context.state.doc.line(i);
const prevText = prevLine.text;
// Stop if we hit a non-indented line (new field)
if (
prevText.trim() &&
!prevText.startsWith(" ") &&
!prevText.startsWith("\t")
) {
break;
}
// Check if we found an entity property field
const entityListFieldRegex = new RegExp(
`^\\s*(${propertyPattern}):\\s*$`
);
if (prevText.match(entityListFieldRegex)) {
// We're in a list under an entity field
const afterListMarker = currentLine.from + listItemMatch[0].length;
if (context.pos >= afterListMarker) {
const states = this._getStates(this.hass!.states);
if (!states || !states.length) {
return null;
}
// Find what's already typed after the list marker
const typedText = context.state.sliceDoc(
afterListMarker,
context.pos
);
// Filter states based on what's typed
const filteredStates = typedText
? states.filter((entityState) =>
entityState.label
.toLowerCase()
.startsWith(typedText.toLowerCase())
)
: states;
return {
from: afterListMarker,
options: filteredStates,
validFor: /^[a-z_]*\.?\w*$/,
};
}
}
}
}
}
// Original entity completion logic for non-YAML or when not in entity_id field
const entityWord = context.matchBefore(/[a-z_]{3,}\.\w*/);
if (
@@ -340,9 +544,77 @@ export class HaCodeEditor extends ReactiveElement {
};
static styles = css`
:host {
position: relative;
display: block;
}
:host(.error-state) .cm-gutters {
border-color: var(--error-state-color, red);
}
.fullscreen-button {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
color: var(--secondary-text-color);
background-color: var(--secondary-background-color);
border-radius: 50%;
opacity: 0.9;
transition: opacity 0.2s;
--mdc-icon-button-size: 32px;
--mdc-icon-size: 18px;
/* Ensure button is clickable on iOS */
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.fullscreen-button:hover,
.fullscreen-button:active {
opacity: 1;
}
@media (hover: none) {
.fullscreen-button {
opacity: 0.8;
}
}
:host(.fullscreen) {
position: fixed !important;
top: calc(var(--header-height, 56px) + 8px) !important;
left: 8px !important;
right: 8px !important;
bottom: 8px !important;
z-index: 9999 !important;
border-radius: 12px !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important;
overflow: hidden !important;
background-color: var(
--code-editor-background-color,
var(--card-background-color)
) !important;
margin: 0 !important;
padding-top: var(--safe-area-inset-top) !important;
padding-left: var(--safe-area-inset-left) !important;
padding-right: var(--safe-area-inset-right) !important;
padding-bottom: var(--safe-area-inset-bottom) !important;
box-sizing: border-box !important;
display: block !important;
}
:host(.fullscreen) .cm-editor {
height: 100% !important;
max-height: 100% !important;
border-radius: 0 !important;
}
:host(.fullscreen) .fullscreen-button {
top: calc(var(--safe-area-inset-top, 0px) + 8px);
right: calc(var(--safe-area-inset-right, 0px) + 8px);
}
`;
}

View File

@@ -369,7 +369,6 @@ export class HaComboBox extends LitElement {
}
vaadin-combo-box-light {
position: relative;
--vaadin-combo-box-overlay-max-height: calc(45vh - 56px);
}
ha-combo-box-textfield {
width: 100%;

View File

@@ -18,6 +18,8 @@ export class HaDomainIcon extends LitElement {
@property({ attribute: false }) public deviceClass?: string;
@property({ attribute: false }) public state?: string;
@property() public icon?: string;
@property({ attribute: "brand-fallback", type: Boolean })
@@ -36,14 +38,17 @@ export class HaDomainIcon extends LitElement {
return this._renderFallback();
}
const icon = domainIcon(this.hass, this.domain, this.deviceClass).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
const icon = domainIcon(
this.hass,
this.domain,
this.deviceClass,
this.state
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
);
return this._renderFallback();
});
return html`${until(icon)}`;
}

View File

@@ -14,6 +14,7 @@ import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-list";
import { deepEqual } from "../common/util/deep-equal";
@customElement("ha-filter-blueprints")
export class HaFilterBlueprints extends LitElement {
@@ -34,10 +35,11 @@ export class HaFilterBlueprints extends LitElement {
public willUpdate(properties: PropertyValues) {
super.willUpdate(properties);
if (!this.hasUpdated) {
if (this.value?.length) {
this._findRelated();
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
) {
this._findRelated();
}
}
@@ -130,17 +132,15 @@ export class HaFilterBlueprints extends LitElement {
}
this.value = value;
this._findRelated();
}
private async _findRelated() {
if (!this.value?.length) {
this.value = [];
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}

View File

@@ -6,6 +6,7 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
import { stringCompare } from "../common/string/compare";
import { deepEqual } from "../common/util/deep-equal";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
@@ -37,9 +38,13 @@ export class HaFilterDevices extends LitElement {
if (!this.hasUpdated) {
loadVirtualizer();
if (this.value?.length) {
this._findRelated();
}
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
) {
this._findRelated();
}
}
@@ -110,7 +115,6 @@ export class HaFilterDevices extends LitElement {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
this._findRelated();
}
protected updated(changed) {
@@ -160,11 +164,11 @@ export class HaFilterDevices extends LitElement {
const relatedPromises: Promise<RelatedResult>[] = [];
if (!this.value?.length) {
this.value = [];
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
@@ -176,7 +180,6 @@ export class HaFilterDevices extends LitElement {
relatedPromises.push(findRelated(this.hass, "device", deviceId));
}
}
this.value = value;
const results = await Promise.all(relatedPromises);
const items = new Set<string>();
for (const result of results) {

View File

@@ -17,6 +17,7 @@ import "./ha-expansion-panel";
import "./ha-list";
import "./ha-state-icon";
import "./search-input-outlined";
import { deepEqual } from "../common/util/deep-equal";
@customElement("ha-filter-entities")
export class HaFilterEntities extends LitElement {
@@ -39,9 +40,13 @@ export class HaFilterEntities extends LitElement {
if (!this.hasUpdated) {
loadVirtualizer();
if (this.value?.length) {
this._findRelated();
}
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
) {
this._findRelated();
}
}
@@ -131,7 +136,6 @@ export class HaFilterEntities extends LitElement {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
this._findRelated();
}
private _expandedWillChange(ev) {
@@ -178,11 +182,11 @@ export class HaFilterEntities extends LitElement {
const relatedPromises: Promise<RelatedResult>[] = [];
if (!this.value?.length) {
this.value = [];
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}

View File

@@ -20,6 +20,7 @@ import "./ha-icon-button";
import "./ha-list";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import { deepEqual } from "../common/util/deep-equal";
@customElement("ha-filter-floor-areas")
export class HaFilterFloorAreas extends LitElement {
@@ -41,10 +42,11 @@ export class HaFilterFloorAreas extends LitElement {
public willUpdate(properties: PropertyValues) {
super.willUpdate(properties);
if (!this.hasUpdated) {
if (this.value?.floors?.length || this.value?.areas?.length) {
this._findRelated();
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
) {
this._findRelated();
}
}
@@ -174,8 +176,6 @@ export class HaFilterFloorAreas extends LitElement {
}
listItem.selected = this.value[type]?.includes(value);
this._findRelated();
}
protected updated(changed) {
@@ -188,10 +188,6 @@ export class HaFilterFloorAreas extends LitElement {
}
}
protected firstUpdated() {
this._findRelated();
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
@@ -226,6 +222,7 @@ export class HaFilterFloorAreas extends LitElement {
!this.value ||
(!this.value.areas?.length && !this.value.floors?.length)
) {
this.value = {};
fireEvent(this, "data-table-filter-changed", {
value: {},
items: undefined,

View File

@@ -30,6 +30,22 @@ export const floorDefaultIconPath = (
return mdiHome;
};
export const floorDefaultIcon = (floor: Pick<FloorRegistryEntry, "level">) => {
switch (floor.level) {
case 0:
return "mdi:home-floor-0";
case 1:
return "mdi:home-floor-1";
case 2:
return "mdi:home-floor-2";
case 3:
return "mdi:home-floor-3";
case -1:
return "mdi:home-floor-negative-1";
}
return "mdi:home";
};
@customElement("ha-floor-icon")
export class HaFloorIcon extends LitElement {
@property({ attribute: false }) public floor!: Pick<

View File

@@ -24,12 +24,16 @@ export class HaFormSelect extends LitElement implements HaFormElement {
@property() public helper?: string;
@property({ attribute: false })
public localizeValue?: (key: string) => string;
@property({ type: Boolean }) public disabled = false;
private _selectSchema = memoizeOne(
(options): SelectSelector => ({
(schema: HaFormSelectSchema): SelectSelector => ({
select: {
options: options.map((option) => ({
translation_key: schema.name,
options: schema.options.map((option) => ({
value: option[0],
label: option[1],
})),
@@ -41,13 +45,13 @@ export class HaFormSelect extends LitElement implements HaFormElement {
return html`
<ha-selector-select
.hass=${this.hass}
.schema=${this.schema}
.value=${this.data}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.schema.required}
.selector=${this._selectSchema(this.schema.options)}
.required=${this.schema.required || false}
.selector=${this._selectSchema(this.schema)}
.localizeValue=${this.localizeValue}
@value-changed=${this._valueChanged}
></ha-selector-select>
`;

View File

@@ -122,22 +122,6 @@ export class HaItemDisplayEditor extends LitElement {
${description
? html`<span slot="supporting-text">${description}</span>`
: nothing}
${isVisible && !disableSorting
? html`
<ha-svg-icon
tabindex=${ifDefined(
this.showNavigationButton ? "0" : undefined
)}
.idx=${idx}
@keydown=${this.showNavigationButton
? this._dragHandleKeydown
: undefined}
class="handle"
.path=${mdiDrag}
slot="start"
></ha-svg-icon>
`
: html`<ha-svg-icon slot="start"></ha-svg-icon>`}
${!showIcon
? nothing
: icon
@@ -162,6 +146,9 @@ export class HaItemDisplayEditor extends LitElement {
<span slot="end"> ${this.actionsRenderer(item)} </span>
`
: nothing}
${this.showNavigationButton
? html`<ha-icon-next slot="end"></ha-icon-next>`
: nothing}
<ha-icon-button
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="end"
@@ -174,9 +161,22 @@ export class HaItemDisplayEditor extends LitElement {
.value=${value}
@click=${this._toggle}
></ha-icon-button>
${this.showNavigationButton
? html` <ha-icon-next slot="end"></ha-icon-next> `
: nothing}
${isVisible && !disableSorting
? html`
<ha-svg-icon
tabindex=${ifDefined(
this.showNavigationButton ? "0" : undefined
)}
.idx=${idx}
@keydown=${this.showNavigationButton
? this._dragHandleKeydown
: undefined}
class="handle"
.path=${mdiDrag}
slot="end"
></ha-svg-icon>
`
: html`<ha-svg-icon slot="end"></ha-svg-icon>`}
</ha-md-list-item>
`;
}

View File

@@ -44,7 +44,7 @@ class HaNavigationList extends LitElement {
>
<ha-svg-icon .path=${page.iconPath}></ha-svg-icon>
</div>
<span>${page.name}</span>
<span slot="headline">${page.name}</span>
${this.hasSecondary
? html`<span slot="supporting-text">${page.description}</span>`
: ""}

View File

@@ -54,7 +54,7 @@ export class HaAreaSelector extends LitElement {
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("selector") && this.value !== undefined) {
if (changedProperties.get("selector") && this.value !== undefined) {
if (this.selector.area?.multiple && !Array.isArray(this.value)) {
this.value = [this.value];
fireEvent(this, "value-changed", { value: this.value });

View File

@@ -56,7 +56,7 @@ export class HaDeviceSelector extends LitElement {
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("selector") && this.value !== undefined) {
if (changedProperties.get("selector") && this.value !== undefined) {
if (this.selector.device?.multiple && !Array.isArray(this.value)) {
this.value = [this.value];
fireEvent(this, "value-changed", { value: this.value });

View File

@@ -43,7 +43,7 @@ export class HaEntitySelector extends LitElement {
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("selector") && this.value !== undefined) {
if (changedProperties.get("selector") && this.value !== undefined) {
if (this.selector.entity?.multiple && !Array.isArray(this.value)) {
this.value = [this.value];
fireEvent(this, "value-changed", { value: this.value });

View File

@@ -54,7 +54,7 @@ export class HaFloorSelector extends LitElement {
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("selector") && this.value !== undefined) {
if (changedProperties.get("selector") && this.value !== undefined) {
if (this.selector.floor?.multiple && !Array.isArray(this.value)) {
this.value = [this.value];
fireEvent(this, "value-changed", { value: this.value });

View File

@@ -1,6 +1,6 @@
import { mdiPlayBox, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../common/dom/fire_event";
@@ -24,6 +24,10 @@ const MANUAL_SCHEMA = [
{ name: "media_content_type", required: false, selector: { text: {} } },
] as const;
const INCLUDE_DOMAINS = ["media_player"];
const EMPTY_FORM = {};
@customElement("ha-selector-media")
export class HaMediaSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -84,85 +88,104 @@ export class HaMediaSelector extends LitElement {
(stateObj &&
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
return html`<ha-entity-picker
.hass=${this.hass}
.value=${this.value?.entity_id}
.label=${this.label ||
this.hass.localize("ui.components.selectors.media.pick_media_player")}
.disabled=${this.disabled}
.helper=${this.helper}
.required=${this.required}
include-domains='["media_player"]'
allow-custom-entity
@value-changed=${this._entityChanged}
></ha-entity-picker>
const hasAccept = this.selector?.media?.accept?.length;
return html`
${hasAccept
? nothing
: html`
<ha-entity-picker
.hass=${this.hass}
.value=${this.value?.entity_id}
.label=${this.label ||
this.hass.localize(
"ui.components.selectors.media.pick_media_player"
)}
.disabled=${this.disabled}
.helper=${this.helper}
.required=${this.required}
.includeDomains=${INCLUDE_DOMAINS}
allow-custom-entity
@value-changed=${this._entityChanged}
></ha-entity-picker>
`}
${!supportsBrowse
? html`<ha-alert>
? html`
<ha-alert>
${this.hass.localize(
"ui.components.selectors.media.browse_not_supported"
)}
</ha-alert>
<ha-form
.hass=${this.hass}
.data=${this.value}
.data=${this.value || EMPTY_FORM}
.schema=${MANUAL_SCHEMA}
.computeLabel=${this._computeLabelCallback}
></ha-form>`
: html`<ha-card
outlined
@click=${this._pickMedia}
class=${this.disabled || !this.value?.entity_id ? "disabled" : ""}
>
<div
class="thumbnail ${classMap({
portrait:
!!this.value?.metadata?.media_class &&
MediaClassBrowserSettings[
this.value.metadata.children_media_class ||
this.value.metadata.media_class
].thumbnail_ratio === "portrait",
})}"
>
${this.value?.metadata?.thumbnail
? html`
<div
class="${classMap({
"centered-image":
!!this.value.metadata.media_class &&
["app", "directory"].includes(
this.value.metadata.media_class
),
})}
image"
style=${this._thumbnailUrl
? `background-image: url(${this._thumbnailUrl});`
: ""}
></div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${!this.value?.media_content_id
? mdiPlus
: this.value?.metadata?.media_class
? MediaClassBrowserSettings[
this.value.metadata.media_class === "directory"
? this.value.metadata.children_media_class ||
this.value.metadata.media_class
: this.value.metadata.media_class
].icon
: mdiPlayBox}
></ha-svg-icon>
</div>
`}
</div>
<div class="title">
${!this.value?.media_content_id
></ha-form>
`
: html`
<ha-card
outlined
tabindex="0"
role="button"
aria-label=${!this.value?.media_content_id
? this.hass.localize("ui.components.selectors.media.pick_media")
: this.value.metadata?.title || this.value.media_content_id}
</div>
</ha-card>`}`;
@click=${this._pickMedia}
@keydown=${this._handleKeyDown}
class=${this.disabled || (!this.value?.entity_id && !hasAccept)
? "disabled"
: ""}
>
<div class="content-container">
<div class="thumbnail">
${this.value?.metadata?.thumbnail
? html`
<div
class="${classMap({
"centered-image":
!!this.value.metadata.media_class &&
["app", "directory"].includes(
this.value.metadata.media_class
),
})}
image"
style=${this._thumbnailUrl
? `background-image: url(${this._thumbnailUrl});`
: ""}
></div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${!this.value?.media_content_id
? mdiPlus
: this.value?.metadata?.media_class
? MediaClassBrowserSettings[
this.value.metadata.media_class ===
"directory"
? this.value.metadata
.children_media_class ||
this.value.metadata.media_class
: this.value.metadata.media_class
].icon
: mdiPlayBox}
></ha-svg-icon>
</div>
`}
</div>
<div class="title">
${!this.value?.media_content_id
? this.hass.localize(
"ui.components.selectors.media.pick_media"
)
: this.value.metadata?.title || this.value.media_content_id}
</div>
</div>
</ha-card>
`}
`;
}
private _computeLabelCallback = (
@@ -184,8 +207,9 @@ export class HaMediaSelector extends LitElement {
private _pickMedia() {
showMediaBrowserDialog(this, {
action: "pick",
entityId: this.value!.entity_id!,
navigateIds: this.value!.metadata?.navigateIds,
entityId: this.value?.entity_id,
navigateIds: this.value?.metadata?.navigateIds,
accept: this.selector.media?.accept,
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
fireEvent(this, "value-changed", {
value: {
@@ -208,6 +232,13 @@ export class HaMediaSelector extends LitElement {
});
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._pickMedia();
}
}
static styles = css`
ha-entity-picker {
display: block;
@@ -222,41 +253,52 @@ export class HaMediaSelector extends LitElement {
}
ha-card {
position: relative;
width: 200px;
width: 100%;
box-sizing: border-box;
cursor: pointer;
transition: background-color 180ms ease-in-out;
min-height: 56px;
}
ha-card:hover:not(.disabled),
ha-card:focus:not(.disabled) {
background-color: var(--state-icon-hover-color, rgba(0, 0, 0, 0.04));
}
ha-card:focus {
outline: none;
}
ha-card.disabled {
pointer-events: none;
color: var(--disabled-text-color);
}
.content-container {
display: flex;
align-items: center;
padding: 8px;
gap: 12px;
}
ha-card .thumbnail {
width: 100%;
width: 40px;
height: 40px;
flex-shrink: 0;
position: relative;
box-sizing: border-box;
transition: padding-bottom 0.1s ease-out;
padding-bottom: 100%;
}
ha-card .thumbnail.portrait {
padding-bottom: 150%;
border-radius: 8px;
overflow: hidden;
}
ha-card .image {
border-radius: 3px 3px 0 0;
border-radius: 8px;
}
.folder {
--mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4);
--mdc-icon-size: 24px;
}
.title {
font-size: var(--ha-font-size-l);
padding-top: 16px;
font-size: var(--ha-font-size-m);
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 16px;
padding-left: 16px;
padding-right: 4px;
padding-inline-start: 16px;
padding-inline-end: 4px;
white-space: nowrap;
line-height: 1.4;
flex: 1;
min-width: 0;
}
.image {
position: absolute;
@@ -269,13 +311,15 @@ export class HaMediaSelector extends LitElement {
background-position: center;
}
.centered-image {
margin: 0 8px;
margin: 4px;
background-size: contain;
}
.icon-holder {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
`;
}

View File

@@ -23,6 +23,9 @@ export class HaNumberSelector extends LitElement {
@property() public helper?: string;
@property({ attribute: false })
public localizeValue?: (key: string) => string;
@property({ type: Boolean }) public required = true;
@property({ type: Boolean }) public disabled = false;
@@ -60,6 +63,14 @@ export class HaNumberSelector extends LitElement {
}
}
const translationKey = this.selector.number?.translation_key;
let unit = this.selector.number?.unit_of_measurement;
if (isBox && unit && this.localizeValue && translationKey) {
unit =
this.localizeValue(`${translationKey}.unit_of_measurement.${unit}`) ||
unit;
}
return html`
${this.label && !isBox
? html`${this.label}${this.required ? "*" : ""}`
@@ -97,7 +108,7 @@ export class HaNumberSelector extends LitElement {
.helper=${isBox ? this.helper : undefined}
.disabled=${this.disabled}
.required=${this.required}
.suffix=${this.selector.number?.unit_of_measurement}
.suffix=${unit}
type="number"
autoValidate
?no-spinner=${!isBox}

View File

@@ -1,16 +1,27 @@
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { mdiClose, mdiDelete, mdiDrag, mdiPencil } from "@mdi/js";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { ObjectSelector } from "../../data/selector";
import { formatSelectorValue } from "../../data/selector/format_selector_value";
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
import type { HomeAssistant } from "../../types";
import "../ha-yaml-editor";
import type { HaFormSchema } from "../ha-form/types";
import "../ha-input-helper-text";
import "../ha-md-list";
import "../ha-md-list-item";
import "../ha-sortable";
import "../ha-yaml-editor";
import type { HaYamlEditor } from "../ha-yaml-editor";
@customElement("ha-selector-object")
export class HaObjectSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: ObjectSelector;
@property() public value?: any;
@property() public label?: string;
@@ -23,11 +34,132 @@ export class HaObjectSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@query("ha-yaml-editor", true) private _yamlEditor!: HaYamlEditor;
@property({ attribute: false }) public localizeValue?: (
key: string
) => string;
@query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor;
private _valueChangedFromChild = false;
private _computeLabel = (schema: HaFormSchema): string => {
const translationKey = this.selector.object?.translation_key;
if (this.localizeValue && translationKey) {
const label = this.localizeValue(
`${translationKey}.fields.${schema.name}`
);
if (label) {
return label;
}
}
return this.selector.object?.fields?.[schema.name]?.label || schema.name;
};
private _renderItem(item: any, index: number) {
const labelField =
this.selector.object!.label_field ||
Object.keys(this.selector.object!.fields!)[0];
const labelSelector = this.selector.object!.fields![labelField].selector;
const label = labelSelector
? formatSelectorValue(this.hass, item[labelField], labelSelector)
: "";
let description = "";
const descriptionField = this.selector.object!.description_field;
if (descriptionField) {
const descriptionSelector =
this.selector.object!.fields![descriptionField].selector;
description = descriptionSelector
? formatSelectorValue(
this.hass,
item[descriptionField],
descriptionSelector
)
: "";
}
const reorderable = this.selector.object!.multiple || false;
const multiple = this.selector.object!.multiple || false;
return html`
<ha-md-list-item class="item">
${reorderable
? html`
<ha-svg-icon
class="handle"
.path=${mdiDrag}
slot="start"
></ha-svg-icon>
`
: nothing}
<div slot="headline" class="label">${label}</div>
${description
? html`<div slot="supporting-text" class="description">
${description}
</div>`
: nothing}
<ha-icon-button
slot="end"
.item=${item}
.index=${index}
.label=${this.hass.localize("ui.common.edit")}
.path=${mdiPencil}
@click=${this._editItem}
></ha-icon-button>
<ha-icon-button
slot="end"
.index=${index}
.label=${this.hass.localize("ui.common.delete")}
.path=${multiple ? mdiDelete : mdiClose}
@click=${this._deleteItem}
></ha-icon-button>
</ha-md-list-item>
`;
}
protected render() {
if (this.selector.object?.fields) {
if (this.selector.object.multiple) {
const items = ensureArray(this.value ?? []);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="items-container">
<ha-sortable
handle-selector=".handle"
draggable-selector=".item"
@item-moved=${this._itemMoved}
>
<ha-md-list>
${items.map((item, index) => this._renderItem(item, index))}
</ha-md-list>
</ha-sortable>
<ha-button outlined @click=${this._addItem}>
${this.hass.localize("ui.common.add")}
</ha-button>
</div>
`;
}
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="items-container">
${this.value
? html`<ha-md-list>
${this._renderItem(this.value, 0)}
</ha-md-list>`
: html`
<ha-button outlined @click=${this._addItem}>
${this.hass.localize("ui.common.add")}
</ha-button>
`}
</div>
`;
}
return html`<ha-yaml-editor
.hass=${this.hass}
.readonly=${this.disabled}
@@ -44,9 +176,103 @@ export class HaObjectSelector extends LitElement {
: ""} `;
}
private _schema = memoizeOne((selector: ObjectSelector) => {
if (!selector.object || !selector.object.fields) {
return [];
}
return Object.entries(selector.object.fields).map(([key, field]) => ({
name: key,
selector: field.selector,
required: field.required ?? false,
}));
});
private _itemMoved(ev) {
ev.stopPropagation();
const newIndex = ev.detail.newIndex;
const oldIndex = ev.detail.oldIndex;
if (!this.selector.object!.multiple) {
return;
}
const newValue = ensureArray(this.value ?? []).concat();
const item = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, item);
fireEvent(this, "value-changed", { value: newValue });
}
private async _addItem(ev) {
ev.stopPropagation();
const newItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.add"),
schema: this._schema(this.selector),
data: {},
computeLabel: this._computeLabel,
submitText: this.hass.localize("ui.common.add"),
});
if (newItem === null) {
return;
}
if (!this.selector.object!.multiple) {
fireEvent(this, "value-changed", { value: newItem });
return;
}
const newValue = ensureArray(this.value ?? []).concat();
newValue.push(newItem);
fireEvent(this, "value-changed", { value: newValue });
}
private async _editItem(ev) {
ev.stopPropagation();
const item = ev.currentTarget.item;
const index = ev.currentTarget.index;
const updatedItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.edit"),
schema: this._schema(this.selector),
data: item,
computeLabel: this._computeLabel,
submitText: this.hass.localize("ui.common.save"),
});
if (updatedItem === null) {
return;
}
if (!this.selector.object!.multiple) {
fireEvent(this, "value-changed", { value: updatedItem });
return;
}
const newValue = ensureArray(this.value ?? []).concat();
newValue[index] = updatedItem;
fireEvent(this, "value-changed", { value: newValue });
}
private _deleteItem(ev) {
ev.stopPropagation();
const index = ev.currentTarget.index;
if (!this.selector.object!.multiple) {
fireEvent(this, "value-changed", { value: undefined });
return;
}
const newValue = ensureArray(this.value ?? []).concat();
newValue.splice(index, 1);
fireEvent(this, "value-changed", { value: newValue });
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("value") && !this._valueChangedFromChild) {
if (
changedProps.has("value") &&
!this._valueChangedFromChild &&
this._yamlEditor
) {
this._yamlEditor.setValue(this.value);
}
this._valueChangedFromChild = false;
@@ -63,6 +289,42 @@ export class HaObjectSelector extends LitElement {
}
fireEvent(this, "value-changed", { value });
}
static get styles() {
return [
css`
ha-md-list {
gap: 8px;
}
ha-md-list-item {
border: 1px solid var(--divider-color);
border-radius: 8px;
--ha-md-list-item-gap: 0;
--md-list-item-top-space: 0;
--md-list-item-bottom-space: 0;
--md-list-item-leading-space: 12px;
--md-list-item-trailing-space: 4px;
--md-list-item-two-line-container-height: 48px;
--md-list-item-one-line-container-height: 48px;
}
.handle {
cursor: move;
padding: 8px;
margin-inline-start: -8px;
}
label {
margin-bottom: 8px;
display: block;
}
ha-md-list-item .label,
ha-md-list-item .description {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
`,
];
}
}
declare global {

View File

@@ -80,7 +80,16 @@ const SELECTOR_SCHEMAS = {
] as const,
icon: [] as const,
location: [] as const,
media: [] as const,
media: [
{
name: "accept",
selector: {
text: {
multiple: true,
},
},
},
] as const,
number: [
{
name: "min",

View File

@@ -276,6 +276,16 @@ export class HaServiceControl extends LitElement {
private _getTargetedEntities = memoizeOne((target, value) => {
const targetSelector = target ? { target } : { target: {} };
if (
hasTemplate(value?.target) ||
hasTemplate(value?.data?.entity_id) ||
hasTemplate(value?.data?.device_id) ||
hasTemplate(value?.data?.area_id) ||
hasTemplate(value?.data?.floor_id) ||
hasTemplate(value?.data?.label_id)
) {
return null;
}
const targetEntities =
ensureArray(
value?.target?.entity_id || value?.data?.entity_id
@@ -349,8 +359,11 @@ export class HaServiceControl extends LitElement {
private _filterField(
filter: ExtHassService["fields"][number]["filter"],
targetEntities: string[]
targetEntities: string[] | null
) {
if (targetEntities === null) {
return true; // Target is a template, show all fields
}
if (!targetEntities.length) {
return false;
}
@@ -386,8 +399,21 @@ export class HaServiceControl extends LitElement {
}
private _targetSelector = memoizeOne(
(targetSelector: TargetSelector | null | undefined) =>
targetSelector ? { target: { ...targetSelector } } : { target: {} }
(targetSelector: TargetSelector | null | undefined, value) => {
if (!value || (typeof value === "object" && !Object.keys(value).length)) {
delete this._stickySelector.target;
} else if (hasTemplate(value)) {
if (typeof value === "string") {
this._stickySelector.target = { template: null };
} else {
this._stickySelector.target = { object: null };
}
}
return (
this._stickySelector.target ??
(targetSelector ? { target: { ...targetSelector } } : { target: {} })
);
}
);
protected render() {
@@ -482,7 +508,8 @@ export class HaServiceControl extends LitElement {
><ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(
serviceData.target as TargetSelector
serviceData.target as TargetSelector,
this._value?.target
)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
@@ -575,7 +602,7 @@ export class HaServiceControl extends LitElement {
private _hasFilteredFields(
dataFields: ExtHassService["fields"],
targetEntities: string[]
targetEntities: string[] | null
) {
return dataFields.some(
(dataField) =>
@@ -588,7 +615,7 @@ export class HaServiceControl extends LitElement {
hasOptional: boolean,
domain: string | undefined,
serviceName: string | undefined,
targetEntities: string[]
targetEntities: string[] | null
) => {
if (
dataField.filter &&
@@ -822,6 +849,10 @@ export class HaServiceControl extends LitElement {
private _targetChanged(ev: CustomEvent) {
ev.stopPropagation();
if (ev.detail.isValid === false) {
// Don't clear an object selector that returns invalid YAML
return;
}
const newValue = ev.detail.value;
if (this._value?.target === newValue) {
return;

View File

@@ -89,6 +89,7 @@ export class HaSettingsRow extends LitElement {
display: var(--settings-row-content-display, flex);
justify-content: flex-end;
flex: 1;
min-width: 0;
padding: 16px 0;
}
.content ::slotted(*) {

View File

@@ -44,6 +44,9 @@ export class HaYamlEditor extends LitElement {
@property({ attribute: "read-only", type: Boolean }) public readOnly = false;
@property({ type: Boolean, attribute: "disable-fullscreen" })
public disableFullscreen = false;
@property({ type: Boolean }) public required = false;
@property({ attribute: "copy-clipboard", type: Boolean })
@@ -110,6 +113,7 @@ export class HaYamlEditor extends LitElement {
.hass=${this.hass}
.value=${this._yaml}
.readOnly=${this.readOnly}
.disableFullscreen=${this.disableFullscreen}
mode="yaml"
autocomplete-entities
autocomplete-icons

View File

@@ -164,6 +164,7 @@ class DialogMediaPlayerBrowse extends LitElement {
.navigateIds=${this._navigateIds}
.action=${this._action}
.preferredLayout=${this._preferredLayout}
.accept=${this._params.accept}
@close-dialog=${this.closeDialog}
@media-picked=${this._mediaPicked}
@media-browsed=${this._mediaBrowsed}

View File

@@ -78,7 +78,7 @@ export interface MediaPlayerItemId {
export class HaMediaPlayerBrowse extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@property({ attribute: false }) public entityId?: string;
@property() public action: MediaPlayerBrowseAction = "play";
@@ -89,6 +89,8 @@ export class HaMediaPlayerBrowse extends LitElement {
@property({ attribute: false }) public navigateIds: MediaPlayerItemId[] = [];
@property({ attribute: false }) public accept?: string[];
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
@property({ type: Boolean, reflect: true }) public narrow = false;
@@ -250,6 +252,7 @@ export class HaMediaPlayerBrowse extends LitElement {
});
} else if (
err.code === "entity_not_found" &&
this.entityId &&
isUnavailableState(this.hass.states[this.entityId]?.state)
) {
this._setError({
@@ -334,7 +337,37 @@ export class HaMediaPlayerBrowse extends LitElement {
const subtitle = this.hass.localize(
`ui.components.media-browser.class.${currentItem.media_class}`
);
const children = currentItem.children || [];
let children = currentItem.children || [];
const canPlayChildren = new Set<string>();
// Filter children based on accept property if provided
if (this.accept && children.length > 0) {
let checks: ((t: string) => boolean)[] = [];
for (const type of this.accept) {
if (type.endsWith("/*")) {
const baseType = type.slice(0, -1);
checks.push((t) => t.startsWith(baseType));
} else if (type === "*") {
checks = [() => true];
break;
} else {
checks.push((t) => t === type);
}
}
children = children.filter((child) => {
const contentType = child.media_content_type.toLowerCase();
const canPlay =
child.media_content_type &&
checks.some((check) => check(contentType));
if (canPlay) {
canPlayChildren.add(child.media_content_id);
}
return !child.media_content_type || child.can_expand || canPlay;
});
}
const mediaClass = MediaClassBrowserSettings[currentItem.media_class];
const childrenMediaClass = currentItem.children_media_class
? MediaClassBrowserSettings[currentItem.children_media_class]
@@ -367,7 +400,12 @@ export class HaMediaPlayerBrowse extends LitElement {
""
)}"
>
${this.narrow && currentItem?.can_play
${this.narrow &&
currentItem?.can_play &&
(!this.accept ||
canPlayChildren.has(
currentItem.media_content_id
))
? html`
<ha-fab
mini
@@ -748,11 +786,11 @@ export class HaMediaPlayerBrowse extends LitElement {
};
private async _fetchData(
entityId: string,
entityId: string | undefined,
mediaContentId?: string,
mediaContentType?: string
): Promise<MediaPlayerItem> {
return entityId !== BROWSER_PLAYER
return entityId && entityId !== BROWSER_PLAYER
? browseMediaPlayer(this.hass, entityId, mediaContentId, mediaContentType)
: browseLocalMediaPlayer(this.hass, mediaContentId);
}

View File

@@ -7,10 +7,11 @@ import type { MediaPlayerItemId } from "./ha-media-player-browse";
export interface MediaPlayerBrowseDialogParams {
action: MediaPlayerBrowseAction;
entityId: string;
entityId?: string;
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => void;
navigateIds?: MediaPlayerItemId[];
minimumNavigateLevel?: number;
accept?: string[];
}
export const showMediaBrowserDialog = (

53
src/data/ai_task.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { HomeAssistant } from "../types";
import type { Selector } from "./selector";
export interface AITaskPreferences {
gen_data_entity_id: string | null;
}
export interface GenDataTaskResult<T = string> {
conversation_id: string;
data: T;
}
export interface AITaskStructureField {
description?: string;
required?: boolean;
selector: Selector;
}
export type AITaskStructure = Record<string, AITaskStructureField>;
export const fetchAITaskPreferences = (hass: HomeAssistant) =>
hass.callWS<AITaskPreferences>({
type: "ai_task/preferences/get",
});
export const saveAITaskPreferences = (
hass: HomeAssistant,
preferences: Partial<AITaskPreferences>
) =>
hass.callWS<AITaskPreferences>({
type: "ai_task/preferences/set",
...preferences,
});
export const generateDataAITask = async <T = string>(
hass: HomeAssistant,
task: {
task_name: string;
entity_id?: string;
instructions: string;
structure?: AITaskStructure;
}
): Promise<GenDataTaskResult<T>> => {
const result = await hass.callService<GenDataTaskResult<T>>(
"ai_task",
"generate_data",
task,
undefined,
true,
true
);
return result.response!;
};

View File

@@ -1114,12 +1114,16 @@ export const formatConsumptionShort = (
if (!consumption) {
return `0 ${unit}`;
}
const units = ["kWh", "MWh", "GWh", "TWh"];
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
let pickedUnit = unit;
let val = consumption;
let unitIndex = units.findIndex((u) => u === unit);
if (unitIndex >= 0) {
while (val >= 1000 && unitIndex < units.length - 1) {
while (Math.abs(val) < 1 && unitIndex > 0) {
val *= 1000;
unitIndex--;
}
while (Math.abs(val) >= 1000 && unitIndex < units.length - 1) {
val /= 1000;
unitIndex++;
}
@@ -1127,7 +1131,8 @@ export const formatConsumptionShort = (
}
return (
formatNumber(val, hass.locale, {
maximumFractionDigits: val < 10 ? 2 : val < 100 ? 1 : 0,
maximumFractionDigits:
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
}) +
" " +
pickedUnit

View File

@@ -37,6 +37,7 @@ import {
mdiRoomService,
mdiScriptText,
mdiSpeakerMessage,
mdiStarFourPoints,
mdiThermostat,
mdiTimerOutline,
mdiToggleSwitch,
@@ -66,6 +67,7 @@ export const DEFAULT_DOMAIN_ICON = mdiBookmark;
/** Fallback icons for each domain */
export const FALLBACK_DOMAIN_ICONS = {
ai_task: mdiStarFourPoints,
air_quality: mdiAirFilter,
alert: mdiAlert,
automation: mdiRobot,
@@ -352,7 +354,10 @@ const getIconFromTranslations = (
}
// Then check for range-based icons if we have a numeric state
if (state !== undefined && translations.range && !isNaN(Number(state))) {
return getIconFromRange(Number(state), translations.range);
return (
getIconFromRange(Number(state), translations.range) ??
translations.default
);
}
// Fallback to default icon
return translations.default;
@@ -502,14 +507,28 @@ export const serviceSectionIcon = async (
export const domainIcon = async (
hass: HomeAssistant,
domain: string,
deviceClass?: string
deviceClass?: string,
state?: string
): Promise<string | undefined> => {
const entityComponentIcons = await getComponentIcons(hass, domain);
if (entityComponentIcons) {
const translations =
(deviceClass && entityComponentIcons[deviceClass]) ||
entityComponentIcons._;
return translations?.default;
// First check for exact state match
if (state && translations.state?.[state]) {
return translations.state[state];
}
// Then check for range-based icons if we have a numeric state
if (state !== undefined && translations.range && !isNaN(Number(state))) {
return (
getIconFromRange(Number(state), translations.range) ??
translations.default
);
}
// Fallback to default icon
return translations.default;
}
return undefined;
};

View File

@@ -1,5 +1,4 @@
import { mdiContentSave, mdiMedal, mdiTrophy } from "@mdi/js";
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import type { LocalizeKeys } from "../common/translations/localize";
/**
@@ -26,11 +25,6 @@ export const QUALITY_SCALE_MAP: Record<
translationKey:
"ui.panel.config.integrations.config_entry.platinum_quality",
},
internal: {
icon: mdiHomeAssistant,
translationKey:
"ui.panel.config.integrations.config_entry.internal_integration",
},
legacy: {
icon: mdiContentSave,
translationKey:

View File

@@ -114,9 +114,13 @@ const getLogbookDataFromServer = (
export const subscribeLogbook = (
hass: HomeAssistant,
callbackFunction: (message: LogbookStreamMessage) => void,
callbackFunction: (
message: LogbookStreamMessage,
subscriptionId: number
) => void,
startDate: string,
endDate: string,
subscriptionId: number,
entityIds?: string[],
deviceIds?: string[]
): Promise<UnsubscribeFunc> => {
@@ -140,7 +144,7 @@ export const subscribeLogbook = (
params.device_ids = deviceIds;
}
return hass.connection.subscribeMessage<LogbookStreamMessage>(
(message) => callbackFunction(message),
(message) => callbackFunction(message, subscriptionId),
params
);
};

View File

@@ -13,7 +13,7 @@ export const subscribePreviewGeneric = (
hass: HomeAssistant,
domain: string,
flow_id: string,
flow_type: "config_flow" | "options_flow",
flow_type: "config_flow" | "options_flow" | "config_subentries_flow",
user_input: Record<string, any>,
callback: (preview: GenericPreview) => void
): Promise<UnsubscribeFunc> =>

View File

@@ -14,6 +14,7 @@ import {
literal,
is,
boolean,
refine,
} from "superstruct";
import { arrayLiteralIncludes } from "../common/array/literal-includes";
import { navigate } from "../common/navigate";
@@ -49,13 +50,18 @@ export const targetStruct = object({
label_id: optional(union([string(), array(string())])),
});
export const serviceActionStruct: Describe<ServiceAction> = assign(
export const serviceActionStruct: Describe<ServiceActionWithTemplate> = assign(
baseActionStruct,
object({
action: optional(string()),
service_template: optional(string()),
entity_id: optional(string()),
target: optional(targetStruct),
target: optional(
union([
targetStruct,
refine(string(), "has_template", (val) => hasTemplate(val)),
])
),
data: optional(object()),
response_variable: optional(string()),
metadata: optional(object()),
@@ -132,6 +138,12 @@ export interface ServiceAction extends BaseAction {
metadata?: Record<string, unknown>;
}
type ServiceActionWithTemplate = ServiceAction & {
target?: HassServiceTarget | string;
};
export type { ServiceActionWithTemplate };
export interface DeviceAction extends BaseAction {
type: string;
device_id: string;
@@ -415,7 +427,7 @@ export const migrateAutomationAction = (
return action.map(migrateAutomationAction) as Action[];
}
if ("service" in action) {
if (typeof action === "object" && action !== null && "service" in action) {
if (!("action" in action)) {
action.action = action.service;
}
@@ -423,7 +435,7 @@ export const migrateAutomationAction = (
}
// legacy scene (scene: scene_name)
if ("scene" in action) {
if (typeof action === "object" && action !== null && "scene" in action) {
action.action = "scene.turn_on";
action.target = {
entity_id: action.scene,
@@ -431,7 +443,7 @@ export const migrateAutomationAction = (
delete action.scene;
}
if ("sequence" in action) {
if (typeof action === "object" && action !== null && "sequence" in action) {
for (const sequenceAction of (action as SequenceAction).sequence) {
migrateAutomationAction(sequenceAction);
}

View File

@@ -303,7 +303,9 @@ export interface LocationSelectorValue {
}
export interface MediaSelector {
media: {} | null;
media: {
accept?: string[];
} | null;
}
export interface MediaSelectorValue {
@@ -331,11 +333,24 @@ export interface NumberSelector {
mode?: "box" | "slider";
unit_of_measurement?: string;
slider_ticks?: boolean;
translation_key?: string;
} | null;
}
interface ObjectSelectorField {
selector: Selector;
label?: string;
required?: boolean;
}
export interface ObjectSelector {
object: {} | null;
object?: {
label_field?: string;
description_field?: string;
translation_key?: string;
fields?: Record<string, ObjectSelectorField>;
multiple?: boolean;
} | null;
}
export interface AssistPipelineSelector {

View File

@@ -0,0 +1,104 @@
import { ensureArray } from "../../common/array/ensure-array";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import type { HomeAssistant } from "../../types";
import type { Selector } from "../selector";
export const formatSelectorValue = (
hass: HomeAssistant,
value: any,
selector?: Selector
) => {
if (value == null) {
return "";
}
if (!selector) {
return ensureArray(value).join(", ");
}
if ("text" in selector) {
const { prefix, suffix } = selector.text || {};
const texts = ensureArray(value);
return texts
.map((text) => `${prefix || ""}${text}${suffix || ""}`)
.join(", ");
}
if ("number" in selector) {
const { unit_of_measurement } = selector.number || {};
const numbers = ensureArray(value);
return numbers
.map((number) => {
const num = Number(number);
if (isNaN(num)) {
return number;
}
return unit_of_measurement
? `${num}${blankBeforeUnit(unit_of_measurement, hass.locale)}${unit_of_measurement}`
: num.toString();
})
.join(", ");
}
if ("floor" in selector) {
const floors = ensureArray(value);
return floors
.map((floorId) => {
const floor = hass.floors[floorId];
if (!floor) {
return floorId;
}
return floor.name || floorId;
})
.join(", ");
}
if ("area" in selector) {
const areas = ensureArray(value);
return areas
.map((areaId) => {
const area = hass.areas[areaId];
if (!area) {
return areaId;
}
return computeAreaName(area);
})
.join(", ");
}
if ("entity" in selector) {
const entities = ensureArray(value);
return entities
.map((entityId) => {
const stateObj = hass.states[entityId];
if (!stateObj) {
return entityId;
}
const { device } = getEntityContext(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const entityName = computeEntityName(stateObj, hass);
return [deviceName, entityName].filter(Boolean).join(" ") || entityId;
})
.join(", ");
}
if ("device" in selector) {
const devices = ensureArray(value);
return devices
.map((deviceId) => {
const device = hass.devices[deviceId];
if (!device) {
return deviceId;
}
return device.name || deviceId;
})
.join(", ");
}
return ensureArray(value).join(", ");
};

View File

@@ -34,6 +34,7 @@ export type SystemHealthInfo = Partial<{
dev: boolean;
hassio: boolean;
docker: boolean;
container_arch: string;
user: string;
virtualenv: boolean;
python_version: string;

View File

@@ -286,7 +286,7 @@ class DataEntryFlowDialog extends LitElement {
scrimClickAction
escapeKeyAction
hideActions
.heading=${dialogTitle}
.heading=${dialogTitle || true}
>
<ha-dialog-header slot="heading">
<ha-icon-button

View File

@@ -82,7 +82,11 @@ export class FlowPreviewGeneric extends LitElement {
(await this._unsub)();
this._unsub = undefined;
}
if (this.flowType !== "config_flow" && this.flowType !== "options_flow") {
if (
this.flowType !== "config_flow" &&
this.flowType !== "options_flow" &&
this.flowType !== "config_subentries_flow"
) {
return;
}
this._error = undefined;

View File

@@ -35,10 +35,16 @@ export const showConfigFlowDialog = (
return step;
},
fetchFlow: async (hass, flowId) => {
const step = await fetchConfigFlow(hass, flowId);
await hass.loadFragmentTranslation("config");
await hass.loadBackendTranslation("config", step.handler);
await hass.loadBackendTranslation("selector", step.handler);
const [step] = await Promise.all([
fetchConfigFlow(hass, flowId),
hass.loadFragmentTranslation("config"),
]);
await Promise.all([
hass.loadBackendTranslation("config", step.handler),
hass.loadBackendTranslation("selector", step.handler),
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", step.handler),
]);
return step;
},
handleFlowStep: handleConfigFlowStep,

View File

@@ -0,0 +1,89 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../make-dialog-manager";
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
import { haStyleDialog } from "../../resources/styles";
@customElement("dialog-form")
export class DialogForm
extends LitElement
implements HassDialog<FormDialogData>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _params?: FormDialogParams;
@state() private _data: FormDialogData = {};
public async showDialog(params: FormDialogParams): Promise<void> {
this._params = params;
this._data = params.data || {};
}
public closeDialog() {
this._params = undefined;
this._data = {};
fireEvent(this, "dialog-closed", { dialog: this.localName });
return true;
}
private _submit(): void {
this._params?.submit?.(this._data);
this.closeDialog();
}
private _cancel(): void {
this._params?.cancel?.();
this.closeDialog();
}
private _valueChanged(ev: CustomEvent): void {
this._data = ev.detail.value;
}
protected render() {
if (!this._params || !this.hass) {
return nothing;
}
return html`
<ha-dialog
open
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(this.hass, this._params.title)}
@closed=${this._cancel}
>
<ha-form
dialogInitialFocus
.hass=${this.hass}
.computeLabel=${this._params.computeLabel}
.computeHelper=${this._params.computeHelper}
.data=${this._data}
.schema=${this._params.schema}
@value-changed=${this._valueChanged}
>
</ha-form>
<ha-button @click=${this._cancel} slot="secondaryAction">
${this._params.cancelText || this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._submit} slot="primaryAction">
${this._params.submitText || this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog>
`;
}
static styles = [haStyleDialog, css``];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-form": DialogForm;
}
}

View File

@@ -0,0 +1,45 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { HaFormSchema } from "../../components/ha-form/types";
export type FormDialogData = Record<string, any>;
export interface FormDialogParams {
title: string;
schema: HaFormSchema[];
data?: FormDialogData;
submit?: (data?: FormDialogData) => void;
cancel?: () => void;
computeLabel?: (schema, data) => string | undefined;
computeHelper?: (schema) => string | undefined;
submitText?: string;
cancelText?: string;
}
export const showFormDialog = (
element: HTMLElement,
dialogParams: FormDialogParams
) =>
new Promise<FormDialogData | null>((resolve) => {
const origCancel = dialogParams.cancel;
const origSubmit = dialogParams.submit;
fireEvent(element, "show-dialog", {
dialogTag: "dialog-form",
dialogImport: () => import("./dialog-form"),
dialogParams: {
...dialogParams,
cancel: () => {
resolve(null);
if (origCancel) {
origCancel();
}
},
submit: (data: FormDialogData) => {
resolve(data);
if (origSubmit) {
origSubmit(data);
}
},
},
});
});

View File

@@ -208,6 +208,7 @@ class DialogEditSidebar extends LitElement {
ha-md-dialog {
min-width: 600px;
max-height: 90%;
--dialog-content-padding: 8px 24px;
}
@media all and (max-width: 600px), all and (max-height: 500px) {

View File

@@ -55,11 +55,7 @@ class HassSubpage extends LitElement {
<div class="main-title"><slot name="header">${this.header}</slot></div>
<slot name="toolbar-icon"></slot>
</div>
<div
class="content ha-scrollbar"
@scroll=${this._saveScrollPos}
@scroll-to=${this._scrollTo}
>
<div class="content ha-scrollbar" @scroll=${this._saveScrollPos}>
<slot></slot>
</div>
<div id="fab">
@@ -73,15 +69,6 @@ class HassSubpage extends LitElement {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
private _scrollTo(e: CustomEvent<{ up: number }>): void {
this.renderRoot
.querySelector(".content")!
.scrollTo(
0,
e.detail.up + this.renderRoot.querySelector(".content")?.scrollTop
);
}
private _backTapped(): void {
if (this.backCallback) {
this.backCallback();

View File

@@ -203,7 +203,7 @@ export default class HaAutomationActionRow extends LitElement {
</div>
`
: nothing}
<ha-automation-row>
<ha-expansion-panel left-chevron>
${type === "service" && "action" in this.action && this.action.action
? html`
<ha-service-icon
@@ -328,6 +328,16 @@ export default class HaAutomationActionRow extends LitElement {
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!this._uiModeAvailable}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
@@ -420,7 +430,7 @@ export default class HaAutomationActionRow extends LitElement {
</div>
`}
</div>
</ha-automation-row>
</ha-expansion-panel>
</ha-card>
`;
}
@@ -666,8 +676,8 @@ export default class HaAutomationActionRow extends LitElement {
}
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--primary-color);
border-color: var(--primary-color);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`,

View File

@@ -93,7 +93,6 @@ export default class HaAutomationAction extends LitElement {
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._actionChanged}
@click=${this._actionClicked}
.hass=${this.hass}
?highlight=${this.highlightedActions?.includes(action)}
>
@@ -103,74 +102,8 @@ export default class HaAutomationAction extends LitElement {
<ha-svg-icon .path=${mdiDrag}></ha-svg-icon>
</div>
`
: nothing} </ha-automation-action-row
>${Object.keys(action)[0] === "choose"
? html`<div
style="padding-left: 24px; border-left: 1px solid var(--primary-color);"
>
<ha-card outlined
><ha-automation-row>
<h3
slot="header"
style=" margin: 0;
font-size: inherit;
font-weight: inherit;"
>
Option 1:
</h3>
</ha-automation-row></ha-card
>
<div
style="padding-left: 24px; border-left: 1px solid var(--primary-color); margin-top: 8px;"
>
<ha-automation-condition></ha-automation-condition>
<ha-button
outlined
style=" padding: 16px 0; padding-top: 8px;"
.disabled=${this.disabled}
.label=${"Condition"}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
<ha-card style=" padding: 0 16px;
padding-top: 8px;"
>Actions</br>
<ha-button
style=" padding: 16px 0;"
outlined
.disabled=${this.disabled}
.label=${"Action"}
>
<ha-svg-icon
.path=${mdiPlus}
slot="icon"
></ha-svg-icon> </ha-button
></ha-card>
</div>
<ha-button
outlined
style=" padding: 16px 0;"
.disabled=${this.disabled}
.label=${"Option"}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
<ha-card style=" padding: 0 16px;
padding-top: 8px;"
>Default actions</br>
<ha-button
style=" padding: 16px 0;"
outlined
.disabled=${this.disabled}
.label=${"Action"}
>
<ha-svg-icon
.path=${mdiPlus}
slot="icon"
></ha-svg-icon></ha-button
></ha-card>
</div>`
: nothing}
: nothing}
</ha-automation-action-row>
`
)}
<div class="buttons">
@@ -199,15 +132,6 @@ export default class HaAutomationAction extends LitElement {
`;
}
private _actionClicked(ev: MouseEvent) {
fireEvent(this, "element-selected", {
type: "action",
element: (ev.currentTarget as HaAutomationActionRow).action,
index: (ev.currentTarget as HaAutomationActionRow).index,
path: (ev.currentTarget as HaAutomationActionRow).path,
});
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);

View File

@@ -2,12 +2,19 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-selector/ha-selector-media";
import "../../../../../components/ha-selector/ha-selector";
import type { PlayMediaAction } from "../../../../../data/script";
import type { MediaSelectorValue } from "../../../../../data/selector";
import type {
MediaSelectorValue,
Selector,
} from "../../../../../data/selector";
import type { HomeAssistant } from "../../../../../types";
import type { ActionElement } from "../ha-automation-action-row";
const MEDIA_SELECTOR_SCHEMA: Selector = {
media: {},
};
@customElement("ha-automation-action-play_media")
export class HaPlayMediaAction extends LitElement implements ActionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -38,12 +45,13 @@ export class HaPlayMediaAction extends LitElement implements ActionElement {
protected render() {
return html`
<ha-selector-media
<ha-selector
.selector=${MEDIA_SELECTOR_SCHEMA}
.hass=${this.hass}
.disabled=${this.disabled}
.value=${this._getSelectorValue(this.action)}
@value-changed=${this._valueChanged}
></ha-selector-media>
></ha-selector>
`;
}

View File

@@ -42,7 +42,7 @@ export class HaServiceAction extends LitElement implements ActionElement {
if (
this.action &&
Object.entries(this.action).some(
([key, val]) => key !== "data" && hasTemplate(val)
([key, val]) => !["data", "target"].includes(key) && hasTemplate(val)
)
) {
fireEvent(

View File

@@ -1,8 +1,9 @@
import "@material/mwc-button";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiClose, mdiPlus } from "@mdi/js";
import { mdiClose, mdiPlus, mdiStarFourPoints } from "@mdi/js";
import { dump } from "js-yaml";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-domain-icon";
@@ -24,6 +25,14 @@ import type {
SaveDialogParams,
} from "./show-dialog-automation-save";
import { supportsMarkdownHelper } from "../../../../common/translations/markdown_support";
import {
fetchAITaskPreferences,
generateDataAITask,
} from "../../../../data/ai_task";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
import { subscribeOne } from "../../../../common/util/subscribe-one";
import { subscribeLabelRegistry } from "../../../../data/label_registry";
@customElement("ha-dialog-automation-save")
class DialogAutomationSave extends LitElement implements HassDialog {
@@ -37,9 +46,11 @@ class DialogAutomationSave extends LitElement implements HassDialog {
@state() private _entryUpdates!: EntityRegistryUpdate;
@state() private _canSuggest = false;
private _params!: SaveDialogParams;
private _newName?: string;
@state() private _newName?: string;
private _newIcon?: string;
@@ -67,7 +78,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
this._entryUpdates.category ? "category" : "",
this._entryUpdates.labels.length > 0 ? "labels" : "",
this._entryUpdates.area ? "area" : "",
];
].filter(Boolean);
}
public closeDialog() {
@@ -81,6 +92,15 @@ class DialogAutomationSave extends LitElement implements HassDialog {
return true;
}
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
if (isComponentLoaded(this.hass, "ai_task")) {
fetchAITaskPreferences(this.hass).then((prefs) => {
this._canSuggest = prefs.gen_data_entity_id !== null;
});
}
}
protected _renderOptionalChip(id: string, label: string) {
if (this._visibleOptionals.includes(id)) {
return nothing;
@@ -250,6 +270,21 @@ class DialogAutomationSave extends LitElement implements HassDialog {
.path=${mdiClose}
></ha-icon-button>
<span slot="title">${this._params.title || title}</span>
${this._canSuggest
? html`
<ha-assist-chip
id="suggest"
slot="actionItems"
@click=${this._suggest}
label=${this.hass.localize("ui.common.suggest_ai")}
>
<ha-svg-icon
slot="icon"
.path=${mdiStarFourPoints}
></ha-svg-icon>
</ha-assist-chip>
`
: nothing}
</ha-dialog-header>
${this._error
? html`<ha-alert alert-type="error"
@@ -313,6 +348,124 @@ class DialogAutomationSave extends LitElement implements HassDialog {
this.closeDialog();
}
private async _suggest() {
const labels = await subscribeOne(
this.hass.connection,
subscribeLabelRegistry
).then((labs) =>
Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name]))
);
const automationInspiration: string[] = [];
for (const automation of Object.values(this.hass.states)) {
const entityEntry = this.hass.entities[automation.entity_id];
if (
computeStateDomain(automation) !== "automation" ||
automation.attributes.restored ||
!automation.attributes.friendly_name ||
!entityEntry
) {
continue;
}
let inspiration = `- ${automation.attributes.friendly_name}`;
if (entityEntry.labels.length) {
inspiration += ` (labels: ${entityEntry.labels
.map((label) => labels[label])
.join(", ")})`;
}
automationInspiration.push(inspiration);
}
const result = await generateDataAITask<{
name: string;
description: string | undefined;
labels: string[] | undefined;
}>(this.hass, {
task_name: "frontend:automation:save",
instructions: `Suggest in language "${this.hass.language}" a name, description, and labels for the following Home Assistant automation.
The name should be relevant to the automation's purpose.
${
automationInspiration.length
? `The name should be in same style as existing automations.
Suggest labels if relevant to the automation's purpose.
Only suggest labels that are already used by existing automations.`
: `The name should be short, descriptive, sentence case, and written in the language ${this.hass.language}.`
}
If the automation contains 5+ steps, include a short description.
For inspiration, here are existing automations:
${automationInspiration.join("\n")}
The automation configuration is as follows:
${dump(this._params.config)}
`,
structure: {
name: {
description: "The name of the automation",
required: true,
selector: {
text: {},
},
},
description: {
description: "A short description of the automation",
required: false,
selector: {
text: {},
},
},
labels: {
description: "Labels for the automation",
required: false,
selector: {
text: {
multiple: true,
},
},
},
},
});
this._newName = result.data.name;
if (result.data.description) {
this._newDescription = result.data.description;
if (!this._visibleOptionals.includes("description")) {
this._visibleOptionals = [...this._visibleOptionals, "description"];
}
}
if (result.data.labels?.length) {
// We get back label names, convert them to IDs
const newLabels: Record<string, undefined | string> = Object.fromEntries(
result.data.labels.map((name) => [name, undefined])
);
let toFind = result.data.labels.length;
for (const [labelId, labelName] of Object.entries(labels)) {
if (labelName in newLabels && newLabels[labelName] === undefined) {
newLabels[labelName] = labelId;
toFind--;
if (toFind === 0) {
break;
}
}
}
const foundLabels = Object.values(newLabels).filter(
(labelId) => labelId !== undefined
);
if (foundLabels.length) {
this._entryUpdates = {
...this._entryUpdates,
labels: foundLabels,
};
if (!this._visibleOptionals.includes("labels")) {
this._visibleOptionals = [...this._visibleOptionals, "labels"];
}
}
}
}
private async _save(): Promise<void> {
if (!this._newName) {
this._error = "Name is required";
@@ -381,6 +534,10 @@ class DialogAutomationSave extends LitElement implements HassDialog {
.destructive {
--mdc-theme-primary: var(--error-color);
}
#suggest {
margin: 8px 16px;
}
`,
];
}

View File

@@ -128,7 +128,7 @@ export default class HaAutomationConditionRow extends LitElement {
`
: ""}
<ha-automation-row>
<ha-expansion-panel left-chevron>
<ha-svg-icon
slot="leading-icon"
class="condition-icon"
@@ -225,6 +225,16 @@ export default class HaAutomationConditionRow extends LitElement {
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${this._warnings}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
@@ -287,8 +297,16 @@ export default class HaAutomationConditionRow extends LitElement {
)}
</ha-alert>`
: ""}
<ha-automation-condition-editor
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._handleChangeEvent}
.yamlMode=${this._yamlMode}
.disabled=${this.disabled}
.hass=${this.hass}
.condition=${this.condition}
></ha-automation-condition-editor>
</div>
</ha-automation-row>
</ha-expansion-panel>
<div
class="testing ${classMap({
active: this._testing,
@@ -571,8 +589,8 @@ export default class HaAutomationConditionRow extends LitElement {
}
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--primary-color);
border-color: var(--primary-color);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`,

View File

@@ -142,7 +142,6 @@ export default class HaAutomationCondition extends LitElement {
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._conditionChanged}
@click=${this._conditionClicked}
.hass=${this.hass}
?highlight=${this.highlightedConditions?.includes(cond)}
>
@@ -182,15 +181,6 @@ export default class HaAutomationCondition extends LitElement {
`;
}
private _conditionClicked(ev: MouseEvent) {
fireEvent(this, "element-selected", {
type: "condition",
element: (ev.currentTarget as HaAutomationConditionRow).condition,
index: (ev.currentTarget as HaAutomationConditionRow).index,
path: (ev.currentTarget as HaAutomationConditionRow).path,
});
}
private _addConditionDialog() {
showAddAutomationElementDialog(this, {
type: "condition",

View File

@@ -502,6 +502,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
.readOnly=${this._readOnly}
@value-changed=${this._yamlChanged}
.showErrors=${false}
disable-fullscreen
></ha-yaml-editor>`
: nothing}
</div>
@@ -1092,6 +1093,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
flex-direction: column;
padding-bottom: 0;
}
manual-automation-editor,
blueprint-automation-editor,
:not(.yaml-mode) > ha-alert {
margin: 0 auto;
@@ -1099,12 +1101,6 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
padding: 28px 20px 0;
display: block;
}
manual-automation-editor {
margin: 0 auto;
max-width: 1540px;
padding: 28px 20px 0;
display: block;
}
ha-yaml-editor {
flex-grow: 1;
--actions-border-radius: 0;

View File

@@ -1,11 +1,5 @@
import "@material/mwc-button/mwc-button";
import {
mdiClose,
mdiDotsVertical,
mdiHelpCircle,
mdiIdentifier,
mdiPlaylistEdit,
} from "@mdi/js";
import { mdiHelpCircle } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -55,51 +49,6 @@ import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input";
import { showToast } from "../../../util/toast";
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
import "@shoelace-style/shoelace/dist/components/split-panel/split-panel";
import "@shoelace-style/shoelace/dist/components/drawer/drawer";
import { dynamicElement } from "../../../common/dom/dynamic-element-directive";
import { classMap } from "lit/directives/class-map";
import { getType } from "./action/ha-automation-action-row";
import { storage } from "../../../common/decorators/storage";
import { nextRender } from "../../../common/util/render-status";
import {
DIRECTION_ALL,
DIRECTION_VERTICAL,
Manager,
Pan,
Swipe,
} from "@egjs/hammerjs";
function findNestedItem(
obj: any,
path: ItemPath,
createNonExistingPath?: boolean
): any {
return path.reduce((ac, p, index, array) => {
if (ac === undefined) return undefined;
if (!ac[p] && createNonExistingPath) {
const nextP = array[index + 1];
// Create object or array depending on next path
if (nextP === undefined || typeof nextP === "number") {
ac[p] = [];
} else {
ac[p] = {};
}
}
return ac[p];
}, obj);
}
function updateNestedItem(obj: any, path: ItemPath, newValue): any {
const lastKey = path.pop()!;
const parent = findNestedItem(obj, path);
parent[lastKey] = newValue
? newValue
: Array.isArray(parent[lastKey])
? [...parent[lastKey]]
: [parent[lastKey]];
return obj;
}
const baseConfigStruct = object({
alias: optional(string()),
@@ -136,14 +85,6 @@ export class HaManualAutomationEditor extends LitElement {
@state() private _pastedConfig?: ManualAutomationConfig;
@state() private _selectedElement?: any;
@state()
@storage({ key: "automationSidebarPosition" })
private _sidebarWidth = 99999;
@state() private _yamlMode = false;
private _previousConfig?: ManualAutomationConfig;
public connectedCallback() {
@@ -173,13 +114,6 @@ export class HaManualAutomationEditor extends LitElement {
}
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("narrow") && this.narrow && this._selectedElement) {
this.renderRoot.querySelector("sl-drawer").show();
}
}
private _clearParam(param: string) {
window.history.replaceState(
null,
@@ -189,411 +123,151 @@ export class HaManualAutomationEditor extends LitElement {
}
protected render() {
const selectedElement = this._selectedElement?.element;
const selectedElementType = this._selectedElement?.type;
const path = this._selectedElement?.path || [];
const type = "";
const supported = true;
const yamlMode = this._yamlMode;
const sidePanel = this._selectedElement
? html`<ha-dialog-header>
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this._closeSidebar}
></ha-icon-button>
<span slot="title">${`Edit ${selectedElementType}`}</span>
<ha-button-menu slot="actionItems" fixed>
<ha-icon-button
.path=${mdiDotsVertical}
slot="trigger"
></ha-icon-button>
${selectedElementType === "trigger"
? html`<ha-md-menu-item
.clickAction=${this._showTriggerId}
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<ha-svg-icon
slot="start"
.path=${mdiIdentifier}
></ha-svg-icon>
</ha-md-menu-item>`
: nothing}
<ha-md-menu-item
@click=${this._toggleYamlMode}
.disabled=${!supported}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon
slot="start"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-md-menu-item>
</ha-button-menu>
</ha-dialog-header>
<div
class=${classMap({
"card-content": true,
disabled:
"enabled" in this._selectedElement &&
this._selectedElement.enabled === false,
})}
>
${this._yamlMode
? html`<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${selectedElement}
.readOnly=${this.disabled}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>`
: selectedElementType === "trigger"
? html`<div
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._onUiChanged}
.path=${path}
>
${dynamicElement(
`ha-automation-trigger-${selectedElement.trigger}`,
{
hass: this.hass,
trigger: selectedElement,
disabled: this.disabled,
}
)}
</div>`
: selectedElementType === "condition"
? html`<ha-automation-condition-editor
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._onUiChanged}
.path=${path}
.yamlMode=${this._yamlMode}
.disabled=${this.disabled}
.hass=${this.hass}
.condition=${selectedElement}
></ha-automation-condition-editor>`
: selectedElementType === "action"
? html`<div
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._onUiChanged}
.path=${path}
>
${dynamicElement(
`ha-automation-action-${getType(selectedElement)}`,
{
hass: this.hass,
action: selectedElement,
narrow: true,
disabled: this.disabled,
}
)}
</div>`
: nothing}
</div>`
: nothing;
return html`
${this.narrow
? html`<sl-drawer
no-header
placement="bottom"
class="drawer-placement-bottom"
@sl-show=${this._drawerOpen}
@sl-hide=${this._drawerClose}
>
${sidePanel}
</sl-drawer>`
${this.stateObj?.state === "off"
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.automation.editor.disabled"
)}
<mwc-button slot="action" @click=${this._enable}>
${this.hass.localize(
"ui.panel.config.automation.editor.enable"
)}
</mwc-button>
</ha-alert>
`
: nothing}
<sl-split-panel
primary="start"
.positionInPixels=${selectedElement && !this.narrow
? this.clientWidth - 40 - this._sidebarWidth || 99999
: 0}
style=${selectedElement && !this.narrow
? "--min: 300px; --max: calc(100% - 300px); --divider-width: 32px;"
: "--min: 100%; --max: 100%;"}
@sl-reposition=${this._splitPanelRepositioned}
>
<div slot="start" style="overflow: auto; height: 100%">
${this.stateObj?.state === "off"
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.automation.editor.disabled"
)}
<mwc-button slot="action" @click=${this._enable}>
${this.hass.localize(
"ui.panel.config.automation.editor.enable"
)}
</mwc-button>
</ha-alert>
`
: nothing}
${this.config.description
? html`<ha-markdown
class="description"
breaks
.content=${this.config.description}
></ha-markdown>`
: nothing}
<div class="header">
<h2 id="triggers-heading" class="name">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.header"
${this.config.description
? html`<ha-markdown
class="description"
breaks
.content=${this.config.description}
></ha-markdown>`
: nothing}
<div class="header">
<h2 id="triggers-heading" class="name">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.header"
)}
</h2>
<a
href=${documentationUrl(this.hass, "/docs/automation/trigger/")}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.learn_more"
)}
></ha-icon-button>
</a>
</div>
${!ensureArray(this.config.triggers)?.length
? html`<p>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.description"
)}
</p>`
: nothing}
<ha-automation-trigger
role="region"
aria-labelledby="triggers-heading"
.triggers=${this.config.triggers || []}
.highlightedTriggers=${this._pastedConfig?.triggers || []}
.path=${["triggers"]}
@value-changed=${this._triggerChanged}
.hass=${this.hass}
.disabled=${this.disabled}
></ha-automation-trigger>
<div class="header">
<h2 id="conditions-heading" class="name">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.header"
)}
<span class="small"
>(${this.hass.localize("ui.common.optional")})</span
>
</h2>
<a
href=${documentationUrl(this.hass, "/docs/automation/condition/")}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.conditions.learn_more"
)}
></ha-icon-button>
</a>
</div>
${!ensureArray(this.config.conditions)?.length
? html`<p>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.description",
{ user: this.hass.user?.name || "Alice" }
)}
</p>`
: nothing}
<ha-automation-condition
role="region"
aria-labelledby="conditions-heading"
.conditions=${this.config.conditions || []}
.highlightedConditions=${this._pastedConfig?.conditions || []}
.path=${["conditions"]}
@value-changed=${this._conditionChanged}
.hass=${this.hass}
.disabled=${this.disabled}
></ha-automation-condition>
<div class="header">
<h2 id="actions-heading" class="name">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.header"
)}
</h2>
<div>
<a
href=${documentationUrl(this.hass, "/docs/automation/action/")}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.learn_more"
)}
</h2>
<a
href=${documentationUrl(this.hass, "/docs/automation/trigger/")}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.learn_more"
)}
></ha-icon-button>
</a>
</div>
${!ensureArray(this.config.triggers)?.length
? html`<p>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.description"
)}
</p>`
: nothing}
<ha-automation-trigger
role="region"
aria-labelledby="triggers-heading"
.triggers=${this.config.triggers || []}
.highlightedTriggers=${this._pastedConfig?.triggers || [
selectedElement,
]}
.path=${["triggers"]}
@value-changed=${this._triggerChanged}
.hass=${this.hass}
.disabled=${this.disabled}
@element-selected=${this._elementSelected}
></ha-automation-trigger>
<div class="header">
<h2 id="conditions-heading" class="name">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.header"
)}
<span class="small"
>(${this.hass.localize("ui.common.optional")})</span
>
</h2>
<a
href=${documentationUrl(this.hass, "/docs/automation/condition/")}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.conditions.learn_more"
)}
></ha-icon-button>
</a>
</div>
${!ensureArray(this.config.conditions)?.length
? html`<p>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.description",
{ user: this.hass.user?.name || "Alice" }
)}
</p>`
: nothing}
<ha-automation-condition
role="region"
aria-labelledby="conditions-heading"
.conditions=${this.config.conditions || []}
.highlightedConditions=${this._pastedConfig?.conditions || [
selectedElement,
]}
.path=${["conditions"]}
@value-changed=${this._conditionChanged}
.hass=${this.hass}
.disabled=${this.disabled}
@element-selected=${this._elementSelected}
></ha-automation-condition>
<div class="header">
<h2 id="actions-heading" class="name">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.header"
)}
</h2>
<div>
<a
href=${documentationUrl(this.hass, "/docs/automation/action/")}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.learn_more"
)}
></ha-icon-button>
</a>
</div>
</div>
${!ensureArray(this.config.actions)?.length
? html`<p>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.description"
)}
</p>`
: nothing}
<ha-automation-action
role="region"
aria-labelledby="actions-heading"
.actions=${this.config.actions || []}
.highlightedActions=${this._pastedConfig?.actions || [
selectedElement,
]}
.path=${["actions"]}
@value-changed=${this._actionChanged}
.hass=${this.hass}
.narrow=${this.narrow}
.disabled=${this.disabled}
@element-selected=${this._elementSelected}
></ha-automation-action>
></ha-icon-button>
</a>
</div>
${!this.narrow && selectedElement
? html`<ha-card
slot="end"
style="--ha-card-border-color: var(--primary-color); --ha-card-border-width: 2px;"
>
${sidePanel}
</ha-card>`
: nothing}
</sl-split-panel>
</div>
${!ensureArray(this.config.actions)?.length
? html`<p>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.description"
)}
</p>`
: nothing}
<ha-automation-action
role="region"
aria-labelledby="actions-heading"
.actions=${this.config.actions || []}
.highlightedActions=${this._pastedConfig?.actions || []}
.path=${["actions"]}
@value-changed=${this._actionChanged}
.hass=${this.hass}
.narrow=${this.narrow}
.disabled=${this.disabled}
></ha-automation-action>
`;
}
private _onUiChanged(ev: CustomEvent): void {
ev.stopPropagation();
const path = ev.currentTarget?.path || [];
const newConfig = updateNestedItem(
{ ...this.config },
path,
ev.detail.value
);
console.log(newConfig);
fireEvent(this, "value-changed", { value: newConfig });
}
private async _toggleYamlMode() {
this._yamlMode = !this._yamlMode;
if (this._yamlMode) {
await this.updateComplete;
// this.renderRoot.querySelector("ha-yaml-editor").positionInPixels = 0;
}
}
private async _elementSelected(ev) {
console.log(ev);
this._selectedElement = ev.detail;
console.log("repo", this._sidebarWidth);
const target = ev.target;
await this.updateComplete;
this.renderRoot.querySelector("sl-split-panel").positionInPixels =
this.clientWidth - 40 - this._sidebarWidth;
if (this.narrow) {
this.renderRoot.querySelector("sl-drawer").show();
console.log(target);
this._targetEl = target;
}
}
private _splitPanelRepositioned(ev: CustomEvent): void {
if (!this._selectedElement) {
return;
}
console.log(ev);
console.log("reposition", ev.target.positionInPixels);
let sidebarWidth = ev.target.clientWidth - ev.target.positionInPixels;
if (this._oldClientWidth && this._oldClientWidth !== this.clientWidth) {
// If the client width has changed, we need to subtract the difference
sidebarWidth = sidebarWidth + (this._oldClientWidth - this.clientWidth);
}
this._oldClientWidth = this.clientWidth;
console.log(sidebarWidth);
console.log(this.clientWidth);
console.log(this.clientWidth - 40 - sidebarWidth);
// if (Math.abs(sidebarWidth - this._sidebarWidth) > 20) {
// this._sidebarWidth = sidebarWidth;
// }
this._sidebarWidth = sidebarWidth;
}
private _closeSidebar() {
if (this.narrow) {
this.renderRoot.querySelector("sl-drawer").hide();
}
this._selectedElement = undefined;
}
private async _drawerOpen() {
// this._oldScrollPosition = window.scrollY;
this.renderRoot.querySelector("div[slot='start']").style.paddingBottom =
"66vh";
await nextRender();
fireEvent(this, "scroll-to", {
up: this._targetEl.getBoundingClientRect().top,
});
this._setupListeners();
}
private _setupListeners() {
const mc = new Manager(this.renderRoot.querySelector("ha-dialog-header"), {
touchAction: "pan-y",
});
mc.add(
new Swipe({
direction: DIRECTION_VERTICAL,
})
);
mc.on("swipeup", (e) => {
console.log("up", e);
this.toggleAttribute("big-drawer", true);
});
mc.on("swipedown", (e) => {
console.log("down", e);
if (this.hasAttribute("big-drawer")) {
this.toggleAttribute("big-drawer", false);
} else {
this.renderRoot.querySelector("sl-drawer").hide();
}
});
this._manager = mc;
}
private _drawerClose() {
this.renderRoot.querySelector("div[slot='start']").style.paddingBottom =
"0";
}
private _triggerChanged(ev: CustomEvent): void {
ev.stopPropagation();
this.resetPastedConfig();
@@ -878,45 +552,6 @@ export class HaManualAutomationEditor extends LitElement {
font-weight: var(--ha-font-weight-normal);
line-height: 0;
}
sl-split-panel {
height: calc(100vh - var(--header-height, 64px) - 28px - 20px - 1px);
}
sl-drawer {
--sl-z-index-drawer: 9999;
--size: 66vh;
--sl-panel-background-color: var(--ha-card-background, white);
--sl-overlay-background-color: rgba(0, 0, 0, 0.32);
--sl-shadow-x-large: var(
--ha-card-box-shadow,
0px -1px 4px 1px rgba(0, 0, 0, 0.2),
0px 1px 1px 0px rgba(0, 0, 0, 0.14),
0px 1px 3px 0px rgba(0, 0, 0, 0.12)
);
--sl-panel-border-color: var(--ha-card-border-color, #e0e0e0);
}
:host([big-drawer]) sl-drawer {
--size: 90vh;
}
sl-drawer::part(panel) {
border-radius: 12px 12px 0 0;
border: 1px solid var(--ha-card-border-color, #e0e0e0);
}
sl-drawer .card-content {
padding: 12px;
}
sl-drawer ha-dialog-header {
position: sticky;
top: 0;
background: var(--card-background-color);
z-index: 999;
}
.card-content {
overflow: auto;
height: 100%;
padding-bottom: 16px;
}
`,
];
}

View File

@@ -70,7 +70,6 @@ import "./types/ha-automation-trigger-time";
import "./types/ha-automation-trigger-time_pattern";
import "./types/ha-automation-trigger-webhook";
import "./types/ha-automation-trigger-zone";
import "../../../../components/ha-automation-row";
export interface TriggerElement extends LitElement {
trigger: Trigger;
@@ -159,7 +158,7 @@ export default class HaAutomationTriggerRow extends LitElement {
`
: nothing}
<ha-automation-row>
<ha-expansion-panel left-chevron>
<ha-svg-icon
slot="leading-icon"
class="trigger-icon"
@@ -194,6 +193,16 @@ export default class HaAutomationTriggerRow extends LitElement {
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._showTriggerId}
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<ha-svg-icon slot="start" .path=${mdiIdentifier}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
@@ -247,6 +256,16 @@ export default class HaAutomationTriggerRow extends LitElement {
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!supported}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
@@ -283,7 +302,77 @@ export default class HaAutomationTriggerRow extends LitElement {
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
</ha-automation-row>
<div
class=${classMap({
"card-content": true,
disabled:
"enabled" in this.trigger && this.trigger.enabled === false,
})}
>
${this._warnings
? html`<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.errors.config.editor_not_supported"
)}
>
${this._warnings.length && this._warnings[0] !== undefined
? html` <ul>
${this._warnings.map(
(warning) => html`<li>${warning}</li>`
)}
</ul>`
: ""}
${this.hass.localize(
"ui.errors.config.edit_in_yaml_supported"
)}
</ha-alert>`
: ""}
${yamlMode
? html`
${!supported
? html`
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.unsupported_platform",
{ platform: type }
)}
`
: ""}
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${this.trigger}
.readOnly=${this.disabled}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>
`
: html`
${showId && !isTriggerList(this.trigger)
? html`
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.id"
)}
.value=${this.trigger.id || ""}
.disabled=${this.disabled}
@change=${this._idChanged}
>
</ha-textfield>
`
: ""}
<div
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._onUiChanged}
>
${dynamicElement(`ha-automation-trigger-${type}`, {
hass: this.hass,
trigger: this.trigger,
disabled: this.disabled,
})}
</div>
`}
</div>
</ha-expansion-panel>
<div
class="triggered ${classMap({
@@ -477,6 +566,7 @@ export default class HaAutomationTriggerRow extends LitElement {
text: html`
<ha-yaml-editor
read-only
disable-fullscreen
.hass=${this.hass}
.defaultValue=${this._triggered}
></ha-yaml-editor>
@@ -651,8 +741,8 @@ export default class HaAutomationTriggerRow extends LitElement {
}
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--primary-color);
border-color: var(--primary-color);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`,

View File

@@ -34,8 +34,6 @@ export default class HaAutomationTrigger extends LitElement {
@property({ attribute: false }) public highlightedTriggers?: Trigger[];
@property({ type: Array }) public path?: ItemPath;
@property({ type: Boolean }) public disabled = false;
@state() private _showReorder = false;
@@ -91,12 +89,10 @@ export default class HaAutomationTrigger extends LitElement {
.first=${idx === 0}
.last=${idx === this.triggers.length - 1}
.trigger=${trg}
.path=${[...(this.path ?? []), idx]}
@duplicate=${this._duplicateTrigger}
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._triggerChanged}
@click=${this._triggerClicked}
.hass=${this.hass}
.disabled=${this.disabled}
?highlight=${this.highlightedTriggers?.includes(trg)}
@@ -140,15 +136,6 @@ export default class HaAutomationTrigger extends LitElement {
});
}
private _triggerClicked(ev: MouseEvent) {
fireEvent(this, "element-selected", {
type: "trigger",
element: (ev.currentTarget as HaAutomationTriggerRow).trigger,
index: (ev.currentTarget as HaAutomationTriggerRow).index,
path: (ev.currentTarget as HaAutomationTriggerRow).path,
});
}
private _addTrigger = (value: string) => {
let triggers: Trigger[];
if (value === PASTE_VALUE) {

View File

@@ -173,6 +173,7 @@ export abstract class HaBlueprintGenericEditor extends LitElement {
.content=${value?.description}
></ha-markdown>
${html`<ha-selector
narrow
.hass=${this.hass}
.selector=${selector}
.key=${key}

View File

@@ -0,0 +1,160 @@
import "@material/mwc-button";
import { mdiHelpCircle, mdiStarFourPoints } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-card";
import "../../../components/ha-settings-row";
import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import {
fetchAITaskPreferences,
saveAITaskPreferences,
type AITaskPreferences,
} from "../../../data/ai_task";
import { documentationUrl } from "../../../util/documentation-url";
@customElement("ai-task-pref")
export class AITaskPref extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _prefs?: AITaskPreferences;
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
fetchAITaskPreferences(this.hass).then((prefs) => {
this._prefs = prefs;
});
}
protected render() {
if (!this._prefs) {
return nothing;
}
return html`
<ha-card outlined>
<h1 class="card-header">
<img
alt=""
src=${brandsUrl({
domain: "ai_task",
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>${this.hass.localize("ui.panel.config.ai_task.header")}
</h1>
<div class="header-actions">
<a
href=${documentationUrl(this.hass, "/integrations/ai_task/")}
target="_blank"
rel="noreferrer"
class="icon-link"
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.cloud.account.alexa.link_learn_how_it_works"
)}
.path=${mdiHelpCircle}
></ha-icon-button>
</a>
</div>
<div class="card-content">
<p>
${this.hass!.localize("ui.panel.config.ai_task.description", {
button: html`<ha-svg-icon
.path=${mdiStarFourPoints}
></ha-svg-icon>`,
})}
</p>
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
${this.hass!.localize("ui.panel.config.ai_task.gen_data_header")}
</span>
<span slot="description">
${this.hass!.localize(
"ui.panel.config.ai_task.gen_data_description"
)}
</span>
<ha-entity-picker
data-name="gen_data_entity_id"
.hass=${this.hass}
.value=${this._prefs.gen_data_entity_id}
.includeDomains=${["ai_task"]}
@value-changed=${this._handlePrefChange}
></ha-entity-picker>
</ha-settings-row>
</div>
</ha-card>
`;
}
private async _handlePrefChange(
ev: CustomEvent<{ value: string | undefined }>
) {
const input = ev.target as HaEntityPicker;
const key = input.getAttribute("data-name") as keyof AITaskPreferences;
const entityId = ev.detail.value || null;
const oldPrefs = this._prefs;
this._prefs = { ...this._prefs!, [key]: entityId };
try {
this._prefs = await saveAITaskPreferences(this.hass, {
[key]: entityId,
});
} catch (_err: any) {
this._prefs = oldPrefs;
}
}
static styles = css`
.card-header {
display: flex;
align-items: center;
}
.card-header img {
max-width: 28px;
margin-right: 16px;
}
a {
color: var(--primary-color);
}
ha-settings-row {
padding: 0;
}
.header-actions {
position: absolute;
right: 0px;
inset-inline-end: 0px;
inset-inline-start: initial;
top: 24px;
display: flex;
flex-direction: row;
}
.header-actions .icon-link {
margin-top: -16px;
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
direction: var(--direction);
color: var(--secondary-text-color);
}
ha-entity-picker {
flex: 1;
margin-left: 16px;
}
:host([narrow]) ha-entity-picker {
margin-left: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ai-task-pref": AITaskPref;
}
}

View File

@@ -25,8 +25,10 @@ import type { ConfigUpdateValues } from "../../../data/core";
import { saveCoreConfig } from "../../../data/core";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import "./ai-task-pref";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
@customElement("ha-config-section-general")
class HaConfigSectionGeneral extends LitElement {
@@ -265,6 +267,12 @@ class HaConfigSectionGeneral extends LitElement {
</ha-progress-button>
</div>
</ha-card>
${isComponentLoaded(this.hass, "ai_task")
? html`<ai-task-pref
.hass=${this.hass}
.narrow=${this.narrow}
></ai-task-pref>`
: nothing}
</div>
</hass-subpage>
`;
@@ -377,7 +385,8 @@ class HaConfigSectionGeneral extends LitElement {
max-width: 1040px;
margin: 0 auto;
}
ha-card {
ha-card,
ai-task-pref {
max-width: 600px;
margin: 0 auto;
height: 100%;
@@ -385,6 +394,10 @@ class HaConfigSectionGeneral extends LitElement {
flex-direction: column;
display: flex;
}
ha-card,
ai-task-pref {
margin-bottom: 24px;
}
.card-content {
display: flex;
justify-content: space-between;

View File

@@ -58,7 +58,7 @@ export class DashboardCard extends LitElement {
.card-header {
padding: 12px;
display: block;
text-align: left;
text-align: var(--float-start);
gap: 8px;
}
.preview {

View File

@@ -54,6 +54,9 @@ class HaConfigNavigation extends LitElement {
`,
}));
return html`
<div class="visually-hidden" role="heading" aria-level="2">
${this.hass.localize("panel.config")}
</div>
<ha-navigation-list
has-secondary
.hass=${this.hass}
@@ -68,6 +71,17 @@ class HaConfigNavigation extends LitElement {
ha-navigation-list {
--navigation-list-item-title-font-size: var(--ha-font-size-l);
}
/* Accessibility */
.visually-hidden {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
`;
}

View File

@@ -64,7 +64,7 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
const updates = this.updateEntities;
return html`
<div class="title">
<div class="title" role="heading" aria-level="2">
${this.hass.localize("ui.panel.config.updates.title", {
count: this.total || this.updateEntities.length,
})}
@@ -115,7 +115,7 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
></ha-spinner>`
: nothing}
</div>
<span
<span slot="headline"
>${deviceEntry
? computeDeviceNameDisplay(deviceEntry, this.hass)
: entity.attributes.friendly_name}</span

View File

@@ -1,6 +1,7 @@
import {
mdiChatQuestion,
mdiCog,
mdiDelete,
mdiDeleteForever,
mdiHospitalBox,
mdiInformation,
@@ -16,17 +17,19 @@ import {
fetchZwaveIsNodeFirmwareUpdateInProgress,
fetchZwaveNetworkStatus,
fetchZwaveNodeStatus,
fetchZwaveProvisioningEntries,
unprovisionZwaveSmartStartNode,
} from "../../../../../../data/zwave_js";
import { showConfirmationDialog } from "../../../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../../../types";
import { showZWaveJSRebuildNodeRoutesDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-rebuild-node-routes";
import { showZWaveJSNodeStatisticsDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics";
import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node";
import { showZWaveJSRemoveFailedNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node";
import { showZWaveJSUpdateFirmwareNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-update-firmware-node";
import type { DeviceAction } from "../../../ha-config-device-page";
import { showZWaveJSHardResetControllerDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-hard-reset-controller";
import { showZWaveJSAddNodeDialog } from "../../../../integrations/integration-panels/zwave_js/add-node/show-dialog-zwave_js-add-node";
import { showZWaveJSRemoveNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-node";
export const getZwaveDeviceActions = async (
el: HTMLElement,
@@ -47,6 +50,43 @@ export const getZwaveDeviceActions = async (
const entryId = configEntry.entry_id;
const provisioningEntries = await fetchZwaveProvisioningEntries(
hass,
entryId
);
const provisioningEntry = provisioningEntries.find(
(entry) => entry.device_id === device.id
);
if (provisioningEntry && !provisioningEntry.nodeId) {
return [
{
label: hass.localize("ui.panel.config.devices.delete_device"),
classes: "warning",
icon: mdiDelete,
action: async () => {
const confirm = await showConfirmationDialog(el, {
title: hass.localize(
"ui.panel.config.zwave_js.provisioned.confirm_unprovision_title"
),
text: hass.localize(
"ui.panel.config.zwave_js.provisioned.confirm_unprovision_text",
{ name: device.name_by_user || device.name }
),
confirmText: hass.localize("ui.common.remove"),
destructive: true,
});
if (confirm) {
await unprovisionZwaveSmartStartNode(
hass,
entryId,
provisioningEntry.dsk
);
}
},
},
];
}
const nodeStatus = await fetchZwaveNodeStatus(hass, device.id);
if (!nodeStatus) {
@@ -84,16 +124,6 @@ export const getZwaveDeviceActions = async (
device,
}),
},
{
label: hass.localize(
"ui.panel.config.zwave_js.device_info.remove_failed"
),
icon: mdiDeleteForever,
action: () =>
showZWaveJSRemoveFailedNodeDialog(el, {
device_id: device.id,
}),
},
{
label: hass.localize(
"ui.panel.config.zwave_js.device_info.node_statistics"
@@ -103,6 +133,16 @@ export const getZwaveDeviceActions = async (
showZWaveJSNodeStatisticsDialog(el, {
device,
}),
},
{
label: hass.localize("ui.panel.config.devices.delete_device"),
classes: "warning",
icon: mdiDelete,
action: () =>
showZWaveJSRemoveNodeDialog(el, {
deviceId: device.id,
entryId,
}),
}
);
}

View File

@@ -10,11 +10,12 @@ import {
mdiPlusCircle,
mdiRestore,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import type { HassEntity } from "home-assistant-js-websocket";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { ASSIST_ENTITIES, SENSOR_ENTITIES } from "../../../common/const";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
@@ -185,6 +186,27 @@ export class HaConfigDevicePage extends LitElement {
)
);
private _getEntitiesSorted = (entities: HassEntity[]) =>
entities.sort((ent1, ent2) =>
stringCompare(
ent1.attributes.friendly_name || `zzz${ent1.entity_id}`,
ent2.attributes.friendly_name || `zzz${ent2.entity_id}`,
this.hass.locale.language
)
);
private _getRelated = memoizeOne((related?: RelatedResult) => ({
automation: this._getEntitiesSorted(
(related?.automation ?? []).map((entityId) => this.hass.states[entityId])
),
scene: this._getEntitiesSorted(
(related?.scene ?? []).map((entityId) => this.hass.states[entityId])
),
script: this._getEntitiesSorted(
(related?.script ?? []).map((entityId) => this.hass.states[entityId])
),
}));
private _deviceIdInList = memoizeOne((deviceId: string) => [deviceId]);
private _entityIds = memoizeOne(
@@ -251,22 +273,24 @@ export class HaConfigDevicePage extends LitElement {
findBatteryChargingEntity(this.hass, entities)
);
public willUpdate(changedProps) {
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (changedProps.has("deviceId") || changedProps.has("entries")) {
if (changedProps.has("deviceId")) {
this._deviceActions = [];
this._deviceAlerts = [];
this._deleteButtons = [];
this._diagnosticDownloadLinks = [];
}
if (changedProps.has("deviceId") || changedProps.has("entries")) {
this._fetchData();
}
}
protected firstUpdated(changedProps) {
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
loadDeviceRegistryDetailDialog();
this._fetchData();
}
protected updated(changedProps) {
@@ -433,23 +457,25 @@ export class HaConfigDevicePage extends LitElement {
${this._related?.automation?.length
? html`
<div class="items">
${this._related.automation.map((automation) => {
const entityState = this.hass.states[automation];
return entityState
? html`<a
href=${ifDefined(
entityState.attributes.id
? `/config/automation/edit/${encodeURIComponent(entityState.attributes.id)}`
: `/config/automation/show/${entityState.entity_id}`
)}
>
<ha-list-item hasMeta .automation=${entityState}>
${computeStateName(entityState)}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>`
: nothing;
})}
${this._getRelated(this._related).automation.map(
(automation) => {
const entityState = automation;
return entityState
? html`<a
href=${ifDefined(
entityState.attributes.id
? `/config/automation/edit/${encodeURIComponent(entityState.attributes.id)}`
: `/config/automation/show/${entityState.entity_id}`
)}
>
<ha-list-item hasMeta .automation=${entityState}>
${computeStateName(entityState)}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>`
: nothing;
}
)}
</div>
`
: html`
@@ -510,8 +536,8 @@ export class HaConfigDevicePage extends LitElement {
${this._related?.scene?.length
? html`
<div class="items">
${this._related.scene.map((scene) => {
const entityState = this.hass.states[scene];
${this._getRelated(this._related).scene.map((scene) => {
const entityState = scene;
return entityState && entityState.attributes.id
? html`
<a
@@ -598,10 +624,10 @@ export class HaConfigDevicePage extends LitElement {
${this._related?.script?.length
? html`
<div class="items">
${this._related.script.map((script) => {
const entityState = this.hass.states[script];
${this._getRelated(this._related).script.map((script) => {
const entityState = script;
const entry = this._entityReg.find(
(e) => e.entity_id === script
(e) => e.entity_id === script.entity_id
);
let url = `/config/script/show/${entityState.entity_id}`;
if (entry) {
@@ -965,6 +991,7 @@ export class HaConfigDevicePage extends LitElement {
}
private _getDeleteActions() {
const deviceId = this.deviceId;
const device = this.hass.devices[this.deviceId];
if (!device) {
@@ -1034,12 +1061,18 @@ export class HaConfigDevicePage extends LitElement {
}
);
if (this.deviceId !== deviceId) {
// abort if the device has changed
return;
}
if (buttons.length > 0) {
this._deleteButtons = buttons;
}
}
private async _getDeviceActions() {
const deviceId = this.deviceId;
const device = this.hass.devices[this.deviceId];
if (!device) {
@@ -1133,14 +1166,25 @@ export class HaConfigDevicePage extends LitElement {
// load matter device actions async to avoid an UI with 0 actions when the matter integration needs very long to get node diagnostics
matter.getMatterDeviceActions(this, this.hass, device).then((actions) => {
if (this.deviceId !== deviceId) {
// abort if the device has changed
return;
}
this._deviceActions = [...actions, ...(this._deviceActions || [])];
});
}
if (this.deviceId !== deviceId) {
// abort if the device has changed
return;
}
this._deviceActions = deviceActions;
}
private async _getDeviceAlerts() {
const deviceId = this.deviceId;
const device = this.hass.devices[this.deviceId];
if (!device) {
@@ -1164,6 +1208,11 @@ export class HaConfigDevicePage extends LitElement {
deviceAlerts.push(...alerts);
}
if (this.deviceId !== deviceId) {
// abort if the device has changed
return;
}
this._deviceAlerts = deviceAlerts;
if (deviceAlerts.length) {
this._deviceAlertsActionsTimeout = window.setTimeout(() => {
@@ -1293,9 +1342,13 @@ export class HaConfigDevicePage extends LitElement {
// eslint-disable-next-line no-await-in-loop
(await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.devices.confirm_disable_config_entry",
{ entry_name: config_entry.title }
"ui.panel.config.devices.confirm_disable_config_entry_title"
),
text: this.hass.localize(
"ui.panel.config.devices.confirm_disable_config_entry_message",
{ name: config_entry.title }
),
destructive: true,
confirmText: this.hass.localize("ui.common.yes"),
dismissText: this.hass.localize("ui.common.no"),
}))

View File

@@ -1099,10 +1099,10 @@ ${
}
protected firstUpdated() {
this._setFiltersFromUrl();
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
this._setFiltersFromUrl();
if (Object.keys(this._filters).length) {
return;
}
@@ -1115,9 +1115,10 @@ ${
const domain = this._searchParms.get("domain");
const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry");
const label = this._searchParms.has("label");
const device = this._searchParms.get("device");
const label = this._searchParms.get("label");
if (!domain && !configEntry && !label) {
if (!domain && !configEntry && !label && !device) {
return;
}
@@ -1126,21 +1127,11 @@ ${
this._filters = {
"ha-filter-states": [],
"ha-filter-integrations": domain ? [domain] : [],
"ha-filter-devices": device ? [device] : [],
"ha-filter-labels": label ? [label] : [],
config_entry: configEntry ? [configEntry] : [],
sub_entry: subEntry ? [subEntry] : [],
};
this._filterLabel();
}
private _filterLabel() {
const label = this._searchParms.get("label");
if (!label) {
return;
}
this._filters = {
...this._filters,
"ha-filter-labels": [label],
};
}
private _clearFilter() {
@@ -1150,6 +1141,11 @@ ${
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._setFiltersFromUrl();
}
const oldHass = changedProps.get("hass");
let changed = false;
if (!this.hass || !this._entities) {

View File

@@ -0,0 +1,98 @@
import { mdiClose } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-dialog-header";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../components/ha-md-dialog";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import { ERROR_STATES, RECOVERABLE_STATES } from "../../../data/config_entries";
import type { HomeAssistant } from "../../../types";
import type { PickConfigEntryDialogParams } from "./show-pick-config-entry-dialog";
@customElement("dialog-pick-config-entry")
export class DialogPickConfigEntry extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: PickConfigEntryDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public showDialog(params: PickConfigEntryDialogParams): void {
this._params = params;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public closeDialog() {
this._dialog?.close();
return true;
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span
slot="title"
.title=${this.hass.localize(
`component.${this._params.domain}.config_subentries.${this._params.subFlowType}.initiate_flow.user`
)}
>${this.hass.localize(
`component.${this._params.domain}.config_subentries.${this._params.subFlowType}.initiate_flow.user`
)}</span
>
</ha-dialog-header>
<ha-md-list slot="content">
${this._params.configEntries.map(
(entry) =>
html`<ha-md-list-item
type="button"
@click=${this._itemPicked}
.entry=${entry}
.disabled=${!ERROR_STATES.includes(entry.state) &&
!RECOVERABLE_STATES.includes(entry.state)}
>${entry.title}</ha-md-list-item
>`
)}
</ha-md-list>
</ha-md-dialog>
`;
}
private _itemPicked(ev: Event) {
this._params?.configEntryPicked((ev.currentTarget as any).entry);
this.closeDialog();
}
static styles = css`
:host {
--dialog-content-padding: 0;
}
@media all and (min-width: 600px) {
ha-dialog {
--mdc-dialog-min-width: 400px;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-pick-config-entry": DialogPickConfigEntry;
}
}

View File

@@ -0,0 +1,330 @@
import {
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiPencil,
mdiShapeOutline,
mdiStopCircleOutline,
mdiTransitConnectionVariant,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { getDeviceContext } from "../../../common/entity/context/get_device_context";
import { navigate } from "../../../common/navigate";
import {
disableConfigEntry,
type ConfigEntry,
type DisableConfigEntryResult,
} from "../../../data/config_entries";
import {
removeConfigEntryFromDevice,
updateDeviceRegistryEntry,
type DeviceRegistryEntry,
} from "../../../data/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../lovelace/custom-card-helpers";
import { showDeviceRegistryDetailDialog } from "../devices/device-registry-detail/show-dialog-device-registry-detail";
import "./ha-config-sub-entry-row";
@customElement("ha-config-entry-device-row")
class HaConfigEntryDeviceRow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public entry!: ConfigEntry;
@property({ attribute: false }) public device!: DeviceRegistryEntry;
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
protected render() {
const device = this.device;
const entities = this._getEntities();
const { area } = getDeviceContext(device, this.hass);
const supportingText = [
device.model || device.sw_version || device.manufacturer,
area ? area.name : undefined,
].filter(Boolean);
return html`<ha-md-list-item
type="button"
@click=${this._handleNavigateToDevice}
class=${classMap({ disabled: Boolean(device.disabled_by) })}
>
<ha-svg-icon
.path=${device.entry_type === "service"
? mdiTransitConnectionVariant
: mdiDevices}
slot="start"
></ha-svg-icon>
<div slot="headline">${computeDeviceNameDisplay(device, this.hass)}</div>
<span slot="supporting-text"
>${supportingText.join(" • ")}
${supportingText.length && entities.length ? " • " : nothing}
${entities.length
? this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)
: nothing}</span
>
${!this.narrow
? html`<ha-icon-next slot="end"> </ha-icon-next>`
: nothing}
<div class="vertical-divider" slot="end" @click=${stopPropagation}></div>
${!this.narrow
? html`<ha-icon-button
slot="end"
@click=${this._handleEditDevice}
.path=${mdiPencil}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.edit"
)}
></ha-icon-button>`
: nothing}
<ha-md-button-menu
positioning="popover"
slot="end"
@click=${stopPropagation}
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${this.narrow
? html`<ha-md-menu-item @click=${this._handleEditDevice}>
<ha-svg-icon .path=${mdiPencil} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.edit"
)}
</ha-md-menu-item>`
: nothing}
${entities.length
? html`
<ha-md-menu-item
href=${`/config/entities/?historyBack=1&device=${device.id}`}
>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item
class=${device.disabled_by !== "user" ? "warning" : ""}
@click=${this._handleDisableDevice}
.disabled=${device.disabled_by !== "user" && device.disabled_by}
>
<ha-svg-icon .path=${mdiStopCircleOutline} slot="start"></ha-svg-icon>
${device.disabled_by && device.disabled_by !== "user"
? this.hass.localize(
"ui.dialogs.device-registry-detail.enabled_cause",
{
type: this.hass.localize(
`ui.dialogs.device-registry-detail.type.${
device.entry_type || "device"
}`
),
cause: this.hass.localize(
`config_entry.disabled_by.${device.disabled_by}`
),
}
)
: device.disabled_by
? this.hass.localize(
"ui.panel.config.integrations.config_entry.device.enable"
)
: this.hass.localize(
"ui.panel.config.integrations.config_entry.device.disable"
)}
</ha-md-menu-item>
${this.entry.supports_remove_device
? html`<ha-md-menu-item
class="warning"
@click=${this._handleDeleteDevice}
>
<ha-svg-icon .path=${mdiDelete} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.delete"
)}
</ha-md-menu-item>`
: nothing}
</ha-md-button-menu>
</ha-md-list-item> `;
}
private _getEntities = (): EntityRegistryEntry[] =>
this.entities?.filter((entity) => entity.device_id === this.device.id);
private _handleEditDevice(ev: MouseEvent) {
ev.stopPropagation(); // Prevent triggering the click handler on the list item
showDeviceRegistryDetailDialog(this, {
device: this.device,
updateEntry: async (updates) => {
await updateDeviceRegistryEntry(this.hass, this.device.id, updates);
},
});
}
private async _handleDisableDevice() {
const disable = this.device.disabled_by === null;
if (disable) {
if (
!Object.values(this.hass.devices).some(
(dvc) =>
dvc.id !== this.device.id &&
dvc.config_entries.includes(this.entry.entry_id)
)
) {
const config_entry = this.entry;
if (
config_entry &&
!config_entry.disabled_by &&
(await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.devices.confirm_disable_config_entry_title"
),
text: this.hass.localize(
"ui.panel.config.devices.confirm_disable_config_entry_message",
{ name: config_entry.title }
),
destructive: true,
confirmText: this.hass.localize("ui.common.yes"),
dismissText: this.hass.localize("ui.common.no"),
}))
) {
let result: DisableConfigEntryResult;
try {
result = await disableConfigEntry(this.hass, this.entry.entry_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_error"
),
text: err.message,
});
return;
}
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_restart_confirm"
),
});
}
return;
}
}
}
if (disable) {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.device.confirm_disable_title"
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.device.confirm_disable_message",
{ name: computeDeviceNameDisplay(this.device, this.hass) }
),
destructive: true,
confirmText: this.hass.localize("ui.common.yes"),
dismissText: this.hass.localize("ui.common.no"),
});
if (!confirm) {
return;
}
}
await updateDeviceRegistryEntry(this.hass, this.device.id, {
disabled_by: disable ? "user" : null,
});
}
private async _handleDeleteDevice() {
const entry = this.entry;
const confirmed = await showConfirmationDialog(this, {
text: this.hass.localize("ui.panel.config.devices.confirm_delete"),
confirmText: this.hass.localize("ui.common.delete"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
try {
await removeConfigEntryFromDevice(
this.hass!,
this.device.id,
entry.entry_id
);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.devices.error_delete"),
text: err.message,
});
}
}
private _handleNavigateToDevice() {
navigate(`/config/devices/device/${this.device.id}`);
}
static styles = [
haStyle,
css`
:host {
border-top: 1px solid var(--divider-color);
}
ha-md-list-item {
--md-list-item-leading-space: 56px;
--md-ripple-hover-color: transparent;
--md-ripple-pressed-color: transparent;
}
.disabled {
opacity: 0.5;
}
:host([narrow]) ha-md-list-item {
--md-list-item-leading-space: 16px;
}
.vertical-divider {
height: 100%;
width: 1px;
background: var(--divider-color);
}
a {
text-decoration: none;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-entry-device-row": HaConfigEntryDeviceRow;
}
}

View File

@@ -0,0 +1,787 @@
import {
mdiAlertCircle,
mdiChevronDown,
mdiCogOutline,
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiDownload,
mdiHandExtendedOutline,
mdiPlayCircleOutline,
mdiPlus,
mdiProgressHelper,
mdiReload,
mdiReloadAlert,
mdiRenameBox,
mdiShapeOutline,
mdiStopCircleOutline,
mdiWrench,
} from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { isDevVersion } from "../../../common/config/version";
import {
deleteApplicationCredential,
fetchApplicationCredentialsConfigEntry,
} from "../../../data/application_credential";
import { getSignedPath } from "../../../data/auth";
import type {
ConfigEntry,
DisableConfigEntryResult,
SubEntry,
} from "../../../data/config_entries";
import {
deleteConfigEntry,
disableConfigEntry,
enableConfigEntry,
ERROR_STATES,
getSubEntries,
RECOVERABLE_STATES,
reloadConfigEntry,
updateConfigEntry,
} from "../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import {
domainToName,
fetchIntegrationManifest,
integrationsWithPanel,
} from "../../../data/integration";
import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entry-system-options/show-dialog-config-entry-system-options";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow";
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { fileDownload } from "../../../util/file_download";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../lovelace/custom-card-helpers";
import "./ha-config-entry-device-row";
import { renderConfigEntryError } from "./ha-config-integration-page";
import "./ha-config-sub-entry-row";
@customElement("ha-config-entry-row")
class HaConfigEntryRow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public manifest?: IntegrationManifest;
@property({ attribute: false }) public diagnosticHandler?: DiagnosticInfo;
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
@property({ attribute: false }) public entry!: ConfigEntry;
@state() private _expanded = true;
@state() private _devicesExpanded = true;
@state() private _subEntries?: SubEntry[];
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("entry")) {
this._fetchSubEntries();
}
}
protected render() {
const item = this.entry;
let stateText: Parameters<typeof this.hass.localize> | undefined;
let stateTextExtra: TemplateResult | string | undefined;
let icon: string = mdiAlertCircle;
if (!item.disabled_by && item.state === "not_loaded") {
stateText = ["ui.panel.config.integrations.config_entry.not_loaded"];
} else if (item.state === "setup_in_progress") {
icon = mdiProgressHelper;
stateText = [
"ui.panel.config.integrations.config_entry.setup_in_progress",
];
} else if (ERROR_STATES.includes(item.state)) {
if (item.state === "setup_retry") {
icon = mdiReloadAlert;
}
stateText = [
`ui.panel.config.integrations.config_entry.state.${item.state}`,
];
stateTextExtra = renderConfigEntryError(this.hass, item);
}
const devices = this._getDevices();
const services = this._getServices();
const entities = this._getEntities();
const ownDevices = [...devices, ...services].filter(
(device) =>
!device.config_entries_subentries[item.entry_id].length ||
device.config_entries_subentries[item.entry_id][0] === null
);
const statusLine: (TemplateResult | string)[] = [];
if (item.disabled_by) {
statusLine.push(
this.hass.localize(
"ui.panel.config.integrations.config_entry.disable.disabled_cause",
{
cause:
this.hass.localize(
`ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}`
) || item.disabled_by,
}
)
);
if (item.state === "failed_unload") {
statusLine.push(`.
${this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_restart_confirm"
)}.`);
}
} else if (!devices.length && !services.length && entities.length) {
statusLine.push(
html`<a
href=${`/config/entities/?historyBack=1&config_entry=${item.entry_id}`}
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)}</a
>`
);
}
const configPanel = this._configPanel(item.domain, this.hass.panels);
const subEntries = this._subEntries || [];
return html`<ha-md-list>
<ha-md-list-item
class=${classMap({
config_entry: true,
"state-not-loaded": item!.state === "not_loaded",
"state-failed-unload": item!.state === "failed_unload",
"state-setup": item!.state === "setup_in_progress",
"state-error": ERROR_STATES.includes(item!.state),
"state-disabled": item.disabled_by !== null,
"has-subentries": this._expanded && subEntries.length > 0,
})}
>
${subEntries.length || ownDevices.length
? html`<ha-icon-button
class="expand-button ${classMap({ expanded: this._expanded })}"
.path=${mdiChevronDown}
slot="start"
@click=${this._toggleExpand}
></ha-icon-button>`
: nothing}
<div slot="headline">
${item.title || domainToName(this.hass.localize, item.domain)}
</div>
<div slot="supporting-text">
<div>${statusLine}</div>
${stateText
? html`
<div class="message">
<ha-svg-icon .path=${icon}></ha-svg-icon>
<div>
${this.hass.localize(...stateText)}${stateTextExtra
? html`: ${stateTextExtra}`
: nothing}
</div>
</div>
`
: nothing}
</div>
${item.disabled_by === "user"
? html`<ha-button unelevated slot="end" @click=${this._handleEnable}>
${this.hass.localize("ui.common.enable")}
</ha-button>`
: configPanel &&
(item.domain !== "matter" ||
isDevVersion(this.hass.config.version)) &&
!stateText
? html`<a
slot="end"
href=${`/${configPanel}?config_entry=${item.entry_id}`}
><ha-icon-button
.path=${mdiCogOutline}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button
></a>`
: item.supports_options
? html`
<ha-icon-button
slot="end"
@click=${this._showOptions}
.path=${mdiCogOutline}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button>
`
: nothing}
<ha-md-button-menu positioning="popover" slot="end">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${devices.length
? html`
<ha-md-menu-item
href=${devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.devices`,
{ count: devices.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${services.length
? html`<ha-md-menu-item
href=${services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon
.path=${mdiHandExtendedOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.services`,
{ count: services.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item> `
: nothing}
${entities.length
? html`
<ha-md-menu-item
href=${`/config/entities?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${!item.disabled_by &&
RECOVERABLE_STATES.includes(item.state) &&
item.supports_unload &&
item.source !== "system"
? html`
<ha-md-menu-item @click=${this._handleReload}>
<ha-svg-icon slot="start" .path=${mdiReload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reload"
)}
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item @click=${this._handleRename} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
)}
</ha-md-menu-item>
${Object.keys(item.supported_subentry_types).map(
(flowType) =>
html`<ha-md-menu-item
@click=${this._addSubEntry}
.entry=${item}
.flowType=${flowType}
graphic="icon"
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
`component.${item.domain}.config_subentries.${flowType}.initiate_flow.user`
)}</ha-md-menu-item
>`
)}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
${this.diagnosticHandler && item.state === "loaded"
? html`
<ha-md-menu-item
href=${getConfigEntryDiagnosticsDownloadUrl(item.entry_id)}
target="_blank"
@click=${this._signUrl}
>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.download_diagnostics"
)}
</ha-md-menu-item>
`
: nothing}
${!item.disabled_by &&
item.supports_reconfigure &&
item.source !== "system"
? html`
<ha-md-menu-item @click=${this._handleReconfigure}>
<ha-svg-icon slot="start" .path=${mdiWrench}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reconfigure"
)}
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item @click=${this._handleSystemOptions} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiCogOutline}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.system_options"
)}
</ha-md-menu-item>
${item.disabled_by === "user"
? html`
<ha-md-menu-item @click=${this._handleEnable}>
<ha-svg-icon
slot="start"
.path=${mdiPlayCircleOutline}
></ha-svg-icon>
${this.hass.localize("ui.common.enable")}
</ha-md-menu-item>
`
: item.source !== "system"
? html`
<ha-md-menu-item
class="warning"
@click=${this._handleDisable}
graphic="icon"
>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiStopCircleOutline}
></ha-svg-icon>
${this.hass.localize("ui.common.disable")}
</ha-md-menu-item>
`
: nothing}
${item.source !== "system"
? html`
<ha-md-menu-item class="warning" @click=${this._handleDelete}>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.delete"
)}
</ha-md-menu-item>
`
: nothing}
</ha-md-button-menu>
</ha-md-list-item>
${this._expanded
? subEntries.length
? html`${ownDevices.length
? html`<ha-md-list class="devices">
<ha-md-list-item
@click=${this._toggleOwnDevices}
type="button"
class="toggle-devices-row ${classMap({
expanded: this._devicesExpanded,
})}"
>
<ha-icon-button
class="expand-button ${classMap({
expanded: this._devicesExpanded,
})}"
.path=${mdiChevronDown}
slot="start"
>
</ha-icon-button>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.devices_without_subentry"
)}
</ha-md-list-item>
${this._devicesExpanded
? ownDevices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
></ha-config-entry-device-row>`
)
: nothing}
</ha-md-list>`
: nothing}
${subEntries.map(
(subEntry) => html`
<ha-config-sub-entry-row
.hass=${this.hass}
.narrow=${this.narrow}
.manifest=${this.manifest}
.diagnosticHandler=${this.diagnosticHandler}
.entities=${this.entities}
.entry=${item}
.subEntry=${subEntry}
data-entry-id=${item.entry_id}
></ha-config-sub-entry-row>
`
)}`
: html`
${ownDevices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
></ha-config-entry-device-row>`
)}
`
: nothing}
</ha-md-list>`;
}
private async _fetchSubEntries() {
this._subEntries = this.entry.num_subentries
? await getSubEntries(this.hass, this.entry.entry_id)
: undefined;
}
private _configPanel = memoizeOne(
(domain: string, panels: HomeAssistant["panels"]): string | undefined =>
Object.values(panels).find(
(panel) => panel.config_panel_domain === domain
)?.url_path || integrationsWithPanel[domain]
);
private _getEntities = (): EntityRegistryEntry[] =>
this.entities.filter(
(entity) => entity.config_entry_id === this.entry.entry_id
);
private _getDevices = (): DeviceRegistryEntry[] =>
Object.values(this.hass.devices).filter(
(device) =>
device.config_entries.includes(this.entry.entry_id) &&
device.entry_type !== "service"
);
private _getServices = (): DeviceRegistryEntry[] =>
Object.values(this.hass.devices).filter(
(device) =>
device.config_entries.includes(this.entry.entry_id) &&
device.entry_type === "service"
);
private _toggleExpand() {
this._expanded = !this._expanded;
}
private _toggleOwnDevices() {
this._devicesExpanded = !this._devicesExpanded;
}
private _showOptions() {
showOptionsFlowDialog(this, this.entry, { manifest: this.manifest });
}
// Return an application credentials id for this config entry to prompt the
// user for removal. This is best effort so we don't stop overall removal
// if the integration isn't loaded or there is some other error.
private async _applicationCredentialForRemove(entryId: string) {
try {
return (await fetchApplicationCredentialsConfigEntry(this.hass, entryId))
.application_credentials_id;
} catch (_err: any) {
// We won't prompt the user to remove credentials
return null;
}
}
private async _removeApplicationCredential(applicationCredentialsId: string) {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_title"
),
text: html`${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_prompt"
)},
<br />
<br />
${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_detail"
)}
<br />
<br />
<a
href=${documentationUrl(
this.hass,
"/integrations/application_credentials/"
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.learn_more"
)}
</a>`,
destructive: true,
confirmText: this.hass.localize("ui.common.remove"),
dismissText: this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.dismiss"
),
});
if (!confirmed) {
return;
}
try {
await deleteApplicationCredential(this.hass, applicationCredentialsId);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_error_title"
),
text: err.message,
});
}
}
private async _handleReload() {
const result = await reloadConfigEntry(this.hass, this.entry.entry_id);
const locale_key = result.require_restart
? "reload_restart_confirm"
: "reload_confirm";
showAlertDialog(this, {
text: this.hass.localize(
`ui.panel.config.integrations.config_entry.${locale_key}`
),
});
}
private async _handleReconfigure() {
showConfigFlowDialog(this, {
startFlowHandler: this.entry.domain,
showAdvanced: this.hass.userData?.showAdvanced,
manifest: await fetchIntegrationManifest(this.hass, this.entry.domain),
entryId: this.entry.entry_id,
navigateToResult: true,
});
}
private async _handleRename() {
const newName = await showPromptDialog(this, {
title: this.hass.localize("ui.panel.config.integrations.rename_dialog"),
defaultValue: this.entry.title,
inputLabel: this.hass.localize(
"ui.panel.config.integrations.rename_input_label"
),
});
if (newName === null) {
return;
}
await updateConfigEntry(this.hass, this.entry.entry_id, {
title: newName,
});
}
private async _signUrl(ev) {
const anchor = ev.currentTarget;
ev.preventDefault();
const signedUrl = await getSignedPath(
this.hass,
anchor.getAttribute("href")
);
fileDownload(signedUrl.path);
}
private async _handleDisable() {
const entryId = this.entry.entry_id;
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_confirm_title",
{ title: this.entry.title }
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_confirm_text"
),
confirmText: this.hass!.localize("ui.common.disable"),
dismissText: this.hass!.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
let result: DisableConfigEntryResult;
try {
result = await disableConfigEntry(this.hass, entryId);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_error"
),
text: err.message,
});
return;
}
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_restart_confirm"
),
});
}
}
private async _handleEnable() {
const entryId = this.entry.entry_id;
let result: DisableConfigEntryResult;
try {
result = await enableConfigEntry(this.hass, entryId);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_error"
),
text: err.message,
});
return;
}
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.enable_restart_confirm"
),
});
}
}
private async _handleDelete() {
const entryId = this.entry.entry_id;
const applicationCredentialsId =
await this._applicationCredentialForRemove(entryId);
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title",
{ title: this.entry.title }
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_text"
),
confirmText: this.hass!.localize("ui.common.delete"),
dismissText: this.hass!.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
const result = await deleteConfigEntry(this.hass, entryId);
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.restart_confirm"
),
});
}
if (applicationCredentialsId) {
this._removeApplicationCredential(applicationCredentialsId);
}
}
private _handleSystemOptions() {
showConfigEntrySystemOptionsDialog(this, {
entry: this.entry,
manifest: this.manifest,
});
}
private _addSubEntry(ev) {
showSubConfigFlowDialog(this, this.entry, ev.target.flowType, {
startFlowHandler: this.entry.entry_id,
});
}
static styles = [
haStyle,
css`
.expand-button {
margin: 0 -12px;
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.expand-button.expanded {
transform: rotate(180deg);
}
ha-md-list {
border: 1px solid var(--divider-color);
border-radius: var(--ha-card-border-radius, 12px);
padding: 0;
}
:host([narrow]) {
margin-left: -12px;
margin-right: -12px;
}
ha-md-list.devices {
margin: 16px;
margin-top: 0;
}
a ha-icon-button {
color: var(
--md-list-item-trailing-icon-color,
var(--md-sys-color-on-surface-variant, #49454f)
);
}
.toggle-devices-row {
overflow: hidden;
border-radius: var(--ha-card-border-radius, 12px);
}
.toggle-devices-row.expanded {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-entry-row": HaConfigEntryRow;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -406,11 +406,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
${!this._showDisabled && this.narrow && disabledConfigEntries.length
? html`<span class="badge">${disabledConfigEntries.length}</span>`
: ""}
<ha-button-menu
multi
@action=${this._handleMenuAction}
@click=${this._preventDefault}
>
<ha-button-menu multi @action=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}

View File

@@ -0,0 +1,264 @@
import {
mdiChevronDown,
mdiCogOutline,
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiHandExtendedOutline,
mdiShapeOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { deleteSubEntry } from "../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
import type { HomeAssistant } from "../../../types";
import { showConfirmationDialog } from "../../lovelace/custom-card-helpers";
import "./ha-config-entry-device-row";
@customElement("ha-config-sub-entry-row")
class HaConfigSubEntryRow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public manifest?: IntegrationManifest;
@property({ attribute: false }) public diagnosticHandler?: DiagnosticInfo;
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
@property({ attribute: false }) public entry!: ConfigEntry;
@property({ attribute: false }) public subEntry!: SubEntry;
@state() private _expanded = true;
protected render() {
const subEntry = this.subEntry;
const configEntry = this.entry;
const devices = this._getDevices();
const services = this._getServices();
const entities = this._getEntities();
return html`<ha-md-list>
<ha-md-list-item
class="sub-entry"
data-entry-id=${configEntry.entry_id}
.configEntry=${configEntry}
.subEntry=${subEntry}
>
${devices.length || services.length
? html`<ha-icon-button
class="expand-button ${classMap({ expanded: this._expanded })}"
.path=${mdiChevronDown}
slot="start"
@click=${this._toggleExpand}
></ha-icon-button>`
: nothing}
<span slot="headline">${subEntry.title}</span>
<span slot="supporting-text"
>${this.hass.localize(
`component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.entry_type`
)}</span
>
${configEntry.supported_subentry_types[subEntry.subentry_type]
?.supports_reconfigure
? html`
<ha-icon-button
slot="end"
@click=${this._handleReconfigureSub}
.path=${mdiCogOutline}
.label=${this.hass.localize(
`component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.initiate_flow.reconfigure`
) ||
this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button>
`
: nothing}
<ha-md-button-menu positioning="popover" slot="end">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${devices.length || services.length
? html`
<ha-md-menu-item
href=${devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
>
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.devices`,
{ count: devices.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${services.length
? html`<ha-md-menu-item
href=${services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
>
<ha-svg-icon
.path=${mdiHandExtendedOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.services`,
{ count: services.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item> `
: nothing}
${entities.length
? html`
<ha-md-menu-item
href=${`/config/entities?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item class="warning" @click=${this._handleDeleteSub}>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.delete"
)}
</ha-md-menu-item>
</ha-md-button-menu>
</ha-md-list-item>
${this._expanded
? html`
${devices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${this.entry}
.device=${device}
.entities=${this.entities}
></ha-config-entry-device-row>`
)}
${services.map(
(service) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${this.entry}
.device=${service}
.entities=${this.entities}
></ha-config-entry-device-row>`
)}
`
: nothing}
</ha-md-list>`;
}
private _toggleExpand() {
this._expanded = !this._expanded;
}
private _getEntities = (): EntityRegistryEntry[] =>
this.entities.filter(
(entity) => entity.config_subentry_id === this.subEntry.subentry_id
);
private _getDevices = (): DeviceRegistryEntry[] =>
Object.values(this.hass.devices).filter(
(device) =>
device.config_entries_subentries[this.entry.entry_id]?.includes(
this.subEntry.subentry_id
) && device.entry_type !== "service"
);
private _getServices = (): DeviceRegistryEntry[] =>
Object.values(this.hass.devices).filter(
(device) =>
device.config_entries_subentries[this.entry.entry_id]?.includes(
this.subEntry.subentry_id
) && device.entry_type === "service"
);
private async _handleReconfigureSub(): Promise<void> {
showSubConfigFlowDialog(this, this.entry, this.subEntry.subentry_type, {
startFlowHandler: this.entry.entry_id,
subEntryId: this.subEntry.subentry_id,
});
}
private async _handleDeleteSub(): Promise<void> {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title",
{ title: this.subEntry.title }
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_text"
),
confirmText: this.hass!.localize("ui.common.delete"),
dismissText: this.hass!.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
await deleteSubEntry(
this.hass,
this.entry.entry_id,
this.subEntry.subentry_id
);
}
static styles = css`
.expand-button {
margin: 0 -12px;
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.expand-button.expanded {
transform: rotate(180deg);
}
ha-md-list {
border: 1px solid var(--divider-color);
border-radius: var(--ha-card-border-radius, 12px);
padding: 0;
margin: 16px;
margin-top: 0;
}
ha-md-list-item.has-subentries {
border-bottom: 1px solid var(--divider-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-sub-entry-row": HaConfigSubEntryRow;
}
}

View File

@@ -60,6 +60,7 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) {
title: localize("ui.panel.config.dhcp.ip_address"),
filterable: true,
sortable: true,
type: "ip",
},
};

View File

@@ -156,7 +156,7 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
newName = name.replace(oldDeviceName, newDeviceName);
}
if (newName === undefined && !newEntityId) {
if (newName !== undefined && !newEntityId) {
return undefined;
}

View File

@@ -80,9 +80,7 @@ export class ZWaveJsAddNodeConfigureDevice extends LitElement {
options: [
{
value: Protocols.ZWaveLongRange.toString(),
label: localize(
"ui.panel.config.zwave_js.add_node.configure_device.long_range_label"
),
label: "Long Range", // brand name and we should not translate that
description: localize(
"ui.panel.config.zwave_js.add_node.configure_device.long_range_description"
),

View File

@@ -1,234 +0,0 @@
import "@material/mwc-button/mwc-button";
import { mdiCheckCircle, mdiCloseCircle, mdiRobotDead } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-spinner";
import { createCloseHeading } from "../../../../../components/ha-dialog";
import type { ZWaveJSRemovedNode } from "../../../../../data/zwave_js";
import { removeFailedZwaveNode } from "../../../../../data/zwave_js";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { ZWaveJSRemoveFailedNodeDialogParams } from "./show-dialog-zwave_js-remove-failed-node";
@customElement("dialog-zwave_js-remove-failed-node")
class DialogZWaveJSRemoveFailedNode extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private device_id?: string;
@state() private _status = "";
@state() private _error?: any;
@state() private _node?: ZWaveJSRemovedNode;
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
public disconnectedCallback(): void {
super.disconnectedCallback();
this._unsubscribe();
}
public async showDialog(
params: ZWaveJSRemoveFailedNodeDialogParams
): Promise<void> {
this.device_id = params.device_id;
}
public closeDialog(): void {
this._unsubscribe();
this.device_id = undefined;
this._status = "";
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public closeDialogFinished(): void {
history.back();
this.closeDialog();
}
protected render() {
if (!this.device_id) {
return nothing;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.title"
)
)}
>
${this._status === ""
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiRobotDead}
class="introduction"
></ha-svg-icon>
<div class="status">
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.introduction"
)}
</div>
</div>
<mwc-button slot="primaryAction" @click=${this._startExclusion}>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.remove_device"
)}
</mwc-button>
`
: ``}
${this._status === "started"
? html`
<div class="flex-container">
<ha-spinner></ha-spinner>
<div class="status">
<p>
<b>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.in_progress"
)}
</b>
</p>
</div>
</div>
`
: ``}
${this._status === "failed"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCloseCircle}
class="error"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.removal_failed"
)}
</p>
${this._error
? html` <p><em> ${this._error.message} </em></p> `
: ``}
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</mwc-button>
`
: ``}
${this._status === "finished"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.removal_finished",
{ id: this._node!.node_id }
)}
</p>
</div>
</div>
<mwc-button
slot="primaryAction"
@click=${this.closeDialogFinished}
>
${this.hass.localize("ui.common.close")}
</mwc-button>
`
: ``}
</ha-dialog>
`;
}
private _startExclusion(): void {
if (!this.hass) {
return;
}
this._status = "started";
this._subscribed = removeFailedZwaveNode(
this.hass,
this.device_id!,
(message: any) => this._handleMessage(message)
).catch((error) => {
this._status = "failed";
this._error = error;
return undefined;
});
}
private _handleMessage(message: any): void {
if (message.event === "exclusion started") {
this._status = "started";
}
if (message.event === "node removed") {
this._status = "finished";
this._node = message.node;
this._unsubscribe();
}
}
private async _unsubscribe(): Promise<void> {
if (this._subscribed) {
const unsubFunc = await this._subscribed;
if (unsubFunc instanceof Function) {
unsubFunc();
}
this._subscribed = undefined;
}
if (this._status !== "finished") {
this._status = "";
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
.success {
color: var(--success-color);
}
.failed {
color: var(--warning-color);
}
.flex-container {
display: flex;
align-items: center;
}
ha-svg-icon {
width: 68px;
height: 48px;
}
.flex-container ha-spinner,
.flex-container ha-svg-icon {
margin-right: 20px;
margin-inline-end: 20px;
margin-inline-start: initial;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-zwave_js-remove-failed-node": DialogZWaveJSRemoveFailedNode;
}
}

View File

@@ -2,6 +2,7 @@ import {
mdiCheckCircle,
mdiClose,
mdiCloseCircle,
mdiRobotDead,
mdiVectorSquareRemove,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
@@ -17,6 +18,14 @@ import "../../../../../components/ha-spinner";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { ZWaveJSRemoveNodeDialogParams } from "./show-dialog-zwave_js-remove-node";
import {
fetchZwaveNodeStatus,
NodeStatus,
removeFailedZwaveNode,
} from "../../../../../data/zwave_js";
import "../../../../../components/ha-list-item";
import "../../../../../components/ha-icon-next";
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
const EXCLUSION_TIMEOUT_SECONDS = 120;
@@ -30,10 +39,16 @@ export interface ZWaveJSRemovedNode {
class DialogZWaveJSRemoveNode extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private entry_id?: string;
@state() private _entryId?: string;
@state() private _deviceId?: string;
private _device?: DeviceRegistryEntry;
@state() private _step:
| "start"
| "start_exclusion"
| "start_removal"
| "exclusion"
| "remove"
| "finished"
@@ -42,7 +57,7 @@ class DialogZWaveJSRemoveNode extends LitElement {
@state() private _node?: ZWaveJSRemovedNode;
@state() private _removedCallback?: () => void;
@state() private _onClose?: () => void;
private _removeNodeTimeoutHandle?: number;
@@ -58,15 +73,23 @@ class DialogZWaveJSRemoveNode extends LitElement {
public async showDialog(
params: ZWaveJSRemoveNodeDialogParams
): Promise<void> {
this.entry_id = params.entry_id;
this._removedCallback = params.removedCallback;
if (params.skipConfirmation) {
this._entryId = params.entryId;
this._deviceId = params.deviceId;
this._onClose = params.onClose;
if (this._deviceId) {
const nodeStatus = await fetchZwaveNodeStatus(this.hass, this._deviceId!);
this._device = this.hass.devices[this._deviceId];
this._step =
nodeStatus.status === NodeStatus.Dead ? "start_removal" : "start";
} else if (params.skipConfirmation) {
this._startExclusion();
} else {
this._step = "start_exclusion";
}
}
protected render() {
if (!this.entry_id) {
if (!this._entryId) {
return nothing;
}
@@ -75,7 +98,12 @@ class DialogZWaveJSRemoveNode extends LitElement {
);
return html`
<ha-dialog open @closed=${this.closeDialog} .heading=${dialogTitle}>
<ha-dialog
open
@closed=${this.handleDialogClosed}
.heading=${dialogTitle}
.hideActions=${this._step === "start"}
>
<ha-dialog-header slot="heading">
<ha-icon-button
slot="navigationIcon"
@@ -100,6 +128,47 @@ class DialogZWaveJSRemoveNode extends LitElement {
"ui.panel.config.zwave_js.remove_node.introduction"
)}
</p>
<div class="menu-options">
<ha-list-item hasMeta @click=${this._startExclusion}>
<span
>${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.menu_exclude_device"
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
<ha-list-item hasMeta @click=${this._startRemoval}>
<span
>${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.menu_remove_device"
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</div>
`;
}
if (this._step === "start_removal") {
return html`
<ha-svg-icon .path=${mdiRobotDead}></ha-svg-icon>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.failed_node_intro",
{ name: this._device!.name_by_user || this._device!.name }
)}
</p>
`;
}
if (this._step === "start_exclusion") {
return html`
<ha-svg-icon .path=${mdiVectorSquareRemove}></ha-svg-icon>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.exclusion_intro"
)}
</p>
`;
}
@@ -143,30 +212,59 @@ class DialogZWaveJSRemoveNode extends LitElement {
`;
}
private _renderAction(): TemplateResult {
private _renderAction() {
if (this._step === "start") {
return nothing;
}
if (this._step === "start_removal") {
return html`
<ha-button slot="secondaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._startRemoval}
destructive
>
${this.hass.localize("ui.common.remove")}
</ha-button>
`;
}
if (this._step === "start_exclusion") {
return html`
<ha-button slot="secondaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._startExclusion}
destructive
>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.start_exclusion"
)}
</ha-button>
`;
}
return html`
<ha-button
slot="primaryAction"
@click=${this._step === "start"
? this._startExclusion
: this.closeDialog}
>
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize(
this._step === "start"
? "ui.panel.config.zwave_js.remove_node.start_exclusion"
: this._step === "exclusion"
? "ui.panel.config.zwave_js.remove_node.cancel_exclusion"
: "ui.common.close"
this._step === "exclusion"
? "ui.panel.config.zwave_js.remove_node.cancel_exclusion"
: "ui.common.close"
)}
</ha-button>
`;
}
private _startExclusion(): void {
private _startExclusion() {
this._subscribed = this.hass.connection
.subscribeMessage((message) => this._handleMessage(message), {
.subscribeMessage(this._handleMessage, {
type: "zwave_js/remove_node",
entry_id: this.entry_id,
entry_id: this._entryId,
})
.catch((err) => {
this._step = "failed";
@@ -180,7 +278,20 @@ class DialogZWaveJSRemoveNode extends LitElement {
}, EXCLUSION_TIMEOUT_SECONDS * 1000);
}
private _handleMessage(message: any): void {
private _startRemoval() {
this._subscribed = removeFailedZwaveNode(
this.hass,
this._deviceId!,
this._handleMessage
).catch((err) => {
this._step = "failed";
this._error = err.message;
return undefined;
});
this._step = "remove";
}
private _handleMessage = (message: any) => {
if (message.event === "exclusion failed") {
this._unsubscribe();
this._step = "failed";
@@ -192,17 +303,14 @@ class DialogZWaveJSRemoveNode extends LitElement {
this._step = "finished";
this._node = message.node;
this._unsubscribe();
if (this._removedCallback) {
this._removedCallback();
}
}
}
};
private _stopExclusion(): void {
try {
this.hass.callWS({
type: "zwave_js/stop_exclusion",
entry_id: this.entry_id,
entry_id: this._entryId,
});
} catch (err) {
// eslint-disable-next-line no-console
@@ -224,10 +332,16 @@ class DialogZWaveJSRemoveNode extends LitElement {
};
public closeDialog(): void {
this._unsubscribe();
this.entry_id = undefined;
this._step = "start";
this._entryId = undefined;
}
public handleDialogClosed(): void {
this._unsubscribe();
this._entryId = undefined;
this._step = "start";
if (this._onClose) {
this._onClose();
}
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -266,6 +380,14 @@ class DialogZWaveJSRemoveNode extends LitElement {
ha-alert {
width: 100%;
}
.menu-options {
align-self: stretch;
}
ha-list-item {
--mdc-list-side-padding: 24px;
}
`,
];
}

View File

@@ -1,4 +1,3 @@
import "@material/mwc-button/mwc-button";
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiCheckCircle, mdiCloseCircle, mdiFileUpload } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
@@ -37,6 +36,7 @@ import {
} from "../../../../../dialogs/generic/show-dialog-box";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import "../../../../../components/ha-button";
import type { ZWaveJSUpdateFirmwareNodeDialogParams } from "./show-dialog-zwave_js-update-firmware-node";
const firmwareTargetSchema: HaFormSchema[] = [
@@ -130,7 +130,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
.schema=${firmwareTargetSchema}
@value-changed=${this._firmwareTargetChanged}
></ha-form>`}
<mwc-button
<ha-button
slot="primaryAction"
@click=${this._beginFirmwareUpdate}
.disabled=${this._firmwareFile === undefined}
@@ -138,7 +138,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
${this.hass.localize(
"ui.panel.config.zwave_js.update_firmware.begin_update"
)}
</mwc-button>`;
</ha-button>`;
const status = this._updateFinishedMessage
? this._updateFinishedMessage.success
@@ -153,13 +153,23 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
const abortFirmwareUpdateButton = this._nodeStatus.is_controller_node
? nothing
: html`
<mwc-button slot="primaryAction" @click=${this._abortFirmwareUpdate}>
<ha-button
destructive
slot="secondaryAction"
@click=${this._abortFirmwareUpdate}
>
${this.hass.localize(
"ui.panel.config.zwave_js.update_firmware.abort"
)}
</mwc-button>
</ha-button>
`;
const closeButton = html`
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
`;
return html`
<ha-dialog
open
@@ -213,7 +223,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
}
)}
</p>
${abortFirmwareUpdateButton}
${abortFirmwareUpdateButton} ${closeButton}
`
: this._updateProgressMessage && !this._updateFinishedMessage
? html`
@@ -242,7 +252,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
}
)}
</p>
${abortFirmwareUpdateButton}
${abortFirmwareUpdateButton} ${closeButton}
`
: html`
<div class="flex-container">

View File

@@ -1,19 +0,0 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
export interface ZWaveJSRemoveFailedNodeDialogParams {
device_id: string;
}
export const loadRemoveFailedNodeDialog = () =>
import("./dialog-zwave_js-remove-failed-node");
export const showZWaveJSRemoveFailedNodeDialog = (
element: HTMLElement,
removeFailedNodeDialogParams: ZWaveJSRemoveFailedNodeDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-zwave_js-remove-failed-node",
dialogImport: loadRemoveFailedNodeDialog,
dialogParams: removeFailedNodeDialogParams,
});
};

View File

@@ -1,9 +1,10 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
export interface ZWaveJSRemoveNodeDialogParams {
entry_id: string;
entryId: string;
deviceId?: string;
skipConfirmation?: boolean;
removedCallback?: () => void;
onClose?: () => void;
}
export const loadRemoveNodeDialog = () =>

View File

@@ -414,7 +414,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
InclusionState.SmartStart)}
>
${this.hass.localize(
"ui.panel.config.zwave_js.common.remove_node"
"ui.panel.config.zwave_js.common.remove_a_node"
)}
</ha-button>
<ha-button
@@ -604,7 +604,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
history.back();
}
private async _fetchData() {
private _fetchData = async () => {
if (!this.configEntryId) {
return;
}
@@ -638,7 +638,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
this._dataCollectionOptIn =
dataCollectionStatus.opted_in === true ||
dataCollectionStatus.enabled === true;
}
};
private async _addNodeClicked() {
this._openInclusionDialog();
@@ -646,10 +646,10 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
private async _removeNodeClicked() {
showZWaveJSRemoveNodeDialog(this, {
entry_id: this.configEntryId!,
entryId: this.configEntryId!,
skipConfirmation:
this._network?.controller.inclusion_state === InclusionState.Excluding,
removedCallback: () => this._fetchData(),
onClose: this._fetchData,
});
}

View File

@@ -123,18 +123,21 @@ class ZWaveJSProvisioned extends LitElement {
}
private _unprovision = async (ev) => {
const dsk = ev.currentTarget.provisioningEntry.dsk;
const { dsk, nodeId } = ev.currentTarget.provisioningEntry;
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.zwave_js.provisioned.confirm_unprovision_title"
),
text: this.hass.localize(
"ui.panel.config.zwave_js.provisioned.confirm_unprovision_text"
nodeId
? "ui.panel.config.zwave_js.provisioned.confirm_unprovision_text_included"
: "ui.panel.config.zwave_js.provisioned.confirm_unprovision_text"
),
confirmText: this.hass.localize(
"ui.panel.config.zwave_js.provisioned.unprovison"
"ui.panel.config.zwave_js.provisioned.unprovision"
),
destructive: true,
});
if (!confirm) {

View File

@@ -0,0 +1,20 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { ConfigEntry } from "../../../data/config_entries";
export interface PickConfigEntryDialogParams {
domain: string;
subFlowType: string;
configEntries: ConfigEntry[];
configEntryPicked: (configEntry: ConfigEntry) => void;
}
export const showPickConfigEntryDialog = (
element: HTMLElement,
dialogParams?: PickConfigEntryDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-pick-config-entry",
dialogImport: () => import("./dialog-pick-config-entry"),
dialogParams: dialogParams,
});
};

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