Compare commits

..
101 changed files with 1408 additions and 3304 deletions

No files matched your search

-8
View File
@@ -380,14 +380,6 @@ const createRspackConfig = ({
resolve: {
extensions: [".ts", ".js", ".json"],
alias: {
// @formatjs/bigdecimal uses BigInt (Safari 14, Chrome 67), which
// breaks the NumberFormat and PluralRules polyfills
...(!latestBuild && {
"@formatjs/bigdecimal$": path.join(
paths.root_dir,
"src/resources/polyfills/bigdecimal.ts"
),
}),
"lit/static-html$": "lit/static-html.js",
"lit/decorators$": "lit/decorators.js",
"lit/directive$": "lit/directive.js",
+11 -6
View File
@@ -63,7 +63,13 @@
"@formatjs/intl-numberformat": "9.4.3",
"@formatjs/intl-pluralrules": "6.3.15",
"@formatjs/intl-relativetimeformat": "12.3.15",
"@home-assistant/webawesome": "3.14.0-ha.1",
"@fullcalendar/core": "6.1.22",
"@fullcalendar/daygrid": "6.1.22",
"@fullcalendar/interaction": "6.1.22",
"@fullcalendar/list": "6.1.22",
"@fullcalendar/luxon3": "6.1.22",
"@fullcalendar/timegrid": "6.1.22",
"@home-assistant/webawesome": "3.14.0-ha.0",
"@lezer/common": "1.5.3",
"@lezer/highlight": "1.2.5",
"@lit-labs/motion": "1.1.0",
@@ -80,7 +86,7 @@
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "7.0.1",
"@versatiles/style": "6.4.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.201.1",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -92,13 +98,11 @@
"cropperjs": "1.7.0",
"culori": "4.0.2",
"date-fns": "4.4.0",
"decimal.js": "10.6.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
"fullcalendar": "7.1.3",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.7.1",
@@ -109,6 +113,7 @@
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "6.14.0",
"marked": "18.1.1",
"memoize-one": "6.0.0",
@@ -123,7 +128,6 @@
"sortablejs": "1.15.7",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"temporal-polyfill": "1.0.6",
"tinykeys": "4.0.1",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
@@ -164,6 +168,7 @@
"@types/leaflet": "1.9.22",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.6",
"@types/node": "24.19.2",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
@@ -176,7 +181,7 @@
"browserslist-useragent-regexp": "4.1.4",
"core-js-compat": "3.50.0",
"del": "9.0.1",
"eslint": "10.13.0",
"eslint": "10.12.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
+152 -127
View File
@@ -167,6 +167,7 @@ overrides:
lit-html: 3.3.3
clean-css: 5.3.3
'@lit/reactive-element': 2.1.2
'@fullcalendar/daygrid': 6.1.22
tslib: 2.8.1
patchedDependencies:
@@ -249,9 +250,27 @@ importers:
'@formatjs/intl-relativetimeformat':
specifier: 12.3.15
version: 12.3.15
'@fullcalendar/core':
specifier: 6.1.22
version: 6.1.22
'@fullcalendar/daygrid':
specifier: 6.1.22
version: 6.1.22(@fullcalendar/[email protected])
'@fullcalendar/interaction':
specifier: 6.1.22
version: 6.1.22(@fullcalendar/[email protected])
'@fullcalendar/list':
specifier: 6.1.22
version: 6.1.22(@fullcalendar/[email protected])
'@fullcalendar/luxon3':
specifier: 6.1.22
version: 6.1.22(@fullcalendar/[email protected])([email protected])
'@fullcalendar/timegrid':
specifier: 6.1.22
version: 6.1.22(@fullcalendar/[email protected])
'@home-assistant/webawesome':
specifier: 3.14.0-ha.1
version: 3.14.0-ha.1(@floating-ui/[email protected])(@types/[email protected])
specifier: 3.14.0-ha.0
version: 3.14.0-ha.0(@floating-ui/[email protected])(@types/[email protected])
'@lezer/common':
specifier: 1.5.3
version: 1.5.3
@@ -301,8 +320,8 @@ importers:
specifier: 4.4.0
version: 4.4.0
'@versatiles/style':
specifier: 7.0.1
version: 7.0.1([email protected])
specifier: 6.4.1
version: 6.4.1([email protected])
'@vibrant/color':
specifier: 4.0.4
version: 4.0.4
@@ -336,9 +355,6 @@ importers:
date-fns:
specifier: 4.4.0
version: 4.4.0
decimal.js:
specifier: 10.6.0
version: 10.6.0
deep-clone-simple:
specifier: 1.1.1
version: 1.1.1
@@ -354,9 +370,6 @@ importers:
element-internals-polyfill:
specifier: 3.0.2
version: 3.0.2
fullcalendar:
specifier: 7.1.3
version: 7.1.3([email protected])
fuse.js:
specifier: 7.5.0
version: 7.5.0
@@ -387,6 +400,9 @@ importers:
lit-html:
specifier: 3.3.3
version: 3.3.3
luxon:
specifier: 3.7.2
version: 3.7.2
maplibre-gl:
specifier: 6.14.0
version: 6.14.0
@@ -429,9 +445,6 @@ importers:
superstruct:
specifier: 2.0.2
version: 2.0.2
temporal-polyfill:
specifier: 1.0.6
version: 1.0.6
tinykeys:
specifier: 4.0.1
version: 4.0.1(patch_hash=cdc7ece96d53357d38ff3c124a2553f86e3a5dc333d641d223eb6f07e28275f0)
@@ -489,13 +502,13 @@ importers:
version: 4.22.3([email protected])
'@eslint/js':
specifier: 10.0.1
version: 10.0.1([email protected]3.0([email protected]))
version: 10.0.1([email protected]2.0([email protected]))
'@gfx/zopfli':
specifier: 1.0.15
version: 1.0.15
'@html-eslint/eslint-plugin':
specifier: 0.67.0
version: 0.67.0([email protected]3.0([email protected]))
version: 0.67.0([email protected]2.0([email protected]))
'@lokalise/node-api':
specifier: 16.6.0
version: 16.6.0
@@ -547,6 +560,9 @@ importers:
'@types/lodash.merge':
specifier: 4.6.9
version: 4.6.9
'@types/luxon':
specifier: 3.7.6
version: 3.7.6
'@types/node':
specifier: 24.19.2
version: 24.19.2
@@ -584,29 +600,29 @@ importers:
specifier: 9.0.1
version: 9.0.1
eslint:
specifier: 10.13.0
version: 10.13.0([email protected])
specifier: 10.12.0
version: 10.12.0([email protected])
eslint-config-prettier:
specifier: 10.1.8
version: 10.1.8([email protected]3.0([email protected]))
version: 10.1.8([email protected]2.0([email protected]))
eslint-import-resolver-webpack:
specifier: 0.13.11
version: 0.13.11([email protected])
eslint-plugin-import-x:
specifier: 4.17.1
version: 4.17.1(@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected])
version: 4.17.1(@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected])([email protected]))([email protected]2.0([email protected]))([email protected])
eslint-plugin-lit:
specifier: 2.3.1
version: 2.3.1([email protected]3.0([email protected]))
version: 2.3.1([email protected]2.0([email protected]))
eslint-plugin-lit-a11y:
specifier: 5.1.3
version: 5.1.3([email protected]3.0([email protected]))
version: 5.1.3([email protected]2.0([email protected]))
eslint-plugin-unused-imports:
specifier: 4.4.1
version: 4.4.1(@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))
version: 4.4.1(@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected])([email protected]))([email protected]2.0([email protected]))([email protected])([email protected]))([email protected]2.0([email protected]))
eslint-plugin-wc:
specifier: 3.1.0
version: 3.1.0([email protected]3.0([email protected]))
version: 3.1.0([email protected]2.0([email protected]))
fancy-log:
specifier: 2.0.0
version: 2.0.0
@@ -696,7 +712,7 @@ importers:
version: 6.0.3
typescript-eslint:
specifier: 8.71.2
version: 8.71.2([email protected]3.0([email protected]))([email protected])([email protected])
version: 8.71.2([email protected]2.0([email protected]))([email protected])([email protected])
vite:
specifier: 8.3.4
version: 8.3.4(@types/[email protected])([email protected])([email protected])
@@ -1994,16 +2010,34 @@ packages:
typescript:
optional: true
'@full-ui/headless-calendar@7.1.3':
resolution: {integrity: sha512-CFomIp87osqY65Hlu2c0e2ZnofBe9tKOO8IoPzIfSPVDK/+aGz4Itj6mLu4xPjIvzRDQiopcVzYlip6u1bvYxw==}
peerDependencies:
temporal-polyfill: ^1.0.1
'@fullcalendar/core@6.1.22':
resolution: {integrity: sha512-ju5PJUE++zr8XXCGtlXlrodF6doUy9BDfzx+VmE0B+XDBM2A5cUi/aU3M1ANfw/StmKhwqew7Ps7bXMiv/JREg==}
'@fullcalendar/core@7.1.3':
resolution: {integrity: sha512-DLGXqX7CnuUQHH679FgE6+jc927wa708ydIcnfVx372IOK/OiDZjqc+IBwuKnu2EyqleCooDoAnUAcvXBhHosA==}
'@fullcalendar/daygrid@6.1.22':
resolution: {integrity: sha512-cpom5+bAvsGG5nX+snsCtpMIyyhAr+QlseD9mhz5Vq7rMBTzx43mSkz6IBvQ3vz5h2idbDmT1cggEn/dzZwt9Q==}
peerDependencies:
'@full-ui/headless-calendar': 7.1.3
temporal-polyfill: ^1.0.1
'@fullcalendar/core': ~6.1.22
'@fullcalendar/[email protected]':
resolution: {integrity: sha512-r8Njw8aPNFGRwDjVAh9NTh3ZJib5tcUoLi/BM4uYJt5Cs9tDiirmImaqxhx56hclVFX7ktd5CdaMX0+ArSbm5A==}
peerDependencies:
'@fullcalendar/core': ~6.1.22
'@fullcalendar/[email protected]':
resolution: {integrity: sha512-7e5kZbeT3fhtXsts6VMQxbGtpoCFOZCxrIckHfn5VTrRh8Up0TKoYj/AOaIj94uz0qebWgaLXwd9Ijiu3uTwjg==}
peerDependencies:
'@fullcalendar/core': ~6.1.22
'@fullcalendar/[email protected]':
resolution: {integrity: sha512-or43T5SQAzb3d8U6Th6Q3vclas8rGEYSsJsUohC+jySJyJuqCLcqgW+NnbrSYIYXx3MUUk32h8au+BEjfjuo1A==}
peerDependencies:
'@fullcalendar/core': ~6.1.22
luxon: ^3.0.0
'@fullcalendar/[email protected]':
resolution: {integrity: sha512-gNMtkXrkfN3iszXty+pYbJwGxcozL0xBVKYmEd1lm0DMEbrffpVJyV+vifmtl/eIbWCNRWrMWUtGLbYy/ukykA==}
peerDependencies:
'@fullcalendar/core': ~6.1.22
'@gar/[email protected]':
resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==}
@@ -2021,8 +2055,8 @@ packages:
resolution: {integrity: sha512-kjotm7XJrJ6v+7knhPaRgaT6q8F8K2jiafwYdNHLzmV0uGLuZY43FK6smNSHUPrhq5kX2slCUy+RGG/xGqmIKA==}
engines: {node: '>=10.13.0'}
'@home-assistant/[email protected].1':
resolution: {integrity: sha512-h/HVT3LnDIk9rQ2+xAm0WTXFG89sV1KEAqLwjxl5tYBDAftQZCxV+5adchRref8anvBtp1opctjxHtuNBwgQ2g==}
'@home-assistant/[email protected].0':
resolution: {integrity: sha512-wG0a6AfDsYcUsT16bkvE8JHbsLTfUNNYoLEWI/ApRr4kewObcYwKhunFT+naLVYy8K9/Kbq6+O5nz57hu85ZWg==}
engines: {node: '>=14.17.0'}
'@html-eslint/[email protected]':
@@ -3332,6 +3366,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==}
'@types/[email protected]':
resolution: {integrity: sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==}
'@types/[email protected]':
resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==}
@@ -3668,11 +3705,11 @@ packages:
cpu: [x64]
os: [win32]
'@versatiles/style@7.0.1':
resolution: {integrity: sha512-ArZ09BwFWthylcvChgm8rwQ2FhIvvL4rRAOy1z+qn2tniOyEkEItBeQIJRTJlh09Wn8HsRgcMw9hFIfDLgWhSw==}
'@versatiles/style@6.4.1':
resolution: {integrity: sha512-0FpDO6PTEUBeWkvkSRt6l9N+EC9+rK8wuGgsxrzjVAMTSCoWG/t4zYjRzYYDkjgVpPXKB02s3/WbuGJiYUw8AA==}
engines: {node: '>=20'}
peerDependencies:
maplibre-gl: '>=5.5.0'
maplibre-gl: '>=5.0.0'
peerDependenciesMeta:
maplibre-gl:
optional: true
@@ -4792,8 +4829,8 @@ packages:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
[email protected]3.0:
resolution: {integrity: sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==}
[email protected]2.0:
resolution: {integrity: sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
hasBin: true
peerDependencies:
@@ -5010,11 +5047,6 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
[email protected]:
resolution: {integrity: sha512-Mn8VNqLX80C93UFh0s9aKqhHUSh0BwK8pczyQ+nNDKutJeM0/SVLkJoizTfX9sdmwGiS6+hkAHAg9YC10Us0gg==}
peerDependencies:
temporal-polyfill: ^1.0.1
[email protected]:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -5866,6 +5898,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
[email protected]:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -6440,13 +6476,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
preact@10.29.8:
resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==}
peerDependencies:
preact-render-to-string: '>=5'
peerDependenciesMeta:
preact-render-to-string:
optional: true
preact@10.12.1:
resolution: {integrity: sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==}
[email protected]:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
@@ -7074,15 +7105,6 @@ packages:
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-tYyEry1HLGUDbm2356W3iYD0OZdo7rlP7h98Zr/EnjqLZQwTAfcBDUvJnJbfA15v/vQad57hJZScG5O0W2oKEA==}
[email protected]:
resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==}
[email protected]:
resolution: {integrity: sha512-9jbTSX3HvkOWvDpNpFzwX/d1mkCrgQgwLmVTOEiuocZGKHsrQJtttGZFmybJ2zYP5zCSZuU5ZyeS8+eOEPABiQ==}
[email protected]:
resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==}
engines: {node: '>=10'}
@@ -9306,9 +9328,9 @@ snapshots:
tslib: 2.8.1
optional: true
'@eslint-community/[email protected]([email protected]3.0([email protected]))':
'@eslint-community/[email protected]([email protected]2.0([email protected]))':
dependencies:
eslint: 10.13.0([email protected])
eslint: 10.12.0([email protected])
eslint-visitor-keys: 3.4.3
'@eslint-community/[email protected]': {}
@@ -9333,9 +9355,9 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/[email protected]([email protected]3.0([email protected]))':
'@eslint/[email protected]([email protected]2.0([email protected]))':
optionalDependencies:
eslint: 10.13.0([email protected])
eslint: 10.12.0([email protected])
'@eslint/[email protected]': {}
@@ -9455,14 +9477,31 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@full-ui/[email protected]([email protected])':
'@fullcalendar/[email protected]':
dependencies:
temporal-polyfill: 1.0.6
preact: 10.12.1
'@fullcalendar/core@7.1.3(@full-ui/[email protected]([email protected]))([email protected])':
'@fullcalendar/daygrid@6.1.22(@fullcalendar/[email protected])':
dependencies:
'@full-ui/headless-calendar': 7.1.3([email protected])
temporal-polyfill: 1.0.6
'@fullcalendar/core': 6.1.22
'@fullcalendar/[email protected](@fullcalendar/[email protected])':
dependencies:
'@fullcalendar/core': 6.1.22
'@fullcalendar/[email protected](@fullcalendar/[email protected])':
dependencies:
'@fullcalendar/core': 6.1.22
'@fullcalendar/[email protected](@fullcalendar/[email protected])([email protected])':
dependencies:
'@fullcalendar/core': 6.1.22
luxon: 3.7.2
'@fullcalendar/[email protected](@fullcalendar/[email protected])':
dependencies:
'@fullcalendar/core': 6.1.22
'@fullcalendar/daygrid': 6.1.22(@fullcalendar/[email protected])
'@gar/[email protected]': {}
@@ -9476,7 +9515,7 @@ snapshots:
dependencies:
is-negated-glob: 1.0.0
'@home-assistant/[email protected].1(@floating-ui/[email protected])(@types/[email protected])':
'@home-assistant/[email protected].0(@floating-ui/[email protected])(@types/[email protected])':
dependencies:
'@ctrl/tinycolor': 4.1.0
'@floating-ui/dom': 1.8.0
@@ -9501,7 +9540,7 @@ snapshots:
'@html-eslint/types': 0.67.0
html-standard: 0.0.13
'@html-eslint/[email protected]([email protected]3.0([email protected]))':
'@html-eslint/[email protected]([email protected]2.0([email protected]))':
dependencies:
'@eslint/plugin-kit': 0.4.1
'@html-eslint/core': 0.67.0
@@ -9510,7 +9549,7 @@ snapshots:
'@html-eslint/template-syntax-parser': 0.67.0
'@html-eslint/types': 0.67.0
'@rviscomi/capo.js': 2.2.1
eslint: 10.13.0([email protected])
eslint: 10.12.0([email protected])
html-standard: 0.0.13
'@html-eslint/[email protected]':
@@ -11013,6 +11052,8 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]':
@@ -11051,15 +11092,15 @@ snapshots:
dependencies:
'@types/node': 24.19.2
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected])([email protected])':
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected])([email protected]))([email protected]2.0([email protected]))([email protected])([email protected])':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.71.2([email protected]3.0([email protected]))([email protected])([email protected])
'@typescript-eslint/parser': 8.71.2([email protected]2.0([email protected]))([email protected])([email protected])
'@typescript-eslint/scope-manager': 8.71.2
'@typescript-eslint/type-utils': 8.71.2([email protected]3.0([email protected]))([email protected])([email protected])
'@typescript-eslint/utils': 8.71.2([email protected]3.0([email protected]))([email protected])([email protected])
'@typescript-eslint/type-utils': 8.71.2([email protected]2.0([email protected]))([email protected])([email protected])
'@typescript-eslint/utils': 8.71.2([email protected]2.0([email protected]))([email protected])([email protected])
'@typescript-eslint/visitor-keys': 8.71.2
eslint: 10.13.0([email protected])
eslint: 10.12.0([email protected])
ignore: 7.0.6
natural-compare: 1.4.0
ts-api-utils: 2.5.0([email protected])
@@ -11067,14 +11108,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected])':
'@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected])([email protected])':
dependencies:
'@typescript-eslint/scope-manager': 8.71.2
'@typescript-eslint/types': 8.71.2
'@typescript-eslint/typescript-estree': 8.71.2([email protected])([email protected])
'@typescript-eslint/visitor-keys': 8.71.2
debug: 4.4.3([email protected])
eslint: 10.13.0([email protected])
eslint: 10.12.0([email protected])
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -11097,13 +11138,13 @@ snapshots:
dependencies:
typescript: 6.0.3
'@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected])':
'@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected])([email protected])':
dependencies:
'@typescript-eslint/types': 8.71.2
'@typescript-eslint/typescript-estree': 8.71.2([email protected])([email protected])
'@typescript-eslint/utils': 8.71.2([email protected]3.0([email protected]))([email protected])([email protected])
'@typescript-eslint/utils': 8.71.2([email protected]2.0([email protected]))([email protected])([email protected])
debug: 4.4.3([email protected])
eslint: 10.13.0([email protected])
eslint: 10.12.0([email protected])
ts-api-utils: 2.5.0([email protected])
typescript: 6.0.3
transitivePeerDependencies:
@@ -11126,13 +11167,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected])':
'@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected])([email protected])':
dependencies:
'@eslint-community/eslint-utils': 4.10.1([email protected]3.0([email protected]))
'@eslint-community/eslint-utils': 4.10.1([email protected]2.0([email protected]))
'@typescript-eslint/scope-manager': 8.71.2
'@typescript-eslint/types': 8.71.2
'@typescript-eslint/typescript-estree': 8.71.2([email protected])([email protected])
eslint: 10.13.0([email protected])
eslint: 10.12.0([email protected])
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -11272,7 +11313,7 @@ snapshots:
'@unrs/[email protected]':
optional: true
'@versatiles/style@7.0.1([email protected])':
'@versatiles/style@6.4.1([email protected])':
dependencies:
'@maplibre/maplibre-gl-style-spec': 26.4.4
optionalDependencies:
@@ -12368,9 +12409,9 @@ snapshots:
[email protected]: {}
[email protected]([email protected]3.0([email protected])):
[email protected]([email protected]2.0([email protected])):
dependencies:
eslint: 10.13.0([email protected])
eslint: 10.12.0([email protected])
[email protected]([email protected]):
dependencies:
@@ -12394,12 +12435,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected](@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected]):
[email protected](@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected])([email protected]))([email protected]2.0([email protected]))([email protected]):
dependencies:
'@typescript-eslint/types': 8.71.2
comment-parser: 1.4.8
debug: 4.4.3([email protected])
eslint: 10.13.0([email protected])
eslint: 10.12.0([email protected])
eslint-import-context: 0.1.9([email protected])
is-glob: 4.0.3
minimatch: 10.2.6
@@ -12407,39 +12448,39 @@ snapshots:
stable-hash-x: 0.2.0
unrs-resolver: 1.12.2
optionalDependencies:
'@typescript-eslint/utils': 8.71.2([email protected]3.0([email protected]))([email protected])([email protected])
'@typescript-eslint/utils': 8.71.2([email protected]2.0([email protected]))([email protected])([email protected])
transitivePeerDependencies:
- supports-color
[email protected]([email protected]3.0([email protected])):
[email protected]([email protected]2.0([email protected])):
dependencies:
'@thepassle/axobject-query': 4.0.0
aria-query: 5.3.2
axe-core: 4.13.0
dom5: 3.0.1
emoji-regex: 10.6.0
eslint: 10.13.0([email protected])
eslint-plugin-lit: 2.3.1([email protected]3.0([email protected]))
eslint: 10.12.0([email protected])
eslint-plugin-lit: 2.3.1([email protected]2.0([email protected]))
eslint-rule-extender: 0.0.1
language-tags: 1.0.9
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 6.0.1
[email protected]([email protected]3.0([email protected])):
[email protected]([email protected]2.0([email protected])):
dependencies:
eslint: 10.13.0([email protected])
eslint: 10.12.0([email protected])
parse5: 8.0.1
parse5-htmlparser2-tree-adapter: 8.0.1
[email protected](@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected])):
[email protected](@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected])([email protected]))([email protected]2.0([email protected]))([email protected])([email protected]))([email protected]2.0([email protected])):
dependencies:
eslint: 10.13.0([email protected])
eslint: 10.12.0([email protected])
optionalDependencies:
'@typescript-eslint/eslint-plugin': 8.71.2(@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected])([email protected])
'@typescript-eslint/eslint-plugin': 8.71.2(@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected])([email protected]))([email protected]2.0([email protected]))([email protected])([email protected])
[email protected]([email protected]3.0([email protected])):
[email protected]([email protected]2.0([email protected])):
dependencies:
eslint: 10.13.0([email protected])
eslint: 10.12.0([email protected])
is-valid-element-name: 1.0.0
js-levenshtein-esm: 2.0.0
@@ -12456,9 +12497,9 @@ snapshots:
[email protected]: {}
[email protected]3.0([email protected]):
[email protected]2.0([email protected]):
dependencies:
'@eslint-community/eslint-utils': 4.10.1([email protected]3.0([email protected]))
'@eslint-community/eslint-utils': 4.10.1([email protected]2.0([email protected]))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.5([email protected])
'@eslint/config-helpers': 0.7.0
@@ -12710,15 +12751,6 @@ snapshots:
[email protected]:
optional: true
[email protected]([email protected]):
dependencies:
'@full-ui/headless-calendar': 7.1.3([email protected])
'@fullcalendar/core': 7.1.3(@full-ui/[email protected]([email protected]))([email protected])
preact: 10.29.8
temporal-polyfill: 1.0.6
transitivePeerDependencies:
- preact-render-to-string
[email protected]: {}
[email protected]:
@@ -13606,6 +13638,8 @@ snapshots:
dependencies:
yallist: 3.1.1
[email protected]: {}
[email protected]:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@@ -14262,7 +14296,7 @@ snapshots:
[email protected]: {}
preact@10.29.8: {}
preact@10.12.1: {}
[email protected]: {}
@@ -15017,15 +15051,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
temporal-spec: 1.0.1
temporal-utils: 1.0.3
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
is-stream: 2.0.1
@@ -15203,13 +15228,13 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
[email protected]([email protected]3.0([email protected]))([email protected])([email protected]):
[email protected]([email protected]2.0([email protected]))([email protected])([email protected]):
dependencies:
'@typescript-eslint/eslint-plugin': 8.71.2(@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected])([email protected])
'@typescript-eslint/parser': 8.71.2([email protected]3.0([email protected]))([email protected])([email protected])
'@typescript-eslint/eslint-plugin': 8.71.2(@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected])([email protected]))([email protected]2.0([email protected]))([email protected])([email protected])
'@typescript-eslint/parser': 8.71.2([email protected]2.0([email protected]))([email protected])([email protected])
'@typescript-eslint/typescript-estree': 8.71.2([email protected])([email protected])
'@typescript-eslint/utils': 8.71.2([email protected]3.0([email protected]))([email protected])([email protected])
eslint: 10.13.0([email protected])
'@typescript-eslint/utils': 8.71.2([email protected]2.0([email protected]))([email protected])([email protected])
eslint: 10.12.0([email protected])
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
+1
View File
@@ -26,6 +26,7 @@ overrides:
lit-html: 3.3.3
clean-css: 5.3.3
"@lit/reactive-element": 2.1.2
"@fullcalendar/daygrid": 6.1.22
tslib: 2.8.1
patchedDependencies:
@@ -3,7 +3,7 @@ import type {
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import { mainWindow } from "../dom/get_main_window";
import { replaceCurrentUrl, type NavigateOptions } from "../navigate";
import { replaceCurrentUrl } from "../navigate";
import { constructUrlCurrentPath } from "../url/construct-url";
import { currentPath } from "../url/current-path";
import {
@@ -30,7 +30,7 @@ export interface QueryParamsControllerOptions {
* shared, bookmarked and reloaded. Writes replace the current history entry,
* so Back leaves the page instead of undoing each change, and only touch the
* config's params. The controller only acts while the URL is still the
* page the host was connected on, or the default page its router opened.
* page the host was connected on.
*/
export class QueryParamsController<
C extends QueryParamConfig,
@@ -120,16 +120,8 @@ export class QueryParamsController<
return this._options.enabled() && currentPath() === this._path;
}
private _navigated = (ev?: Event & { detail?: NavigateOptions }) => {
private _navigated = () => {
this._cancelPending();
// A router opening its default page, like /config/devices going to
// /config/devices/dashboard, replaces the URL after the host connected
const path = currentPath();
if (ev?.detail?.replace && path.startsWith(`${this._path}/`)) {
this._path = path;
}
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated();
+40 -139
View File
@@ -45,168 +45,69 @@ export const formatNumericDuration = (
return null;
};
const DURATION_FIELDS = [
["days", "day"],
["hours", "hour"],
["minutes", "minute"],
["seconds", "second"],
["milliseconds", "millisecond"],
] as const satisfies readonly (readonly [keyof HaDurationData, string])[];
type DurationFormatOptions = {
style: "long" | "narrow" | "digital";
} & Partial<Record<`${keyof HaDurationData}Display`, "always" | "auto">>;
type DurationFormatter = (duration: HaDurationData) => string;
// Throws like Intl.DurationFormat on fractions, NaN and mixed signs
const isNegativeDuration = (duration: HaDurationData) => {
const values = DURATION_FIELDS.map(([field]) => Number(duration[field] ?? 0));
const negative = values.some((value) => value < 0);
if (
!values.every((value) => Number.isInteger(value)) ||
(negative && values.some((value) => value > 0))
) {
throw new RangeError("Invalid duration");
}
return negative;
};
const fallbackUnitsFormat = (
language: string,
options: DurationFormatOptions
): DurationFormatter => {
const style = options.style === "narrow" ? "narrow" : "long";
const list = new Intl.ListFormat(language, { type: "unit", style });
const unitFormats = DURATION_FIELDS.map(
([, unit]) =>
new Intl.NumberFormat(language, {
style: "unit",
unit,
unitDisplay: style,
})
);
return (duration) => {
const negative = isNegativeDuration(duration);
const parts: string[] = [];
DURATION_FIELDS.forEach(([field], index) => {
const value = Math.abs(duration[field] ?? 0);
if (value || options[`${field}Display`] === "always") {
// Like Intl.DurationFormat, only the first unit shows the sign
parts.push(
unitFormats[index].format(negative && !parts.length ? -value : value)
);
}
});
return list.format(parts);
};
};
const fallbackDigitalFormat = (
language: string,
options: DurationFormatOptions
): DurationFormatter => {
const list = new Intl.ListFormat(language, { type: "unit", style: "short" });
const dayFormat = new Intl.NumberFormat(language, {
style: "unit",
unit: "day",
unitDisplay: "short",
});
const hourFormat = new Intl.NumberFormat(language, { useGrouping: false });
const twoDigitFormat = new Intl.NumberFormat(language, {
minimumIntegerDigits: 2,
maximumFractionDigits: 3,
useGrouping: false,
});
return (duration) => {
const sign = isNegativeDuration(duration) ? "-" : "";
const days = Math.abs(duration.days ?? 0);
const hours = Math.abs(duration.hours ?? 0);
const minutes = Math.abs(duration.minutes ?? 0);
const seconds =
Math.abs(duration.seconds ?? 0) +
Math.abs(duration.milliseconds ?? 0) / 1000;
let time = `${twoDigitFormat.format(minutes)}:${twoDigitFormat.format(seconds)}`;
if (hours || options.hoursDisplay !== "auto") {
time = `${hourFormat.format(hours)}:${time}`;
}
return days
? list.format([sign + dayFormat.format(days), time])
: sign + time;
};
};
// The Intl.DurationFormat polyfill needs BigInt, which some legacy browsers lack
const createDurationFormat = (
language: string,
options: DurationFormatOptions
): DurationFormatter => {
if ("DurationFormat" in Intl) {
const durationFormat = new Intl.DurationFormat(language, options);
return (duration) => durationFormat.format(duration);
}
return options.style === "digital"
? fallbackDigitalFormat(language, options)
: fallbackUnitsFormat(language, options);
};
const formatDurationLongMem = memoizeOne((locale: FrontendLocaleData) =>
createDurationFormat(locale.language, {
style: "long",
})
const formatDurationLongMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DurationFormat(locale.language, {
style: "long",
})
);
export const formatDurationLong = (
locale: FrontendLocaleData,
duration: HaDurationData
) => formatDurationLongMem(locale)(duration);
) => formatDurationLongMem(locale).format(duration);
const formatDurationNarrowMem = memoizeOne((locale: FrontendLocaleData) =>
createDurationFormat(locale.language, {
style: "narrow",
})
const formatDurationNarrowMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DurationFormat(locale.language, {
style: "narrow",
})
);
export const formatDurationNarrow = (
locale: FrontendLocaleData,
duration: HaDurationData
) => formatDurationNarrowMem(locale)(duration);
) => formatDurationNarrowMem(locale).format(duration);
const formatDigitalDurationMem = memoizeOne((locale: FrontendLocaleData) =>
createDurationFormat(locale.language, {
style: "digital",
hoursDisplay: "auto",
})
const formatDigitalDurationMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DurationFormat(locale.language, {
style: "digital",
hoursDisplay: "auto",
})
);
export const formatDurationDigital = (
locale: FrontendLocaleData,
duration: HaDurationData
) => formatDigitalDurationMem(locale)(duration);
) => formatDigitalDurationMem(locale).format(duration);
export const DURATION_UNITS = ["min", "h", "d"] as const;
type DurationUnit = (typeof DURATION_UNITS)[number];
const formatDurationDayMem = memoizeOne((locale: FrontendLocaleData) =>
createDurationFormat(locale.language, {
style: "narrow",
daysDisplay: "always",
})
const formatDurationDayMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DurationFormat(locale.language, {
style: "narrow",
daysDisplay: "always",
})
);
const formatDurationHourMem = memoizeOne((locale: FrontendLocaleData) =>
createDurationFormat(locale.language, {
style: "narrow",
hoursDisplay: "always",
})
const formatDurationHourMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DurationFormat(locale.language, {
style: "narrow",
hoursDisplay: "always",
})
);
const formatDurationMinuteMem = memoizeOne((locale: FrontendLocaleData) =>
createDurationFormat(locale.language, {
style: "narrow",
minutesDisplay: "always",
})
const formatDurationMinuteMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DurationFormat(locale.language, {
style: "narrow",
minutesDisplay: "always",
})
);
export const formatDuration = (
@@ -228,7 +129,7 @@ export const formatDuration = (
days,
hours,
};
return formatDurationDayMem(locale)(input);
return formatDurationDayMem(locale).format(input);
}
case "h": {
const hours = Math.floor(value);
@@ -237,7 +138,7 @@ export const formatDuration = (
hours,
minutes,
};
return formatDurationHourMem(locale)(input);
return formatDurationHourMem(locale).format(input);
}
case "min": {
const minutes = Math.floor(value);
@@ -246,7 +147,7 @@ export const formatDuration = (
minutes,
seconds,
};
return formatDurationMinuteMem(locale)(input);
return formatDurationMinuteMem(locale).format(input);
}
default:
throw new Error("Invalid duration unit");
-99
View File
@@ -1,99 +0,0 @@
import type {
DataTableFilter,
DataTableFilters,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
decodeQueryParams,
hasQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
// The filters of the devices table. Search, sorting and grouping are kept by
// the data table, see data-table-query-params.
export const devicesFilterQueryParamConfig = {
list: [
"status",
"floor",
"area",
"domain",
"label",
"config_entry",
"sub_entry",
],
} as const satisfies QueryParamConfig;
export type DevicesFilterQueryParams = QueryParamValues<
typeof devicesFilterQueryParamConfig
>;
const list = (filter: DataTableFilter | undefined): string[] | undefined =>
Array.isArray(filter?.value) ? filter.value : undefined;
const floorAreas = (
filter: DataTableFilter | undefined
): Record<string, string[]> | undefined =>
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
export const devicesFilterQueryParams = (
filters: DataTableFilters
): DevicesFilterQueryParams => ({
status: list(filters["ha-filter-states"]),
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
domain: list(filters["ha-filter-integrations"]),
label: list(filters["ha-filter-labels"]),
config_entry: list(filters.config_entry),
sub_entry: list(filters.sub_entry),
});
// What a link says to show, undefined when it has no table params so what was
// picked before stays. A link replaces the search and the filters, ones left
// behind would hide part of what it shows. The table applies its sorting and
// grouping. The filters work out their items from the values.
export interface DevicesLink {
search: string;
filters: DataTableFilters;
}
export const devicesLinkFromUrl = (
searchParams: SearchParamsSource
): DevicesLink | undefined => {
if (
!hasQueryParams(searchParams, devicesFilterQueryParamConfig) &&
!hasQueryParams(searchParams, dataTableQueryParamConfig)
) {
return undefined;
}
const params = decodeQueryParams(searchParams, devicesFilterQueryParamConfig);
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof DevicesFilterQueryParams): string[] =>
params[key]?.filter(Boolean) ?? [];
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
value,
items: undefined,
});
const floors = values("floor");
const areas = values("area");
return {
search: search ?? "",
filters: {
"ha-filter-states": filter(values("status")),
"ha-filter-floor-areas": filter(
floors.length || areas.length ? { floors, areas } : undefined
),
"ha-filter-integrations": filter(values("domain")),
"ha-filter-labels": filter(values("label")),
config_entry: filter(values("config_entry")),
sub_entry: filter(values("sub_entry")),
},
};
};
+3 -8
View File
@@ -1504,10 +1504,7 @@ export class HaDataTable extends LitElement {
}
.mdc-data-table {
background: var(
--ha-data-table-background,
var(--data-table-background-color)
);
background-color: var(--data-table-background-color);
border-radius: var(--ha-border-radius-sm);
border-width: 1px;
border-style: solid;
@@ -1779,6 +1776,7 @@ export class HaDataTable extends LitElement {
display: flex;
align-items: center;
cursor: pointer;
background-color: var(--primary-background-color);
}
.group-header ha-icon-button {
@@ -1929,10 +1927,7 @@ export class HaDataTable extends LitElement {
}
.mdc-data-table__row:has(.group-header) {
background: var(
--ha-data-table-background,
var(--primary-background-color)
);
background-color: var(--primary-background-color);
}
.mdc-data-table__table.auto-height .scroller {
+5 -37
View File
@@ -1,6 +1,6 @@
import WaCheckbox from "@home-assistant/webawesome/dist/components/checkbox/checkbox";
import { css, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement } from "lit/decorators";
/**
* Home Assistant checkbox component
@@ -40,13 +40,9 @@ import { customElement, property } from "lit/decorators";
* @attr {boolean} disabled - Disables the checkbox.
* @attr {boolean} indeterminate - Draws the checkbox in an indeterminate state.
* @attr {boolean} required - Makes the checkbox a required field.
* @attr {("brand"|"neutral"|"danger"|"warning"|"success")} variant - Sets the checked color variant. "brand" is default.
*/
@customElement("ha-checkbox")
export class HaCheckbox extends WaCheckbox {
@property({ reflect: true })
variant: "brand" | "neutral" | "success" | "warning" | "danger" = "brand";
/**
* Returns the configured checkbox value, independent of checked state.
*
@@ -82,19 +78,15 @@ export class HaCheckbox extends WaCheckbox {
);
--checked-icon-color: var(
--ha-checkbox-checked-icon-color,
var(--checkbox-on-fill-color)
var(--wa-color-brand-on-loud)
);
--wa-form-control-activated-color: var(
--ha-checkbox-checked-background-color,
var(--checkbox-fill-color)
var(--ha-color-fill-primary-loud-resting)
);
-webkit-tap-highlight-color: transparent;
--checkbox-fill-color: var(--ha-color-fill-primary-loud-resting);
--checkbox-fill-color-hover: var(--ha-color-fill-primary-loud-hover);
--checkbox-on-fill-color: var(--wa-color-brand-on-loud);
--checked-icon-scale: var(--ha-checkbox-checked-icon-scale, 0.9);
--wa-form-control-required-content: var(
--ha-checkbox-required-marker,
@@ -106,30 +98,6 @@ export class HaCheckbox extends WaCheckbox {
);
}
:host([variant="neutral"]) {
--checkbox-fill-color: var(--ha-color-fill-neutral-loud-resting);
--checkbox-fill-color-hover: var(--ha-color-fill-neutral-loud-hover);
--checkbox-on-fill-color: var(--ha-color-on-neutral-loud);
}
:host([variant="success"]) {
--checkbox-fill-color: var(--ha-color-fill-success-loud-resting);
--checkbox-fill-color-hover: var(--ha-color-fill-success-loud-hover);
--checkbox-on-fill-color: var(--ha-color-on-success-loud);
}
:host([variant="warning"]) {
--checkbox-fill-color: var(--ha-color-fill-warning-loud-resting);
--checkbox-fill-color-hover: var(--ha-color-fill-warning-loud-hover);
--checkbox-on-fill-color: var(--ha-color-on-warning-loud);
}
:host([variant="danger"]) {
--checkbox-fill-color: var(--ha-color-fill-danger-loud-resting);
--checkbox-fill-color-hover: var(--ha-color-fill-danger-loud-hover);
--checkbox-on-fill-color: var(--ha-color-on-danger-loud);
}
[part~="base"] {
align-items: center;
gap: var(--ha-space-2);
@@ -190,11 +158,11 @@ export class HaCheckbox extends WaCheckbox {
label:has(input:indeterminate:not(:disabled)):hover [part~="control"] {
background-color: var(
--ha-checkbox-checked-background-color-hover,
var(--checkbox-fill-color-hover)
var(--ha-color-fill-primary-loud-hover)
);
border-color: var(
--ha-checkbox-checked-background-color-hover,
var(--checkbox-fill-color-hover)
var(--ha-color-fill-primary-loud-hover)
);
}
`,
+1 -2
View File
@@ -346,8 +346,7 @@ export class HaControlSelect extends LitElement {
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: clip;
unicode-bidi: plaintext;
text-overflow: ellipsis;
hyphens: manual;
line-height: var(--ha-line-height-condensed);
}
+2 -2
View File
@@ -16,8 +16,8 @@ import { customElement } from "lit/decorators";
*
* @remarks
* **Button Styling Guidance:**
* - `primaryAction` slot: Use the default `appearance="accent"`
* - `secondaryAction` slot: Use `appearance="plain"`
* - `primaryAction` slot: Use `variant="accent"`
* - `secondaryAction` slot: Use `variant="plain"`
*/
@customElement("ha-dialog-footer")
export class HaDialogFooter extends LitElement {
-266
View File
@@ -1,266 +0,0 @@
import type { ContextType } from "@lit/context";
import {
mdiDotsHorizontal,
mdiFilterVariantRemove,
mdiPuzzle,
mdiRobot,
mdiScriptText,
} from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import {
formattersContext,
internationalizationContext,
statesContext,
} from "../data/context";
import type { LogbookCauseGroup } from "../data/logbook";
import { LOGBOOK_CAUSE_GROUPS, logbookUserCauseKey } from "../data/logbook";
import type { User } from "../data/user";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import "./user/ha-user-badge";
interface PersonUser {
id: string;
name: string;
}
const GROUP_ICONS: Record<LogbookCauseGroup, string> = {
automation: mdiRobot,
script: mdiScriptText,
integration: mdiPuzzle,
other: mdiDotsHorizontal,
};
@customElement("ha-filter-logbook-causes")
export class HaFilterLogbookCauses extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: formattersContext, subscribe: true })
@state()
private _formatters!: ContextType<typeof formattersContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean, reflect: true }) public expanded = false;
@query("ha-list-selectable") private _list?: HaListSelectable;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
protected render() {
const count = this.value?.length ?? 0;
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.logbook.cause_filter.caption")}
${
count
? html`<div class="badge">${count}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
.label=${this._localize(
"ui.components.subpage-data-table.clear_filter"
)}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
</ha-expansion-panel>
${this._panel.showContent ? this._renderContent() : nothing}
`;
}
private _renderContent() {
const users = this._personUsers(
this._states,
this._formatters,
this._i18n.locale.language
);
const value = this.value ?? [];
return html`<div class="content" ${ref(this._content)}>
<ha-list-selectable
multi
controlled
aria-label=${this._localize("ui.components.logbook.cause_filter.caption")}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
users,
(user) => user.id,
(user) => {
const key = logbookUserCauseKey(user.id);
return html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${key}
.selected=${value.includes(key)}
>
<ha-user-badge slot="start" .user=${user as User}></ha-user-badge>
<span slot="headline">${user.name}</span>
</ha-list-item-option>`;
}
)}
${LOGBOOK_CAUSE_GROUPS.map(
(group) =>
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${group}
.selected=${value.includes(group)}
>
<ha-svg-icon
slot="start"
.path=${GROUP_ICONS[group]}
></ha-svg-icon>
<span slot="headline"
>${this._localize(
`ui.components.logbook.cause_filter.${group}`
)}</span
>
</ha-list-item-option>`
)}
</ha-list-selectable>
</div>`;
}
private _personUsers = memoizeOne(
(
states: ContextType<typeof statesContext>,
formatters: ContextType<typeof formattersContext>,
language: string | undefined
): PersonUser[] =>
Object.values(states)
.filter(
(stateObj) =>
computeStateDomain(stateObj) === "person" &&
stateObj.attributes.user_id
)
.map((stateObj) => ({
id: stateObj.attributes.user_id as string,
name: formatters.formatEntityName(stateObj),
}))
.sort((a, b) => stringCompare(a.name, b.name, language))
);
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _handleItemToggled(ev: CustomEvent<number>) {
const option = this._list?.items[ev.detail] as HaListItemOption | undefined;
const key = option?.value;
if (!key) {
return;
}
const value = new Set(this.value ?? []);
if (!value.delete(key)) {
value.add(key);
}
this.value = [...value];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
filterPanelStyles,
css`
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-user-badge {
width: 24px;
height: 24px;
font-size: var(--ha-font-size-xs);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-logbook-causes": HaFilterLogbookCauses;
}
}
+1
View File
@@ -162,6 +162,7 @@ export class HaFilterPane extends LitElement {
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
}
+82 -31
View File
@@ -1,6 +1,6 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
@@ -10,6 +10,8 @@ import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import { entityTypeFilterFunc } from "../data/entity/entity_type";
import type { EntitySources } from "../data/entity/entity_sources";
import type { HomeAssistant } from "../types";
import "./ha-filter-entity-types";
import "./ha-filter-integrations";
import "./ha-target-picker";
/**
@@ -37,16 +39,6 @@ export const countTargets = (target: HassServiceTarget): number =>
0
);
/** Sets one filter from the value of its `data-table-filter-changed` event. */
export const setSourceFilter = <T extends SourceFilters>(
filters: T,
key: keyof T,
value: DataTableFiltersValue
): T => ({
...filters,
[key]: Array.isArray(value) && value.length ? value : undefined,
});
/** Number of filters that have at least one option selected. */
export const countSourceFilters = (filters: SourceFilters): number =>
Object.values(filters).filter((value) => value?.length).length;
@@ -100,8 +92,8 @@ export const applySourceFilters = (
};
/**
* Picks the targets a page shows. Meant to be placed in an `ha-filter-pane`,
* above the filters the page narrows them down with.
* Picker for what a page shows: the targets to include, narrowed down by
* entity type and integration. Meant to be placed in an `ha-filter-pane`.
*
* The pages resolve every entity of a target, secondary ones included, so the
* target picker counts them too.
@@ -112,7 +104,6 @@ export class HaSourcesPicker extends LitElement {
@property({ attribute: false }) public value: HassServiceTarget = {};
/** The filters the page applies, so the picker counts what they keep. */
@property({ attribute: false }) public filters: SourceFilters = {};
@property({ attribute: false })
@@ -125,6 +116,10 @@ export class HaSourcesPicker extends LitElement {
@property({ type: Boolean }) public disabled = false;
// Only one filter panel is expanded at a time, so that the expanded one can
// use the height that is left in the pane.
@state() private _expandedFilter?: keyof SourceFilters;
protected render() {
const noTargets = countTargets(this.value) === 0;
@@ -149,16 +144,72 @@ export class HaSourcesPicker extends LitElement {
.disabled=${this.disabled}
@value-changed=${this._targetsChanged}
></ha-target-picker>
<div
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
>
<ha-filter-entity-types
.value=${this.filters.types}
.expanded=${this._expandedFilter === "types"}
@data-table-filter-changed=${this._typesChanged}
@expanded-changed=${this._typesExpanded}
></ha-filter-entity-types>
<ha-filter-integrations
.value=${this.filters.integrations}
.expanded=${this._expandedFilter === "integrations"}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._integrationsExpanded}
></ha-filter-integrations>
</div>
`;
}
private _activeFilter = memoizeOne(sourceFilterFunc);
protected firstUpdated() {
// The filter panels label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
}
private _targetsChanged(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
}
private _typesChanged(ev: CustomEvent) {
this._filterChanged("types", ev);
}
private _integrationsChanged(ev: CustomEvent) {
this._filterChanged("integrations", ev);
}
private _filterChanged(key: keyof SourceFilters, ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value as DataTableFiltersValue;
fireEvent(this, "source-filters-changed", {
value: {
...this.filters,
[key]: Array.isArray(value) && value.length ? value : undefined,
},
});
}
private _typesExpanded(ev: CustomEvent) {
this._filterExpanded("types", ev);
}
private _integrationsExpanded(ev: CustomEvent) {
this._filterExpanded("integrations", ev);
}
private _filterExpanded(key: keyof SourceFilters, ev: CustomEvent) {
if (ev.detail.expanded) {
this._expandedFilter = key;
} else if (this._expandedFilter === key) {
this._expandedFilter = undefined;
}
}
static styles = css`
/* The sections are laid out by the pane, so that an expanded filter
panel can use the height that is left. */
@@ -190,28 +241,28 @@ export class HaSourcesPicker extends LitElement {
ha-target-picker.no-padding-top {
padding-top: 0;
}
.filters {
display: flex;
flex-direction: column;
flex: 1 0 auto;
border-top: 1px solid var(--divider-color);
}
/* An expanded panel sizes itself to the space that is left over, but
keeps room for its list when the targets fill the pane. */
.filters.expanded {
flex: 1 1 auto;
min-height: min(360px, 100%);
}
`;
}
/** Lays out the filters that follow the picker in the pane. */
export const sourceFiltersStyles = css`
.filters {
display: flex;
flex-direction: column;
flex: 1 0 auto;
border-top: 1px solid var(--divider-color);
}
/* An expanded panel sizes itself to the space that is left over, but
keeps room for its list when the targets fill the pane. */
.filters.expanded {
flex: 1 1 auto;
min-height: min(360px, 100%);
}
`;
declare global {
interface HTMLElementTagNameMap {
"ha-sources-picker": HaSourcesPicker;
}
interface HASSDomEvents {
"source-filters-changed": { value: SourceFilters };
}
}
+24 -18
View File
@@ -28,12 +28,7 @@ import type {
TraceExtended,
} from "../../data/trace";
import type { TargetSelector } from "../../data/selector";
import {
getDataFromPath,
getNestedStepConfig,
getTracedEntityId,
isTriggerPath,
} from "../../data/trace";
import { getDataFromPath, isTriggerPath } from "../../data/trace";
import { getTraceTriggers } from "../../data/trace-tree";
import type { TriggerDescriptions } from "../../data/trigger";
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
@@ -225,12 +220,20 @@ export class HaTracePathDetails extends LitElement {
const data: ActionTraceStep[] = paths[curPath];
// Extract details from this.selected.config child properties used to add 'alias' (to headline), describeCondition and 'entity_id' (to result)
const nestedPath = curPath.substring(this.selected.path.length + 1);
const currentDetail = getNestedStepConfig(
this.selected.config,
nestedPath
);
const tracedEntityId = getTracedEntityId(currentDetail, nestedPath);
const nestPath = curPath
.substring(this.selected.path.length + 1)
.split("/");
let currentDetail: unknown = this.selected.config;
for (const part of nestPath) {
if (typeof currentDetail !== "object" || currentDetail === null) {
break;
}
const child = (currentDetail as Record<string, unknown>)[part];
if (child === undefined || typeof child === "string") {
break;
}
currentDetail = child;
}
parts.push(
data.map((trace, idx) => {
@@ -325,8 +328,14 @@ export class HaTracePathDetails extends LitElement {
: html`<pre>${dump(rest)}</pre>`
}
${
tracedEntityId
? html`<pre>entity: ${tracedEntityId}</pre>`
typeof currentDetail === "object" &&
currentDetail !== null &&
"entity_id" in currentDetail &&
currentDetail.entity_id &&
curPath
.substring(this.selected.path.length + 1)
.includes("entity_id")
? html`<pre>entity: ${currentDetail.entity_id}</pre>`
: nothing
}
`;
@@ -667,10 +676,7 @@ export class HaTracePathDetails extends LitElement {
}
ha-tab-group {
background: var(
--ha-panel-toolbar-background,
var(--ha-panel-background, var(--primary-background-color))
);
background-color: var(--primary-background-color);
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
}
+35 -6
View File
@@ -5,7 +5,10 @@ import type {
HassServiceTarget,
} from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import { createDurationData } from "../common/datetime/create_duration_data";
import { applyDurationSign } from "../common/datetime/normalize_duration";
import type { WeekdayShort } from "../common/datetime/weekday";
import { computeDomain } from "../common/entity/compute_domain";
import { navigate } from "../common/navigate";
import type { LocalizeKeys } from "../common/translations/localize";
import { createSearchParam } from "../common/url/search-params";
@@ -524,6 +527,26 @@ export const pickRowConfig = <T extends object>(
return config as Partial<T>;
};
// Older sun and calendar triggers set a positive offset with an offset type,
// which the backend folds into the sign of the offset.
const OFFSET_TYPE_TRIGGER_DOMAINS = ["sun", "calendar"];
const foldOffsetType = (
options: Record<string, unknown>
): Record<string, unknown> => {
const { offset_type: offsetType, ...folded } = options;
const offset = createDurationData(
folded.offset as string | number | ForDict | undefined
);
if (offset && !Object.values(offset).every(Number.isFinite)) {
return options;
}
if (offsetType === "before" && offset) {
folded.offset = applyDurationSign(offset, true);
}
return folded;
};
export const getRowNote = (note: unknown): string | undefined =>
typeof note === "string" ? note.trim() || undefined : undefined;
@@ -551,12 +574,12 @@ export const migrateAutomationTrigger = (
delete trigger.platform;
}
if ("options" in trigger) {
if (
typeof trigger.options === "object" &&
trigger.options !== null &&
"behavior" in trigger.options
) {
if (
"options" in trigger &&
typeof trigger.options === "object" &&
trigger.options !== null
) {
if ("behavior" in trigger.options) {
// Deprecated behavior values renamed in 2026; the backend raises a repair
// when they are still used, so flag the migration to offer saving.
if (trigger.options.behavior === "any") {
@@ -571,6 +594,12 @@ export const migrateAutomationTrigger = (
}
}
}
if (
"offset_type" in trigger.options &&
OFFSET_TYPE_TRIGGER_DOMAINS.includes(computeDomain(trigger.trigger))
) {
trigger.options = foldOffsetType(trigger.options);
}
}
return trigger;
+24 -25
View File
@@ -1,6 +1,8 @@
import { TZDate } from "@date-fns/tz";
import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import { createDurationData } from "../common/datetime/create_duration_data";
import { durationDataToSeconds } from "../common/datetime/duration_to_seconds";
import {
formatDurationDigital,
formatDurationLong,
@@ -10,6 +12,7 @@ import {
formatTime,
formatTimeWithSeconds,
} from "../common/datetime/format_time";
import { normalizeDuration } from "../common/datetime/normalize_duration";
import secondsToDuration from "../common/datetime/seconds_to_duration";
import { sortWeekdays } from "../common/datetime/sort_weekdays";
import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display";
@@ -412,16 +415,7 @@ const describeLegacyTrigger = (
// Sun Trigger
if (trigger.trigger === "sun" && trigger.event) {
let duration = "";
if (trigger.offset) {
if (typeof trigger.offset === "number") {
duration = secondsToDuration(trigger.offset)!;
} else if (typeof trigger.offset === "string") {
duration = trigger.offset;
} else {
duration = JSON.stringify(trigger.offset);
}
}
const duration = formatSunOffset(hass, trigger.offset);
return hass.localize(
trigger.event === "sunset"
@@ -831,19 +825,16 @@ const describeLegacyTrigger = (
let offsetChoice = "other";
let offset = "";
if (typeof trigger.offset === "string" && trigger.offset) {
offsetChoice = trigger.offset.startsWith("-") ? "before" : "after";
const parts = trigger.offset.startsWith("-")
? trigger.offset.substring(1).split(":")
: trigger.offset.split(":");
const duration = {
hours: parts.length > 0 ? +parts[0] : 0,
minutes: parts.length > 1 ? +parts[1] : 0,
seconds: parts.length > 2 ? +parts[2] : 0,
};
offset = formatDurationLong(hass.locale, duration);
if (offset === "") {
offsetChoice = "other";
const duration = createDurationData(trigger.offset);
if (duration) {
const normalized = normalizeDuration(duration, {
enableDay: true,
enableSecond: true,
enableMillisecond: true,
});
offset = formatDurationLong(hass.locale, normalized.duration);
if (offset) {
offsetChoice = normalized.negative ? "before" : "after";
}
}
@@ -874,9 +865,17 @@ const formatSunOffset = (
if (typeof offset === "string") {
return offset;
}
if (!durationDataToSeconds(offset)) {
return "";
}
try {
const formatted = formatDurationDigital(hass.locale, offset);
return formatted.startsWith("-") ? formatted : `+${formatted}`;
const normalized = normalizeDuration(offset, {
enableDay: false,
enableSecond: true,
enableMillisecond: true,
});
const formatted = formatDurationDigital(hass.locale, normalized.duration);
return `${normalized.negative ? "-" : "+"}${formatted}`;
} catch (_e) {
return JSON.stringify(offset);
}
-14
View File
@@ -103,20 +103,6 @@ export const subscribeBackupEvents = (
{ preCheck }
);
export const getBackupState = async (
hass: Pick<HomeAssistant, "connection">
): Promise<BackupManagerState> => {
let state: BackupManagerState = "idle";
const unsub = await subscribeBackupEvents(hass, (event) => {
// Skip replayed upload progress events, only the manager state matters
if (!("agent_id" in event)) {
state = event.manager_state;
}
});
unsub();
return state;
};
export const DEFAULT_MANAGER_STATE: ManagerStateEvent = {
manager_state: "idle",
};
+8 -7
View File
@@ -22,9 +22,10 @@ export interface CalendarEvent {
title: string;
start: string;
end?: string;
color?: string;
contrastColor?: string;
className?: string;
backgroundColor?: string;
borderColor?: string;
textColor?: string;
classNames?: string[];
calendar: string;
eventData: CalendarEventData;
[key: string]: any;
@@ -288,12 +289,12 @@ export const normalizeSubscriptionEventData = (
start: eventStart,
end: eventEnd,
title: eventData.summary,
color: calendar.backgroundColor,
// FullCalendar reads undefined as the text "undefined", and empty as unset
contrastColor: calendar.textColor ?? "",
backgroundColor: calendar.backgroundColor,
borderColor: calendar.backgroundColor,
textColor: calendar.textColor,
// Only a tentative event is drawn differently. A confirmed event and one
// without a status look the same, as most integrations report none.
className: eventData.status === "tentative" ? "tentative" : undefined,
classNames: eventData.status === "tentative" ? ["tentative"] : undefined,
calendar: calendar.entity_id,
eventData: normalizedEventData,
};
-10
View File
@@ -710,16 +710,6 @@ export const isNumericEntity = (
isNumericSensorDeviceClass(currentState.attributes.device_class)) ||
numericStateFromHistory != null;
// Non-numeric domains that computeHistory gives a unit, so they get a line chart
const LINE_CHART_DOMAINS = ["climate", "humidifier", "water_heater", "zone"];
export const isTimelineEntity = (
domain: string,
currentState: HassEntity | undefined
): boolean =>
!isNumericEntity(domain, currentState, undefined) &&
!LINE_CHART_DOMAINS.includes(domain);
export const mergeHistoryResults = (
historyResult: HistoryResult,
ltsResult?: HistoryResult,
-12
View File
@@ -329,15 +329,3 @@ export const filterLogbookCompatibleEntities = (entity) => {
(domain === "sensor" && isNumericEntity(domain, entity, undefined));
return !continuous;
};
export type LogbookCauseGroup =
"automation" | "script" | "integration" | "other";
export const LOGBOOK_CAUSE_GROUPS: LogbookCauseGroup[] = [
"automation",
"script",
"integration",
"other",
];
export const logbookUserCauseKey = (userId: string) => `user/${userId}`;
-59
View File
@@ -266,65 +266,6 @@ export const getDataFromPath = (
return result;
};
/**
* Follows a trace path relative to a step config (for example
* `choose/0/conditions/1`) and returns the deepest config it reaches. A
* template condition written as a plain string is returned as that string.
*/
export const getNestedStepConfig = (config: unknown, path: string): unknown => {
let currentDetail = config;
const parts = path.split("/");
for (const [index, part] of parts.entries()) {
if (typeof currentDetail !== "object" || currentDetail === null) {
break;
}
// Core traces each entity of a state condition one level deeper than the
// config, as `entity_id/<index>`. The condition itself is the deepest step.
if (part === "entity_id") {
break;
}
// Core traces the conditions of an `if` action as `if/condition/<index>`,
// while the config keeps them directly in `if`, as a list or as a single
// condition.
if (part === "condition" && parts[index - 1] === "if") {
continue;
}
// A list of conditions or actions can also be written as a single item,
// which Core traces as index 0.
if (!Array.isArray(currentDetail) && /^\d+$/.test(part)) {
if (part !== "0") {
break;
}
continue;
}
const child = (currentDetail as Record<string, unknown>)[part];
if (child === undefined) {
break;
}
currentDetail = child;
}
return currentDetail;
};
/**
* Returns the entity a condition trace step such as `entity_id/1` checked.
* Core traces each entity of a condition with a list of entities separately.
*/
export const getTracedEntityId = (
config: unknown,
path: string
): string | undefined => {
const match = /(?:^|\/)entity_id\/(\d+)$/.exec(path);
if (!match || typeof config !== "object" || config === null) {
return undefined;
}
const entityIds = (config as { entity_id?: unknown }).entity_id;
const entityId = Array.isArray(entityIds)
? entityIds[Number(match[1])]
: entityIds;
return typeof entityId === "string" ? entityId : undefined;
};
// It is 'trigger' if manually triggered by the user via UI
export const isTriggerPath = (path: string): boolean =>
path === "trigger" || strStartsWith(path, "trigger/");
-9
View File
@@ -229,7 +229,6 @@ export const checkForEntityUpdates = async (
};
// When updating, and entity does not support % show "Installing"
// When updating, and entity does support % but has not reported it yet, show "Preparing"
// When updating, and entity does support % show "Installing (xx%)"
// When update available, show "Update available"
// When the latest version is skipped, show the latest version
@@ -246,14 +245,6 @@ export const computeUpdateStateDisplay = (
// downgrading firmware (installed_version is newer than latest_version).
// Show the installing status regardless of state in that case.
if (updateIsInstalling(stateObj)) {
// The install has not started yet, for example while a backup is
// created before the update.
if (
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
attributes.update_percentage == null
) {
return options.localize("ui.card.update.preparing");
}
const supportsProgress =
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
attributes.update_percentage !== null;
@@ -721,20 +721,11 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
return;
}
this._progress = undefined;
let catchingUp = true;
// Fetching re-runs the current step, which external steps can treat as the user returning,
// and only progress tasks can finish before the subscription is in place
let fetchPending = this._step?.type === "progress";
const unsubs = [
subscribeDataEntryFlowProgressed(this.hass.connection, (ev) => {
if (ev.data.flow_id !== this._step?.flow_id) {
return;
}
if (catchingUp) {
// Two concurrent fetches can both advance the flow, so let the catch-up below fetch again
fetchPending = true;
return;
}
this._processStep(
this._params!.flowConfig.fetchFlow(this.hass, this._step.flow_id)
);
@@ -745,54 +736,9 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
this._progress = Math.ceil(ev.data.progress * 100);
}),
];
const unsubscribe = async () => {
this._unsubDataEntryFlowProgress = async () => {
(await Promise.all(unsubs)).map((unsub) => unsub());
};
this._unsubDataEntryFlowProgress = unsubscribe;
// The backend fires the progressed event even if we have not subscribed yet,
// so fetch the flow once in case its progress task already finished
try {
await Promise.all(unsubs);
while (fetchPending) {
fetchPending = false;
const step = this._step;
if (
this._unsubDataEntryFlowProgress !== unsubscribe ||
!step ||
(step.type !== "external" && step.type !== "progress")
) {
break;
}
// A failed fetch only ends the loop if no progressed event arrived while it was in flight
// eslint-disable-next-line no-await-in-loop
const current = await this._params!.flowConfig.fetchFlow(
this.hass,
step.flow_id
).catch(() => undefined);
if (
current &&
this._step === step &&
!(
current.type === step.type &&
"step_id" in current &&
current.step_id === step.step_id &&
// Progress steps can move on to another task without changing step_id
(current.type !== "progress" ||
(step.type === "progress" &&
current.progress_action === step.progress_action))
)
) {
this._progress = undefined;
// eslint-disable-next-line no-await-in-loop
await this._processStep(current);
}
}
} catch (_err) {
// Keep waiting for the progressed event
} finally {
catchingUp = false;
}
}
private _focusFormStep = async (): Promise<void> => {
@@ -7,7 +7,6 @@ import { classMap } from "lit/directives/class-map";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { consume } from "../../common/decorators/consume";
import { transform } from "../../common/decorators/transform";
import { debounce } from "../../common/util/debounce";
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import type { LocalizeKeys } from "../../common/translations/localize";
import "../../components/ha-alert";
@@ -33,7 +32,6 @@ import {
uiContext,
} from "../../data/context";
import type { DataEntryFlowProgress } from "../../data/data_entry_flow";
import { UNAVAILABLE } from "../../data/entity/entity";
import {
fetchESPHomeDeviceCapabilities,
type ESPHomeDeviceCapabilities,
@@ -208,19 +206,12 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
this._loadStarted = true;
this._capabilities = this.params.capabilities;
this._load();
} else if (this._loadStarted && this._capabilities?.serial_proxies.length) {
if (changedProps.has("_configEntries")) {
this._refreshSerialPorts();
} else if (
changedProps.has("_states") &&
this._unavailableEntityCount(this._states) !==
this._unavailableEntityCount(
changedProps.get("_states") as ContextType<typeof statesContext>
)
) {
// The device reconnected or dropped, which changes its scanned ports
this._refreshSerialPortsDebounced();
}
} else if (
this._loadStarted &&
changedProps.has("_configEntries") &&
this._capabilities?.serial_proxies.length
) {
this._refreshSerialPorts();
}
}
@@ -588,25 +579,14 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
return this._configEntries.find((entry) => entry.entry_id === entryId);
}
/**
* Undefined when USB usage has not been loaded. A port of a connected device
* is always in the scan, so a missing or absent port means it is offline.
* Without the usb integration nothing is scanned, so a missing port says nothing.
*/
private _serialPortUsage(url: string) {
/** Undefined when USB usage has not been loaded. An empty array is a real scan. */
private _serialConsumers(url: string) {
if (!this._serialPorts) {
return undefined;
}
const usage = this._serialPorts.find((port) => port.device === url);
return {
consumers: usage?.consumers ?? [],
offline: usage
? !usage.present
: Boolean(
this._hassConfig &&
isComponentLoaded(this._hassConfig.config, "usb")
),
};
return (
this._serialPorts.find((port) => port.device === url)?.consumers ?? []
);
}
private _renderSerialPortList() {
@@ -618,8 +598,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
return html`
<ul class="ports">
${ports.map((port) => {
const usage = this._serialPortUsage(port.url);
const consumers = usage?.consumers;
const consumers = this._serialConsumers(port.url);
return html`
<li>
<span class="port-text">
@@ -640,31 +619,23 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
}
</span>
${
usage === undefined
consumers === undefined
? nothing
: usage.offline
: consumers.length
? html`
<span class="chip">
<span class="chip active">
${localize(
"ui.panel.config.devices.esphome.setup_serial_offline"
"ui.panel.config.devices.esphome.setup_serial_configured"
)}
</span>
`
: usage.consumers.length
? html`
<span class="chip active">
${localize(
"ui.panel.config.devices.esphome.setup_serial_configured"
)}
</span>
`
: html`
<ha-button size="s" @click=${this._setupSerialPort}>
${localize(
"ui.panel.config.devices.esphome.setup_action"
)}
</ha-button>
`
: html`
<ha-button size="s" @click=${this._setupSerialPort}>
${localize(
"ui.panel.config.devices.esphome.setup_action"
)}
</ha-button>
`
}
</li>
`;
@@ -829,27 +800,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
}
}
/** ESPHome marks the entities of a disconnected device unavailable. */
private _unavailableEntityCount(
states: ContextType<typeof statesContext> | undefined
): number {
if (!this.params || !this._entities || !states) {
return 0;
}
const deviceId = this.params.deviceId;
return Object.values(this._entities).filter(
(entity) =>
entity.device_id === deviceId &&
states[entity.entity_id]?.state === UNAVAILABLE
).length;
}
private _refreshSerialPortsDebounced = debounce(() => {
if (this.isConnected) {
this._refreshSerialPorts();
}
}, 1000);
private async _refreshSerialPorts() {
const capabilities = this._capabilities;
if (!capabilities) {
@@ -1265,8 +1215,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-medium);
line-height: 1;
background: var(--ha-color-fill-neutral-normal-resting);
color: var(--primary-text-color);
}
.chip.active {
background: var(--success-color);
@@ -39,14 +39,12 @@ import {
updateReleaseNotes,
} from "../../../data/update";
import type {
CurrentUser,
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantFormatters,
} from "../../../types";
import { showAlertDialog } from "../../generic/show-dialog-box";
import { showToast } from "../../../util/toast";
import { showLogsDialog } from "../../../panels/config/logs/show-dialog-logs";
import "../components/update/ha-more-info-update-backup";
import type { HaMoreInfoUpdateBackup } from "../components/update/ha-more-info-update-backup";
@@ -84,13 +82,6 @@ class MoreInfoUpdate extends LitElement {
})
private _config!: HassConfig;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, CurrentUser | undefined>({
transformer: ({ user }) => user,
})
private _user?: CurrentUser;
@state() private _releaseNotes?: string | null;
@state() private _error?: string;
@@ -295,20 +286,6 @@ class MoreInfoUpdate extends LitElement {
"ui.dialogs.more_info_control.update.install_failed",
{ error: this._installError }
)}
${
this._canShowSupervisorLogs()
? html`<ha-button
slot="action"
appearance="plain"
size="small"
@click=${this._handleShowSupervisorLogs}
>
${this._localize(
"ui.dialogs.more_info_control.update.supervisor_logs"
)}
</ha-button>`
: nothing
}
</ha-alert>`
: nothing
}
@@ -439,41 +416,11 @@ class MoreInfoUpdate extends LitElement {
{ error: message }
),
duration: 10000,
action: this._canShowSupervisorLogs()
? {
text: this._localize(
"ui.dialogs.more_info_control.update.supervisor_logs"
),
action: () =>
this._showSupervisorLogs(toastHost as HTMLElement),
}
: undefined,
});
}
}
}
// Supervisor errors only say "Check Supervisor logs for details".
private _canShowSupervisorLogs(): boolean {
return (
this._entityEntry?.platform === "hassio" && Boolean(this._user?.is_admin)
);
}
private _handleShowSupervisorLogs(): void {
this._showSupervisorLogs(this);
}
// The toast outlives this element, so it opens the dialog from its own host.
private _showSupervisorLogs(host: HTMLElement): void {
showLogsDialog(host, {
provider: "supervisor",
title: this._localize(
"ui.dialogs.more_info_control.update.supervisor_logs"
),
});
}
private _isShown(): boolean {
if (!this.isConnected) {
return false;
+4 -5
View File
@@ -21,10 +21,8 @@ import "../../components/item/ha-list-item-button";
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-base";
import "../../components/progress/ha-progress-bar";
import {
getBackupState,
type BackupManagerState,
} from "../../data/backup_manager";
import { fetchBackupInfo } from "../../data/backup";
import type { BackupManagerState } from "../../data/backup_manager";
import {
extractApiErrorMessage,
ignoreSupervisorError,
@@ -76,7 +74,8 @@ class DialogRestart extends LitElement {
private async _loadBackupState() {
try {
return await getBackupState(this.hass);
const { state: backupState } = await fetchBackupInfo(this.hass);
return backupState;
} catch (_err) {
// Do nothing
return "idle";
+1
View File
@@ -86,6 +86,7 @@ class HassSubpage extends LitElement {
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
overflow: hidden;
position: relative;
}
+1 -4
View File
@@ -961,10 +961,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
padding-right: calc(16px + var(--data-table-inset-right, 0px));
gap: var(--ha-space-4);
box-sizing: border-box;
background: var(
--ha-panel-toolbar-background,
var(--primary-background-color)
);
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
overflow-x: auto;
scrollbar-width: none;
+1
View File
@@ -309,6 +309,7 @@ export class HassTabsSubpage extends LitElement {
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
}
:host([narrow]) {
-14
View File
@@ -198,20 +198,6 @@ export class HomeAssistantMain extends LitElement {
/* allow a light tap highlight on the actual interface elements */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
}
partial-panel-resolver {
display: block;
height: 100%;
isolation: isolate;
}
partial-panel-resolver::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background: var(--ha-panel-background, var(--primary-background-color));
background-attachment: scroll !important;
}
`;
}
+225 -168
View File
@@ -1,14 +1,11 @@
import { TZDate } from "@date-fns/tz";
import type {
CalendarOptions,
DateClickInfo,
EventClickInfo,
} from "fullcalendar";
import { Calendar, joinClassNames } from "fullcalendar";
import allLocales from "fullcalendar/locales-all";
import dayGridPlugin from "fullcalendar/daygrid";
import interactionPlugin from "fullcalendar/interaction";
import listPlugin from "fullcalendar/list";
import type { CalendarOptions } from "@fullcalendar/core";
import { Calendar } from "@fullcalendar/core";
import allLocales from "@fullcalendar/core/locales-all";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import listPlugin from "@fullcalendar/list";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import {
mdiPlus,
mdiViewAgenda,
@@ -18,7 +15,7 @@ import {
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoize from "memoize-one";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
@@ -37,10 +34,6 @@ import type {
} from "../../data/calendar";
import { CalendarEntityFeature } from "../../data/calendar";
import { TimeZone } from "../../data/translation";
import {
fullCalendarStyles,
fullCalendarTheme,
} from "../../resources/fullcalendar-theme";
import { haStyle } from "../../resources/styles";
import type {
CalendarViewChanged,
@@ -61,56 +54,18 @@ declare global {
}
}
type EventTimeFormatter = Extract<
NonNullable<CalendarOptions["eventTimeFormat"]>,
(...args: any[]) => string
>;
const weekTitleFormat: CalendarOptions["titleFormat"] = {
year: "numeric",
month: "short",
day: "numeric",
};
const defaultFullCalendarConfig: CalendarOptions = {
headerToolbar: false,
plugins: [fullCalendarTheme, dayGridPlugin, listPlugin, interactionPlugin],
plugins: [dayGridPlugin, listPlugin, interactionPlugin],
initialView: "dayGridMonth",
height: "var(--calendar-height)",
dayMaxEventRows: true,
height: "parent",
handleWindowResize: false,
locales: allLocales,
viewClass: ({ view }) => `ha-fc-${view.type}`,
dayHeaderClass: ({ inPopover }) => (inPopover ? "ha-fc-popover-header" : ""),
dayRowClass: "ha-fc-day-row",
dayCellClass: ({ isPast, inPopover }) =>
isPast && !inPopover ? "ha-fc-day-past" : "",
dayCellTopClass: "ha-fc-day-top",
dayCellTopInnerClass: ({ isToday }) =>
joinClassNames("ha-fc-day-number", isToday && "ha-fc-day-number-today"),
eventClass: "ha-fc-event",
rowEventClass: "ha-fc-row-event",
rowEventInnerClass: "ha-fc-row-event-inner",
moreLinkClass: "ha-fc-more-link",
popoverClass: "ha-fc-popover",
listDayClass: "ha-fc-list-day",
listDayHeaderClass: "ha-fc-list-day-header",
listDayHeaderInnerClass: "ha-fc-list-day-text",
views: {
dayGrid: {
listItemEventClass: "ha-fc-dot-event",
listItemEventBeforeClass: "ha-fc-dot",
listItemEventInnerClass: "ha-fc-dot-event-text",
},
dayGridWeek: {
titleFormat: weekTitleFormat,
},
list: {
listItemEventClass: "ha-fc-list-event",
listItemEventBeforeClass: "ha-fc-dot ha-fc-dot-large",
},
listWeek: {
type: "list",
duration: { days: 7 },
titleFormat: weekTitleFormat,
},
},
};
@@ -156,11 +111,19 @@ export class HAFullCalendar extends LitElement {
@state() private _activeView = this.initialView;
@query("style[data-fullcalendar]") private _fullCalendarStyle?: HTMLElement;
// @ts-ignore
private _resizeController = new ResizeController(this, {
callback: () => this.calendar?.updateSize(),
});
disconnectedCallback(): void {
this._clearMidnightRefreshTimeout();
super.disconnectedCallback();
this.calendar?.destroy();
this.calendar = undefined;
this._fullCalendarStyle?.remove();
}
connectedCallback(): void {
@@ -356,7 +319,6 @@ export class HAFullCalendar extends LitElement {
if (changedProps.has("autoHeight")) {
this.calendar.setOption("height", this._height);
this.calendar.setOption("dayMaxEventRows", !this.autoHeight);
}
const oldHass = changedProps.get("hass") as HomeAssistant;
@@ -371,9 +333,18 @@ export class HAFullCalendar extends LitElement {
this._activeView = this.initialView;
}
private _loadCalendar(initialView: FullCalendarView) {
private async _loadCalendar(initialView: FullCalendarView) {
const luxonPlugin =
this.hass.locale.time_zone === TimeZone.local
? undefined
: (await import("@fullcalendar/luxon3")).default;
const config: CalendarOptions = {
...defaultFullCalendarConfig,
plugins:
this.hass.locale.time_zone === TimeZone.local
? defaultFullCalendarConfig.plugins
: [...defaultFullCalendarConfig.plugins!, luxonPlugin!],
locale: this.hass.language,
timeZone:
this.hass.locale.time_zone === TimeZone.local
@@ -382,14 +353,21 @@ export class HAFullCalendar extends LitElement {
firstDay: firstWeekdayIndex(this.hass.locale),
initialView,
height: this._height,
// Rows grow to fit all events when the height is automatic
dayMaxEventRows: !this.autoHeight,
eventDisplay: this.eventDisplay,
eventTimeFormat: this._formatEventTime,
eventTimeFormat: {
hour: useAmPm(this.hass.locale) ? "numeric" : "2-digit",
minute: useAmPm(this.hass.locale) ? "numeric" : "2-digit",
hour12: useAmPm(this.hass.locale),
},
};
config.dateClick = (info) => this._handleDateClick(info);
config.eventClick = (info) => this._handleEventClick(info);
// fullcalendar sets the event colors only inline, where styles cannot mix them
config.eventDidMount = ({ el, event }) => {
el.style.setProperty("--event-color", event.borderColor);
el.style.setProperty("--event-text-color", event.textColor);
};
this.calendar = new Calendar(
this.shadowRoot!.getElementById("calendar")!,
@@ -433,7 +411,7 @@ export class HAFullCalendar extends LitElement {
});
}
private _handleEventClick(info: EventClickInfo): void {
private _handleEventClick(info): void {
const entityStateObj = this.hass.states[info.event.extendedProps.calendar];
const canEdit =
entityStateObj &&
@@ -444,7 +422,7 @@ export class HAFullCalendar extends LitElement {
showCalendarEventDetailDialog(this, {
calendarId: info.event.extendedProps.calendar,
entry: info.event.extendedProps.eventData,
color: info.event.color,
color: info.event.backgroundColor,
updated: () => {
this._fireViewChanged();
},
@@ -453,7 +431,7 @@ export class HAFullCalendar extends LitElement {
});
}
private _handleDateClick(info: DateClickInfo): void {
private _handleDateClick(info): void {
if (info.view.type !== "dayGridMonth") {
return;
}
@@ -544,34 +522,6 @@ export class HAFullCalendar extends LitElement {
);
}
// FullCalendar 7 drops the space before AM/PM and shortens time ranges
private _formatEventTime: EventTimeFormatter = ({
start,
end,
localeCodes,
timeZone,
}) => {
const formatter = this._eventTimeFormatter(
localeCodes,
timeZone,
useAmPm(this.hass.locale)
);
const startText = formatter.format(start.instantMs);
return end
? `${startText} - ${formatter.format(end.instantMs)}`
: startText;
};
private _eventTimeFormatter = memoize(
(localeCodes: string[], timeZone: string, amPm: boolean) =>
new Intl.DateTimeFormat(localeCodes, {
hour: amPm ? "numeric" : "2-digit",
minute: amPm ? "numeric" : "2-digit",
hour12: amPm,
timeZone: timeZone === "local" ? undefined : timeZone,
})
);
private _calendarTimeZone(): string {
return resolveTimeZone(
this.hass.locale.time_zone,
@@ -622,12 +572,11 @@ export class HAFullCalendar extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyle,
fullCalendarStyles,
css`
:host {
display: flex;
flex-direction: column;
--fc-classic-today: transparent;
--fc-theme-standard-border-color: var(--divider-color);
}
.header {
@@ -660,6 +609,10 @@ export class HAFullCalendar extends LitElement {
flex-grow: 0;
}
a {
color: var(--primary-color);
}
.controls {
display: flex;
justify-content: space-between;
@@ -716,65 +669,116 @@ export class HAFullCalendar extends LitElement {
--ha-card-background,
var(--card-background-color, white)
);
font-size: inherit;
line-height: inherit;
height: var(--calendar-height);
--fc-neutral-bg-color: var(
--ha-card-background,
var(--card-background-color, white)
);
--fc-list-event-hover-bg-color: var(
--ha-card-background,
var(--card-background-color, white)
);
--fc-theme-standard-border-color: var(--divider-color);
--fc-border-color: var(--divider-color);
--fc-page-bg-color: var(
--ha-card-background,
var(--card-background-color, white)
);
}
.ha-fc-listWeek {
border-style: none;
a {
color: inherit !important;
}
.ha-fc-day-top {
justify-content: center;
.fc-theme-standard .fc-scrollgrid {
border: 1px solid var(--divider-color);
border-width: var(--calendar-border-width, 1px);
border-radius: var(
--calendar-border-radius,
var(--mdc-shape-small, 4px)
);
}
.fc-theme-standard td {
border-bottom-left-radius: var(--mdc-shape-small, 4px);
border-bottom-right-radius: var(--mdc-shape-small, 4px);
}
.fc-scrollgrid-section-header td {
border: none;
}
th.fc-col-header-cell.fc-day {
background-color: var(--table-header-background-color);
color: var(--primary-text-color);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-bold);
text-transform: uppercase;
}
.fc-daygrid-dot-event:hover {
background-color: inherit;
}
.fc-daygrid-day-top {
text-align: center;
padding-top: 5px;
padding-bottom: 4px;
justify-content: center;
}
.ha-fc-day-top:empty,
.ha-fc-day-row:first-child .ha-fc-day-top {
table.fc-scrollgrid-sync-table
tbody
tr:first-child
.fc-daygrid-day-top {
padding-top: 0;
}
.ha-fc-day-number {
margin: 0;
padding: 3px;
a.fc-daygrid-day-number {
float: none !important;
font-size: var(--ha-font-size-s);
line-height: var(--ha-line-height-normal);
cursor: pointer;
}
.ha-fc-day-number-today {
display: flex;
align-items: center;
justify-content: center;
.fc .fc-daygrid-day-number {
padding: 3px !important;
}
.fc .fc-daygrid-day.fc-day-today {
background: inherit;
}
td.fc-day-today .fc-daygrid-day-number {
height: 26px;
min-width: 24px;
box-sizing: border-box;
color: var(--text-primary-color);
color: var(--text-primary-color) !important;
background-color: var(--primary-color);
border-radius: var(--ha-border-radius-circle);
display: inline-block;
text-align: center;
white-space: nowrap;
width: max-content;
min-width: 24px;
}
.ha-fc-day-past .ha-fc-event,
.ha-fc-day-past .ha-fc-more-link {
opacity: 0.5;
.fc-daygrid-day-events {
margin-top: 4px;
}
.ha-fc-event {
.fc-event {
border-radius: var(--ha-border-radius-sm);
line-height: var(--ha-line-height-normal);
cursor: pointer;
}
.ha-fc-row-event-inner,
.ha-fc-dot-event-text {
line-height: var(--ha-line-height-normal);
}
.ha-fc-dot-event:hover,
.ha-fc-list-event:hover {
background-color: transparent;
.fc-daygrid-block-event .fc-event-main {
padding: 0 1px;
}
.tentative {
--tentative-tint: color-mix(
in srgb,
var(--event-color) 25%,
transparent
);
/* In the text color, so the stripes show on every calendar color */
--tentative-stripe: color-mix(
in srgb,
@@ -785,18 +789,16 @@ export class HAFullCalendar extends LitElement {
/* A dot keeps its size and color and gets stripes in the color that
contrasts with it, 1.5px wide every 4px */
.tentative > .ha-fc-dot {
.tentative .fc-daygrid-event-dot,
.tentative .fc-list-event-dot {
--tentative-dot-stripe: color-mix(
in srgb,
var(--fc-event-contrast-color) 80%,
var(--event-text-color, #fff) 80%,
transparent
);
/* A long title would otherwise squeeze the dot into an oval */
flex-shrink: 0;
width: var(--fc-classic-small-dot-width);
height: var(--fc-classic-small-dot-width);
border: none;
background-color: var(--fc-event-color);
border-radius: 50%;
background-color: var(--event-color);
background-image: linear-gradient(
45deg,
var(--tentative-dot-stripe) 0 9.375%,
@@ -810,18 +812,21 @@ export class HAFullCalendar extends LitElement {
background-position: 2px 0;
}
.tentative > .ha-fc-dot-large {
width: var(--fc-classic-large-dot-width);
height: var(--fc-classic-large-dot-width);
.tentative .fc-daygrid-event-dot {
/* A long title would otherwise squeeze the dot into an oval */
flex-shrink: 0;
width: var(--fc-daygrid-event-dot-width, 8px);
height: var(--fc-daygrid-event-dot-width, 8px);
}
.ha-fc-row-event.tentative {
border-color: var(--fc-event-color);
background-color: color-mix(
in srgb,
var(--fc-event-color) 25%,
transparent
);
.tentative .fc-list-event-dot {
width: var(--fc-list-event-dot-width, 10px);
height: var(--fc-list-event-dot-width, 10px);
}
/* fullcalendar sets the colors as inline styles */
.fc-h-event.tentative {
background-color: var(--tentative-tint) !important;
/* A tile of whole pixels puts every stripe on the same pixels,
2.8px wide every 14px */
background-image: linear-gradient(
@@ -835,62 +840,114 @@ export class HAFullCalendar extends LitElement {
background-size: 14px 14px;
}
.tentative > .ha-fc-row-event-inner {
color: var(--primary-text-color);
.fc-h-event.tentative .fc-event-main {
color: var(--primary-text-color) !important;
}
/* Forced colors drop the stripes and the fill of a dot, so a tentative
event falls back to its outline */
@media (forced-colors: active) {
.ha-fc-row-event.tentative {
.fc-h-event.tentative {
border-style: dashed;
}
.tentative > .ha-fc-dot {
.tentative .fc-daygrid-event-dot,
.tentative .fc-list-event-dot {
box-sizing: border-box;
border-style: solid;
border-width: 2px;
}
}
.ha-fc-popover {
background-color: var(--primary-background-color);
font-size: inherit;
line-height: inherit;
.fc-day-past .fc-daygrid-day-events {
opacity: 0.5;
}
.ha-fc-popover-header {
background-color: var(--secondary-background-color);
.fc-icon-x:before {
font-family: var(--ha-font-family-body);
content: "X";
}
.ha-fc-list-day {
border-bottom-style: none;
.fc-popover {
background-color: var(--primary-background-color) !important;
}
.ha-fc-list-day-header {
margin-bottom: 0;
border-bottom-style: none;
border-top: 1px solid var(--divider-color);
background: var(--fc-classic-background);
.fc-popover-header {
background-color: var(--secondary-background-color) !important;
}
.ha-fc-list-day-text {
.fc-theme-standard .fc-list-day-frame {
background-color: transparent;
}
.fc-list.fc-view,
.fc-list-event.fc-event td {
border: none;
}
.fc-list-day.fc-day th {
border-bottom: none;
border-top: 1px solid var(--fc-theme-standard-border-color, #ddd) !important;
}
.fc-list-day-text {
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-normal);
}
.ha-fc-list-event {
padding-block: 12px;
border-top-style: none;
.fc-list-day-side-text {
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-normal);
color: var(--primary-color);
}
:host([narrow]) .ha-fc-dayGridMonth .ha-fc-dot-event {
justify-content: center;
.fc-list-table td,
.fc-list-day-frame {
padding-top: 12px;
padding-bottom: 12px;
}
:host([narrow]) .ha-fc-dayGridMonth .ha-fc-dot-event-text {
:host([narrow])
.fc-dayGridMonth-view
.fc-daygrid-dot-event
.fc-event-time,
:host([narrow])
.fc-dayGridMonth-view
.fc-daygrid-dot-event
.fc-event-title {
display: none;
}
:host([narrow]) .fc-dayGridMonth-view .fc-daygrid-event-harness {
margin-top: 0 !important;
}
:host([narrow]) .fc-dayGridMonth-view .fc-daygrid-day-events {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
:host([narrow]) .fc-dayGridMonth-view .fc-scrollgrid-sync-table {
overflow: hidden;
}
.fc-scroller::-webkit-scrollbar {
width: 0.4rem;
height: 0.4rem;
}
.fc-scroller::-webkit-scrollbar-thumb {
border-radius: var(--ha-border-radius-sm);
background: var(--scrollbar-thumb-color);
}
.fc-scroller {
overflow-y: auto;
scrollbar-color: var(--scrollbar-thumb-color) transparent;
scrollbar-width: thin;
}
`,
];
}
@@ -103,7 +103,6 @@ import { getAppDisplayName } from "../../common/app";
import "../../components/supervisor-apps-state";
import "../../components/supervisor-apps-tag";
import "../components/supervisor-app-metric";
import { showLogsDialog } from "../../../logs/show-dialog-logs";
import { extractChangelog } from "../util/supervisor-app";
import "./supervisor-app-system-managed";
@@ -549,17 +548,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
<div class="description">
${
this._error
? html`<ha-alert alert-type="error">
${this._error}
<ha-button
slot="action"
appearance="plain"
size="small"
@click=${this._showSupervisorLogs}
>
${this.i18n.localize("ui.panel.config.logs.supervisor_logs")}
</ha-button>
</ha-alert>`
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<div class="description-text">${this._currentAddon.description}</div>
@@ -1324,7 +1313,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
dismissText: this.i18n.localize(
"ui.panel.config.apps.dashboard.action_error.view_supervisor_logs"
),
cancel: () => this._showSupervisorLogs(),
cancel: () => navigate("/config/logs?provider=supervisor"),
});
}
button.progress = false;
@@ -1569,14 +1558,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
}
);
// Supervisor errors often only say "Check Supervisor logs for details".
private _showSupervisorLogs() {
showLogsDialog(this, {
provider: "supervisor",
title: this.i18n.localize("ui.panel.config.logs.supervisor_logs"),
});
}
private _openUpdate() {
showMoreInfoDialog(this, { entityId: this._updateEntityId! });
}
@@ -319,6 +319,7 @@ export class HaConfigAppsAvailable extends LitElement {
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
}
supervisor-apps-repository {
margin-top: 24px;
@@ -257,6 +257,7 @@ export class HaConfigAppsInstalled extends LitElement {
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
}
ha-card {
@@ -276,13 +277,10 @@ export class HaConfigAppsInstalled extends LitElement {
position: sticky;
top: 0;
z-index: 2;
background-color: var(--primary-background-color);
padding: 0 var(--ha-space-4);
box-sizing: border-box;
border-bottom: 1px solid var(--divider-color);
background: var(
--ha-panel-toolbar-background,
var(--primary-background-color)
);
}
ha-input-search {
@@ -174,6 +174,7 @@ export class HaConfigAppsRegistries extends LitElement {
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
}
ha-data-table {
width: 100%;
@@ -264,6 +264,7 @@ export class HaConfigAppsRepositories extends LitElement {
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
}
ha-data-table {
width: 100%;
+4 -15
View File
@@ -89,14 +89,12 @@ type AreaQuickLinkKey =
const NAVIGATION_ACTIONS: {
value: string;
path: string;
params?: string;
icon: string;
countKey: AreaQuickLinkKey;
}[] = [
{
value: "navigate-devices",
path: "/config/devices/dashboard",
params: "&status=disabled",
icon: mdiDevices,
countKey: "devices",
},
@@ -658,9 +656,7 @@ class HaConfigAreaPage extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
${NAVIGATION_ACTIONS.filter(
(action) => quickLinkCounts[action.countKey]
).map(
${NAVIGATION_ACTIONS.map(
(action) => html`
<ha-dropdown-item value=${action.value}>
<ha-svg-icon slot="icon" .path=${action.icon}></ha-svg-icon>
@@ -672,13 +668,8 @@ class HaConfigAreaPage extends LitElement {
</ha-dropdown-item>
`
)}
${
NAVIGATION_ACTIONS.some(
(action) => quickLinkCounts[action.countKey]
)
? html`<wa-divider></wa-divider>`
: nothing
}
<wa-divider></wa-divider>
<ha-dropdown-item value="edit" .data=${area}>
<ha-svg-icon slot="icon" .path=${mdiPencil}> </ha-svg-icon>
@@ -854,9 +845,7 @@ class HaConfigAreaPage extends LitElement {
const navAction = NAVIGATION_ACTIONS.find((a) => a.value === action);
if (navAction) {
navigate(
`${navAction.path}?historyBack=1&area=${this.areaId}${navAction.params ?? ""}`
);
navigate(`${navAction.path}?historyBack=1&area=${this.areaId}`);
return;
}
@@ -184,10 +184,9 @@ const DYNAMIC_KEYWORDS = ["dynamicGroups", "helpers", "integrationGroups"];
const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
// Group keys surfaced in the "Other" section of the "by target" tab because
// Group keys surfaced as their own section in the "by target" tab because
// their elements have no target (time/calendar/schedule, sun). Picking one
// drills into its items, like selecting the matching group in the "by type" tab.
// People are shown in the same section by ha-automation-add-from-target.
const TIME_LOCATION_GROUPS = ["time", "sun"];
type CollectionGroupType = "helper" | "dynamic" | "integration";
@@ -1986,7 +1985,7 @@ class DialogAddAutomationElement
if (type !== "trigger" && type !== "condition") {
return undefined;
}
return this.hass.localize("ui.panel.config.automation.editor.other");
return this.hass.localize("ui.panel.config.automation.editor.time_sun");
}
private _getTimeLocationGroups = memoizeOne(
@@ -2205,10 +2204,6 @@ class DialogAddAutomationElement
return this.hass.localize("ui.panel.config.automation.editor.services");
}
if (targetId === undefined && targetType === "person") {
return this.hass.localize("ui.panel.config.person.caption");
}
if (targetId === undefined && targetType === "device") {
return this.hass.localize(
"ui.panel.config.automation.editor.unassigned_entities"
@@ -3,7 +3,7 @@ import type WaTreeItem from "@home-assistant/webawesome/dist/components/tree-ite
import "@home-assistant/webawesome/dist/components/tree/tree";
import type { WaSelectionChangeEvent } from "@home-assistant/webawesome/dist/events/selection-change";
import type { ContextType } from "@lit/context";
import { mdiAccountMultiple, mdiTextureBox } from "@mdi/js";
import { mdiTextureBox } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import {
css,
@@ -20,7 +20,6 @@ import { consume } from "../../../../common/decorators/consume";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeEntityNameParts } from "../../../../common/entity/compute_entity_name_display";
import { getDeviceAreaId } from "../../../../common/entity/context/get_device_context";
import { stringCompare } from "../../../../common/string/compare";
@@ -71,15 +70,10 @@ import type { AddAutomationElementListItem } from "../add-automation-element-dia
import type { AddAutomationElementDialogParams } from "../show-add-automation-element-dialog";
import "./ha-automation-add-element-paste";
// All person entities are also listed under "People" in the "Other" section
// next to time and sun (triggers/conditions only), wherever they are assigned.
const PERSON_TARGET = `person${TARGET_SEPARATOR}`;
interface Level1Entries {
open: boolean;
areas?: Record<string, Level2Entries>;
devices?: Record<string, Level3Entries>;
entities?: string[];
}
interface Level2Entries {
@@ -220,11 +214,9 @@ export default class HaAutomationAddFromTarget extends LitElement {
${this._renderFloors(this.narrow, this._entries, this.value)}
${this._renderTimeLocation(
this.narrow,
this._entries,
this.timeLocationLabel,
this.timeLocationGroups,
this.selectedGroup,
this.value
this.selectedGroup
)}
${this._renderUnassigned(this.narrow, this._entries, this.value)}
${this._renderLabels(
@@ -309,10 +301,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
`;
}
if (valueType === "person" && !valueId) {
return this._renderEntities(entries[PERSON_TARGET]?.entities, false);
}
if (valueType === "device" || valueType === "helper") {
const { devices } = entries[`${valueType}${TARGET_SEPARATOR}`];
return this._renderDomains(
@@ -402,83 +390,42 @@ export default class HaAutomationAddFromTarget extends LitElement {
private _renderTimeLocation = memoizeOne(
(
narrow: boolean,
entries: Record<string, Level1Entries>,
label?: string,
groups?: AddAutomationElementListItem[],
selectedGroup?: string,
// only used to invalidate the memoized selected state of the people
_value?: SingleHassServiceTarget
selectedGroup?: string
) => {
const people = entries[PERSON_TARGET];
if (!label || (!groups?.length && !people)) {
if (!label || !groups?.length) {
return nothing;
}
const peopleItem = people
? this._renderItem(
this._i18n.localize("ui.panel.config.person.caption"),
PERSON_TARGET,
true,
false,
!people.open && !!people.entities!.length,
people.open,
(slot) =>
html`<ha-svg-icon
slot=${ifDefined(slot)}
.path=${mdiAccountMultiple}
></ha-svg-icon>`,
people.open ? this._renderEntities(people.entities!) : undefined
)
: nothing;
// With an expandable item in the section, render everything as one
// tree so the leaf rows reserve the chevron space and icons line up.
const asTree = !narrow && !!people;
const groupItems = groups?.map((group) => {
const icon = group.icon
? html`<span slot=${ifDefined(asTree ? undefined : "start")}
>${group.icon}</span
>`
: group.iconPath
? html`<ha-svg-icon
slot=${ifDefined(asTree ? undefined : "start")}
.path=${group.iconPath}
></ha-svg-icon>`
: nothing;
return asTree
? html`<wa-tree-item
.value=${group.key}
.selected=${group.key === selectedGroup}
.title=${group.name}
>
${icon} ${group.name}
</wa-tree-item>`
: html`<ha-list-item-button
.value=${group.key}
@click=${this._selectTimeLocationGroup}
class=${group.key === selectedGroup ? "selected" : ""}
>
${icon}
<div slot="headline">${group.name}</div>
${
narrow
? html`<ha-icon-next slot="end"></ha-icon-next>`
: nothing
}
</ha-list-item-button>`;
});
return html`<ha-section-title>${label}</ha-section-title> ${
asTree
? html`<wa-tree @wa-selection-change=${this._handleSelectionChange}>
${groupItems} ${peopleItem}
</wa-tree>`
: html`<ha-list-base>
${groupItems} ${narrow ? peopleItem : nothing}
</ha-list-base>`
}`;
return html`<ha-section-title>${label}</ha-section-title>
<ha-list-base>
${groups.map(
(group) =>
html`<ha-list-item-button
.value=${group.key}
@click=${this._selectTimeLocationGroup}
class=${group.key === selectedGroup ? "selected" : ""}
>
${
group.icon
? html`<span slot="start">${group.icon}</span>`
: group.iconPath
? html`<ha-svg-icon
slot="start"
.path=${group.iconPath}
></ha-svg-icon>`
: nothing
}
<div slot="headline">${group.name}</div>
${
narrow
? html`<ha-icon-next slot="end"></ha-icon-next>`
: nothing
}
</ha-list-item-button>`
)}
</ha-list-base>`;
}
);
@@ -814,7 +761,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
return renderedDomains;
}
private _renderEntities(entities: string[] = [], showTitle = true) {
private _renderEntities(entities: string[] = []) {
if (!entities.length) {
return nothing;
}
@@ -852,15 +799,12 @@ export default class HaAutomationAddFromTarget extends LitElement {
);
if (this.narrow) {
return html`${
showTitle
? html`<ha-section-title
>${this._i18n.localize(
"ui.components.target-picker.type.entities"
)}</ha-section-title
>`
: nothing
} <ha-list-base>${renderedEntites}</ha-list-base>`;
return html`<ha-section-title
>${this._i18n.localize(
"ui.components.target-picker.type.entities"
)}</ha-section-title
>
<ha-list-base>${renderedEntites}</ha-list-base>`;
}
return renderedEntites;
@@ -1030,7 +974,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
this._loadUnassignedDevices();
this._loadUnassignedEntities();
this._loadPeople();
this._entries = { ...this._entries };
if (this.value) {
@@ -1038,15 +981,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
}
private _loadPeople() {
const people = Object.keys(this._registries.entities).filter(
(entityId) => computeDomain(entityId) === "person"
);
if (people.length) {
this._entries[PERSON_TARGET] = { open: false, entities: people };
}
}
private _loadUnassignedDevices() {
const unassignedDevices = Object.values(this._registries.devices).filter(
(device) =>
@@ -1326,13 +1260,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
if (type === "entity") {
if (computeDomain(id) === "person" && this._entries[PERSON_TARGET]) {
this._entries = {
...this._entries,
[PERSON_TARGET]: { ...this._entries[PERSON_TARGET], open: true },
};
}
const deviceId = this._registries.entities[id]?.device_id;
if (deviceId && this._registries.devices[deviceId]) {
this._setDeviceOpen(deviceId, true, true);
@@ -1409,14 +1336,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
private _handleSelectionChange(ev: WaSelectionChangeEvent) {
const treeItem = ev.detail.selection[0] as unknown as
{ target?: string; value?: string } | undefined;
if (treeItem?.value) {
fireEvent(this, "time-location-group-selected", {
value: treeItem.value,
});
return;
}
{ target?: string } | undefined;
if (treeItem?.target) {
this._valueChanged(treeItem.target);
@@ -1467,14 +1387,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
private _toggleItem(targetId: string, open: boolean) {
const [type, id] = targetId.split(TARGET_SEPARATOR, 2);
if (type === "person") {
this._entries = {
...this._entries,
[targetId]: { ...this._entries[targetId], open },
};
return;
}
if (type === "floor") {
this._entries = {
...this._entries,
@@ -1659,7 +1571,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
font-weight: var(--ha-font-weight-medium);
overflow: hidden;
}
ha-list-item-button::part(headline) {
ha-list-item-button::part(label) {
font-weight: var(--ha-font-weight-medium);
font-family: var(--ha-font-family-heading);
}
@@ -65,7 +65,7 @@ export class HaSunCondition extends LitElement implements ConditionElement {
name: "after_offset",
selector: {
duration: {
allow_negative: true,
mode: "offset",
},
},
},
@@ -96,7 +96,7 @@ export class HaSunCondition extends LitElement implements ConditionElement {
name: "before_offset",
selector: {
duration: {
allow_negative: true,
mode: "offset",
},
},
},
@@ -5,16 +5,22 @@ import memoizeOne from "memoize-one";
import { consume } from "../../../common/decorators/consume";
import { createDurationData } from "../../../common/datetime/create_duration_data";
import { formatDurationNarrow } from "../../../common/datetime/format_duration";
import { normalizeDuration } from "../../../common/datetime/normalize_duration";
import { hasTemplate } from "../../../common/string/has-template";
import type { ForDict } from "../../../data/automation";
import { internationalizationContext } from "../../../data/context";
import { rowSummaryStyles } from "./styles";
const SUMMARY_UNITS = {
enableDay: true,
enableSecond: true,
enableMillisecond: true,
};
interface HaAutomationRowOptionsConfig {
options?: {
for?: string | number | ForDict;
offset?: string | number | ForDict;
offset_type?: "before" | "after";
};
timeout?: string | number | ForDict;
}
@@ -63,20 +69,18 @@ export class HaAutomationRowOptions extends LitElement {
this._i18n.localize(
"ui.panel.config.automation.editor.row_options.for",
{
duration: forDuration,
duration: forDuration.text,
}
)
);
}
const offsetDuration = this._duration(options.offset);
if (offsetDuration) {
const offsetType =
options.offset_type === "before" ? "before" : "after";
const offset = this._duration(options.offset);
if (offset) {
parts.push(
this._i18n.localize(
`ui.panel.config.automation.editor.row_options.offset_${offsetType}`,
{ offset: offsetDuration }
`ui.panel.config.automation.editor.row_options.offset_${offset.negative ? "before" : "after"}`,
{ offset: offset.text }
)
);
}
@@ -88,7 +92,7 @@ export class HaAutomationRowOptions extends LitElement {
parts.push(
this._i18n.localize(
"ui.panel.config.automation.editor.row_options.timeout",
{ duration: timeoutDuration }
{ duration: timeoutDuration.text }
)
);
}
@@ -98,7 +102,9 @@ export class HaAutomationRowOptions extends LitElement {
}
);
private _duration(value: unknown): string | undefined {
private _duration(
value: unknown
): { negative: boolean; text: string } | undefined {
if (value === undefined || hasTemplate(value)) {
return undefined;
}
@@ -113,7 +119,9 @@ export class HaAutomationRowOptions extends LitElement {
) {
return undefined;
}
return formatDurationNarrow(this._i18n.locale, duration);
const normalized = normalizeDuration(duration, SUMMARY_UNITS);
const text = formatDurationNarrow(this._i18n.locale, normalized.duration);
return text ? { negative: normalized.negative, text } : undefined;
}
static styles = rowSummaryStyles;
@@ -708,6 +708,7 @@ export class HaAutomationTrace extends LitElement {
font-size: var(--ha-font-size-xl);
height: var(--header-height);
padding: 4px;
background-color: var(--primary-background-color);
font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
@@ -719,6 +720,7 @@ export class HaAutomationTrace extends LitElement {
min-height: 0;
display: flex;
overflow: hidden;
background-color: var(--card-background-color);
direction: ltr;
}
@@ -743,10 +745,7 @@ export class HaAutomationTrace extends LitElement {
}
.graph {
background: var(
--ha-panel-background,
var(--primary-background-color)
);
background-color: var(--primary-background-color);
box-sizing: border-box;
display: flex;
flex-direction: column;
@@ -773,13 +772,10 @@ export class HaAutomationTrace extends LitElement {
min-width: 0;
min-height: 0;
overflow-y: auto;
background: var(--ha-card-background, var(--card-background-color));
background-color: var(--card-background-color);
}
ha-tab-group {
background: var(
--ha-panel-toolbar-background,
var(--ha-panel-background, var(--primary-background-color))
);
background-color: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
direction: var(--direction);
}
@@ -5,10 +5,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import type { CalendarTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import type { TriggerElement } from "../ha-automation-trigger-row";
import type { HaDurationData } from "../../../../../components/ha-duration-input";
import "../../../../../components/ha-form/ha-form";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
@@ -47,25 +44,10 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
],
],
},
{ name: "offset", required: true, selector: { duration: {} } },
{
name: "offset_type",
type: "select",
name: "offset",
required: true,
options: [
[
"before",
localize(
"ui.panel.config.automation.editor.triggers.type.calendar.before"
),
],
[
"after",
localize(
"ui.panel.config.automation.editor.triggers.type.calendar.after"
),
],
],
selector: { duration: { enable_day: true, mode: "offset" } },
},
] as const
);
@@ -81,34 +63,10 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
protected render() {
const schema = this._schema(this.hass.localize);
// Convert from string representation to ha form duration representation
const trigger_offset = this.trigger.offset;
// Copy, `createDurationData` returns the input object as-is for dict values.
const duration: HaDurationData = { ...createDurationData(trigger_offset)! };
let offset_type = "after";
if (durationDataToSeconds(duration) < 0) {
// A negative offset negates the whole period, and the sign is shown by
// the separate before/after select instead.
if (duration.days) {
duration.days = Math.abs(duration.days);
}
duration.hours = Math.abs(duration.hours ?? 0);
duration.minutes = Math.abs(duration.minutes ?? 0);
duration.seconds = Math.abs(duration.seconds ?? 0);
if (duration.milliseconds) {
duration.milliseconds = Math.abs(duration.milliseconds);
}
offset_type = "before";
}
const data = {
...this.trigger,
offset: duration,
offset_type: offset_type,
};
return html`
<ha-form
.schema=${schema}
.data=${data}
.data=${this.trigger}
.hass=${this.hass}
.disabled=${this.disabled}
.computeLabel=${this._computeLabelCallback}
@@ -119,18 +77,7 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
// Convert back to duration string representation
const duration = ev.detail.value.offset;
const offsetType = ev.detail.value.offset_type === "before" ? "-" : "";
const h = (duration.days ?? 0) * 24 + (duration.hours ?? 0);
const m = duration.minutes ?? 0;
const s = (duration.seconds ?? 0) + (duration.milliseconds ?? 0) / 1000;
const newTrigger = {
...ev.detail.value,
offset: `${offsetType}${h}:${m}:${s}`,
};
delete newTrigger.offset_type;
fireEvent(this, "value-changed", { value: newTrigger });
fireEvent(this, "value-changed", { value: ev.detail.value });
}
private _computeLabelCallback = (
@@ -143,6 +90,10 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
return this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.calendar.event"
);
case "offset":
return this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.calendar.offset"
);
}
return "";
};
@@ -39,7 +39,7 @@ export class HaSunTrigger extends LitElement implements TriggerElement {
],
],
},
{ name: "offset", selector: { text: {} } },
{ name: "offset", selector: { duration: { mode: "offset" } } },
] as const
);
@@ -319,12 +319,12 @@ class HaBlueprintOverview extends LitElement {
})
);
protected async firstUpdated(changedProps: PropertyValues<this>) {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._loadUsageCounts();
if (this.route.path === "/import") {
const url = extractSearchParam("blueprint_url");
await navigate("/config/blueprint/dashboard", { replace: true });
navigate("/config/blueprint/dashboard", { replace: true });
if (url) {
this._addBlueprint(url);
}
+145 -126
View File
@@ -578,8 +578,7 @@ export class HaConfigDevicePage extends LitElement {
this._related.scene?.length
? html`
${
isComponentLoaded(this.hass.config, "automation") &&
this._related.automation?.length
isComponentLoaded(this.hass.config, "automation")
? html`
<h3 class="section-header">
${this.hass.localize(
@@ -591,131 +590,156 @@ export class HaConfigDevicePage extends LitElement {
"ui.panel.config.devices.automation.automations_heading"
)}
>
${this._getRelated(
this._related
).automation.map((automation) =>
automation
? html`<ha-list-item-button
.headline=${this.hass.formatEntityName(automation)}
.href=${
automation.attributes.id
? `/config/automation/edit/${encodeURIComponent(automation.attributes.id)}`
: `/config/automation/show/${automation.entity_id}`
${
this._related.automation?.length
? this._getRelated(
this._related
).automation.map((automation) =>
automation
? html`<ha-list-item-button
.headline=${this.hass.formatEntityName(automation)}
.href=${
automation.attributes.id
? `/config/automation/edit/${encodeURIComponent(automation.attributes.id)}`
: `/config/automation/show/${automation.entity_id}`
}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-button>`
: nothing
)
: html`<ha-list-item-base
.headline=${this.hass.localize(
"ui.panel.config.devices.automation.no_automations"
)}
></ha-list-item-base>`
}
</ha-list-nav>
`
: nothing
}
${
isComponentLoaded(this.hass.config, "script")
? html`
<h3 class="section-header">
${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
</h3>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
>
${
this._related.script?.length
? this._getRelated(
this._related
).script.map((script) => {
if (!script) {
return nothing;
}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-button>`
: nothing
)}
</ha-list-nav>
`
: nothing
}
${
isComponentLoaded(this.hass.config, "script") &&
this._related.script?.length
? html`
<h3 class="section-header">
${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
</h3>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
>
${this._getRelated(this._related).script.map(
(script) => {
if (!script) {
return nothing;
}
const entry = this._entityReg.find(
(e) => e.entity_id === script.entity_id
);
const url = entry
? `/config/script/edit/${entry.unique_id}`
: `/config/script/show/${script.entity_id}`;
return html`
<ha-list-item-button
.headline=${this.hass.formatEntityName(script)}
.href=${url}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-button>
`;
}
)}
</ha-list-nav>
`
: nothing
}
${
hasSceneSupport && this._related.scene?.length
? html`
<h3 class="section-header">
${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
</h3>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
>
${this._getRelated(this._related).scene.map(
(scene) => {
if (!scene) {
return nothing;
}
const sceneId = `scene-${slugify(
scene.entity_id
)}`;
return scene.attributes.id
? html`
const entry = this._entityReg.find(
(e) =>
e.entity_id === script.entity_id
);
const url = entry
? `/config/script/edit/${entry.unique_id}`
: `/config/script/show/${script.entity_id}`;
return html`
<ha-list-item-button
.headline=${this.hass.formatEntityName(scene)}
.href=${`/config/scene/edit/${scene.attributes.id}`}
.headline=${this.hass.formatEntityName(script)}
.href=${url}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-button>
`
: html`
<ha-list-item-base
id=${sceneId}
.headline=${this.hass.formatEntityName(scene)}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-base>
<ha-tooltip
.for=${sceneId}
placement=${
computeRTL(
this.hass.language,
this.hass.translationMetadata
.translations
)
? "left"
: "right"
}
>
${this.hass.localize(
"ui.panel.config.devices.cant_edit"
)}
</ha-tooltip>
`;
}
})
: html`<ha-list-item-base
.headline=${this.hass.localize(
"ui.panel.config.devices.script.no_scripts"
)}
></ha-list-item-base>`
}
</ha-list-nav>
`
: nothing
}
${
hasSceneSupport
? html`
<h3 class="section-header">
${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
</h3>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
>
${
this._related.scene?.length
? this._getRelated(
this._related
).scene.map((scene) => {
if (!scene) {
return nothing;
}
const sceneId = `scene-${slugify(
scene.entity_id
)}`;
return scene.attributes.id
? html`
<ha-list-item-button
.headline=${this.hass.formatEntityName(scene)}
.href=${`/config/scene/edit/${scene.attributes.id}`}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-button>
`
: html`
<ha-list-item-base
id=${sceneId}
.headline=${this.hass.formatEntityName(scene)}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-base>
<ha-tooltip
.for=${sceneId}
placement=${
computeRTL(
this.hass.language,
this.hass
.translationMetadata
.translations
)
? "left"
: "right"
}
>
${this.hass.localize(
"ui.panel.config.devices.cant_edit"
)}
</ha-tooltip>
`;
})
: html`<ha-list-item-base
.headline=${this.hass.localize(
"ui.panel.config.devices.scene.no_scenes"
)}
></ha-list-item-base>`
}
</ha-list-nav>
`
: nothing
@@ -989,9 +1013,7 @@ export class HaConfigDevicePage extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
${NAVIGATION_ACTIONS.filter(
(action) => quickLinkCounts[action.countKey]
).map(
${NAVIGATION_ACTIONS.map(
(action) => html`
<ha-dropdown-item value=${action.value}>
<ha-svg-icon slot="icon" .path=${action.icon}></ha-svg-icon>
@@ -1003,11 +1025,8 @@ export class HaConfigDevicePage extends LitElement {
</ha-dropdown-item>
`
)}
${
NAVIGATION_ACTIONS.some((action) => quickLinkCounts[action.countKey])
? html`<wa-divider></wa-divider>`
: nothing
}
<wa-divider></wa-divider>
<ha-dropdown-item value="reset_entity_ids">
<ha-svg-icon slot="icon" .path=${mdiRestore}></ha-svg-icon>
@@ -12,7 +12,6 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
import { consume } from "../../../common/decorators/consume";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
@@ -25,14 +24,12 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { deepEqual } from "../../../common/util/deep-equal";
import {
devicesFilterQueryParamConfig,
devicesFilterQueryParams,
devicesLinkFromUrl,
} from "../../../common/url/devices-query-params";
getHistoryState,
navigate,
updateHistoryState,
} from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
rejectedItems,
@@ -135,6 +132,8 @@ export class HaConfigDeviceDashboard extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _selected: string[] = [];
private _selectedCanDelete: string[] = [];
@@ -149,7 +148,7 @@ export class HaConfigDeviceDashboard extends LitElement {
state: true,
subscribe: false,
})
private _filter = "";
private _filter: string = getHistoryState()?.filter || "";
@state()
private _filters: DataTableFilters = {};
@@ -197,16 +196,39 @@ export class HaConfigDeviceDashboard extends LitElement {
callback: (entries) => entries[0]?.contentRect.width,
});
// The filters are kept in the URL by the page, the search, sorting and
// grouping by the table
private _filterParams = new QueryParamsController(
this,
devicesFilterQueryParamConfig,
{
enabled: () => true,
navigated: () => this._applyLink(),
private _ignoreLocationChange = false;
private _fromUrl = false;
public connectedCallback() {
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
window.removeEventListener("popstate", this._popState);
}
private _locationChanged = () => {
if (this._ignoreLocationChange) {
this._ignoreLocationChange = false;
return;
}
);
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _popState = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _states = memoizeOne((localize: LocalizeFunc) => [
{
@@ -219,7 +241,7 @@ export class HaConfigDeviceDashboard extends LitElement {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._filters = this._storageFilters;
this._applyLink();
this._setFiltersFromUrl();
}
if (changedProps.has("_selected") || changedProps.has("entries")) {
this._selectedCanDelete = this._selected.filter((d) => {
@@ -234,38 +256,60 @@ export class HaConfigDeviceDashboard extends LitElement {
}
}
// Links, like the ones on an integration page, say in the URL what to show.
// The router keeps the page open, so a link can land on it.
private _applyLink() {
const searchParams = this._filterParams.searchParams();
const link = searchParams ? devicesLinkFromUrl(searchParams) : undefined;
private _setFiltersFromUrl() {
const area = this._searchParms.get("area");
const domain = this._searchParms.get("domain");
const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry");
const label = this._searchParms.has("label");
if (link) {
// The filters only work out their items again when their value changes,
// so keep the items of the ones the link leaves as they are
const filters = Object.fromEntries(
Object.entries(link.filters).map(([key, filter]) => {
const current = this._filters[key];
return [
key,
current && deepEqual(filter.value, current.value)
? current
: filter,
];
})
);
this._filter = link.search;
this._filters = filters;
this._storageFilters = filters;
if (!area && !domain && !configEntry && !label) {
return;
}
this._writeFilters();
this._fromUrl = true;
this._filter = getHistoryState()?.filter || "";
this._filters = {
"ha-filter-states": {
value: [
...((this._filters["ha-filter-states"]?.value as string[]) || []),
"disabled",
],
items: undefined,
},
"ha-filter-floor-areas": {
value: area ? { areas: [area] } : undefined,
items: undefined,
},
"ha-filter-integrations": {
value: domain ? [domain] : [],
items: undefined,
},
config_entry: {
value: configEntry ? [configEntry] : [],
items: undefined,
},
sub_entry: {
value: subEntry ? [subEntry] : [],
items: undefined,
},
};
this._filterLabel();
}
private _writeFilters() {
this._filterParams.write(devicesFilterQueryParams(this._filters));
private _filterLabel() {
const label = this._searchParms.get("label");
if (!label) {
return;
}
this._filters = {
...this._filters,
"ha-filter-labels": {
value: [label],
items: undefined,
},
};
}
private _reloadConfigEntries() {
@@ -274,8 +318,9 @@ export class HaConfigDeviceDashboard extends LitElement {
private _clearFilter() {
this._filters = {};
this._storageFilters = {};
this._writeFilters();
if (!this._fromUrl) {
this._storageFilters = {};
}
}
private _devicesAndFilterDomains = memoizeOne(
@@ -354,29 +399,24 @@ export class HaConfigDeviceDashboard extends LitElement {
Array.isArray(filter.value) &&
filter.value.length
) {
const configEntryIds = this._filters.config_entry?.value;
const subEntryIds = filter.value;
if (
!Array.isArray(configEntryIds) ||
!configEntryIds.length ||
!Array.isArray(subEntryIds)
) {
if (!(
Array.isArray(this._filters.config_entry?.value) &&
this._filters.config_entry.value.length === 1
)) {
return;
}
outputDevices = outputDevices.filter((device) =>
configEntryIds.some((configEntryId) =>
device.config_entries_subentries[configEntryId]?.some(
(subEntryId) =>
subEntryId !== null && subEntryIds.includes(subEntryId)
const configEntryId = this._filters.config_entry.value[0];
outputDevices = outputDevices.filter(
(device) =>
device.config_entries_subentries[configEntryId] &&
(filter.value as string[]).some((subEntryId) =>
device.config_entries_subentries[configEntryId].includes(
subEntryId
)
)
)
);
// The titles shown in the filter chip need a single config entry
if (configEntryIds.length === 1 && !this._subEntries) {
this._loadSubEntries(configEntryIds[0]);
if (!this._subEntries) {
this._loadSubEntries(configEntryId);
}
} else if (
key === "ha-filter-integrations" &&
@@ -614,7 +654,6 @@ export class HaConfigDeviceDashboard extends LitElement {
sortable: true,
filterable: true,
groupable: true,
urlName: "integration",
minWidth: "120px",
template: (device) =>
!this.entries
@@ -630,7 +669,6 @@ export class HaConfigDeviceDashboard extends LitElement {
sortable: true,
filterable: true,
groupable: true,
urlName: "parent",
defaultHidden: true,
minWidth: "120px",
template: (device) => device.parent_device_name || "",
@@ -640,7 +678,6 @@ export class HaConfigDeviceDashboard extends LitElement {
sortable: true,
filterable: true,
groupable: true,
urlName: "manufacturer",
minWidth: "120px",
},
model: {
@@ -863,7 +900,6 @@ export class HaConfigDeviceDashboard extends LitElement {
@collapsed-changed=${this._handleCollapseChanged}
@row-click=${this._handleRowClicked}
clickable
sync-url
has-fab
class=${this.narrow ? "narrow" : ""}
>
@@ -1058,8 +1094,9 @@ export class HaConfigDeviceDashboard extends LitElement {
private _filterChanged(ev) {
const type = ev.target.localName;
this._filters = { ...this._filters, [type]: ev.detail };
this._storageFilters = this._filters;
this._writeFilters();
if (!this._fromUrl) {
this._storageFilters = this._filters;
}
}
private _batteryEntity(
@@ -1086,11 +1123,13 @@ export class HaConfigDeviceDashboard extends LitElement {
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const deviceId = ev.detail.id;
this._ignoreLocationChange = true;
navigate(`/config/devices/device/${deviceId}`);
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
updateHistoryState({ filter: this._filter });
}
private _addDevice() {
@@ -1118,13 +1157,8 @@ export class HaConfigDeviceDashboard extends LitElement {
});
return;
}
const integrations = this._filters["ha-filter-integrations"]?.value;
showAddIntegrationDialog(this, {
domain:
Array.isArray(integrations) && integrations.length === 1
? integrations[0]
: undefined,
domain: this._searchParms.get("domain") || undefined,
navigateToResult: true,
});
}
@@ -25,6 +25,7 @@ import "../../../helpers/forms/ha-input_datetime-form";
import "../../../helpers/forms/ha-input_number-form";
import "../../../helpers/forms/ha-input_select-form";
import "../../../helpers/forms/ha-input_text-form";
import "../../../helpers/forms/ha-schedule-form";
import "../../../helpers/forms/ha-timer-form";
import "../../entity-registry-settings-editor";
import type { EntityRegistrySettingsEditor } from "../../entity-registry-settings-editor";
@@ -151,14 +152,7 @@ export class EntitySettingsHelperTab extends LitElement {
}
private async _getItem() {
const [items] = await Promise.all([
HELPERS_CRUD[this.entry.platform].fetch(this.hass!),
// Only schedules need FullCalendar, which some older browsers cannot
// load. The form renders with the item, so it must be defined by then.
this.entry.platform === "schedule"
? import("../../../helpers/forms/ha-schedule-form")
: undefined,
]);
const items = await HELPERS_CRUD[this.entry.platform].fetch(this.hass!);
const item =
items.find((helper) => helper.id === this.entry.unique_id) || null;
this._item = item;
+2 -14
View File
@@ -286,8 +286,6 @@ class HaPanelConfig extends HassRouterPage {
private _cloudStatusRequestId = 0;
private _cloudStatusTimeout?: number;
public connectedCallback() {
super.connectedCallback();
this._listeners.push(
@@ -325,7 +323,6 @@ class HaPanelConfig extends HassRouterPage {
}
this._unsubCloudEvents?.then((unsub) => unsub()).catch(() => undefined);
this._unsubCloudEvents = undefined;
this._clearCloudStatusTimeout();
entityRegistryByEntityId.clear();
entityRegistryById.clear();
}
@@ -389,11 +386,10 @@ class HaPanelConfig extends HassRouterPage {
}
private async _updateCloudStatus() {
this._clearCloudStatusTimeout();
const requestId = ++this._cloudStatusRequestId;
const status = await fetchCloudStatus(this.hass);
if (requestId !== this._cloudStatusRequestId || !this.isConnected) {
if (requestId !== this._cloudStatusRequestId) {
return;
}
this._cloudStatus = status;
@@ -406,17 +402,9 @@ class HaPanelConfig extends HassRouterPage {
this._cloudStatus.prefs.remote_enabled &&
!this._cloudStatus.remote_connected)
) {
this._cloudStatusTimeout = window.setTimeout(
() => this._updateCloudStatus(),
5000
);
setTimeout(() => this._updateCloudStatus(), 5000);
}
}
private _clearCloudStatusTimeout() {
clearTimeout(this._cloudStatusTimeout);
this._cloudStatusTimeout = undefined;
}
}
declare global {
@@ -1,13 +1,8 @@
import type {
CalendarOptions,
DateSelectInfo,
EventClickInfo,
EventInput,
} from "fullcalendar";
import { Calendar } from "fullcalendar";
import allLocales from "fullcalendar/locales-all";
import interactionPlugin from "fullcalendar/interaction";
import timeGridPlugin from "fullcalendar/timegrid";
import type { CalendarOptions } from "@fullcalendar/core";
import { Calendar } from "@fullcalendar/core";
import allLocales from "@fullcalendar/core/locales-all";
import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import type { Day } from "date-fns";
import { addDays, isSameDay, isSameWeek, nextDay } from "date-fns";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -22,16 +17,12 @@ import "../../../../components/input/ha-input";
import type { Schedule, ScheduleDay } from "../../../../data/schedule";
import { weekdays } from "../../../../data/schedule";
import { TimeZone } from "../../../../data/translation";
import {
fullCalendarStyles,
fullCalendarTheme,
} from "../../../../resources/fullcalendar-theme";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showScheduleBlockInfoDialog } from "./show-dialog-schedule-block-info";
const defaultFullCalendarConfig: CalendarOptions = {
plugins: [fullCalendarTheme, timeGridPlugin, interactionPlugin],
plugins: [timeGridPlugin, interactionPlugin],
headerToolbar: false,
initialView: "timeGridWeek",
editable: true,
@@ -40,13 +31,10 @@ const defaultFullCalendarConfig: CalendarOptions = {
selectOverlap: false,
eventOverlap: false,
allDaySlot: false,
slotMinHeight: 24,
height: 450,
height: "parent",
locales: allLocales,
firstDay: 1,
dayHeaderFormat: { weekday: "short", month: undefined, day: undefined },
columnEventClass: "ha-fc-block",
columnEventTimeClass: ({ isShort }) => (isShort ? "" : "ha-fc-block-time"),
};
@customElement("ha-schedule-form")
@@ -77,6 +65,8 @@ class HaScheduleForm extends LitElement {
@state() private calendar?: Calendar;
@query("style[data-fullcalendar]") private _fullCalendarStyle?: HTMLElement;
@query("[dialogInitialFocus]") private _focusElement?: HTMLElement;
private _item?: Schedule;
@@ -110,6 +100,7 @@ class HaScheduleForm extends LitElement {
super.disconnectedCallback();
this.calendar?.destroy();
this.calendar = undefined;
this._fullCalendarStyle?.remove();
}
public connectedCallback(): void {
@@ -197,7 +188,7 @@ class HaScheduleForm extends LitElement {
...defaultFullCalendarConfig,
locale: this.hass.language,
firstDay: firstWeekdayIndex(this.hass.locale),
slotHeaderFormat: {
slotLabelFormat: {
hour: "numeric",
minute: undefined,
hour12: useAmPm(this.hass.locale),
@@ -225,7 +216,7 @@ class HaScheduleForm extends LitElement {
}
private get _events() {
const events: EventInput[] = [];
const events: any[] = [];
for (const [i, day] of weekdays.entries()) {
if (!this[`_${day}`].length) {
@@ -265,7 +256,7 @@ class HaScheduleForm extends LitElement {
return events;
}
private _handleSelect(info: DateSelectInfo) {
private _handleSelect(info: { start: Date; end: Date }) {
const { start, end } = info;
const day = weekdays[start.getDay()];
@@ -366,7 +357,7 @@ class HaScheduleForm extends LitElement {
}
}
private async _handleEventClick(info: EventClickInfo) {
private async _handleEventClick(info: any) {
const [day, index] = info.event.id.split("-");
const item = [...this[`_${day}`]][index];
showScheduleBlockInfoDialog(this, {
@@ -429,15 +420,7 @@ class HaScheduleForm extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyle,
fullCalendarStyles,
css`
:host {
--fc-classic-background: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
}
.form {
color: var(--primary-text-color);
}
@@ -449,16 +432,61 @@ class HaScheduleForm extends LitElement {
#calendar {
margin: var(--ha-space-2) 0;
height: 450px;
width: 100%;
user-select: none;
--fc-border-color: var(--divider-color);
--fc-event-border-color: var(--divider-color);
}
.ha-fc-block {
border-color: var(--divider-color);
.fc-v-event .fc-event-time {
white-space: inherit;
}
.fc-theme-standard .fc-scrollgrid {
border: 1px solid var(--divider-color);
border-radius: var(--mdc-shape-small, 4px);
}
.ha-fc-block-time {
white-space: normal;
.fc-scrollgrid-section-header td {
border: none;
}
:host([narrow]) .fc-scrollgrid-sync-table {
overflow: hidden;
}
table.fc-scrollgrid-sync-table
tbody
tr:first-child
.fc-daygrid-day-top {
padding-top: 0;
}
.fc-scroller::-webkit-scrollbar {
width: 0.4rem;
height: 0.4rem;
}
.fc-scroller::-webkit-scrollbar-thumb {
border-radius: var(--ha-border-radius-sm);
background: var(--scrollbar-thumb-color);
}
.fc-scroller {
overflow-y: auto;
scrollbar-color: var(--scrollbar-thumb-color) transparent;
scrollbar-width: thin;
}
.fc-timegrid-event-short .fc-event-time:after {
content: ""; /* prevent trailing dash in half hour events since we do not have event titles */
}
a {
color: inherit !important;
}
th.fc-col-header-cell.fc-day {
background-color: var(--table-header-background-color);
color: var(--primary-text-color);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-bold);
text-transform: uppercase;
}
`,
];
@@ -735,7 +735,6 @@ class AddIntegrationDialog extends LitElement {
}
if (
!this._integrationFilter &&
(PROTOCOL_INTEGRATIONS as readonly string[]).includes(
integration.domain
) &&
@@ -793,10 +792,7 @@ class AddIntegrationDialog extends LitElement {
}
private async _createFlow(domain: string) {
// A filtered dialog sets up a given port, not a discovered device
const flowsInProgress = this._integrationFilter
? []
: this._getFlowsInProgressForDomains([domain]);
const flowsInProgress = this._getFlowsInProgressForDomains([domain]);
if (flowsInProgress.length) {
this._pickedBrand = domain;
@@ -253,7 +253,7 @@ export class HaConfigEntryRow extends LitElement {
href=${
allDevices.length === 1
? `/config/devices/device/${allDevices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}&status=disabled`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`
}
>
<ha-dropdown-item value="devices">
@@ -278,7 +278,7 @@ export class HaConfigEntryRow extends LitElement {
href=${
allServices.length === 1
? `/config/devices/device/${allServices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}&status=disabled`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`
}
>
<ha-dropdown-item value="services">
@@ -617,7 +617,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
href=${
devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}&status=disabled`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}`
}
>
${this.hass.localize(
@@ -635,7 +635,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
href=${
services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}&status=disabled`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}`
}
>
${this.hass.localize(
@@ -1109,10 +1109,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
position: sticky;
top: 0;
z-index: 2;
background: var(
--ha-panel-toolbar-background,
var(--primary-background-color)
);
background-color: var(--primary-background-color);
padding: 0 16px;
gap: var(--ha-space-4);
box-sizing: border-box;
@@ -113,7 +113,7 @@ class HaConfigSubEntryRow extends LitElement {
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}&status=disabled`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`
}
>
<ha-dropdown-item value="devices">
@@ -138,7 +138,7 @@ class HaConfigSubEntryRow extends LitElement {
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}&status=disabled`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`
}
>
<ha-dropdown-item value="services">
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import {
domainToName,
@@ -25,10 +25,6 @@ export class HaIntegrationActionCard extends LitElement {
@property({ attribute: false }) public manifest?: IntegrationManifest;
protected render(): TemplateResult {
const integrationName =
this.localizedDomainName && this.localizedDomainName !== this.domain
? this.localizedDomainName
: domainToName(this.hass.localize, this.domain, this.manifest);
return html`
<ha-card outlined>
<div class="card-content">
@@ -48,11 +44,14 @@ export class HaIntegrationActionCard extends LitElement {
@load=${this._onImageLoad}
/>
<h2>${this.label}</h2>
${
integrationName !== this.label
? html`<h3>${integrationName}</h3>`
: nothing
}
<h3>
${
this.localizedDomainName &&
this.localizedDomainName !== this.domain
? this.localizedDomainName
: domainToName(this.hass.localize, this.domain, this.manifest)
}
</h3>
</div>
<div class="filler"></div>
<div class="card-actions"><slot></slot></div>
@@ -178,7 +178,7 @@ export class MatterConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
@@ -221,7 +221,7 @@ export class MQTTConfigPanel extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
@@ -237,7 +237,7 @@ class ZHAConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
@@ -16,7 +16,7 @@ export class DialogZWaveJSAddNode extends HTMLElement {
private async _openDialog() {
await navigate(
`/config/devices/dashboard?config_entry=${this.configEntryId}&status=disabled`,
`/config/devices/dashboard?config_entry=${this.configEntryId}`,
{
replace: true,
}
@@ -54,6 +54,7 @@ import {
import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "../../../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { brandsUrl } from "../../../../../util/brands-url";
@@ -62,7 +63,7 @@ import { showZWaveJSAddNodeDialog } from "./add-node/show-dialog-zwave_js-add-no
import { showZWaveJSRemoveNodeDialog } from "./show-dialog-zwave_js-remove-node";
@customElement("zwave_js-config-dashboard")
class ZWaveJSConfigDashboard extends LitElement {
class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@@ -95,31 +96,13 @@ class ZWaveJSConfigDashboard extends LitElement {
private _unsubscribeRestore?: UnsubscribeFunc;
@state() private _backupProgress?: number;
private _backupProgress?: number;
@state() private _restoreProgress?: number;
public connectedCallback() {
super.connectedCallback();
if (this.hass && this.hasUpdated && !this._dialogOpen) {
this._subscribeS2Inclusion();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeS2Inclusion();
}
private _restoreProgress?: number;
protected async firstUpdated() {
if (this.hass) {
if (this.isConnected) {
this._subscribeS2Inclusion();
}
await this._fetchData();
if (!this.isConnected) {
return;
}
if (this._status === "connected") {
const inclusion_state = this._network?.controller.inclusion_state;
// show dialog if inclusion/exclusion is already in progress
@@ -132,6 +115,10 @@ class ZWaveJSConfigDashboard extends LitElement {
}
}
public hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [this._subscribeS2Inclusion()];
}
protected render() {
if (!this._configEntry) {
return nothing;
@@ -326,7 +313,7 @@ class ZWaveJSConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}&status=disabled`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
@@ -798,7 +785,11 @@ class ZWaveJSConfigDashboard extends LitElement {
private _openInclusionDialog(dsk?: string, inclusionOngoing = false) {
if (!this._dialogOpen) {
this._unsubscribeS2Inclusion();
// Unsubscribe from S2 inclusion before opening dialog
if (this._s2InclusionUnsubscribe) {
this._s2InclusionUnsubscribe.then((unsubscribe) => unsubscribe());
this._s2InclusionUnsubscribe = undefined;
}
showZWaveJSAddNodeDialog(this, {
entry_id: this.configEntryId!,
@@ -815,13 +806,10 @@ class ZWaveJSConfigDashboard extends LitElement {
// refresh the data after the dialog is closed. add a small delay for the inclusion state to update
setTimeout(() => this._fetchData(), 100);
this._dialogOpen = false;
if (this.isConnected) {
this._subscribeS2Inclusion();
}
this._subscribeS2Inclusion();
};
private _subscribeS2Inclusion() {
this._unsubscribeS2Inclusion();
this._s2InclusionUnsubscribe = subscribeS2Inclusion(
this.hass,
this.configEntryId,
@@ -829,13 +817,7 @@ class ZWaveJSConfigDashboard extends LitElement {
this._openInclusionDialog(message.dsk);
}
);
}
private _unsubscribeS2Inclusion() {
this._s2InclusionUnsubscribe
?.then((unsubscribe) => unsubscribe())
.catch(() => undefined);
this._s2InclusionUnsubscribe = undefined;
return this._s2InclusionUnsubscribe;
}
private _handleBackupMessage = (message: any) => {
+3 -5
View File
@@ -478,7 +478,7 @@ export class HaConfigLabels extends LitElement {
this._navigateConfig("/config/entities");
break;
case "navigate-devices":
this._navigateConfig("/config/devices/dashboard", "&status=disabled");
this._navigateConfig("/config/devices/dashboard");
break;
case "navigate-automations":
this._navigateConfig("/config/automation/dashboard");
@@ -495,10 +495,8 @@ export class HaConfigLabels extends LitElement {
}
};
private _navigateConfig = (path: string, params = "") => {
navigate(
`${path}?historyBack=1&label=${this._overflowLabel.label_id}${params}`
);
private _navigateConfig = (path: string) => {
navigate(`${path}?historyBack=1&label=${this._overflowLabel.label_id}`);
};
private _handleSortingChanged(ev: CustomEvent) {
-74
View File
@@ -1,74 +0,0 @@
import type { ContextType } from "@lit/context";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consume } from "../../../common/decorators/consume";
import "../../../components/ha-dialog";
import "../../../components/input/ha-input-search";
import { internationalizationContext } from "../../../data/context";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { haStyleDialog } from "../../../resources/styles";
import "./error-log-card";
import type { LogsDialogParams } from "./show-dialog-logs";
@customElement("dialog-logs")
class LogsDialog extends DialogMixin<LogsDialogParams>(LitElement) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state() private _filter = "";
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
// The log card's strings live in the config fragment, which isn't loaded on dashboards.
this._i18n.loadFragmentTranslation("config");
}
protected render() {
if (!this.params) {
return nothing;
}
return html`
<ha-dialog open header-title=${this.params.title} width="large">
<ha-input-search
appearance="outlined"
.value=${this._filter}
.placeholder=${this._i18n.localize("ui.panel.config.logs.search")}
@input=${this._filterChanged}
></ha-input-search>
<error-log-card
.header=${this.params.title}
.provider=${this.params.provider}
.filter=${this._filter}
></error-log-card>
</ha-dialog>
`;
}
private _filterChanged(ev: InputEvent) {
this._filter = (ev.target as HTMLInputElement).value;
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-input-search {
display: block;
margin-bottom: var(--ha-space-2);
}
error-log-card {
--error-log-card-height: calc(100vh - 340px);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-logs": LogsDialog;
}
}
@@ -1,17 +0,0 @@
import { fireEvent } from "../../../common/dom/fire_event";
export interface LogsDialogParams {
provider: string;
title: string;
}
export const showLogsDialog = (
element: HTMLElement,
dialogParams: LogsDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-logs",
dialogImport: () => import("./dialog-logs"),
dialogParams,
});
};
+5 -9
View File
@@ -681,6 +681,7 @@ export class HaScriptTrace extends LitElement {
align-items: center;
justify-content: center;
height: var(--header-height);
background-color: var(--primary-background-color);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
box-sizing: border-box;
@@ -691,6 +692,7 @@ export class HaScriptTrace extends LitElement {
min-height: 0;
display: flex;
overflow: hidden;
background-color: var(--card-background-color);
}
:host([narrow]) .main {
@@ -714,10 +716,7 @@ export class HaScriptTrace extends LitElement {
}
.graph {
background: var(
--ha-panel-background,
var(--primary-background-color)
);
background-color: var(--primary-background-color);
box-sizing: border-box;
display: flex;
flex-direction: column;
@@ -744,13 +743,10 @@ export class HaScriptTrace extends LitElement {
min-width: 0;
min-height: 0;
overflow-y: auto;
background: var(--ha-card-background, var(--card-background-color));
background-color: var(--card-background-color);
}
ha-tab-group {
background: var(
--ha-panel-toolbar-background,
var(--ha-panel-background, var(--primary-background-color))
);
background-color: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
direction: var(--direction);
}
@@ -119,7 +119,6 @@ export class DialogDebugConsoleWarning extends DialogMixin<DebugConsoleWarningDi
</ha-alert>
<ha-checkbox
autofocus
variant="warning"
.checked=${this._understood}
.disabled=${this._accepting}
@change=${this._understoodChanged}
@@ -140,7 +139,6 @@ export class DialogDebugConsoleWarning extends DialogMixin<DebugConsoleWarningDi
</ha-button>
<ha-button
slot="primaryAction"
variant="warning"
.disabled=${!this._understood || this._secondsLeft > 0}
.loading=${this._accepting}
@click=${this._accept}
@@ -912,13 +912,10 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
);
gap: var(--ha-space-4);
box-sizing: border-box;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
overflow-x: auto;
scrollbar-width: none;
background: var(
--ha-panel-toolbar-background,
var(--ha-panel-background, var(--primary-background-color))
);
}
ha-input-search {
flex: 1;
@@ -266,6 +266,7 @@ export class AssistPipelineDebug extends LitElement {
align-items: center;
justify-content: center;
height: var(--header-height);
background-color: var(--primary-background-color);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
box-sizing: border-box;
+6 -67
View File
@@ -13,14 +13,10 @@ import type {
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import type {
HASSDomEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
@@ -49,8 +45,6 @@ import "../../components/ha-dropdown-item";
import "../../components/ha-empty-state";
import "../../components/ha-filter-pane";
import type { HaFilterPane } from "../../components/ha-filter-pane";
import "../../components/ha-filter-entity-types";
import "../../components/ha-filter-integrations";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-icon-button";
import type { SourceFilters } from "../../components/ha-sources-picker";
@@ -58,8 +52,6 @@ import {
applySourceFilters,
countSourceFilters,
countTargets,
setSourceFilter,
sourceFiltersStyles,
} from "../../components/ha-sources-picker";
import "../../components/ha-spinner";
import "../../components/ha-top-app-bar-fixed";
@@ -113,10 +105,6 @@ class HaPanelHistory extends LitElement {
@state() private _filters: SourceFilters = {};
// Only one filter is expanded at a time, so that it can use the height
// that is left in the pane.
@state() private _expandedFilter?: string;
@storage({
key: "historySourceFilters",
state: false,
@@ -247,30 +235,8 @@ class HaPanelHistory extends LitElement {
"ui.panel.history.no_targets"
)}
@value-changed=${this._targetsChanged}
@source-filters-changed=${this._filtersChanged}
></ha-sources-picker>
<div
class=${classMap({
filters: true,
expanded: !!this._expandedFilter,
})}
>
<ha-filter-entity-types
.value=${this._filters.types}
.expanded=${
this._expandedFilter === "ha-filter-entity-types"
}
@data-table-filter-changed=${this._typesChanged}
@expanded-changed=${this._filterExpanded}
></ha-filter-entity-types>
<ha-filter-integrations
.value=${this._filters.integrations}
.expanded=${
this._expandedFilter === "ha-filter-integrations"
}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._filterExpanded}
></ha-filter-integrations>
</div>
</ha-filter-pane>`
: nothing
}
@@ -402,8 +368,6 @@ class HaPanelHistory extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
// The filters label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
@@ -452,37 +416,13 @@ class HaPanelHistory extends LitElement {
this._showSources = false;
}
private _typesChanged(
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
private _filtersChanged(
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
) {
this._setFilter("types", ev);
}
private _integrationsChanged(
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
) {
this._setFilter("integrations", ev);
}
private _setFilter(
key: keyof SourceFilters,
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
) {
this._filters = setSourceFilter(this._filters, key, ev.detail.value);
this._filters = ev.detail.value;
this._storedFilters = this._filters;
}
private _filterExpanded(
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]> &
HASSDomTargetEvent<HTMLElement>
) {
if (ev.detail.expanded) {
this._expandedFilter = ev.target.localName;
} else if (this._expandedFilter === ev.target.localName) {
this._expandedFilter = undefined;
}
}
private _clearSources() {
this._filters = {};
this._storedFilters = this._filters;
@@ -857,14 +797,12 @@ class HaPanelHistory extends LitElement {
static get styles() {
return [
haStyle,
sourceFiltersStyles,
haStyleScrollbar,
css`
:host {
/* The target picker chips need more room than a plain filter list. */
--ha-filter-pane-width: 340px;
}
ha-top-app-bar-fixed {
height: 100vh;
overflow-x: hidden;
@@ -916,6 +854,7 @@ class HaPanelHistory extends LitElement {
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
direction: var(--direction);
overflow-x: auto;
+7 -61
View File
@@ -2,7 +2,6 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { throttle } from "../../common/util/throttle";
@@ -15,7 +14,6 @@ import { fetchUsers } from "../../data/user";
import type { HomeAssistant } from "../../types";
import "./ha-logbook-renderer";
import type { LogbookNameDetail } from "./logbook-entry-model";
import { computeLogbookCauseKey, computeRowCause } from "./logbook-entry-model";
interface LogbookTimePeriod {
now: Date;
@@ -59,9 +57,6 @@ export class HaLogbook extends LitElement {
@property({ attribute: false }) public stateFilter?: string[];
/** Keys from `computeLogbookCauseKey` to keep. */
@property({ attribute: false }) public causeFilter?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public virtualize = false;
@@ -86,12 +81,10 @@ export class HaLogbook extends LitElement {
@state() private _traceContexts: TraceContexts = {};
@state() private _userIdToName: Record<string, string> = {};
@state() private _userIdToName = {};
@state() private _systemUserIds = new Set<string>();
@state() private _personUserIds = new Set<string>();
@state() private _error?: string;
private _unsubLogbook?: Promise<UnsubscribeFunc>;
@@ -110,7 +103,7 @@ export class HaLogbook extends LitElement {
private _readyListenerAttached = false;
public getEntries(): LogbookEntry[] {
return this._shownEntries() ?? [];
return this._logbookEntries || [];
}
protected render() {
@@ -126,9 +119,7 @@ export class HaLogbook extends LitElement {
</div>`;
}
const entries = this._shownEntries();
if (entries === undefined) {
if (this._logbookEntries === undefined) {
return html`
<div class="progress-wrapper">
<ha-spinner></ha-spinner>
@@ -136,7 +127,7 @@ export class HaLogbook extends LitElement {
`;
}
if (entries.length === 0) {
if (this._logbookEntries.length === 0) {
return html`<slot name="empty">
<div class="no-entries">
${this.hass.localize("ui.components.logbook.entries_not_found")}
@@ -153,7 +144,7 @@ export class HaLogbook extends LitElement {
.graphColor=${this.graphColor}
.showCause=${this.showCause}
.nameDetail=${this.nameDetail}
.entries=${entries}
.entries=${this._logbookEntries}
.traceContexts=${this._traceContexts}
.userIdToName=${this._userIdToName}
.systemUserIds=${this._systemUserIds}
@@ -162,40 +153,6 @@ export class HaLogbook extends LitElement {
`;
}
private _shownEntries(): LogbookEntry[] | undefined {
return this._filterEntriesByCause(
this._logbookEntries,
this.causeFilter,
this._userIdToName,
this._systemUserIds,
this._personUserIds
);
}
private _filterEntriesByCause = memoizeOne(
(
entries: LogbookEntry[] | undefined,
causeFilter: string[] | undefined,
userIdToName: Record<string, string>,
systemUserIds: Set<string>,
personUserIds: Set<string>
): LogbookEntry[] | undefined => {
if (!entries || !causeFilter?.length) {
return entries;
}
const causes = new Set(causeFilter);
return entries.filter((entry) =>
causes.has(
computeLogbookCauseKey(
entry,
computeRowCause(this.hass, entry, userIdToName, systemUserIds),
personUserIds
)
)
);
}
);
public async refresh(force = false) {
if (!force && (this._unsubLogbook || this._logbookEntries === undefined)) {
return;
@@ -247,26 +204,18 @@ export class HaLogbook extends LitElement {
if (changed) {
this.refresh(true);
} else if (changedProps.has("causeFilter")) {
// A filter without matches removes the renderer, which is what resumes
// the live updates paused while scrolled down.
this._setLiveUpdates(true);
}
}
private _handleLogbookLive(ev: CustomEvent) {
this._setLiveUpdates(ev.detail.enable);
}
private _setLiveUpdates(enable: boolean) {
if (enable && !this._liveUpdatesEnabled) {
if (ev.detail.enable && !this._liveUpdatesEnabled) {
// Process everything we queued up while we were scrolled down
this._pendingStreamMessages.forEach((msg) =>
this._processStreamMessage(msg)
);
this._pendingStreamMessages = [];
}
this._liveUpdatesEnabled = enable;
this._liveUpdatesEnabled = ev.detail.enable;
}
private get _filterAlwaysEmptyResults(): boolean {
@@ -522,7 +471,6 @@ export class HaLogbook extends LitElement {
private _updateUsers = throttle(async () => {
const userIdToName = {};
const systemUserIds = new Set<string>();
const personUserIds = new Set<string>();
// Start loading users
const userProm = this.hass.user?.is_admin && fetchUsers(this.hass);
@@ -535,7 +483,6 @@ export class HaLogbook extends LitElement {
) {
userIdToName[entity.attributes.user_id] =
this.hass.formatEntityName(entity);
personUserIds.add(entity.attributes.user_id);
}
}
@@ -554,7 +501,6 @@ export class HaLogbook extends LitElement {
this._userIdToName = userIdToName;
this._systemUserIds = systemUserIds;
this._personUserIds = personUserIds;
}, 60000);
static get styles() {
+10 -94
View File
@@ -9,15 +9,11 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fromUnixTime } from "date-fns";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import type {
HASSDomEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
@@ -38,9 +34,6 @@ import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-empty-state";
import "../../components/ha-filter-entity-types";
import "../../components/ha-filter-integrations";
import "../../components/ha-filter-logbook-causes";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-filter-pane";
import type { HaFilterPane } from "../../components/ha-filter-pane";
@@ -48,8 +41,6 @@ import "../../components/ha-icon-button";
import {
applySourceFilters,
countSourceFilters,
setSourceFilter,
sourceFiltersStyles,
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
@@ -68,11 +59,6 @@ import { csvDownload, csvSafeString } from "../../util/csv";
const EMPTY_STATES: HomeAssistant["states"] = {};
interface LogbookFilters extends SourceFilters {
/** Keys from `computeLogbookCauseKey`. */
causes?: string[];
}
interface LogbookState {
time: { range: [Date, Date] };
targetPickerValue: HassServiceTarget;
@@ -91,11 +77,7 @@ export class HaPanelLogbook extends LitElement {
@state()
private _showBack?: boolean;
@state() private _filters: LogbookFilters = {};
// Only one filter is expanded at a time, so that it can use the height
// that is left in the pane.
@state() private _expandedFilter?: string;
@state() private _filters: SourceFilters = {};
@state() private _showSources?: boolean;
@@ -120,7 +102,7 @@ export class HaPanelLogbook extends LitElement {
state: false,
subscribe: false,
})
private _storedFilters?: LogbookFilters;
private _storedFilters?: SourceFilters;
public constructor() {
super();
@@ -131,7 +113,7 @@ export class HaPanelLogbook extends LitElement {
const entityIds = this._getEntityIds();
const filterCount = countSourceFilters(this._filters);
const sourceCount = countTargets(this._targetPickerValue) + filterCount;
const sourcesLabel = entityIds
const sourcesLabel = sourceCount
? this.hass.localize("ui.panel.logbook.sources_count", {
count: entityIds?.length ?? 0,
})
@@ -185,38 +167,8 @@ export class HaPanelLogbook extends LitElement {
"ui.panel.logbook.no_targets"
)}
@value-changed=${this._targetsChanged}
@source-filters-changed=${this._filtersChanged}
></ha-sources-picker>
<div
class=${classMap({
filters: true,
expanded: !!this._expandedFilter,
})}
>
<ha-filter-entity-types
.value=${this._filters.types}
.expanded=${
this._expandedFilter === "ha-filter-entity-types"
}
@data-table-filter-changed=${this._typesChanged}
@expanded-changed=${this._filterExpanded}
></ha-filter-entity-types>
<ha-filter-integrations
.value=${this._filters.integrations}
.expanded=${
this._expandedFilter === "ha-filter-integrations"
}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._filterExpanded}
></ha-filter-integrations>
<ha-filter-logbook-causes
.value=${this._filters.causes}
.expanded=${
this._expandedFilter === "ha-filter-logbook-causes"
}
@data-table-filter-changed=${this._causesChanged}
@expanded-changed=${this._filterExpanded}
></ha-filter-logbook-causes>
</div>
</ha-filter-pane>`
: nothing
}
@@ -246,7 +198,6 @@ export class HaPanelLogbook extends LitElement {
.hass=${this.hass}
.time=${this._time}
.entityIds=${this._shownEntityIds}
.causeFilter=${this._shownCauses}
.narrow=${this.narrow}
show-cause
virtualize
@@ -302,43 +253,13 @@ export class HaPanelLogbook extends LitElement {
this._showSources = false;
}
private _typesChanged(
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
private _filtersChanged(
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
) {
this._setFilter("types", ev);
}
private _integrationsChanged(
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
) {
this._setFilter("integrations", ev);
}
private _causesChanged(
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
) {
this._setFilter("causes", ev);
}
private _setFilter(
key: keyof LogbookFilters,
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
) {
this._filters = setSourceFilter(this._filters, key, ev.detail.value);
this._filters = ev.detail.value;
this._storedFilters = this._filters;
}
private _filterExpanded(
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]> &
HASSDomTargetEvent<HTMLElement>
) {
if (ev.detail.expanded) {
this._expandedFilter = ev.target.localName;
} else if (this._expandedFilter === ev.target.localName) {
this._expandedFilter = undefined;
}
}
private _clearSources() {
this._filters = {};
this._storedFilters = this._filters;
@@ -361,15 +282,12 @@ export class HaPanelLogbook extends LitElement {
// the sources once it is closed instead of on every source change.
if (!this.narrow || !this._sourcesShown()) {
this._shownEntityIds = this._getEntityIds();
this._shownCauses = this._filters.causes;
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("title");
// The filters label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
@@ -414,7 +332,7 @@ export class HaPanelLogbook extends LitElement {
)
: undefined;
if (!this._filters.types?.length && !this._filters.integrations?.length) {
if (!countSourceFilters(this._filters)) {
return targetEntities;
}
@@ -480,8 +398,6 @@ export class HaPanelLogbook extends LitElement {
private _shownEntityIds?: string[];
private _shownCauses?: string[];
// A list keyed on the states must keep its identity or ha-logbook resubscribes.
private _stableEntityIds(entityIds: string[]): string[] {
if (this._lastEntityIds && shallowEqual(this._lastEntityIds, entityIds)) {
@@ -664,7 +580,6 @@ export class HaPanelLogbook extends LitElement {
static get styles() {
return [
haStyle,
sourceFiltersStyles,
css`
:host {
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
@@ -707,6 +622,7 @@ export class HaPanelLogbook extends LitElement {
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
direction: var(--direction);
overflow-x: auto;
+11 -47
View File
@@ -9,7 +9,6 @@ import { domainToName } from "../../data/integration";
import type { LogbookEntry } from "../../data/logbook";
import {
createHistoricState,
logbookUserCauseKey,
localizeStateMessage,
parseTriggerSource,
} from "../../data/logbook";
@@ -306,46 +305,6 @@ export const computeLogbookCause = (
export const isRunCause = (cause?: LogbookCause): boolean =>
cause?.type === "automation" || cause?.type === "script";
// A row shows the run over the user who started it; the dialog shows both.
export const computeRowCause = (
hass: HomeAssistant,
item: LogbookEntry,
userIdToName: Record<string, string>,
systemUserIds?: Set<string>
): LogbookCause | undefined => {
const contextCause = computeContextCause(hass, item);
if (isRunCause(contextCause)) {
return contextCause;
}
return computeUserCause(item, userIdToName, systemUserIds) ?? contextCause;
};
// Only the users linked to a person can be picked. The other users are
// accounts for tools like Node-RED, so they count as integrations. A service
// call without a user carries the domain of the service, not the integration
// that acted, so it is left in other.
export const computeLogbookCauseKey = (
item: LogbookEntry,
cause: LogbookCause | undefined,
personUserIds: Set<string>
): string => {
if (cause?.type === "user") {
return personUserIds.has(cause.userId!)
? logbookUserCauseKey(cause.userId!)
: "integration";
}
if (cause?.type === "automation" || cause?.type === "script") {
return cause.type;
}
if (
cause?.type === "integration" &&
item.context_event_type !== "call_service"
) {
return "integration";
}
return "other";
};
export type LogbookGlyph =
| { type: "state"; stateObj: HassEntity; icon?: string }
| { type: "automation"; script: boolean }
@@ -453,6 +412,13 @@ export const computeLogbookItem = (
? entityDisplay(hass, entry.entity_id, opts.nameDetail)
: undefined;
const userCause = computeUserCause(
entry,
opts.userIdToName ?? {},
opts.systemUserIds
);
const contextCause = computeContextCause(hass, entry);
return {
category,
glyph: computeLogbookGlyph(entry, category, historicStateObj, domain),
@@ -460,12 +426,10 @@ export const computeLogbookItem = (
name: display?.primary ?? entry.name,
context: display?.secondary,
value: computeLogbookValue(hass, entry, domain, historicStateObj),
cause: computeRowCause(
hass,
entry,
opts.userIdToName ?? {},
opts.systemUserIds
),
// A row shows the run over the user who started it; the dialog shows both.
cause: isRunCause(contextCause)
? contextCause
: (userCause ?? contextCause),
when: entry.when * 1000,
};
};
@@ -1,6 +1,6 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -55,8 +55,6 @@ export class HuiClockCardAnalog extends LitElement {
@state() private _date?: string;
@queryAll(".hand") private _hands!: NodeListOf<HTMLElement>;
private _dateInterval?: number;
private _timeZone?: string;
@@ -89,27 +87,6 @@ export class HuiClockCardAnalog extends LitElement {
this._initDate();
}
}
if (
changedProps.has("_hourOffsetSec") ||
changedProps.has("_minuteOffsetSec") ||
changedProps.has("_secondOffsetSec")
) {
this._restartHandAnimations();
}
}
// The hands are positioned by a negative animation-delay computed from the
// current time. Changing the delay of a running animation keeps its original
// start time, so the time elapsed since then would be counted twice and the
// clock would run ahead. Restart the animations so the new delay applies
// from now.
private _restartHandAnimations() {
this._hands.forEach((hand) => {
hand.getAnimations().forEach((animation) => {
animation.currentTime = 0;
});
});
}
private _handleVisibilityChange = () => {
@@ -974,7 +974,7 @@ export class HuiEnergySourcesTableCard
text-transform: inherit;
}
.mdc-data-table {
background: transparent;
background-color: var(--card-background-color);
border-radius: var(--ha-border-radius-sm);
border: 0;
box-sizing: border-box;
@@ -1022,7 +1022,7 @@ export class HuiEnergySourcesTableCard
white-space: nowrap;
}
.mdc-data-table__header-cell {
background: transparent;
background-color: var(--card-background-color);
font-family: var(--ha-font-family-body);
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
@@ -375,7 +375,6 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
up-to-now
.hoursToShow=${this._hoursToShow}
.showNames=${showNames}
?inside-labels=${this._config.label_position === "below"}
.logarithmicScale=${this._config.logarithmic_scale || false}
.minYAxis=${this._config.min_y_axis}
.maxYAxis=${this._config.max_y_axis}
-1
View File
@@ -507,7 +507,6 @@ export interface HistoryGraphCardConfig extends LovelaceCardConfig {
hours_to_show?: number;
title?: string;
show_names?: boolean;
label_position?: "side" | "below";
logarithmic_scale?: boolean;
min_y_axis?: number;
max_y_axis?: number;
@@ -1,24 +1,6 @@
import { downSampleLineData } from "../../../../components/chart/down-sample";
import type { EntityHistoryState } from "../../../../data/history";
export interface GraphSize {
width: number;
height: number;
}
export const scaleCoordinates = (
coordinates: number[][],
from: GraphSize,
to: GraphSize
): number[][] => {
if (from.width === to.width && from.height === to.height) {
return coordinates;
}
const scaleX = to.width / from.width;
const scaleY = to.height / from.height;
return coordinates.map(([x, y]) => [x * scaleX, y * scaleY]);
};
const calcPoints = (
history: [number, number][],
width: number,
@@ -5,8 +5,6 @@ import { consume } from "../../../common/decorators/consume";
import { parseAnimationDuration } from "../../../common/util/parse-animation-duration";
import { reducedMotionContext } from "../../../data/context";
import { strokeWidth } from "../../../data/graph";
import type { GraphSize } from "../common/graph/coordinates";
import { scaleCoordinates } from "../common/graph/coordinates";
import { getPath } from "../common/graph/get-path";
export interface HuiGraphGradient {
@@ -39,13 +37,9 @@ export class HuiGraphBase extends LitElement {
private _animationFrame?: number;
// The size the current coordinates were computed for. The viewBox keeps it
// until new coordinates arrive, so a resize during the animation scales the
// graph instead of drawing it in a viewBox of another size.
private _size?: GraphSize;
protected render(): TemplateResult {
const { width, height } = this._size ?? this._measureSize();
const width = this.clientWidth || 500;
const height = this.clientHeight || width / 5;
const yAxisOrigin = this.yAxisOrigin ?? height;
const path =
(this._displayCoordinates && getPath(this._displayCoordinates)) ??
@@ -141,25 +135,9 @@ export class HuiGraphBase extends LitElement {
this._cancelAnimation();
}
private _measureSize(): GraphSize {
const width = this.clientWidth || 500;
return { width, height: this.clientHeight || width / 5 };
}
private _setCoordinates(coordinates: number[][]) {
this._cancelAnimation();
const previousSize = this._size;
this._size = this._measureSize();
if (this._displayCoordinates && previousSize) {
this._displayCoordinates = scaleCoordinates(
this._displayCoordinates,
previousSize,
this._size
);
}
const displayCoordinates = this._displayCoordinates;
if (!displayCoordinates || coordinates.length < 2) {
@@ -8,7 +8,6 @@ import {
assert,
assign,
boolean,
enums,
number,
object,
optional,
@@ -24,7 +23,6 @@ import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import { isTimelineEntity } from "../../../../data/history";
import type { HomeAssistant } from "../../../../types";
import { DEFAULT_HOURS_TO_SHOW } from "../../cards/hui-history-graph-card";
import type {
@@ -48,7 +46,6 @@ const cardConfigStruct = assign(
hours_to_show: optional(number()),
refresh_interval: optional(number()), // deprecated
show_names: optional(boolean()),
label_position: optional(enums(["side", "below"])),
logarithmic_scale: optional(boolean()),
expand_legend: optional(boolean()),
min_y_axis: optional(number()),
@@ -57,6 +54,65 @@ const cardConfigStruct = assign(
})
);
const SCHEMA = [
{ name: "title", selector: { text: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 0, step: "any", mode: "box" } },
},
{
name: "show_names",
default: true,
required: false,
selector: { boolean: {} },
},
{
name: "logarithmic_scale",
required: false,
selector: { boolean: {} },
},
{
name: "expand_legend",
required: false,
selector: { boolean: {} },
},
],
},
{
name: "",
type: "grid",
schema: [
{
name: "min_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
{
name: "max_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
],
},
{
name: "fit_y_data",
required: false,
visible: {
condition: "or",
conditions: [
{ field: "min_y_axis", operator: "exists" },
{ field: "max_y_axis", operator: "exists" },
],
},
selector: { boolean: {} },
},
] as const satisfies readonly HaFormSchema[];
@customElement("hui-history-graph-card-editor")
export class HuiHistoryGraphCardEditor
extends LitElement
@@ -68,90 +124,6 @@ export class HuiHistoryGraphCardEditor
@state() private _subElementEditorConfig?: SubElementEditorConfig;
private _schema = memoizeOne(
(localize: LocalizeFunc, hasTimeline: boolean) =>
[
{ name: "title", selector: { text: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 0, step: "any", mode: "box" } },
},
{
name: "show_names",
default: true,
required: false,
selector: { boolean: {} },
},
{
name: "logarithmic_scale",
required: false,
selector: { boolean: {} },
},
{
name: "expand_legend",
required: false,
selector: { boolean: {} },
},
],
},
...(hasTimeline
? ([
{
name: "label_position",
required: false,
visible: [
{ field: "show_names", operator: "not_eq", value: false },
],
selector: {
select: {
mode: "dropdown",
options: ["side", "below"].map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.history-graph.label_position_options.${value}`
),
})),
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
{
name: "",
type: "grid",
schema: [
{
name: "min_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
{
name: "max_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
],
},
{
name: "fit_y_data",
required: false,
visible: {
condition: "or",
conditions: [
{ field: "min_y_axis", operator: "exists" },
{ field: "max_y_axis", operator: "exists" },
],
},
selector: { boolean: {} },
},
] as const satisfies readonly HaFormSchema[]
);
public setConfig(config: HistoryGraphCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
@@ -207,19 +179,12 @@ export class HuiHistoryGraphCardEditor
const configEntities = this._config.entities
? (processEditorEntities(this._config.entities) as GraphEntityConfig[])
: [];
const hasTimeline = configEntities.some((entityConf) =>
isTimelineEntity(
computeDomain(entityConf.entity),
this.hass!.states[entityConf.entity]
)
);
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${this._schema(this.hass.localize, hasTimeline)}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
></ha-form>
<hui-entity-editor
@@ -313,21 +278,9 @@ export class HuiHistoryGraphCardEditor
) as HistoryGraphCardConfig;
}
private _computeHelperCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
schema.name === "label_position"
? this.hass!.localize(
"ui.panel.lovelace.editor.card.history-graph.label_position_helper"
)
: undefined;
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
switch (schema.name) {
case "show_names":
case "label_position":
case "logarithmic_scale":
case "expand_legend":
case "min_y_axis":
+2
View File
@@ -1206,6 +1206,8 @@ class HUIRoot extends LitElement {
if (viewIndex !== this._curView) {
const path = this.config.views[viewIndex].path || viewIndex;
this._navigateToView(path);
} else if (!this._editMode) {
scrollTo({ behavior: "smooth", top: 0 });
}
}
@@ -22,21 +22,6 @@ class HuiCastRow extends LitElement implements LovelaceRow {
@state() private _noHTTPS = false;
private _removeCastListeners?: (() => void)[];
public connectedCallback() {
super.connectedCallback();
if (this._castManager) {
this._attachCastListeners(this._castManager);
this.requestUpdate();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._detachCastListeners();
}
public setConfig(config: CastConfig): void {
this._config = {
icon: "mdi:television",
@@ -102,9 +87,12 @@ class HuiCastRow extends LitElement implements LovelaceRow {
getCastManager(this.hass.auth).then(
(mgr) => {
this._castManager = mgr;
if (this.isConnected) {
this._attachCastListeners(mgr);
}
mgr.addEventListener("connection-changed", () => {
this.requestUpdate();
});
mgr.addEventListener("state-changed", () => {
this.requestUpdate();
});
},
() => {
this._castManager = null;
@@ -124,22 +112,6 @@ class HuiCastRow extends LitElement implements LovelaceRow {
}
}
private _attachCastListeners(mgr: CastManager) {
this._detachCastListeners();
const update = () => {
this.requestUpdate();
};
this._removeCastListeners = [
mgr.addEventListener("connection-changed", update),
mgr.addEventListener("state-changed", update),
];
}
private _detachCastListeners() {
this._removeCastListeners?.forEach((remove) => remove());
this._removeCastListeners = undefined;
}
private async _sendLovelace() {
await ensureConnectedCastSession(this._castManager!, this.hass.auth);
castSendShowLovelaceView(
@@ -85,9 +85,10 @@ export class HUIViewBackground extends LitElement {
private _applyTheme() {
const computedStyles = getComputedStyle(this);
const themeBackground =
computedStyles.getPropertyValue("--lovelace-background") ||
computedStyles.getPropertyValue("--ha-panel-background");
const themeBackground = computedStyles.getPropertyValue(
"--lovelace-background"
);
const fixedBackground = this._isFixedBackground(
this.background || themeBackground
);
@@ -200,10 +201,7 @@ export class HUIViewBackground extends LitElement {
width: 100%;
background: var(
--view-background,
var(
--lovelace-background,
var(--ha-panel-background, var(--primary-background-color))
)
var(--lovelace-background, var(--primary-background-color))
);
opacity: var(--view-background-opacity);
}
@@ -116,7 +116,6 @@ export class HaMarketplaceWarning extends LitElement {
<div class="box agree">
<ha-checkbox
autofocus
variant="warning"
.checked=${this._understood}
.disabled=${this._accepting}
@change=${this._understoodChanged}
@@ -134,7 +133,6 @@ export class HaMarketplaceWarning extends LitElement {
</ha-button>
<ha-button
slot="primaryAction"
variant="warning"
.disabled=${!this._understood}
.loading=${this._accepting}
@click=${this._accept}
@@ -19,7 +19,6 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../common/datetime/relative_time";
import { consume } from "../../../common/decorators/consume";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { fireEvent } from "../../../common/dom/fire_event";
import { formatNumber } from "../../../common/number/format_number";
@@ -40,10 +39,9 @@ import {
apiContext,
configContext,
internationalizationContext,
statesContext,
uiContext,
} from "../../../data/context";
import type { UpdateEntity } from "../../../data/update";
import { latestVersionIsSkipped } from "../../../data/update";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
@@ -149,9 +147,8 @@ export class HaMarketplaceRepositoryDashboard extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consumeEntityState({ entityIdPath: ["_repository", "update_entity_id"] })
private _updateEntity?: UpdateEntity;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state() private _repository?: RepositoryInfo;
@@ -604,11 +601,7 @@ export class HaMarketplaceRepositoryDashboard extends LitElement {
}
// Installing and updating are the main actions, a reinstall stays in the menu
const updateSkipped =
this._updateEntity !== undefined &&
latestVersionIsSkipped(this._updateEntity);
if (repository.pending_upgrade && !updateSkipped) {
if (repository.pending_upgrade) {
return {
kind: "update",
icon: mdiArrowUpBoldCircleOutline,
@@ -637,43 +630,12 @@ export class HaMarketplaceRepositoryDashboard extends LitElement {
detail: localize("ui.panel.marketplace.repository.next_step.restart", {
name,
}),
action: html`<ha-button
variant="warning"
appearance="filled"
@click=${this._restart}
>
<ha-svg-icon slot="start" .path=${mdiRestart}></ha-svg-icon>
action: html`<ha-button appearance="filled" @click=${this._restart}>
${localize("ui.panel.marketplace.repository.next_step.restart_button")}
</ha-button>`,
};
}
// A skipped update can still be installed or unskipped from its entity
if (repository.pending_upgrade) {
return {
kind: "installed",
icon: mdiCheckCircle,
title: localize(
"ui.panel.marketplace.repository_status.update_skipped"
),
detail: localize("ui.panel.marketplace.repository.version_skipped", {
installed: repository.installed_version,
version: repository.available_version,
}),
action: html`<ha-button
variant="neutral"
appearance="outlined"
@click=${this._update}
>
<ha-svg-icon
slot="start"
.path=${mdiArrowUpBoldCircleOutline}
></ha-svg-icon>
${localize("ui.common.update")}
</ha-button>`,
};
}
return {
kind: "installed",
icon: mdiCheckCircle,
@@ -959,10 +921,10 @@ type: module</pre>`,
// Updates are installed from their update entity everywhere else too, the
// install dialog is for when there is none
private _update() {
if (this._updateEntity) {
fireEvent(this, "hass-more-info", {
entityId: this._updateEntity.entity_id,
});
const entityId = this._repository!.update_entity_id;
if (entityId && this._states[entityId]) {
fireEvent(this, "hass-more-info", { entityId });
return;
}
-16
View File
@@ -31,21 +31,6 @@ const marketplaceTableParams = {
direction: "string?",
} satisfies Redirect["params"];
// The devices table reads these from the link
const devicesTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
group: "string?",
status: "string?",
floor: "string?",
area: "string?",
domain: "string?",
label: "string?",
config_entry: "string?",
sub_entry: "string?",
} satisfies Redirect["params"];
export const getMyRedirects = (): Redirects => ({
application_credentials: {
redirect: "/config/application_credentials",
@@ -235,7 +220,6 @@ export const getMyRedirects = (): Redirects => ({
},
devices: {
redirect: "/config/devices/dashboard",
params: devicesTableParams,
},
entities: {
redirect: "/config/entities",
-94
View File
@@ -1,94 +0,0 @@
import type { PluginInput } from "fullcalendar";
// @ts-ignore
import skeletonStyles from "fullcalendar/skeleton.css";
import classicThemePlugin from "fullcalendar/themes/classic";
// @ts-ignore
import classicThemeStyles from "fullcalendar/themes/classic/theme.css";
import { css, unsafeCSS } from "lit";
// The classic theme plus the class hooks that fullCalendarStyles styles
export const fullCalendarTheme: PluginInput = {
name: "theme-home-assistant",
deps: [classicThemePlugin],
optionDefaults: {
viewClass: "ha-fc-view",
tableHeaderClass: "ha-fc-table-header",
dayHeaderInnerClass: ({ inPopover }) =>
inPopover ? "" : "ha-fc-day-header-inner",
},
};
export const fullCalendarStyles = [
unsafeCSS(skeletonStyles),
unsafeCSS(classicThemeStyles),
css`
/* FullCalendar declares these on :root, which a shadow root never
matches. Its popover and drag mirror sit outside the calendar element,
so they go on the host. */
:host {
--fc-sticky-header-footer-z: 3;
--fc-popover-z: 4;
--fc-classic-primary: var(--primary-color);
--fc-classic-primary-foreground: var(--text-primary-color);
--fc-classic-event: #3788d8;
--fc-classic-event-contrast: #fff;
--fc-classic-background-event: var(--primary-color);
--fc-classic-background-event-opacity: 15%;
--fc-classic-background-event-foreground-opacity: 50%;
--fc-classic-highlight: rgba(188, 232, 241, 0.3);
--fc-classic-today: rgba(255, 220, 40, 0.15);
--fc-classic-now: var(--error-color);
--fc-classic-small-dot-width: 8px;
--fc-classic-large-dot-width: 10px;
--fc-classic-background: var(
--ha-card-background,
var(--card-background-color, white)
);
--fc-classic-faint: rgba(var(--rgb-primary-text-color), 0.04);
--fc-classic-muted: rgba(var(--rgb-primary-text-color), 0.08);
--fc-classic-strong: rgba(var(--rgb-primary-text-color), 0.14);
--fc-classic-foreground: var(--primary-text-color);
--fc-classic-faint-foreground: var(--disabled-text-color);
--fc-classic-muted-foreground: var(--secondary-text-color);
--fc-classic-border: var(--divider-color);
--fc-classic-strong-border: var(--divider-color);
}
.ha-fc-view {
border-width: var(--calendar-border-width, 1px);
border-radius: var(--calendar-border-radius, var(--mdc-shape-small, 4px));
overflow: clip;
}
.ha-fc-view * {
scrollbar-color: var(--scrollbar-thumb-color) transparent;
scrollbar-width: thin;
}
.ha-fc-view *::-webkit-scrollbar {
width: 0.4rem;
height: 0.4rem;
}
.ha-fc-view *::-webkit-scrollbar-thumb {
border-radius: var(--ha-border-radius-sm);
background: var(--scrollbar-thumb-color);
}
/* Sticky with an automatic height, so the header color needs an opaque
background below it */
.ha-fc-table-header {
background: linear-gradient(
var(--table-header-background-color),
var(--table-header-background-color)
)
var(--fc-classic-background);
}
.ha-fc-day-header-inner {
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-bold);
text-transform: uppercase;
}
`,
];
-23
View File
@@ -1,23 +0,0 @@
import Decimal from "decimal.js";
// Like @formatjs/bigdecimal: never exponent notation, and log(10) from
// Math.log10, as the precise log is slow and NumberFormat calls it per value
const BigDecimal = Decimal.clone({
precision: 40,
toExpNeg: -9e15,
toExpPos: 9e15,
});
// Clones share this prototype; this shim is the only decimal.js user
const { log } = BigDecimal.prototype;
BigDecimal.prototype.log = function (this: Decimal, base?: Decimal.Value) {
const value = this.toNumber();
if (base !== 10 || !(value > 0) || !Number.isFinite(value)) {
return log.call(this, base);
}
// Math.log10 rounds values just below a power of ten up to it
const result = Math.log10(value);
return new BigDecimal(result < this.e + 1 ? result : this.e + 0.5);
};
export { BigDecimal, BigDecimal as Decimal, BigDecimal as default };
+2 -5
View File
@@ -18,9 +18,6 @@ let polyfilled = false;
const _polyfillTimeZoneData = polyfillTimeZoneData;
// The DateTimeFormat and DurationFormat polyfills call BigInt() when formatting
const hasBigInt = typeof BigInt !== "undefined";
const polyfillIntl = async () => {
if (polyfilled) {
return;
@@ -34,14 +31,14 @@ const polyfillIntl = async () => {
if (shouldPolyfillLocale()) {
await import("@formatjs/intl-locale/polyfill-force");
}
if (hasBigInt && shouldPolyfillDateTimeFormat(locale)) {
if (shouldPolyfillDateTimeFormat(locale)) {
polyfills.push(
import("@formatjs/intl-datetimeformat/polyfill-force").then(() =>
_polyfillTimeZoneData()
)
);
}
if (hasBigInt && shouldPolyfillDurationFormat()) {
if (shouldPolyfillDurationFormat()) {
polyfills.push(import("@formatjs/intl-durationformat/polyfill-force"));
}
if (shouldPolyfillDisplayNames(locale)) {
+3 -23
View File
@@ -322,7 +322,6 @@
"update": {
"installing": "Installing",
"installing_with_progress": "Installing ({progress}%)",
"preparing": "Preparing",
"up_to_date": "Up-to-date"
},
"text": {
@@ -779,13 +778,6 @@
"time_pattern": "time pattern",
"homeassistant_stopping": "Home Assistant stopping",
"homeassistant_starting": "Home Assistant starting",
"cause_filter": {
"caption": "Triggered by",
"automation": "Automations",
"script": "Scripts",
"integration": "Integrations",
"other": "Other"
},
"cause": {
"by": "By {name}",
"by_automation": "By automation: {name}",
@@ -1876,7 +1868,6 @@
"backups_link": "View backups"
},
"install_failed": "The update could not be started: {error}",
"supervisor_logs": "[%key:ui::panel::config::logs::supervisor_logs%]",
"create_backup": {
"automatic": "Automatic backup before update",
"automatic_description_last": "Last automatic backup {relative_time}.",
@@ -4915,7 +4906,6 @@
"custom_integration": "custom integration",
"error_from_custom_integration": "This log entry is from a custom integration.",
"show_full_logs": "Show raw logs",
"supervisor_logs": "Supervisor logs",
"show_condensed_logs": "Show condensed logs",
"select_number_of_lines": "Select number of lines to download",
"lines": "Lines",
@@ -5572,7 +5562,7 @@
"select_target": "Select a target",
"home": "Home",
"unassigned": "Unassigned",
"other": "Other",
"time_sun": "Time and sun",
"blocks": "Blocks",
"tabs": {
"target": "By target",
@@ -5678,9 +5668,7 @@
"event": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::event%]",
"start": "Event start",
"end": "Event end",
"offset": "Offset (optional)",
"before": "Before",
"after": "After",
"offset": "Offset",
"description": {
"picker": "Triggers when a calendar event starts or ends.",
"full": "When{offsetChoice, select, \n before { it's {offset} before}\n after { it's {offset} after}\n other {}\n} a calendar event{eventChoice, select, \n start { starts}\n end { ends}\n other { starts or ends}\n}{hasCalendar, select, \n true { in {calendar}}\n other {}\n}"
@@ -5803,7 +5791,7 @@
"event": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::event%]",
"sunrise": "Sunrise",
"sunset": "Sunset",
"offset": "Offset in seconds or HH:MM:SS (optional)",
"offset": "Offset",
"description": {
"picker": "Triggers when the sun sets or rises.",
"sets": "When the sun sets{hasDuration, select, \n true { offset by {duration}} \n other {}\n }",
@@ -7376,7 +7364,6 @@
"setup_adapter_zwa2": "Connect ZWA-2",
"setup_adapter_zwa2_description": "Adds a Z-Wave radio.",
"setup_serial_configured": "Configured",
"setup_serial_offline": "Offline",
"setup_serial_port_ttl": "TTL",
"setup_serial_port_rs232": "RS-232",
"setup_serial_port_rs485": "RS-485",
@@ -10667,12 +10654,6 @@
"name": "History graph",
"description": "This card allows you to display a graph for each of the entities listed.",
"show_names": "Show entity names",
"label_position": "Label position",
"label_position_options": {
"side": "Beside the graph",
"below": "Below each bar"
},
"label_position_helper": "Applies only to entities shown as bars.",
"logarithmic_scale": "Logarithmic scale",
"expand_legend": "Expand legend",
"min_y_axis": "Y axis minimum",
@@ -12776,7 +12757,6 @@
},
"version_installed": "Version {version} installed",
"version_update": "Version {installed} installed, {version} available",
"version_skipped": "Version {installed} installed, {version} skipped",
"requires_homeassistant": "The newest version needs Home Assistant {version} or newer.",
"requires_newer_homeassistant": "The newest version needs a newer version of Home Assistant.",
"earlier_version_hint": "Update Home Assistant, or select an earlier version with Install another version in the menu."
@@ -107,31 +107,6 @@ describe("QueryParamsController", () => {
expect(navigated).not.toHaveBeenCalled();
});
it("follows the router replacing the URL with its default page", () => {
setUrl("/page/dashboard?other=1");
window.dispatchEvent(
Object.assign(new Event("location-changed"), {
detail: { replace: true },
})
);
expect(navigated).toHaveBeenCalledOnce();
controller.write({ search: "abc" });
expect(currentUrl()).toBe("/page/dashboard?other=1&search=abc");
});
it("does nothing once a link pushes a page below it", () => {
setUrl("/page/item?other=1");
window.dispatchEvent(
Object.assign(new Event("location-changed"), {
detail: { replace: false },
})
);
controller.write({ search: "abc" });
expect(currentUrl()).toBe("/page/item?other=1");
expect(navigated).not.toHaveBeenCalled();
});
it("does nothing when not enabled", () => {
enabled = false;
controller.write({ search: "abc" });
+1 -68
View File
@@ -1,14 +1,12 @@
import "@formatjs/intl-durationformat/polyfill-force";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import {
DURATION_UNITS,
formatDuration,
formatDurationDigital,
formatDurationLong,
formatDurationNarrow,
formatNumericDuration,
} from "../../../src/common/datetime/format_duration";
import type { HaDurationData } from "../../../src/components/ha-duration-input";
import type { FrontendLocaleData } from "../../../src/data/translation";
import {
DateFormat,
@@ -129,68 +127,3 @@ describe("formatNumericDuration", () => {
expect(formatNumericDuration(LOCALE, duration)).toBe(null);
});
});
describe("without Intl.DurationFormat", () => {
// Legacy browsers without BigInt cannot load the polyfill
const DURATIONS: HaDurationData[] = [
{ hours: 0, minutes: 0, seconds: 0 },
{ seconds: 45 },
{ hours: 1, minutes: 30 },
{ days: 1, hours: 2, minutes: 3, seconds: 4 },
{ hours: 0, minutes: 2, seconds: 3 },
{ hours: 25, minutes: 0, seconds: 0 },
{ hours: -1, minutes: -30, seconds: 0 },
{ seconds: 1, milliseconds: 500 },
{ milliseconds: 250 },
{ days: 2, hours: 0, minutes: 0, seconds: 7 },
// Rejected by Intl.DurationFormat
{ hours: 1, minutes: -30 },
{ seconds: 1.5 },
{ hours: NaN, minutes: 5 },
];
const attempt = (format: () => string) => {
try {
return format();
} catch (err) {
return (err as Error).name;
}
};
const formatAll = (locale: FrontendLocaleData) => [
...DURATIONS.flatMap((duration) => [
attempt(() => formatDurationLong(locale, duration)),
attempt(() => formatDurationNarrow(locale, duration)),
attempt(() => formatDurationDigital(locale, duration)),
]),
...["0", "0.25", "1", "95.5", "-1", "-0.5", "-1.25"].flatMap((value) =>
DURATION_UNITS.map((unit) =>
attempt(() => formatDuration(locale, value, unit))
)
),
];
const descriptor = Object.getOwnPropertyDescriptor(Intl, "DurationFormat")!;
const expected = Object.fromEntries(
["en", "de", "nl"].map((language) => [
language,
formatAll({ ...LOCALE, language }),
])
);
beforeAll(() => {
Reflect.deleteProperty(Intl, "DurationFormat");
});
afterAll(() => {
Object.defineProperty(Intl, "DurationFormat", descriptor);
});
it.each(["en", "de", "nl"])(
"matches Intl.DurationFormat in %s",
(language) => {
expect("DurationFormat" in Intl).toBe(false);
expect(formatAll({ ...LOCALE, language })).toEqual(expected[language]);
}
);
});
@@ -1,70 +0,0 @@
import { describe, expect, it } from "vitest";
import {
devicesFilterQueryParamConfig,
devicesFilterQueryParams,
devicesLinkFromUrl,
} from "../../../src/common/url/devices-query-params";
import { createQueryString } from "../../../src/common/url/query-params";
describe("devices query params", () => {
it("reads nothing without table params", () => {
expect(devicesLinkFromUrl("?historyBack=1")).toBeUndefined();
});
it("replaces every filter and the search with what a link asks for", () => {
expect(devicesLinkFromUrl("?config_entry=abc&label=a,b")).toEqual({
search: "",
filters: {
"ha-filter-states": { value: [], items: undefined },
"ha-filter-floor-areas": { value: undefined, items: undefined },
"ha-filter-integrations": { value: [], items: undefined },
"ha-filter-labels": { value: ["a", "b"], items: undefined },
config_entry: { value: ["abc"], items: undefined },
sub_entry: { value: [], items: undefined },
},
});
});
it("round trips the filters", () => {
const filters = {
"ha-filter-states": { value: ["disabled"], items: undefined },
"ha-filter-floor-areas": {
value: { floors: ["ground"], areas: ["kitchen"] },
items: undefined,
},
"ha-filter-integrations": { value: ["hue"], items: undefined },
"ha-filter-labels": { value: ["a"], items: undefined },
config_entry: { value: ["abc"], items: undefined },
sub_entry: { value: ["def"], items: undefined },
};
expect(
devicesLinkFromUrl(
createQueryString(
devicesFilterQueryParams(filters),
devicesFilterQueryParamConfig
)
)
).toEqual({ search: "", filters });
});
it("writes the values, not the items the filters found", () => {
expect(
devicesFilterQueryParams({
"ha-filter-floor-areas": {
value: { areas: ["kitchen"] },
items: new Set(["device_1"]),
},
})
).toEqual({
status: undefined,
floor: undefined,
area: ["kitchen"],
domain: undefined,
label: undefined,
config_entry: undefined,
sub_entry: undefined,
});
});
});
+80
View File
@@ -108,3 +108,83 @@ describe("normalizeAutomationConfig deprecated option reporting", () => {
).not.toThrow();
});
});
describe("migrateAutomationConfig offset type", () => {
const migrateTrigger = (trigger: Record<string, unknown>) =>
(
migrateAutomationConfig({ triggers: [trigger as any] }).triggers as any
)[0];
it.each([
["sun.sunrise", { hours: 1 }, { hours: -1 }],
[
"calendar.event_started",
{ hours: 1, minutes: 30 },
{ hours: -1, minutes: -30 },
],
["sun.sunset", { hours: -1 }, { hours: 1 }],
[
"sun.dawn",
"00:30:00",
{ hours: 0, minutes: -30, seconds: 0, milliseconds: 0 },
],
])(
"folds a `before` offset type into the sign for %s",
(trigger, offset, expected) => {
expect(
migrateTrigger({ trigger, options: { offset, offset_type: "before" } })
).toEqual({ trigger, options: { offset: expected } });
}
);
it("drops an `after` offset type and keeps the offset", () => {
expect(
migrateTrigger({
trigger: "calendar.event_ended",
options: { offset: { hours: 1 }, offset_type: "after" },
})
).toEqual({
trigger: "calendar.event_ended",
options: { offset: { hours: 1 } },
});
});
it("drops the offset type when there is no offset", () => {
expect(
migrateTrigger({
trigger: "sun.sunrise",
options: { offset_type: "before" },
})
).toEqual({ trigger: "sun.sunrise", options: {} });
});
it("leaves an offset it cannot read untouched", () => {
const options = { offset: "{{ offset }}", offset_type: "before" };
expect(
migrateTrigger({ trigger: "sun.sunrise", options: { ...options } })
).toEqual({ trigger: "sun.sunrise", options });
});
it("leaves other integrations untouched", () => {
const options = { offset: { hours: 1 }, offset_type: "before" };
expect(
migrateTrigger({ trigger: "custom.event", options: { ...options } })
).toEqual({ trigger: "custom.event", options });
});
it("does not flag the migration as deprecated", () => {
const report: AutomationMigrationReport = { deprecated: false };
migrateAutomationConfig(
{
triggers: [
{
trigger: "sun.sunrise",
options: { offset: { hours: 1 }, offset_type: "before" },
} as any,
],
},
report
);
expect(report.deprecated).toBe(false);
});
});
+1 -14
View File
@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest";
import {
getCalendarColors,
normalizeSubscriptionEventData,
} from "../../src/data/calendar";
import { getCalendarColors } from "../../src/data/calendar";
const style = {
getPropertyValue: (prop: string) => (prop === "--color-1" ? "#4269d0" : ""),
@@ -59,13 +56,3 @@ describe("getCalendarColors", () => {
).toBeUndefined();
});
});
describe("normalizeSubscriptionEventData", () => {
it("leaves the event text color empty when the calendar has none", () => {
const event = normalizeSubscriptionEventData(
{ summary: "Event", start: "2026-10-15", end: "2026-10-16" },
{ entity_id: "calendar.home", backgroundColor: "rgba(0, 0, 0, 0.1)" }
);
expect(event?.contrastColor).toBe("");
});
});
+1 -48
View File
@@ -1,10 +1,5 @@
import { describe, it, assert, expect, vi } from "vitest";
import { computeDomain } from "../../src/common/entity/compute_domain";
import {
HistoryStream,
computeHistory,
isTimelineEntity,
} from "../../src/data/history";
import { HistoryStream, computeHistory } from "../../src/data/history";
import type { HomeAssistant } from "../../src/types";
import { mockDevice, mockEntity } from "../common/entity/context/context-mock";
import {
@@ -263,45 +258,3 @@ describe("computeHistory entity names", () => {
expect(result.timeline[0]?.name).toBe("Caméra Allée ◂ Battery state");
});
});
describe("isTimelineEntity", () => {
it("matches how computeHistory splits timeline and line entities", () => {
const states = Object.fromEntries(
(
[
["binary_sensor.door", "on", {}],
["light.hallway", "off", {}],
["sensor.power", "12", { unit_of_measurement: "W" }],
["counter.visits", "3", {}],
["climate.bedroom", "heat", {}],
["humidifier.bedroom", "on", {}],
["water_heater.tank", "eco", {}],
["zone.home", "1", {}],
] as const
).map(([entityId, state, attributes]) => [
entityId,
createMockEntityState(entityId, state, attributes),
])
);
const result = computeHistory(
createMockHass(states),
Object.fromEntries(
Object.values(states).map((stateObj) => [
stateObj.entity_id,
[{ s: stateObj.state, a: stateObj.attributes, lu: 1_700_000_000 }],
])
),
[],
mockLocalize
);
const timelineIds = result.timeline.map((entity) => entity.entity_id);
for (const [entityId, stateObj] of Object.entries(states)) {
expect(
isTimelineEntity(computeDomain(entityId), stateObj),
entityId
).toBe(timelineIds.includes(entityId));
}
expect(timelineIds).toEqual(["binary_sensor.door", "light.hallway"]);
});
});
@@ -0,0 +1,85 @@
import { IntlMessageFormat } from "intl-messageformat";
import { describe, expect, it } from "vitest";
import type { ForDict } from "../../src/data/automation";
import { describeTrigger } from "../../src/data/automation_i18n";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../src/data/translation";
import en from "../../src/translations/en.json";
import type { HomeAssistant } from "../../src/types";
type TranslationNode = string | { [key: string]: TranslationNode };
const localize = (key: string, values?: Record<string, unknown>) => {
const message = key
.split(".")
.reduce<TranslationNode | undefined>(
(translations, part) =>
typeof translations === "object" ? translations[part] : undefined,
en as TranslationNode
);
return typeof message === "string"
? (new IntlMessageFormat(message, "en").format(values) as string)
: "";
};
const hass = {
localize,
locale: {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.twenty_four,
date_format: DateFormat.language,
first_weekday: FirstWeekday.language,
time_zone: TimeZone.local,
},
config: { time_zone: "Etc/UTC" },
states: {},
} as unknown as HomeAssistant;
describe("legacy calendar trigger offset description", () => {
it.each([
[
"-01:30:00",
"When it's 1 hour, 30 minutes before a calendar event starts",
],
[
{ hours: 1, minutes: -30 },
"When it's 30 minutes after a calendar event starts",
],
[
{ seconds: 1.5 },
"When it's 1 second, 500 milliseconds after a calendar event starts",
],
[{ hours: 0, minutes: 0, seconds: 0 }, "When a calendar event starts"],
])("describes %j", (offset, expected) => {
expect(
describeTrigger(
{
trigger: "calendar",
event: "start",
entity_id: "",
offset: offset as string | ForDict,
},
hass,
[]
)
).toBe(expected);
});
});
describe("legacy sun trigger offset description", () => {
it.each([
[{ minutes: -30 }, "When the sun sets offset by -30:00"],
[{ hours: 1, minutes: -30 }, "When the sun sets offset by +30:00"],
[{ hours: 0, minutes: 0, seconds: 0 }, "When the sun sets"],
])("describes %j", (offset, expected) => {
expect(
describeTrigger({ trigger: "sun", event: "sunset", offset }, hass, [])
).toBe(expected);
});
});
+1 -122
View File
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
getNestedStepConfig,
getTracedEntityId,
getTraceUrl,
} from "../../src/data/trace";
import { getTraceUrl } from "../../src/data/trace";
// Trace links from the logbook and from trace steps share this URL. Automation
// ids are free-form, and the automation router decodes the path segment, so
@@ -32,120 +28,3 @@ describe("getTraceUrl", () => {
expect(parsed.searchParams.get("run_id")).toBe("run_1");
});
});
// The step details of a trace describe nested steps by following their trace
// path through the config of the selected step. Core traces the conditions of
// an `if` action as `if/condition/<index>`, while the config keeps them in `if`.
describe("getNestedStepConfig", () => {
const stateCondition = {
condition: "state",
entity_id: "input_boolean.a",
state: "off",
};
const orCondition = { condition: "or", conditions: [stateCondition] };
it("follows the conditions of a choose option", () => {
const config = { choose: [{ conditions: [orCondition], sequence: [] }] };
expect(getNestedStepConfig(config, "choose/0/conditions/0")).toBe(
orCondition
);
expect(
getNestedStepConfig(config, "choose/0/conditions/0/conditions/0")
).toBe(stateCondition);
});
it("follows the conditions of an if action", () => {
const config = { if: [orCondition], then: [] };
expect(getNestedStepConfig(config, "if/condition/0")).toBe(orCondition);
expect(getNestedStepConfig(config, "if/condition/0/conditions/0")).toBe(
stateCondition
);
expect(
getNestedStepConfig(config, "if/condition/0/conditions/0/entity_id/0")
).toBe(stateCondition);
});
it("follows an if action with a single condition", () => {
const config = { if: stateCondition, then: [] };
expect(getNestedStepConfig(config, "if/condition/0")).toBe(stateCondition);
});
it("follows nested conditions of a single condition", () => {
const config = { if: orCondition, then: [] };
expect(getNestedStepConfig(config, "if/condition/0/conditions/0")).toBe(
stateCondition
);
});
it("returns template conditions written as plain strings", () => {
const template = "{{ is_state('light.a', 'off') }}";
expect(
getNestedStepConfig({ if: [template], then: [] }, "if/condition/0")
).toBe(template);
expect(
getNestedStepConfig({ if: template, then: [] }, "if/condition/0")
).toBe(template);
expect(
getNestedStepConfig(
{ choose: [{ conditions: [template], sequence: [] }] },
"choose/0/conditions/0"
)
).toBe(template);
});
it("stops at a state condition with a list of entities", () => {
const condition = {
condition: "state",
entity_id: ["light.a", "light.b"],
state: "off",
};
const config = { if: [condition], then: [] };
expect(getNestedStepConfig(config, "if/condition/0/entity_id/1")).toBe(
condition
);
});
});
describe("getTracedEntityId", () => {
it("returns the entity checked by an entity_id step", () => {
const condition = {
condition: "state",
entity_id: ["light.a", "light.b"],
state: "off",
};
expect(getTracedEntityId(condition, "if/condition/0/entity_id/1")).toBe(
"light.b"
);
expect(
getTracedEntityId(
{ condition: "state", entity_id: "light.a", state: "off" },
"entity_id/0"
)
).toBe("light.a");
});
it("handles a single entity and indexes outside the list", () => {
expect(getTracedEntityId({ entity_id: "light.a" }, "entity_id/1")).toBe(
"light.a"
);
expect(
getTracedEntityId({ entity_id: ["light.a"] }, "entity_id/1")
).toBeUndefined();
});
it("ignores steps that are not entity_id steps", () => {
const condition = { condition: "state", entity_id: "light.a" };
expect(getTracedEntityId(condition, "if/condition/0")).toBeUndefined();
expect(
getTracedEntityId("{{ true }}", "if/condition/0/entity_id/0")
).toBeUndefined();
});
});
@@ -1,12 +1,9 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HaDialog } from "../../../src/components/ha-dialog";
import type {
DataEntryFlowProgressedEvent,
DataEntryFlowStep,
DataEntryFlowStepExternal,
DataEntryFlowStepForm,
DataEntryFlowStepMenu,
DataEntryFlowStepProgress,
} from "../../../src/data/data_entry_flow";
import "../../../src/dialogs/config-flow/dialog-data-entry-flow";
import type { FlowConfig } from "../../../src/dialogs/config-flow/show-dialog-data-entry-flow";
@@ -75,28 +72,6 @@ const menuStep: DataEntryFlowStepMenu = {
menu_options: ["next"],
};
const progressStep: DataEntryFlowStepProgress = {
type: "progress",
flow_id: "test-flow",
handler: "test",
step_id: "install",
progress_action: "install",
};
const externalStep: DataEntryFlowStepExternal = {
type: "external",
flow_id: "test-flow",
handler: "test",
step_id: "auth",
url: "https://example.com/authorize",
description_placeholders: {},
};
const nextFormStep: DataEntryFlowStepForm = {
...formStep,
step_id: "finish",
};
const flowConfig: FlowConfig = {
flowType: "config_flow",
showDevices: false,
@@ -124,28 +99,10 @@ const flowConfig: FlowConfig = {
renderLoadingDescription: () => "",
};
const eventListeners: Record<string, (ev: unknown) => void> = {};
const hass = {
localize: (key: string) => key,
devices: {},
connection: {
subscribeEvents: vi.fn(
async (callback: (ev: unknown) => void, eventType: string) => {
eventListeners[eventType] = callback;
return () => undefined;
}
),
},
} as unknown as HomeAssistant;
const fireFlowProgressed = (flowId = "test-flow") => {
const event: DataEntryFlowProgressedEvent = {
type: "data_entry_flow_progressed",
data: { handler: "test", flow_id: flowId, refresh: false },
};
eventListeners.data_entry_flow_progressed(event);
};
} as HomeAssistant;
const nextTask = () =>
new Promise<void>((resolve) => {
@@ -248,151 +205,3 @@ describe("dialog-data-entry-flow dismissal", () => {
expect(innerDialog(dialog).preventScrimClose).toBe(true);
});
});
describe("dialog-data-entry-flow progress", () => {
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
vi.mocked(flowConfig.fetchFlow).mockReset().mockResolvedValue(formStep);
});
const shownStep = (dialog: HTMLElementTagNameMap["dialog-data-entry-flow"]) =>
dialog.shadowRoot!.querySelector<HTMLElement & { step: DataEntryFlowStep }>(
"step-flow-form, step-flow-progress"
)?.step;
const deferFetchFlow = () => {
let resolveFetch!: (step: DataEntryFlowStep) => void;
vi.mocked(flowConfig.fetchFlow).mockReturnValueOnce(
new Promise((resolve) => {
resolveFetch = resolve;
})
);
return resolveFetch;
};
const settle = async (
dialog: HTMLElementTagNameMap["dialog-data-entry-flow"]
) => {
await nextTask();
await dialog.updateComplete;
await nextTask();
await dialog.updateComplete;
};
it("shows the next step when the progress task finished before subscribing", async () => {
vi.mocked(flowConfig.fetchFlow).mockResolvedValueOnce(nextFormStep);
const dialog = await openDialog(progressStep);
await settle(dialog);
expect(flowConfig.fetchFlow).toHaveBeenCalledOnce();
expect(shownStep(dialog)).toEqual(nextFormStep);
});
it("does not fetch an external step on subscribe", async () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
const dialog = await openDialog(externalStep);
await settle(dialog);
expect(flowConfig.fetchFlow).not.toHaveBeenCalled();
expect(open).toHaveBeenCalledOnce();
open.mockRestore();
});
it("shows the next task when a progress step keeps its step_id", async () => {
const nextTaskStep: DataEntryFlowStepProgress = {
...progressStep,
progress_action: "configure",
};
vi.mocked(flowConfig.fetchFlow).mockResolvedValueOnce(nextTaskStep);
const dialog = await openDialog(progressStep);
await settle(dialog);
expect(flowConfig.fetchFlow).toHaveBeenCalledOnce();
expect(shownStep(dialog)).toEqual(nextTaskStep);
});
it("retries the catch-up fetch when it fails after a progressed event", async () => {
let rejectFetch!: (err: Error) => void;
vi.mocked(flowConfig.fetchFlow)
.mockReturnValueOnce(
new Promise((_resolve, reject) => {
rejectFetch = reject;
})
)
.mockResolvedValueOnce(nextFormStep);
const dialog = await openDialog(progressStep);
fireFlowProgressed();
rejectFetch(new Error("connection lost"));
await settle(dialog);
expect(flowConfig.fetchFlow).toHaveBeenCalledTimes(2);
expect(shownStep(dialog)).toEqual(nextFormStep);
});
it("keeps waiting when the catch-up fetch fails without a progressed event", async () => {
vi.mocked(flowConfig.fetchFlow).mockRejectedValueOnce(
new Error("connection lost")
);
const dialog = await openDialog(progressStep);
await settle(dialog);
expect(flowConfig.fetchFlow).toHaveBeenCalledOnce();
expect(shownStep(dialog)).toEqual(progressStep);
});
it("follows a progressed event after catching up", async () => {
vi.mocked(flowConfig.fetchFlow)
.mockResolvedValueOnce(progressStep)
.mockResolvedValueOnce(nextFormStep);
const dialog = await openDialog(progressStep);
await settle(dialog);
fireFlowProgressed();
await settle(dialog);
expect(flowConfig.fetchFlow).toHaveBeenCalledTimes(2);
expect(shownStep(dialog)).toEqual(nextFormStep);
});
it("fetches the flow once when the progressed event arrives while catching up", async () => {
const resolveFetch = deferFetchFlow();
const dialog = await openDialog(progressStep);
fireFlowProgressed();
resolveFetch(nextFormStep);
await settle(dialog);
expect(flowConfig.fetchFlow).toHaveBeenCalledOnce();
expect(shownStep(dialog)).toEqual(nextFormStep);
});
it("fetches again when the flow progressed after the catch-up fetch", async () => {
const resolveFetch = deferFetchFlow();
vi.mocked(flowConfig.fetchFlow).mockResolvedValueOnce(nextFormStep);
const dialog = await openDialog(progressStep);
fireFlowProgressed();
resolveFetch(progressStep);
await settle(dialog);
expect(flowConfig.fetchFlow).toHaveBeenCalledTimes(2);
expect(shownStep(dialog)).toEqual(nextFormStep);
});
it("ignores the catch-up fetch once the dialog has closed", async () => {
const resolveFetch = deferFetchFlow();
const dialog = await openDialog(progressStep);
innerDialog(dialog).dispatchEvent(new CustomEvent("closed"));
resolveFetch(nextFormStep);
await settle(dialog);
expect(dialog["_step"]).toBeUndefined();
});
});
@@ -1,43 +0,0 @@
import { describe, expect, it } from "vitest";
import { scaleCoordinates } from "../../../../../src/panels/lovelace/common/graph/coordinates";
// hui-graph-base rescales the line on screen into a new size before it
// animates to coordinates computed for that size. Wrong math here makes the
// graph jump at the start of the animation after a resize.
describe("scaleCoordinates", () => {
it("scales x and y independently into the new size", () => {
const scaled = scaleCoordinates(
[
[0, 60],
[150, 30],
[300, 0],
],
{ width: 300, height: 60 },
{ width: 200, height: 80 }
);
const expected = [
[0, 80],
[100, 40],
[200, 0],
];
expect(scaled).toHaveLength(expected.length);
scaled.forEach(([x, y], index) => {
expect(x).toBeCloseTo(expected[index][0]);
expect(y).toBeCloseTo(expected[index][1]);
});
});
it("returns the same coordinates when the size did not change", () => {
const coordinates = [
[0, 10],
[300, 50],
];
expect(
scaleCoordinates(
coordinates,
{ width: 300, height: 60 },
{ width: 300, height: 60 }
)
).toBe(coordinates);
});
});
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more