mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-21 21:23:32 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28ca86ac94 | ||
|
|
a37c4eefae | ||
|
|
ee2ba819f9 | ||
|
|
62c8534e43 | ||
|
|
526b39e779 | ||
|
|
9d9841da0b | ||
|
|
48c870cf40 | ||
|
|
664b74479d | ||
|
|
04240a18ba | ||
|
|
22f26b0ed7 | ||
|
|
c6ca2e0dd0 |
No files matched your search
@@ -46,7 +46,7 @@ When a router reuses one element for different items, such as one app's tab afte
|
||||
- Debounce search writes. Write sort, group and filter changes straight away.
|
||||
- Leave empty and default values out of the URL, such as an empty search or ascending direction.
|
||||
- A param in the URL wins over stored preferences. A link with none of the page's params keeps what the user picked before; `hasQueryParams` makes that check.
|
||||
- A link with any of the page's params replaces the search and every filter, since a filter left over from before would hide part of what the link shows. Store what the link applied as the user's choice.
|
||||
- A link with any of the page's params replaces the search and every filter, since a filter left over from before would hide part of what the link shows. Show what the link applied, but don't store it: links often carry someone else's choices, such as a My Home Assistant link made with `m`. Store only what the user changes on the page, and only that setting.
|
||||
- The URL is the whole state. Don't add filters while applying a link; a link that needs one, such as showing disabled devices, says so in its params (`status=disabled`).
|
||||
- A link to a page that is already open must apply the new URL. Handle `navigated()`, not only the first render. The router often keeps a page alive, so this is the common case, not an edge case.
|
||||
- Use one comma-separated param for several values (`?status=new,installed`), which `list` params do.
|
||||
@@ -58,12 +58,13 @@ When a router reuses one element for different items, such as one app's tab afte
|
||||
|
||||
## Data Tables
|
||||
|
||||
`hass-tabs-subpage-data-table` with the `sync-url` attribute keeps `search`, `sort`, `direction` and `group` in the URL through `dataTableQueryParamsFromState` and `dataTableQueryStateFromUrl`. A linked search replaces the current one, and sorting or grouping a link leaves out keeps what was picked before. The table fires `search-changed`, `sorting-changed` and `grouping-changed` when a link changes them, so the page can keep its stored values in step.
|
||||
`hass-tabs-subpage-data-table` with the `sync-url` attribute keeps `search`, `sort`, `direction` and `group` in the URL through `dataTableQueryParamsFromState` and `dataTableQueryStateFromUrl`. A linked search replaces the current one, and sorting or grouping a link leaves out keeps what was picked before. The table fires `search-changed`, `sorting-changed` and `grouping-changed` when a link changes them, with `link` set, so the page shows them without storing them. Use `DataTableSettingsController` (`src/common/controllers/data-table-settings-controller.ts`) for the stored search, sorting and grouping: give it the page's storage keys, bind its `search`, `sorting` and `grouping` to the table, and its `searchChanged`, `sortingChanged` and `groupingChanged` to the table's events. Call `showSearch` for a search the page reads from a link itself. A page the router keeps open shows what the user stored when it's opened again without params, like from the sidebar: the controller and table restore the search, sorting and grouping, and a page with filters restores its stored filters when its URL has no params.
|
||||
|
||||
- Give sortable and groupable columns a short public `urlName` (for example `type`, not `translated_category`). Sorting falls back to the column id without one; grouping is only written for columns that have one, so internal ids don't end up in links.
|
||||
- When a column definition is shared between pages, set its `urlName` where the shared column is defined, so every page uses the same name.
|
||||
- The table reads the URL once, as soon as it has columns. When a column only exists once data has loaded, such as Filename on the dashboards page, turn syncing on only after loading succeeds (`?sync-url=${!this._loading && !this._loadFailed}`), or a link that sorts or groups by that column is dropped.
|
||||
- The page owns its filters. Give it a filter config in its `*-query-params.ts`, a helper that turns filter values into params, and one that turns a link back into the search and filter values. The link helper returns `undefined` when the URL has none of the filter params and none of the table's (`dataTableQueryParamConfig`), so stored choices stay. Use a second controller on the page for them, as `ha-marketplace-dashboard.ts` and `lorawan-devices.ts` do. `browseUrl` and `browseLinkFromUrl` in `src/panels/marketplace/dashboards/dashboard-repositories.ts` show the pair.
|
||||
- Filter components also send the value the page gave them once they have looked up their items, so a filter event isn't always the user's. Store a filter only when it changes what the URL holds, comparing the filters' query strings before and after, as `helpersFilterQueryString` does, and store only that filter.
|
||||
- A memoized filter that also reads async state (anything loaded after the page connects) must take that state as a memoize argument, or a linked filter shows stale results until something else changes.
|
||||
- A filter component that looks up related items asynchronously should drop a lookup whose value has since changed, or two links landing within one lookup apply the older one.
|
||||
|
||||
|
||||
+22
-29
@@ -15,45 +15,38 @@ not UCAndroid > 0
|
||||
# Floors are pinned explicitly (not usage-based) so the support policy is
|
||||
# deliberate and does not drift with global usage statistics, which do not
|
||||
# represent old tablets and wall displays used as Home Assistant dashboards:
|
||||
# - iOS/Safari >= 15.4: where native <dialog> (ha-drawer, popovers,
|
||||
# wa-dialog) landed. Dropped: the iOS 12 devices, iPad Air 1 / mini 2 /
|
||||
# mini 3, iPhone 5s / 6 / 6 Plus and iPod touch 6. Nothing ends between
|
||||
# iOS 12 and 15.8, so no other device is affected. On the Mac, Safari 15.4
|
||||
# runs on macOS 10.15 Catalina and later. The Chrome floor, not Safari,
|
||||
# decides what the legacy build has to transpile.
|
||||
# - Chrome >= 67: the first Chromium with BigInt, which Babel cannot
|
||||
# transpile. The boundary on Fire tablets is the Amazon WebView build, not
|
||||
# the Fire OS major: WebView 70+ (Fire OS 5.6.4+ on the 2017 models, Fire
|
||||
# OS 6.3.1.2+, Fire OS 7) keeps working, WebView 59 (Fire OS 5 on the 2015
|
||||
# and 2016 models, Fire OS 6.3.0.1) is dropped. Also covers the Chromecast
|
||||
# 1st gen (frozen at Chrome 70, the next hard ceiling) and old kiosk
|
||||
# webviews above it.
|
||||
# - iOS/Safari >= 12: iPad Air 1 / mini 2 / mini 3 (last supported iOS).
|
||||
# Older iPads (iPad 2/3/mini 1 on iOS 9.3, iPad 4 on iOS 10.3) cannot run
|
||||
# the app: their engines lack custom elements, shadow DOM, and/or CSS grid.
|
||||
# - Chrome >= 59: Fire OS 5 tablets (Fire 7/HD 8/HD 10 through ~2017) have
|
||||
# their system WebView pinned at Chromium 59 and commonly run Fully Kiosk;
|
||||
# also covers old kiosk browsers and no-longer-updating webviews above it.
|
||||
# - Edge >= 79: all Chromium-based Edge. Costs nothing (above the Chrome
|
||||
# floor); mainly catches Edge 109 on Windows 7/8.1 and update-frozen
|
||||
# enterprise installs, whose engines can run the legacy build fine.
|
||||
# - Firefox >= 98: where native <dialog> landed; otherwise a zero-cost floor,
|
||||
# not a chased population — pinning it adds no babel transforms, core-js
|
||||
# modules, or Lightning CSS prefixes to the output. Mozilla-supported Firefox (incl. current ESR) always matches
|
||||
# - Firefox >= 94: the zero-cost floor, not a chased population — pinning it
|
||||
# adds no babel transforms, core-js modules, or Lightning CSS prefixes to
|
||||
# the output. Mozilla-supported Firefox (incl. current ESR) always matches
|
||||
# [modern]; Firefox on old OSes is not supported (use Chrome 109 instead).
|
||||
# If Firefox ever becomes the pin forcing extra output, raise it first.
|
||||
# - Samsung >= 9: Samsung Internet on old Galaxy tablets (9.2 is Chromium 67)
|
||||
Chrome >= 67
|
||||
ChromeAndroid >= 67
|
||||
# - Samsung >= 9: Samsung Internet on old Galaxy tablets
|
||||
Chrome >= 59
|
||||
ChromeAndroid >= 59
|
||||
Edge >= 79
|
||||
Firefox >= 98
|
||||
FirefoxAndroid >= 98
|
||||
iOS >= 15.4
|
||||
Safari >= 15.4
|
||||
Firefox >= 94
|
||||
FirefoxAndroid >= 94
|
||||
iOS >= 12
|
||||
Safari >= 12
|
||||
Samsung >= 9
|
||||
|
||||
[legacy-sw]
|
||||
# Same as legacy, restricted to browsers that support service workers
|
||||
# (currently resolves to the same set; guards the service worker build if the legacy floor ever drops below them)
|
||||
Chrome >= 67 and supports serviceworkers
|
||||
ChromeAndroid >= 67 and supports serviceworkers
|
||||
Chrome >= 59 and supports serviceworkers
|
||||
ChromeAndroid >= 59 and supports serviceworkers
|
||||
Edge >= 79 and supports serviceworkers
|
||||
Firefox >= 98 and supports serviceworkers
|
||||
FirefoxAndroid >= 98 and supports serviceworkers
|
||||
iOS >= 15.4 and supports serviceworkers
|
||||
Safari >= 15.4 and supports serviceworkers
|
||||
Firefox >= 94 and supports serviceworkers
|
||||
FirefoxAndroid >= 94 and supports serviceworkers
|
||||
iOS >= 12 and supports serviceworkers
|
||||
Safari >= 12 and supports serviceworkers
|
||||
Samsung >= 9 and supports serviceworkers
|
||||
@@ -18,6 +18,17 @@ const polyfillSupport = {
|
||||
safari: 17.4,
|
||||
samsung: 15.0,
|
||||
},
|
||||
"element-getattributenames": {
|
||||
android: 61,
|
||||
chrome: 61,
|
||||
edge: 18,
|
||||
firefox: 45,
|
||||
ios: 10.3,
|
||||
opera: 48,
|
||||
opera_mobile: 45,
|
||||
safari: 10.1,
|
||||
samsung: 8.0,
|
||||
},
|
||||
"element-toggleattribute": {
|
||||
android: 69,
|
||||
chrome: 69,
|
||||
@@ -56,20 +67,39 @@ const polyfillSupport = {
|
||||
"intl-other": {
|
||||
// Not specified (i.e. always try polyfill) since compatibility depends on supported locales
|
||||
},
|
||||
"resize-observer": {
|
||||
android: 64,
|
||||
chrome: 64,
|
||||
edge: 79,
|
||||
firefox: 69,
|
||||
ios: 13.4,
|
||||
opera: 51,
|
||||
opera_mobile: 47,
|
||||
safari: 13.1,
|
||||
samsung: 9.0,
|
||||
},
|
||||
};
|
||||
|
||||
// Map of instance and static properties to the corresponding polyfill key
|
||||
// and actual module to import
|
||||
// Map of global variables and/or instance and static properties to the
|
||||
// corresponding polyfill key and actual module to import
|
||||
const polyfillMap = {
|
||||
global: {
|
||||
ResizeObserver: {
|
||||
key: "resize-observer",
|
||||
module: join(POLYFILL_DIR, "resize-observer.ts"),
|
||||
},
|
||||
},
|
||||
instance: {
|
||||
attachInternals: {
|
||||
key: "element-internals",
|
||||
module: "element-internals-polyfill",
|
||||
},
|
||||
toggleAttribute: {
|
||||
key: "element-toggleattribute",
|
||||
module: join(POLYFILL_DIR, "element-toggleattribute.ts"),
|
||||
},
|
||||
...Object.fromEntries(
|
||||
["getAttributeNames", "toggleAttribute"].map((prop) => {
|
||||
const key = `element-${prop.toLowerCase()}`;
|
||||
return [prop, { key, module: join(POLYFILL_DIR, `${key}.ts`) }];
|
||||
})
|
||||
),
|
||||
},
|
||||
static: {
|
||||
Intl: {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 627654,
|
||||
"core": 55147,
|
||||
"authorize": 593862,
|
||||
"onboarding": 709255
|
||||
"app": 624240,
|
||||
"core": 55144,
|
||||
"authorize": 594400,
|
||||
"onboarding": 705499
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 724225,
|
||||
"core": 157026,
|
||||
"authorize": 693913,
|
||||
"onboarding": 812415
|
||||
"app": 776696,
|
||||
"core": 188309,
|
||||
"authorize": 763316,
|
||||
"onboarding": 879807
|
||||
}
|
||||
}
|
||||
@@ -96,9 +96,8 @@ module.exports.htmlMinifierOptions = {
|
||||
module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({
|
||||
// Highest syntax the minifier may emit; it never downlevels. Every browser
|
||||
// in [modern] is well past ES2020 (universal since spring 2020); the
|
||||
// [legacy] floors (Chrome 67 / Safari 15) top out at ES2019: optional
|
||||
// catch binding is in, `?.` and `??` (Chrome 80) are not.
|
||||
ecma: latestBuild ? 2020 : 2019,
|
||||
// [legacy] floors (Chrome 59 / Safari 12) top out at ES2017.
|
||||
ecma: latestBuild ? 2020 : 2017,
|
||||
module: latestBuild,
|
||||
format: { comments: false },
|
||||
sourceMap: !isTestBuild,
|
||||
@@ -186,6 +185,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
|
||||
path.join(paths.root_dir, "src/resources/polyfills"),
|
||||
...[
|
||||
"@formatjs/(?:ecma402-abstract|intl-\\w+)",
|
||||
"@lit-labs/virtualizer/polyfills",
|
||||
"@webcomponents/scoped-custom-element-registry",
|
||||
"element-internals-polyfill",
|
||||
].map((p) => new RegExp(`/node_modules/${p}/`)),
|
||||
|
||||
@@ -3,7 +3,6 @@ const fs = require("fs");
|
||||
const { existsSync } = fs;
|
||||
const path = require("path");
|
||||
const rspack = require("@rspack/core");
|
||||
const browserslist = require("browserslist");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { RsdoctorRspackPlugin } = require("@rsdoctor/rspack-plugin");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -229,22 +228,6 @@ const createRspackConfig = ({
|
||||
{
|
||||
test: /\.css$/,
|
||||
type: "asset/source",
|
||||
// Imported stylesheets skip the minifier that compiles css``
|
||||
// templates, so legacy builds compile logical properties and :is()
|
||||
// here
|
||||
use: latestBuild
|
||||
? []
|
||||
: [
|
||||
{
|
||||
loader: "builtin:lightningcss-loader",
|
||||
options: {
|
||||
targets: browserslist.loadConfig({
|
||||
path: paths.root_dir,
|
||||
env: "legacy",
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -434,6 +417,8 @@ const createRspackConfig = ({
|
||||
"lit/directives/keyed$": "lit/directives/keyed.js",
|
||||
"@lit-labs/virtualizer/layouts/grid":
|
||||
"@lit-labs/virtualizer/layouts/grid.js",
|
||||
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver":
|
||||
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver.js",
|
||||
"@lit-labs/observers/resize-controller":
|
||||
"@lit-labs/observers/resize-controller.js",
|
||||
"@formatjs/intl-durationformat/should-polyfill$":
|
||||
|
||||
@@ -109,29 +109,6 @@ const disabledFailedUnloadEntry = createConfigEntry(
|
||||
}
|
||||
);
|
||||
|
||||
const brandEntries: ConfigEntryExtended[] = (
|
||||
[
|
||||
["hue", "Philips Hue", "loaded"],
|
||||
["hue", "Philips Hue", "setup_error"],
|
||||
["mqtt", "MQTT", "setup_retry"],
|
||||
["zha", "Zigbee Home Automation", "not_loaded"],
|
||||
["sonos", "Sonos", "setup_error"],
|
||||
["shelly", "Shelly", "failed_unload"],
|
||||
["spotify", "Spotify", "setup_retry"],
|
||||
["tuya", "Tuya", "setup_error"],
|
||||
["unifi", "UniFi Network", "not_loaded"],
|
||||
["met", "Meteorologisk institutt (Met.no)", "setup_error"],
|
||||
["google_assistant", "Google Assistant", "not_loaded"],
|
||||
["homekit_controller", "HomeKit Device", "setup_retry"],
|
||||
] as [string, string, ConfigEntryExtended["state"]][]
|
||||
).map(([domain, name, entryState]) =>
|
||||
createConfigEntry(`${name} ${entryState}`, {
|
||||
domain,
|
||||
localized_domain_name: name,
|
||||
state: entryState,
|
||||
})
|
||||
);
|
||||
|
||||
const configFlows: DataEntryFlowProgressExtended[] = [
|
||||
{
|
||||
flow_id: "adbb401329d8439ebb78ef29837826a8",
|
||||
@@ -348,21 +325,6 @@ export class DemoIntegrationCard extends LitElement {
|
||||
.deviceRegistryEntries=${createDeviceRegistryEntries(loadedEntry)}
|
||||
></ha-integration-card>
|
||||
</div>
|
||||
<div class="container">
|
||||
${brandEntries.map(
|
||||
(entry) => html`
|
||||
<ha-integration-card
|
||||
.domain=${entry.domain}
|
||||
.items=${[entry]}
|
||||
.manifest=${createManifest(
|
||||
this.isCustomIntegration,
|
||||
this.isCloud
|
||||
)}
|
||||
.entityRegistryEntries=${createEntityRegistryEntries(entry)}
|
||||
></ha-integration-card>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"marked": "18.1.1",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
|
||||
Generated
+19
-19
@@ -278,7 +278,7 @@ importers:
|
||||
version: 1.0.3
|
||||
'@maplibre/maplibre-gl-leaflet':
|
||||
specifier: 0.1.4
|
||||
version: 0.1.4(@types/[email protected])([email protected])([email protected]5.0)
|
||||
version: 0.1.4(@types/[email protected])([email protected])([email protected]4.0)
|
||||
'@material/mwc-list':
|
||||
specifier: 0.27.0
|
||||
version: 0.27.0(patch_hash=9450e509a86887d8f68ca9cf3f81e6ed70e94e49de13c5f6f20f94519ad4a987)
|
||||
@@ -302,7 +302,7 @@ importers:
|
||||
version: 4.4.0
|
||||
'@versatiles/style':
|
||||
specifier: 7.0.1
|
||||
version: 7.0.1([email protected]5.0)
|
||||
version: 7.0.1([email protected]4.0)
|
||||
'@vibrant/color':
|
||||
specifier: 4.0.4
|
||||
version: 4.0.4
|
||||
@@ -388,8 +388,8 @@ importers:
|
||||
specifier: 3.3.3
|
||||
version: 3.3.3
|
||||
maplibre-gl:
|
||||
specifier: 6.15.0
|
||||
version: 6.15.0
|
||||
specifier: 6.14.0
|
||||
version: 6.14.0
|
||||
marked:
|
||||
specifier: 18.1.1
|
||||
version: 18.1.1
|
||||
@@ -2232,8 +2232,8 @@ packages:
|
||||
'@mapbox/[email protected]':
|
||||
resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==}
|
||||
|
||||
'@mapbox/tiny-sdf@2.3.0':
|
||||
resolution: {integrity: sha512-gBvcNDBBB66bgQYvnaJ6Jk+VCaMmVq5+IZR2kWPisT88Z8s0kBQt6hfVi8QgRW8Vpg0UWZzBA4ZSyzHHC+VeRA==}
|
||||
'@mapbox/tiny-sdf@2.2.0':
|
||||
resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==}
|
||||
|
||||
'@mapbox/[email protected]':
|
||||
resolution: {integrity: sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==}
|
||||
@@ -2241,8 +2241,8 @@ packages:
|
||||
'@mapbox/[email protected]':
|
||||
resolution: {integrity: sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==}
|
||||
|
||||
'@maplibre/geojson-vt@6.3.0':
|
||||
resolution: {integrity: sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==}
|
||||
'@maplibre/geojson-vt@6.1.2':
|
||||
resolution: {integrity: sha512-2ZYhobdAY+y4/P/z1xe0698165LywD0yDdWugdu3NRUrajgVWut+tLkcz7i/RJguMTURwK7gxwmwYMFJbgbQFw==}
|
||||
|
||||
'@maplibre/[email protected]':
|
||||
resolution: {integrity: sha512-k57wcndZIvq3m08g2je4pjUPGr43ffNW3j+gXbymt8Vi2l1lUERUi1UmdTZcTPN8LuDDYOMWRNBaSLwpAu02ew==}
|
||||
@@ -5893,8 +5893,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==}
|
||||
|
||||
[email protected]5.0:
|
||||
resolution: {integrity: sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==}
|
||||
[email protected]4.0:
|
||||
resolution: {integrity: sha512-L7U9y9TyPJ3P/JJbW+eH+A3FzdPq+3fnZFqqeCwf/S6cRGganHwVI5b/C8/zOYzreUzsloDPZVVaMQA94wOt4A==}
|
||||
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
|
||||
|
||||
[email protected]:
|
||||
@@ -9763,7 +9763,7 @@ snapshots:
|
||||
|
||||
'@mapbox/[email protected]': {}
|
||||
|
||||
'@mapbox/tiny-sdf@2.3.0': {}
|
||||
'@mapbox/tiny-sdf@2.2.0': {}
|
||||
|
||||
'@mapbox/[email protected]': {}
|
||||
|
||||
@@ -9773,15 +9773,15 @@ snapshots:
|
||||
'@types/geojson': 7946.0.16
|
||||
pbf: 5.1.2
|
||||
|
||||
'@maplibre/geojson-vt@6.3.0':
|
||||
'@maplibre/geojson-vt@6.1.2':
|
||||
dependencies:
|
||||
kdbush: 4.1.0
|
||||
|
||||
'@maplibre/[email protected](@types/[email protected])([email protected])([email protected]5.0)':
|
||||
'@maplibre/[email protected](@types/[email protected])([email protected])([email protected]4.0)':
|
||||
dependencies:
|
||||
'@types/leaflet': 1.9.22
|
||||
leaflet: 1.9.4
|
||||
maplibre-gl: 6.15.0
|
||||
maplibre-gl: 6.14.0
|
||||
|
||||
'@maplibre/[email protected]':
|
||||
dependencies:
|
||||
@@ -11272,11 +11272,11 @@ snapshots:
|
||||
'@unrs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@versatiles/[email protected]([email protected]5.0)':
|
||||
'@versatiles/[email protected]([email protected]4.0)':
|
||||
dependencies:
|
||||
'@maplibre/maplibre-gl-style-spec': 26.4.4
|
||||
optionalDependencies:
|
||||
maplibre-gl: 6.15.0
|
||||
maplibre-gl: 6.14.0
|
||||
|
||||
'@vibrant/[email protected]': {}
|
||||
|
||||
@@ -13659,13 +13659,13 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]5.0:
|
||||
[email protected]4.0:
|
||||
dependencies:
|
||||
'@mapbox/point-geometry': 1.1.0
|
||||
'@mapbox/tiny-sdf': 2.3.0
|
||||
'@mapbox/tiny-sdf': 2.2.0
|
||||
'@mapbox/unitbezier': 1.0.0
|
||||
'@mapbox/vector-tile': 3.0.0
|
||||
'@maplibre/geojson-vt': 6.3.0
|
||||
'@maplibre/geojson-vt': 6.1.2
|
||||
'@maplibre/maplibre-gl-style-spec': 26.4.4
|
||||
'@maplibre/mlt': 1.3.0
|
||||
'@maplibre/vt-pbf': 4.3.2
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { SortingChangedEvent } from "../../components/data-table/ha-data-table";
|
||||
import { getStorage } from "../decorators/storage";
|
||||
import type { HASSDomEvent } from "../dom/fire_event";
|
||||
import type { QueryParamConfig } from "../url/query-params";
|
||||
import { QueryParamsController } from "./query-params-controller";
|
||||
|
||||
export interface DataTableSettingsKeys {
|
||||
/** Session storage key for the search. */
|
||||
search?: string;
|
||||
/** Local storage key for the sorting. */
|
||||
sorting?: string;
|
||||
/** Local storage key for the grouping. */
|
||||
grouping?: string;
|
||||
}
|
||||
|
||||
export interface DataTableSettingsOptions {
|
||||
/** The grouping when none is stored. */
|
||||
defaultGrouping?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the search, sorting and grouping a user picks on a data table page.
|
||||
* What a link applies (`link` on the table's events) is shown, but not
|
||||
* stored, as links often carry someone else's choices. Bind
|
||||
* `searchChanged`, `sortingChanged` and `groupingChanged` to the table's
|
||||
* events.
|
||||
*/
|
||||
export class DataTableSettingsController implements ReactiveController {
|
||||
/** The search shown, which can be a link's. */
|
||||
public search: string;
|
||||
|
||||
/** The grouping shown, which can be a link's. */
|
||||
public grouping?: string;
|
||||
|
||||
// The search is kept for the session, sorting and grouping for good
|
||||
private _sessionStorage = getStorage("sessionStorage");
|
||||
|
||||
private _localStorage = getStorage("localStorage");
|
||||
|
||||
// Only used to know when the page is opened again
|
||||
private _queryParams: QueryParamsController<QueryParamConfig>;
|
||||
|
||||
constructor(
|
||||
private _host: ReactiveControllerHost,
|
||||
private _keys: DataTableSettingsKeys,
|
||||
private _options: DataTableSettingsOptions = {}
|
||||
) {
|
||||
this._host.addController(this);
|
||||
this._queryParams = new QueryParamsController(
|
||||
this._host,
|
||||
{},
|
||||
{
|
||||
enabled: () => true,
|
||||
navigated: () => this._navigated(),
|
||||
}
|
||||
);
|
||||
this.search = this._storedSearch();
|
||||
this.grouping = this._storedGrouping();
|
||||
}
|
||||
|
||||
/** The stored sorting, for the table to start with. */
|
||||
public get sorting(): SortingChangedEvent | undefined {
|
||||
return this._keys.sorting
|
||||
? this._localStorage.getValue<SortingChangedEvent>(this._keys.sorting)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Show a search, like one a page reads from a link, without storing it. */
|
||||
public showSearch(value: string) {
|
||||
this.search = value;
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
public searchChanged = (
|
||||
ev: HASSDomEvent<HASSDomEvents["search-changed"]>
|
||||
) => {
|
||||
this.showSearch(ev.detail.value);
|
||||
|
||||
if (this._keys.search && !ev.detail.link) {
|
||||
this._sessionStorage.setValue(this._keys.search, ev.detail.value);
|
||||
}
|
||||
};
|
||||
|
||||
public sortingChanged = (ev: HASSDomEvent<SortingChangedEvent>) => {
|
||||
if (this._keys.sorting && !ev.detail.link) {
|
||||
const { column, direction } = ev.detail;
|
||||
this._localStorage.setValue(this._keys.sorting, { column, direction });
|
||||
}
|
||||
};
|
||||
|
||||
public groupingChanged = (
|
||||
ev: HASSDomEvent<HASSDomEvents["grouping-changed"]>
|
||||
) => {
|
||||
this.grouping = ev.detail.value;
|
||||
this._host.requestUpdate();
|
||||
|
||||
if (this._keys.grouping && !ev.detail.link) {
|
||||
this._localStorage.setValue(this._keys.grouping, ev.detail.value);
|
||||
}
|
||||
};
|
||||
|
||||
// A page opened again without params, like from the sidebar, shows what
|
||||
// the user stored rather than what a link showed before
|
||||
private _navigated() {
|
||||
if (this._queryParams.searchParams()?.toString() !== "") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.search = this._storedSearch();
|
||||
this.grouping = this._storedGrouping();
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
private _storedSearch(): string {
|
||||
return (
|
||||
(this._keys.search
|
||||
? this._sessionStorage.getValue<string>(this._keys.search)
|
||||
: undefined) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
private _storedGrouping(): string | undefined {
|
||||
return (
|
||||
(this._keys.grouping
|
||||
? this._localStorage.getValue<string>(this._keys.grouping)
|
||||
: undefined) ?? this._options.defaultGrouping
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
import { timeZonesNames } from "@vvo/tzdb";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
|
||||
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
|
||||
|
||||
// Some environments (e.g. Android emulator) return a UTC offset like "+00:00"
|
||||
// instead of an IANA zone name. Only accept values that are known IANA zones,
|
||||
// matching the list used by ha-timezone-picker.
|
||||
const isKnownTimeZone = (zone: string | undefined): zone is string =>
|
||||
!!zone &&
|
||||
(zone === "UTC" || zone === "Etc/UTC" || timeZonesNames.includes(zone));
|
||||
|
||||
// The FormatJS polyfill reports the canonical zone (Europe/Amsterdam becomes
|
||||
// Europe/Brussels), so the name read before it loaded comes first. Its own
|
||||
// name still covers old aliases the list lacks, like Asia/Calcutta.
|
||||
const RESOLVED_TIME_ZONE = [
|
||||
globalThis.__haNativeTimeZone,
|
||||
Intl.DateTimeFormat?.().resolvedOptions?.().timeZone,
|
||||
].find(isKnownTimeZone);
|
||||
const RESOLVED_TIME_ZONE =
|
||||
RESOLVED_RAW &&
|
||||
(RESOLVED_RAW === "UTC" ||
|
||||
RESOLVED_RAW === "Etc/UTC" ||
|
||||
timeZonesNames.includes(RESOLVED_RAW))
|
||||
? RESOLVED_RAW
|
||||
: undefined;
|
||||
|
||||
export const HAS_RESOLVED_IANA_TIME_ZONE = RESOLVED_TIME_ZONE !== undefined;
|
||||
|
||||
|
||||
@@ -76,11 +76,15 @@ class StorageClass {
|
||||
return storageKey in this._storage;
|
||||
}
|
||||
|
||||
public getValue(storageKey: string): any {
|
||||
public getValue<T = any>(storageKey: string): T | undefined {
|
||||
if (!(storageKey in this._storage)) {
|
||||
this.addFromStorage(storageKey);
|
||||
}
|
||||
|
||||
return this._storage[storageKey];
|
||||
}
|
||||
|
||||
public setValue(storageKey: string, value: any): any {
|
||||
public setValue<T = any>(storageKey: string, value: T): void {
|
||||
const oldValue = this._storage[storageKey];
|
||||
this._storage[storageKey] = value;
|
||||
try {
|
||||
@@ -103,9 +107,22 @@ class StorageClass {
|
||||
|
||||
const storages: Record<string, StorageClass> = {};
|
||||
|
||||
export type StorageName = "localStorage" | "sessionStorage";
|
||||
|
||||
// Shared with `@storage`, so code that isn't a decorated field sees the same values
|
||||
export const getStorage = (
|
||||
storageName: StorageName = "localStorage"
|
||||
): StorageClass => {
|
||||
if (!(storageName in storages)) {
|
||||
storages[storageName] = new StorageClass(window[storageName]);
|
||||
}
|
||||
|
||||
return storages[storageName];
|
||||
};
|
||||
|
||||
export function storage(options: {
|
||||
key?: string;
|
||||
storage?: "localStorage" | "sessionStorage";
|
||||
storage?: StorageName;
|
||||
subscribe?: boolean;
|
||||
state?: boolean;
|
||||
serializer?: (value: any) => any;
|
||||
@@ -119,15 +136,7 @@ export function storage(options: {
|
||||
throw new Error("This decorator does not support this compilation type.");
|
||||
}
|
||||
|
||||
const storageName = options.storage || "localStorage";
|
||||
|
||||
let storageInstance: StorageClass;
|
||||
if (storageName && storageName in storages) {
|
||||
storageInstance = storages[storageName];
|
||||
} else {
|
||||
storageInstance = new StorageClass(window[storageName]);
|
||||
storages[storageName] = storageInstance;
|
||||
}
|
||||
const storageInstance = getStorage(options.storage);
|
||||
|
||||
const storageKey = options.key || String(propertyKey);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export const listenMediaQuery = (
|
||||
): MediaQueriesListener => {
|
||||
const mql = matchMedia(mediaQuery);
|
||||
const listener = (e) => matchesChanged(e.matches);
|
||||
mql.addEventListener("change", listener);
|
||||
mql.addListener(listener);
|
||||
matchesChanged(mql.matches);
|
||||
return () => mql.removeEventListener("change", listener);
|
||||
return () => mql.removeListener(listener);
|
||||
};
|
||||
@@ -70,10 +70,13 @@ export const supportsWebGL2 = (): boolean => {
|
||||
|
||||
// The one gate for MapLibre, whether through the Leaflet adapter or the
|
||||
// engine `ha-map` drives directly. Its worker is bundled per build target
|
||||
// like the rest of it, so wherever the page runs, the worker runs too: the
|
||||
// legacy floor (Chrome 67 / Safari 15) guarantees the BigInt literals MapLibre
|
||||
// uses, which Babel cannot transpile.
|
||||
export const supportsVectorMaps = (): boolean => supportsWebGL2();
|
||||
// like the rest of it, so wherever the page runs, the worker runs too - with
|
||||
// one exception. Babel transpiles everything MapLibre is written in except
|
||||
// BigInt literals, which have no ES2017 spelling. A browser without BigInt
|
||||
// (Chromium below 67, such as Fire OS 5 tablets) cannot parse the chunk, and
|
||||
// a worker that cannot parse never reports back, hence the check up front.
|
||||
export const supportsVectorMaps = (): boolean =>
|
||||
supportsWebGL2() && typeof BigInt === "function";
|
||||
|
||||
// The demo has no core to proxy through. OSM sets CORS on its tiles but not on
|
||||
// its glyphs, which is why those come from VersaTiles.
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
createQueryString,
|
||||
decodeQueryParams,
|
||||
hasQueryParams,
|
||||
type QueryParamConfig,
|
||||
@@ -49,6 +50,16 @@ export const helpersFilterQueryParams = (
|
||||
assistants: list(filters["ha-filter-voice-assistants"]),
|
||||
});
|
||||
|
||||
// The filters as the URL holds them, so values that filter the same, such as
|
||||
// no list and an empty one, compare equal
|
||||
export const helpersFilterQueryString = (
|
||||
filters: DataTableFiltersValues
|
||||
): string =>
|
||||
createQueryString(
|
||||
helpersFilterQueryParams(filters),
|
||||
helpersFilterQueryParamConfig
|
||||
);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -44,7 +44,7 @@ export class HaFilterChip extends HaChipBase {
|
||||
// Dispatch from the chip so listeners see the new selection before deciding
|
||||
// whether to cancel it. Preserve pointer and modifier-key information.
|
||||
const click =
|
||||
event instanceof PointerEvent
|
||||
typeof PointerEvent !== "undefined" && event instanceof PointerEvent
|
||||
? new PointerEvent("click", event)
|
||||
: new MouseEvent("click", event);
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface CollapsedChangedEvent {
|
||||
export interface SortingChangedEvent {
|
||||
column: string;
|
||||
direction: SortingDirection;
|
||||
// Set when a link sorted the table, not the user
|
||||
link?: boolean;
|
||||
}
|
||||
|
||||
export type SortingDirection = "desc" | "asc" | null;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type {
|
||||
HaFormBaseSchema,
|
||||
HaFormCondition,
|
||||
HaFormDataContainer,
|
||||
HaFormFieldCondition,
|
||||
HaFormSchema,
|
||||
HaFormVisible,
|
||||
} from "./types";
|
||||
|
||||
const isEmpty = (value: unknown): boolean =>
|
||||
@@ -57,10 +57,11 @@ export const evaluateCondition = (
|
||||
return matchFieldCondition(condition, data);
|
||||
};
|
||||
|
||||
export const isVisible = (
|
||||
visible: HaFormVisible | undefined,
|
||||
export const isFieldVisible = (
|
||||
schema: HaFormSchema,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
const { visible } = schema as HaFormBaseSchema;
|
||||
if (visible === undefined || visible === true) {
|
||||
return true;
|
||||
}
|
||||
@@ -71,26 +72,10 @@ export const isVisible = (
|
||||
return conditions.every((condition) => evaluateCondition(condition, data));
|
||||
};
|
||||
|
||||
export const combineVisible = (
|
||||
...options: (HaFormVisible | undefined)[]
|
||||
): HaFormVisible => {
|
||||
const conditions: HaFormCondition[] = [];
|
||||
for (const visible of options) {
|
||||
if (visible === undefined || visible === true) {
|
||||
continue;
|
||||
}
|
||||
if (visible === false) {
|
||||
return false;
|
||||
}
|
||||
conditions.push(...(Array.isArray(visible) ? visible : [visible]));
|
||||
}
|
||||
return conditions;
|
||||
};
|
||||
|
||||
// Hiding a field drops its value, which can flip another field's condition, so
|
||||
// resolve the set to a fixpoint. Fields are given in order as name and visible.
|
||||
// resolve the set to a fixpoint.
|
||||
export const getHiddenFields = (
|
||||
fields: readonly (readonly [string, HaFormVisible | undefined])[],
|
||||
schema: readonly HaFormSchema[],
|
||||
data: HaFormDataContainer | undefined
|
||||
): Set<string> => {
|
||||
const hidden = new Set<string>();
|
||||
@@ -98,23 +83,14 @@ export const getHiddenFields = (
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const [name, visible] of fields) {
|
||||
if (hidden.has(name) || isVisible(visible, evalData)) {
|
||||
for (const field of schema) {
|
||||
if (hidden.has(field.name) || isFieldVisible(field, evalData)) {
|
||||
continue;
|
||||
}
|
||||
hidden.add(name);
|
||||
delete evalData[name];
|
||||
hidden.add(field.name);
|
||||
delete evalData[field.name];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
};
|
||||
|
||||
export const getHiddenSchemaFields = (
|
||||
schema: readonly HaFormSchema[],
|
||||
data: HaFormDataContainer | undefined
|
||||
): Set<string> =>
|
||||
getHiddenFields(
|
||||
schema.map((field) => [field.name, field.visible] as const),
|
||||
data
|
||||
);
|
||||
@@ -2,7 +2,7 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { getHiddenSchemaFields } from "./conditions";
|
||||
import { getHiddenFields } from "./conditions";
|
||||
import "./ha-form";
|
||||
import type { HaForm } from "./ha-form";
|
||||
import type {
|
||||
@@ -68,7 +68,7 @@ export class HaFormGrid extends LitElement implements HaFormElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const hiddenFields = getHiddenSchemaFields(this.schema.schema, this.data);
|
||||
const hiddenFields = getHiddenFields(this.schema.schema, this.data);
|
||||
|
||||
return html`
|
||||
${this.schema.schema
|
||||
|
||||
@@ -7,7 +7,7 @@ import { resolveSelectorContext } from "../../data/selector";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-alert";
|
||||
import "../ha-selector/ha-selector";
|
||||
import { getHiddenSchemaFields } from "./conditions";
|
||||
import { getHiddenFields } from "./conditions";
|
||||
import type {
|
||||
HaFormData,
|
||||
HaFormDataContainer,
|
||||
@@ -136,7 +136,7 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
let isValid = true;
|
||||
let firstInvalidElement: HTMLElement | undefined;
|
||||
|
||||
const hiddenFields = getHiddenSchemaFields(this.schema, this.data);
|
||||
const hiddenFields = getHiddenFields(this.schema, this.data);
|
||||
const visibleSchema = this.schema.filter(
|
||||
(item) => !hiddenFields.has(item.name)
|
||||
);
|
||||
@@ -195,7 +195,7 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const renderHiddenFields = getHiddenSchemaFields(this.schema, this.data);
|
||||
const renderHiddenFields = getHiddenFields(this.schema, this.data);
|
||||
|
||||
return html`
|
||||
<div class="root" part="root">
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface HaFormBaseSchema {
|
||||
disabled?: boolean;
|
||||
// Field is visible while the condition holds (visible by default).
|
||||
// Serializable so it can be shared with the backend and other renderers.
|
||||
visible?: HaFormVisible;
|
||||
visible?: boolean | HaFormCondition | HaFormCondition[];
|
||||
description?: {
|
||||
suffix?: string;
|
||||
// This value will be set initially when form is loaded
|
||||
@@ -34,8 +34,6 @@ export interface HaFormBaseSchema {
|
||||
context?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type HaFormVisible = boolean | HaFormCondition | HaFormCondition[];
|
||||
|
||||
export type HaFormConditionOperator =
|
||||
"eq" | "not_eq" | "in" | "not_in" | "exists" | "not_exists";
|
||||
|
||||
|
||||
@@ -116,7 +116,6 @@ export class HaTracePathDetails extends LitElement {
|
||||
showIndices: false,
|
||||
select: () => undefined,
|
||||
fixDuplicateIds: async () => undefined,
|
||||
updateTriggerId: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -709,7 +709,6 @@ export interface BaseSidebarConfig {
|
||||
export interface TriggerSidebarConfig extends BaseSidebarConfig {
|
||||
save: (value: Trigger) => void;
|
||||
rename: () => void;
|
||||
editId: () => void;
|
||||
disable: () => void;
|
||||
duplicate: () => void;
|
||||
cut: () => void;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { HaFormVisible } from "../components/ha-form/types";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { ManualAutomationConfig } from "./automation";
|
||||
import type { ManualScriptConfig } from "./script";
|
||||
@@ -28,7 +27,6 @@ export interface BlueprintInput {
|
||||
description?: string;
|
||||
selector?: Selector;
|
||||
default?: any;
|
||||
visible?: HaFormVisible;
|
||||
}
|
||||
|
||||
export interface BlueprintInputSection {
|
||||
@@ -36,7 +34,6 @@ export interface BlueprintInputSection {
|
||||
icon?: string;
|
||||
description?: string;
|
||||
collapsed?: boolean;
|
||||
visible?: HaFormVisible;
|
||||
input: Record<string, BlueprintInput | null>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ASSIST_ENTITIES, SENSOR_ENTITIES } from "../../common/const";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import type { EntityCategory } from "../entity/entity_registry";
|
||||
|
||||
export const DEVICE_ENTITY_CATEGORIES = [
|
||||
"control",
|
||||
"sensor",
|
||||
"notify",
|
||||
"event",
|
||||
"assist",
|
||||
"config",
|
||||
"diagnostic",
|
||||
] as const;
|
||||
|
||||
export type DeviceEntityCategory = (typeof DEVICE_ENTITY_CATEGORIES)[number];
|
||||
|
||||
export const computeDeviceEntityCategory = (entry: {
|
||||
entity_id: string;
|
||||
entity_category?: EntityCategory | null;
|
||||
}): DeviceEntityCategory => {
|
||||
const domain = computeDomain(entry.entity_id);
|
||||
|
||||
if (ASSIST_ENTITIES.includes(domain)) {
|
||||
return "assist";
|
||||
}
|
||||
|
||||
if (domain === "event" || domain === "notify") {
|
||||
return domain;
|
||||
}
|
||||
|
||||
if (entry.entity_category) {
|
||||
return entry.entity_category;
|
||||
}
|
||||
|
||||
if (SENSOR_ENTITIES.includes(domain)) {
|
||||
return "sensor";
|
||||
}
|
||||
|
||||
return "control";
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import type { LightColor } from "../light";
|
||||
import type { NextNamePart, RegistryEntry } from "../registry";
|
||||
import type { Segment } from "../vacuum";
|
||||
|
||||
export type EntityCategory = "config" | "diagnostic";
|
||||
type EntityCategory = "config" | "diagnostic";
|
||||
|
||||
export interface EntityRegistryDisplayEntry {
|
||||
entity_id: string;
|
||||
|
||||
+27
-69
@@ -20,7 +20,7 @@ export type ESPHomeCapabilityId =
|
||||
export type ESPHomeCapabilityStatus = "not-started" | "detected" | "completed";
|
||||
|
||||
export interface ESPHomeSetupStatus {
|
||||
bluetooth?: "not-started" | "completed";
|
||||
bluetooth?: "completed";
|
||||
audio?: "not-started" | "completed";
|
||||
connectivity?: "not-started" | "detected" | "completed";
|
||||
serial?: "not-started" | "completed";
|
||||
@@ -30,7 +30,6 @@ export interface ESPHomeDeviceEntityRef {
|
||||
entity_id: string;
|
||||
device_id?: string | null;
|
||||
platform?: string;
|
||||
disabled_by?: string | null;
|
||||
}
|
||||
|
||||
export interface ESPHomeEntityStateRef {
|
||||
@@ -90,36 +89,12 @@ export const ESPHOME_CAPABILITY_TITLE_KEYS: Record<
|
||||
*/
|
||||
const SENDSPIN_ENABLED_OBJECT_ID = "sendspin_enabled";
|
||||
|
||||
/**
|
||||
* Object id of the Bluetooth switch, a template switch named "Bluetooth"
|
||||
* that runs ble.enable and ble.disable. Device capabilities do not report it.
|
||||
*/
|
||||
const BLUETOOTH_OBJECT_ID = "bluetooth";
|
||||
|
||||
const findESPHomeSwitch = (
|
||||
deviceId: string,
|
||||
entities: Iterable<ESPHomeDeviceEntityRef>,
|
||||
objectId: string
|
||||
): string | undefined => {
|
||||
for (const entity of entities) {
|
||||
// The device page passes full registry entries, including disabled ones
|
||||
if (
|
||||
entity.device_id !== deviceId ||
|
||||
entity.disabled_by ||
|
||||
computeDomain(entity.entity_id) !== "switch" ||
|
||||
(entity.platform && entity.platform !== "esphome")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const entityObjectId = computeObjectId(entity.entity_id);
|
||||
if (
|
||||
entityObjectId === objectId ||
|
||||
entityObjectId.endsWith(`_${objectId}`)
|
||||
) {
|
||||
return entity.entity_id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
const isSendspinEnabledSwitch = (entityId: string): boolean => {
|
||||
const objectId = computeObjectId(entityId);
|
||||
return (
|
||||
objectId === SENDSPIN_ENABLED_OBJECT_ID ||
|
||||
objectId.endsWith(`_${SENDSPIN_ENABLED_OBJECT_ID}`)
|
||||
);
|
||||
};
|
||||
|
||||
export const deviceHasMediaPlayerEntity = (
|
||||
@@ -142,11 +117,20 @@ export const getESPHomeAudioControls = (
|
||||
entities: Iterable<ESPHomeDeviceEntityRef>,
|
||||
states: Readonly<Record<string, ESPHomeEntityStateRef>> = {}
|
||||
): ESPHomeAudioControls => {
|
||||
const sendspinEntityId = findESPHomeSwitch(
|
||||
deviceId,
|
||||
entities,
|
||||
SENDSPIN_ENABLED_OBJECT_ID
|
||||
);
|
||||
let sendspinEntityId: string | undefined;
|
||||
|
||||
for (const entity of entities) {
|
||||
if (
|
||||
entity.device_id !== deviceId ||
|
||||
computeDomain(entity.entity_id) !== "switch" ||
|
||||
(entity.platform && entity.platform !== "esphome") ||
|
||||
!isSendspinEnabledSwitch(entity.entity_id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
sendspinEntityId = entity.entity_id;
|
||||
break;
|
||||
}
|
||||
|
||||
const sendspinState = sendspinEntityId
|
||||
? states[sendspinEntityId]?.state
|
||||
@@ -161,41 +145,18 @@ export const getESPHomeAudioControls = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Whether the device has an enabled Bluetooth switch, whatever its state. */
|
||||
export const hasESPHomeBluetoothSwitch = (
|
||||
deviceId: string,
|
||||
entities: Iterable<ESPHomeDeviceEntityRef>
|
||||
): boolean =>
|
||||
findESPHomeSwitch(deviceId, entities, BLUETOOTH_OBJECT_ID) !== undefined;
|
||||
|
||||
/** The device's Bluetooth switch, if it exposes one that can be switched now. */
|
||||
export const getESPHomeBluetoothSwitch = (
|
||||
deviceId: string,
|
||||
entities: Iterable<ESPHomeDeviceEntityRef>,
|
||||
states: Readonly<Record<string, ESPHomeEntityStateRef>> = {}
|
||||
): { entityId: string; on: boolean } | undefined => {
|
||||
const entityId = findESPHomeSwitch(deviceId, entities, BLUETOOTH_OBJECT_ID);
|
||||
// Disabled has no state and unavailable cannot be switched, so neither counts as off
|
||||
const state = entityId ? states[entityId]?.state : undefined;
|
||||
return entityId && (state === "on" || state === "off")
|
||||
? { entityId, on: state === "on" }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the device page should show the setup wizard: the device supports
|
||||
* at least one of its parts. Bluetooth only counts with its switch, since
|
||||
* without one there is nothing to set up on older Bluetooth proxies. Zigbee
|
||||
* has no separate capability bit; the Z-Wave proxy row covers it.
|
||||
* Whether the device page should show the setup banner.
|
||||
* Bluetooth is status inside the wizard and is not enough on its own.
|
||||
* Zigbee has no separate capability bit; the Z-Wave proxy row covers it.
|
||||
*/
|
||||
export const hasESPHomeSetupCapabilities = (
|
||||
capabilities: ESPHomeDeviceCapabilities | undefined | null,
|
||||
options: { mediaPlayerSupported?: boolean; bluetoothSwitch?: boolean } = {}
|
||||
options: { mediaPlayerSupported?: boolean } = {}
|
||||
): boolean =>
|
||||
Boolean(
|
||||
capabilities &&
|
||||
((capabilities.bluetooth_proxy.supported && options.bluetoothSwitch) ||
|
||||
options.mediaPlayerSupported ||
|
||||
(options.mediaPlayerSupported ||
|
||||
capabilities.zwave_proxy.supported ||
|
||||
capabilities.serial_proxies.length > 0)
|
||||
);
|
||||
@@ -213,8 +174,6 @@ export const deriveESPHomeSetupStatus = (
|
||||
options: {
|
||||
mediaPlayerSupported: boolean;
|
||||
musicAssistantLoaded: boolean;
|
||||
/** Undefined when the device has no Bluetooth switch to turn it off. */
|
||||
bluetoothEnabled?: boolean;
|
||||
sendspinSupported?: boolean;
|
||||
sendspinEnabled?: boolean;
|
||||
serialConfigured?: boolean;
|
||||
@@ -224,8 +183,7 @@ export const deriveESPHomeSetupStatus = (
|
||||
const status: ESPHomeSetupStatus = {};
|
||||
|
||||
if (capabilities.bluetooth_proxy.supported) {
|
||||
status.bluetooth =
|
||||
options.bluetoothEnabled === false ? "not-started" : "completed";
|
||||
status.bluetooth = "completed";
|
||||
}
|
||||
|
||||
if (options.mediaPlayerSupported || options.sendspinSupported) {
|
||||
|
||||
@@ -505,12 +505,6 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
`;
|
||||
}
|
||||
|
||||
// A flow cannot be resumed after a page reload, so block reloads for as long
|
||||
// as the dialog is open. This freezes and hides the frontend update prompt.
|
||||
protected override get _blocksReload(): boolean {
|
||||
return this.isDirtyState || this._params !== undefined;
|
||||
}
|
||||
|
||||
private get _preventScrimClose(): boolean {
|
||||
return (
|
||||
this.isDirtyState ||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { isNavigationClick } from "../../common/dom/is-navigation-click";
|
||||
import "../../components/ha-alert";
|
||||
import { computeInitialHaFormData } from "../../components/ha-form/compute-initial-ha-form-data";
|
||||
import { getHiddenSchemaFields } from "../../components/ha-form/conditions";
|
||||
import { getHiddenFields } from "../../components/ha-form/conditions";
|
||||
import "../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
@@ -228,7 +228,7 @@ class StepFlowForm extends LitElement {
|
||||
schema: readonly HaFormSchema[],
|
||||
data: Record<string, any>
|
||||
) => {
|
||||
const hidden = getHiddenSchemaFields(schema, data);
|
||||
const hidden = getHiddenFields(schema, data);
|
||||
return schema.every(
|
||||
(field) =>
|
||||
hidden.has(field.name) ||
|
||||
@@ -259,7 +259,7 @@ class StepFlowForm extends LitElement {
|
||||
|
||||
const flowId = this.step.flow_id;
|
||||
|
||||
const hiddenFields = getHiddenSchemaFields(this.step.data_schema, stepData);
|
||||
const hiddenFields = getHiddenFields(this.step.data_schema, stepData);
|
||||
|
||||
const toSendData: Record<string, unknown> = {};
|
||||
Object.keys(stepData).forEach((key) => {
|
||||
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
ESPHOME_CAPABILITY_TITLE_KEYS,
|
||||
findESPHomeZWaveFlow,
|
||||
getESPHomeAudioControls,
|
||||
getESPHomeBluetoothSwitch,
|
||||
getESPHomeSetupCapabilityIds,
|
||||
isESPHomeSerialConfigured,
|
||||
MUSIC_ASSISTANT_DOCS_URL,
|
||||
@@ -175,8 +174,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
|
||||
@state() private _installStatus?: string;
|
||||
|
||||
@state() private _switchingBluetooth = false;
|
||||
|
||||
/** True until the Sendspin switch state catches up after we turn it on. */
|
||||
@state() private _sendspinOptimisticOn = false;
|
||||
|
||||
@@ -368,7 +365,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
: nothing
|
||||
}
|
||||
<p>${localize(CAPABILITY_DESCRIPTION_KEYS[id])}</p>
|
||||
${id === "bluetooth" ? this._renderBluetoothActions() : nothing}
|
||||
${id === "audio" ? this._renderAudioActions(status) : nothing}
|
||||
${
|
||||
id === "connectivity"
|
||||
@@ -401,28 +397,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderBluetoothActions() {
|
||||
const bluetoothSwitch = this._bluetoothSwitch();
|
||||
if (!bluetoothSwitch) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="actions">
|
||||
<ha-button
|
||||
appearance=${bluetoothSwitch.on ? "plain" : "accent"}
|
||||
.disabled=${this._switchingBluetooth}
|
||||
@click=${this._toggleBluetooth}
|
||||
>
|
||||
${this._i18n!.localize(
|
||||
bluetoothSwitch.on
|
||||
? "ui.panel.config.devices.esphome.setup_bluetooth_turn_off"
|
||||
: "ui.panel.config.devices.esphome.setup_bluetooth_turn_on"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAudioActions(status: ESPHomeCapabilityStatus) {
|
||||
const localize = this._i18n!.localize;
|
||||
if (status === "completed") {
|
||||
@@ -762,7 +736,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
}
|
||||
const audio = this._audioControls();
|
||||
const status = deriveESPHomeSetupStatus(this._capabilities, {
|
||||
bluetoothEnabled: this._bluetoothSwitch()?.on,
|
||||
mediaPlayerSupported:
|
||||
Boolean(this.params.mediaPlayerSupported) || audio.supported,
|
||||
sendspinSupported: Boolean(audio.sendspinEntityId),
|
||||
@@ -920,37 +893,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
this._view = "zwave-adapters";
|
||||
};
|
||||
|
||||
private _bluetoothSwitch() {
|
||||
if (!this.params) {
|
||||
return undefined;
|
||||
}
|
||||
return getESPHomeBluetoothSwitch(
|
||||
this.params.deviceId,
|
||||
this._entities ? Object.values(this._entities) : [],
|
||||
this._states ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleBluetooth = async (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
const bluetoothSwitch = this._bluetoothSwitch();
|
||||
if (!bluetoothSwitch || !this._api) {
|
||||
return;
|
||||
}
|
||||
this._switchingBluetooth = true;
|
||||
try {
|
||||
await this._api.callService(
|
||||
"switch",
|
||||
bluetoothSwitch.on ? "turn_off" : "turn_on",
|
||||
{ entity_id: bluetoothSwitch.entityId }
|
||||
);
|
||||
} catch {
|
||||
// callService already shows the failure toast.
|
||||
} finally {
|
||||
this._switchingBluetooth = false;
|
||||
}
|
||||
};
|
||||
|
||||
private _audioControls(): ESPHomeAudioControls {
|
||||
if (!this.params) {
|
||||
return {
|
||||
|
||||
@@ -95,7 +95,7 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
|
||||
>
|
||||
<ha-dialog-header slot="header">
|
||||
${
|
||||
!confirmPrompt || this._params.showCloseButton
|
||||
!confirmPrompt
|
||||
? html`<slot name="headerNavigationIcon" slot="navigationIcon">
|
||||
<ha-icon-button
|
||||
data-dialog="close"
|
||||
@@ -169,13 +169,6 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._params.prompt && this._params.textBelowInput
|
||||
? html`<div class="after-input">
|
||||
${this._params.textBelowInput}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
${
|
||||
@@ -314,9 +307,6 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
|
||||
ha-input {
|
||||
width: 100%;
|
||||
}
|
||||
.after-input {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
.title {
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
|
||||
@@ -5,8 +5,6 @@ interface BaseDialogBoxParams {
|
||||
/** Whether back closes the dialog. Off while a navigation is in flight. */
|
||||
addHistory?: boolean;
|
||||
confirmText?: string;
|
||||
/** Show the close button for confirmations and prompts, which hide it by default. */
|
||||
showCloseButton?: boolean;
|
||||
text?: string | TemplateResult;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
@@ -28,7 +26,6 @@ export interface ConfirmationDialogParams extends BaseDialogBoxParams {
|
||||
export interface PromptDialogParams extends BaseDialogBoxParams {
|
||||
inputLabel?: string;
|
||||
inputSuffix?: string;
|
||||
textBelowInput?: string | TemplateResult;
|
||||
dismissText?: string;
|
||||
inputType?: string;
|
||||
defaultValue?: string;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -17,8 +17,8 @@
|
||||
* show a message instead of reloading forever.
|
||||
*
|
||||
* Never transpiled, so it stays within the [legacy] browserslist floor
|
||||
* (Chrome 67 / Safari 15): no optional chaining, nullish coalescing or
|
||||
* class fields.
|
||||
* (Chrome 59 / Safari 12): no object spread, Promise.finally, optional
|
||||
* chaining or AbortController.
|
||||
*/
|
||||
(function () {
|
||||
const BUST_PARAM = "ha_cache_bust";
|
||||
@@ -193,23 +193,14 @@
|
||||
* from its own cache.
|
||||
*/
|
||||
function probeGone(url) {
|
||||
// Status 0 stands for "no usable answer": unreachable, or too slow. A
|
||||
// request that is too slow is aborted so it does not hold a connection.
|
||||
const controller = new AbortController();
|
||||
// Status 0 stands for "no usable answer": unreachable, or too slow.
|
||||
return Promise.race([
|
||||
fetch(url, {
|
||||
method: "HEAD",
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}).then(
|
||||
fetch(url, { method: "HEAD", cache: "no-store" }).then(
|
||||
(response) => response.status,
|
||||
() => 0
|
||||
),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
resolve(0);
|
||||
}, PROBE_TIMEOUT);
|
||||
setTimeout(() => resolve(0), PROBE_TIMEOUT);
|
||||
}),
|
||||
]).then((status) => status === 404 || status === 410);
|
||||
}
|
||||
|
||||
@@ -164,23 +164,10 @@
|
||||
<% } %>
|
||||
<script>
|
||||
if (!window.latestJS) {
|
||||
// Below the [legacy] browserslist floor the entry bundles cannot be
|
||||
// parsed, or dialogs cannot open, and nothing would ever leave the
|
||||
// launch screen. Test the features that set the floors: BigInt for
|
||||
// Chrome 67, native <dialog> for Safari 15.4 and Firefox 98. Not
|
||||
// transpiled, so keep this ES5.
|
||||
if (
|
||||
typeof BigInt === "undefined" ||
|
||||
typeof document.createElement("dialog").showModal !== "function"
|
||||
) {
|
||||
document.getElementById("ha-launch-screen-info-box").textContent =
|
||||
"This browser is too old to run Home Assistant.";
|
||||
} else {
|
||||
window.customPanelJS = "<%= es5CustomPanelJS %>";
|
||||
<% for (const entry of es5EntryJS) { %>
|
||||
_ls("<%= entry %>", true);
|
||||
<% } %>
|
||||
}
|
||||
window.customPanelJS = "<%= es5CustomPanelJS %>";
|
||||
<% for (const entry of es5EntryJS) { %>
|
||||
_ls("<%= entry %>", true);
|
||||
<% } %>
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ class HassSubpage extends LitElement {
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.content.not-scrollable {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -260,6 +260,8 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
private _typedSearch?: string;
|
||||
|
||||
private _restoreInitial = false;
|
||||
|
||||
private _queryParams = new QueryParamsController(
|
||||
this,
|
||||
dataTableQueryParamConfig,
|
||||
@@ -267,6 +269,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
enabled: () => this.syncUrl,
|
||||
navigated: () => {
|
||||
if (this._urlRead) {
|
||||
// Opened again without params, like from the sidebar
|
||||
this._restoreInitial =
|
||||
this._queryParams.searchParams()?.toString() === "";
|
||||
this._applyUrl();
|
||||
}
|
||||
},
|
||||
@@ -300,6 +305,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
// Done here, after the page has set the initial sorting and grouping it
|
||||
// stored
|
||||
if (this._restoreInitial) {
|
||||
this._restoreInitial = false;
|
||||
this._restoreInitialState();
|
||||
}
|
||||
|
||||
if (this.syncUrl && !this._urlRead && Object.keys(this.columns).length) {
|
||||
this._urlRead = true;
|
||||
this._applyUrl();
|
||||
@@ -708,8 +720,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
fireEvent(this, "clear-filter");
|
||||
}
|
||||
|
||||
// A link with table params wins over what was picked before, the page is
|
||||
// told so it keeps the same
|
||||
// A link with table params wins over what was picked before. The page is
|
||||
// told with `link` set, so it shows it without storing it as the user's
|
||||
// choice.
|
||||
private _applyUrl() {
|
||||
const searchParams = this._queryParams.searchParams();
|
||||
const linked = searchParams
|
||||
@@ -719,7 +732,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
if (linked) {
|
||||
if (linked.search !== this.filter) {
|
||||
this.filter = linked.search;
|
||||
fireEvent(this, "search-changed", { value: this.filter });
|
||||
fireEvent(this, "search-changed", { value: this.filter, link: true });
|
||||
}
|
||||
if (
|
||||
linked.sorting &&
|
||||
@@ -728,10 +741,10 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
) {
|
||||
this._sortColumn = linked.sorting.column;
|
||||
this._sortDirection = linked.sorting.direction;
|
||||
fireEvent(this, "sorting-changed", linked.sorting);
|
||||
fireEvent(this, "sorting-changed", { ...linked.sorting, link: true });
|
||||
}
|
||||
if (linked.groupColumn && linked.groupColumn !== this._groupColumn) {
|
||||
this._setGroupColumn(linked.groupColumn);
|
||||
this._setGroupColumn(linked.groupColumn, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,6 +752,36 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _restoreInitialState() {
|
||||
// Without a stored sort, use the column's default like a fresh load does
|
||||
const defaultColumn = Object.keys(this.columns).find(
|
||||
(columnId) => this.columns[columnId].direction
|
||||
);
|
||||
|
||||
const defaultDirection = defaultColumn
|
||||
? this.columns[defaultColumn].direction
|
||||
: undefined;
|
||||
|
||||
const sorting =
|
||||
this.initialSorting && this.columns[this.initialSorting.column]
|
||||
? this.initialSorting
|
||||
: defaultColumn && defaultDirection
|
||||
? { column: defaultColumn, direction: defaultDirection }
|
||||
: undefined;
|
||||
|
||||
this._sortColumn = sorting?.column;
|
||||
this._sortDirection = sorting?.direction ?? null;
|
||||
|
||||
const groupColumn =
|
||||
this.initialGroupColumn && this.columns[this.initialGroupColumn]
|
||||
? this.initialGroupColumn
|
||||
: "";
|
||||
|
||||
if (groupColumn !== (this._groupColumn ?? "")) {
|
||||
this._setGroupColumn(groupColumn, true);
|
||||
}
|
||||
}
|
||||
|
||||
private _flushUrl() {
|
||||
this._queryParams.flush();
|
||||
}
|
||||
@@ -807,9 +850,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
this._setGroupColumn(group);
|
||||
}
|
||||
|
||||
private _setGroupColumn(columnId: string) {
|
||||
private _setGroupColumn(columnId: string, link = false) {
|
||||
this._groupColumn = columnId;
|
||||
fireEvent(this, "grouping-changed", { value: columnId });
|
||||
fireEvent(this, "grouping-changed", { value: columnId, link });
|
||||
}
|
||||
|
||||
private _openSettings() {
|
||||
@@ -1126,8 +1169,9 @@ declare global {
|
||||
|
||||
// for fire event
|
||||
interface HASSDomEvents {
|
||||
"search-changed": { value: string };
|
||||
"grouping-changed": { value: string };
|
||||
// `link` is set when a link changed it, not the user
|
||||
"search-changed": { value: string; link?: boolean };
|
||||
"grouping-changed": { value: string; link?: boolean };
|
||||
"columns-changed": {
|
||||
columnOrder: string[] | undefined;
|
||||
hiddenColumns: string[] | undefined;
|
||||
|
||||
@@ -425,6 +425,7 @@ export class HassTabsSubpage extends LitElement {
|
||||
box-sizing: border-box;
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
:host([narrow]) .content {
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
|
||||
@@ -134,24 +134,15 @@ export const DirtyStateProviderMixin =
|
||||
this._dirtyStateContext = this._buildContextValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this provider holds off automatic frontend reloads, published
|
||||
* as the global dirty state. Override to block reloads for more than
|
||||
* unsaved edits.
|
||||
*/
|
||||
protected get _blocksReload(): boolean {
|
||||
return this.isDirtyState;
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
connectedDirtyStateProviders.set(this, this._blocksReload);
|
||||
connectedDirtyStateProviders.set(this, this.isDirtyState);
|
||||
publishGlobalDirtyState();
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>): void {
|
||||
super.updated(changedProperties);
|
||||
connectedDirtyStateProviders.set(this, this._blocksReload);
|
||||
connectedDirtyStateProviders.set(this, this.isDirtyState);
|
||||
publishGlobalDirtyState();
|
||||
}
|
||||
|
||||
|
||||
@@ -84,13 +84,13 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
|
||||
this._mql = window.matchMedia(
|
||||
"(max-width: 450px), all and (max-height: 500px)"
|
||||
);
|
||||
this._mql.addEventListener("change", this._setIsMobile);
|
||||
this._mql.addListener(this._setIsMobile);
|
||||
this.mobile = this._mql.matches;
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._mql?.removeEventListener("change", this._setIsMobile!);
|
||||
this._mql?.removeListener(this._setIsMobile!);
|
||||
this._mql = undefined;
|
||||
this._unsubscribeAll();
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
SelectionChangedEvent,
|
||||
SortingChangedEvent,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-help-tooltip";
|
||||
@@ -52,12 +52,10 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
@query("hass-tabs-subpage-data-table", true)
|
||||
private _dataTable!: HaTabsSubpageDataTable;
|
||||
|
||||
@storage({
|
||||
key: "application-credentials-table-sort",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _activeSorting?: SortingChangedEvent;
|
||||
private _tableSettings = new DataTableSettingsController(this, {
|
||||
search: "application-credentials-table-search",
|
||||
sorting: "application-credentials-table-sort",
|
||||
});
|
||||
|
||||
@storage({
|
||||
key: "application-credentials-table-column-order",
|
||||
@@ -73,15 +71,6 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
})
|
||||
private _activeHiddenColumns?: string[];
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
key: "application-credentials-table-search",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter = "";
|
||||
|
||||
private _columns = memoizeOne(
|
||||
(localize: LocalizeFunc): DataTableColumnContainer => {
|
||||
const columns: DataTableColumnContainer<ApplicationCredential> = {
|
||||
@@ -107,6 +96,7 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
urlName: "integration",
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
@@ -175,13 +165,14 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
selectable
|
||||
.selected=${this._selected.length}
|
||||
@selection-changed=${this._handleSelectionChanged}
|
||||
.initialSorting=${this._activeSorting}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
.hiddenColumns=${this._activeHiddenColumns}
|
||||
@columns-changed=${this._handleColumnsChanged}
|
||||
@sorting-changed=${this._handleSortingChanged}
|
||||
.filter=${this._filter}
|
||||
@search-changed=${this._handleSearchChange}
|
||||
@sorting-changed=${this._tableSettings.sortingChanged}
|
||||
.filter=${this._tableSettings.search}
|
||||
@search-changed=${this._tableSettings.searchChanged}
|
||||
sync-url
|
||||
>
|
||||
<div class="header-btns" slot="selection-bar">
|
||||
${
|
||||
@@ -323,19 +314,11 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSortingChanged(ev: CustomEvent) {
|
||||
this._activeSorting = ev.detail;
|
||||
}
|
||||
|
||||
private _handleColumnsChanged(ev: CustomEvent) {
|
||||
this._activeColumnOrder = ev.detail.columnOrder;
|
||||
this._activeHiddenColumns = ev.detail.hiddenColumns;
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.table-header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { mdiClose, mdiHelpCircleOutline } from "@mdi/js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
@@ -631,18 +632,6 @@ class DialogAddAutomationElement
|
||||
this._tab === "targets" ||
|
||||
(this._narrow && this._selectedGroup);
|
||||
|
||||
const addFromTargetHidden =
|
||||
!!this._getAddFromTargetHidden(this._narrow, this._selectedTarget) ||
|
||||
(this._narrow && !!this._selectedGroup);
|
||||
|
||||
const listColumnHidden =
|
||||
this._tab === "targets" ? addFromTargetHidden : !!hideCollections;
|
||||
|
||||
// The narrow target details have no paste bar, like the other details views.
|
||||
const showPasteFooter =
|
||||
this._tab !== "blocks" &&
|
||||
!(this._tab === "targets" && this._narrow && this._selectedTarget);
|
||||
|
||||
const collections = hideCollections
|
||||
? []
|
||||
: this._getCollections(
|
||||
@@ -731,147 +720,138 @@ class DialogAddAutomationElement
|
||||
@search-element-picked=${this._searchItemSelected}
|
||||
>
|
||||
</ha-automation-add-search>`
|
||||
: html`<div
|
||||
class=${classMap({
|
||||
"list-column": true,
|
||||
hidden: listColumnHidden,
|
||||
})}
|
||||
>
|
||||
${
|
||||
this._tab === "targets"
|
||||
? html`<ha-automation-add-from-target
|
||||
.hass=${this.hass}
|
||||
.value=${this._selectedTarget}
|
||||
@value-changed=${this._handleTargetSelected}
|
||||
@time-location-group-selected=${
|
||||
this._handleTimeLocationGroupSelected
|
||||
: this._tab === "targets"
|
||||
? html`<ha-automation-add-from-target
|
||||
.hass=${this.hass}
|
||||
.value=${this._selectedTarget}
|
||||
@value-changed=${this._handleTargetSelected}
|
||||
@time-location-group-selected=${
|
||||
this._handleTimeLocationGroupSelected
|
||||
}
|
||||
.narrow=${this._narrow}
|
||||
.timeLocationLabel=${this._getTimeLocationLabel(
|
||||
automationElementType
|
||||
)}
|
||||
.timeLocationGroups=${this._getTimeLocationGroups(
|
||||
automationElementType,
|
||||
this.hass.localize,
|
||||
automationElementType === "condition"
|
||||
? this._conditionDescriptions
|
||||
: this._triggerDescriptions
|
||||
)}
|
||||
.selectedGroup=${this._selectedGroup}
|
||||
.recentTargets=${this._recentTargets}
|
||||
@clear-recent-targets=${this._clearRecentTargets}
|
||||
class=${classMap({
|
||||
"ha-scrollbar": true,
|
||||
hidden:
|
||||
!!this._getAddFromTargetHidden(
|
||||
this._narrow,
|
||||
this._selectedTarget
|
||||
) ||
|
||||
(this._narrow && !!this._selectedGroup),
|
||||
})}
|
||||
.manifests=${this._manifests}
|
||||
.clipboardItem=${this._params!.clipboardItem}
|
||||
.automationElementType=${automationElementType}
|
||||
@paste-element=${this._paste}
|
||||
></ha-automation-add-from-target>`
|
||||
: html`
|
||||
<ha-list-base
|
||||
class=${classMap({
|
||||
groups: true,
|
||||
hidden: hideCollections,
|
||||
"ha-scrollbar": true,
|
||||
})}
|
||||
>
|
||||
<ha-automation-add-element-paste
|
||||
.automationElementType=${automationElementType}
|
||||
.clipboardItem=${this._params!.clipboardItem}
|
||||
@paste-element=${this._paste}
|
||||
divider
|
||||
></ha-automation-add-element-paste>
|
||||
${
|
||||
recentElements.length
|
||||
? html`<ha-section-title>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.recently_used"
|
||||
)}
|
||||
<ha-button
|
||||
class="clear-recent"
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.clear_recently_used"
|
||||
)}
|
||||
appearance="plain"
|
||||
variant="neutral"
|
||||
size="s"
|
||||
@click=${this._clearRecentElements}
|
||||
>
|
||||
${this.hass.localize("ui.common.clear")}
|
||||
</ha-button>
|
||||
</ha-section-title>
|
||||
${repeat(
|
||||
recentElements,
|
||||
({ item }) => `recent-${item.key}`,
|
||||
({ item, groupName }) =>
|
||||
this._renderRecentButton(item, groupName)
|
||||
)}`
|
||||
: nothing
|
||||
}
|
||||
${collections.map(
|
||||
(collection) => html`
|
||||
${
|
||||
collection.groups.length > 0 &&
|
||||
(!!collection.titleKey || recentElements.length > 0)
|
||||
? html`<ha-section-title>
|
||||
${
|
||||
collection.titleKey
|
||||
? this.hass.localize(collection.titleKey)
|
||||
: this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${automationElementType}s.name`
|
||||
)
|
||||
}
|
||||
</ha-section-title>`
|
||||
: nothing
|
||||
}
|
||||
.narrow=${this._narrow}
|
||||
.timeLocationLabel=${this._getTimeLocationLabel(
|
||||
automationElementType
|
||||
)}
|
||||
.timeLocationGroups=${this._getTimeLocationGroups(
|
||||
automationElementType,
|
||||
this.hass.localize,
|
||||
automationElementType === "condition"
|
||||
? this._conditionDescriptions
|
||||
: this._triggerDescriptions
|
||||
)}
|
||||
.selectedGroup=${this._selectedGroup}
|
||||
.recentTargets=${this._recentTargets}
|
||||
@clear-recent-targets=${this._clearRecentTargets}
|
||||
class=${classMap({
|
||||
"ha-scrollbar": true,
|
||||
hidden: addFromTargetHidden,
|
||||
})}
|
||||
.manifests=${this._manifests}
|
||||
></ha-automation-add-from-target>`
|
||||
: html`
|
||||
<ha-list-base
|
||||
class=${classMap({
|
||||
groups: true,
|
||||
hidden: hideCollections,
|
||||
"ha-scrollbar": true,
|
||||
})}
|
||||
>
|
||||
${
|
||||
recentElements.length
|
||||
? html`<ha-section-title>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.recently_used"
|
||||
)}
|
||||
<ha-button
|
||||
class="clear-recent"
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.clear_recently_used"
|
||||
)}
|
||||
appearance="plain"
|
||||
variant="neutral"
|
||||
size="s"
|
||||
@click=${this._clearRecentElements}
|
||||
>
|
||||
${this.hass.localize("ui.common.clear")}
|
||||
</ha-button>
|
||||
</ha-section-title>
|
||||
${repeat(
|
||||
recentElements,
|
||||
({ item }) => `recent-${item.key}`,
|
||||
({ item, groupName }) =>
|
||||
this._renderRecentButton(item, groupName)
|
||||
)}`
|
||||
: nothing
|
||||
}
|
||||
${collections.map(
|
||||
(collection) => html`
|
||||
${repeat(
|
||||
collection.groups,
|
||||
(item) => item.key,
|
||||
(item) => html`
|
||||
<ha-list-item-button
|
||||
.value=${item.key}
|
||||
.index=${collection.collectionIndex}
|
||||
@click=${this._groupSelected}
|
||||
class=${
|
||||
item.key === this._selectedGroup
|
||||
? "selected"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div slot="headline">${item.name}</div>
|
||||
${
|
||||
collection.groups.length > 0 &&
|
||||
(!!collection.titleKey ||
|
||||
recentElements.length > 0)
|
||||
? html`<ha-section-title>
|
||||
${
|
||||
collection.titleKey
|
||||
? this.hass.localize(
|
||||
collection.titleKey
|
||||
)
|
||||
: this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${automationElementType}s.name`
|
||||
)
|
||||
}
|
||||
</ha-section-title>`
|
||||
item.icon
|
||||
? html`<span slot="start">${item.icon}</span>`
|
||||
: item.iconPath
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${item.iconPath}
|
||||
></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._narrow
|
||||
? html`<ha-icon-next
|
||||
slot="end"
|
||||
></ha-icon-next>`
|
||||
: nothing
|
||||
}
|
||||
${repeat(
|
||||
collection.groups,
|
||||
(item) => item.key,
|
||||
(item) => html`
|
||||
<ha-list-item-button
|
||||
.value=${item.key}
|
||||
.index=${collection.collectionIndex}
|
||||
@click=${this._groupSelected}
|
||||
class=${
|
||||
item.key === this._selectedGroup
|
||||
? "selected"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div slot="headline">${item.name}</div>
|
||||
${
|
||||
item.icon
|
||||
? html`<span slot="start"
|
||||
>${item.icon}</span
|
||||
>`
|
||||
: item.iconPath
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${item.iconPath}
|
||||
></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._narrow
|
||||
? html`<ha-icon-next
|
||||
slot="end"
|
||||
></ha-icon-next>`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-item-button>
|
||||
`
|
||||
)}
|
||||
`
|
||||
)}
|
||||
</ha-list-base>
|
||||
</ha-list-item-button>
|
||||
`
|
||||
)}
|
||||
`
|
||||
}
|
||||
${
|
||||
showPasteFooter
|
||||
? html`<ha-automation-add-element-paste
|
||||
.automationElementType=${automationElementType}
|
||||
.clipboardItem=${this._params!.clipboardItem}
|
||||
@paste-element=${this._paste}
|
||||
></ha-automation-add-element-paste>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
)}
|
||||
</ha-list-base>
|
||||
`
|
||||
}
|
||||
${
|
||||
!this._filter
|
||||
@@ -879,7 +859,7 @@ class DialogAddAutomationElement
|
||||
<ha-automation-add-items
|
||||
.hass=${this.hass}
|
||||
.items=${this._getItems()}
|
||||
.scrollable=${!this._narrow || this._tab === "blocks"}
|
||||
.scrollable=${!this._narrow}
|
||||
.error=${
|
||||
this._tab === "targets" && this._loadItemsError
|
||||
? this.hass.localize(
|
||||
@@ -921,15 +901,6 @@ class DialogAddAutomationElement
|
||||
}
|
||||
@value-changed=${this._selected}
|
||||
>
|
||||
${
|
||||
this._tab === "blocks"
|
||||
? html`<ha-automation-add-element-paste
|
||||
.automationElementType=${automationElementType}
|
||||
.clipboardItem=${this._params!.clipboardItem}
|
||||
@paste-element=${this._paste}
|
||||
></ha-automation-add-element-paste>`
|
||||
: nothing
|
||||
}
|
||||
</ha-automation-add-items>
|
||||
`
|
||||
: nothing
|
||||
@@ -2552,16 +2523,6 @@ class DialogAddAutomationElement
|
||||
--ha-bottom-sheet-surface-background: var(--card-background-color);
|
||||
}
|
||||
|
||||
ha-bottom-sheet
|
||||
.list-column:has(> ha-automation-add-element-paste[clipboard-item]) {
|
||||
margin-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3));
|
||||
}
|
||||
ha-bottom-sheet
|
||||
ha-automation-add-items
|
||||
> ha-automation-add-element-paste {
|
||||
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3));
|
||||
}
|
||||
|
||||
ha-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
--ha-dialog-min-height: min(
|
||||
@@ -2622,46 +2583,29 @@ class DialogAddAutomationElement
|
||||
color: var(--ha-color-on-neutral-quiet);
|
||||
}
|
||||
|
||||
.list-column,
|
||||
.content.column ha-automation-add-from-target {
|
||||
ha-automation-add-from-target,
|
||||
.groups {
|
||||
border-radius: var(--ha-border-radius-xl);
|
||||
border: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
margin: var(--ha-space-3);
|
||||
overflow: auto;
|
||||
flex: 0 0 360px;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.list-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-column > ha-automation-add-from-target,
|
||||
.list-column > .groups {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.content.column .list-column {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@media (max-width: 870px), (max-height: 500px) {
|
||||
.list-column {
|
||||
ha-automation-add-from-target,
|
||||
.groups {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.list-column.hidden,
|
||||
ha-automation-add-from-target.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ha-bottom-sheet .list-column,
|
||||
ha-bottom-sheet .content.column ha-automation-add-from-target {
|
||||
ha-bottom-sheet .groups,
|
||||
ha-bottom-sheet ha-automation-add-from-target {
|
||||
margin: var(--ha-space-3);
|
||||
}
|
||||
.groups .selected {
|
||||
@@ -2726,15 +2670,6 @@ class DialogAddAutomationElement
|
||||
.groups {
|
||||
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3));
|
||||
}
|
||||
.list-column:has(> ha-automation-add-element-paste[clipboard-item])
|
||||
> .groups {
|
||||
padding-bottom: var(--ha-space-3);
|
||||
}
|
||||
ha-automation-add-items:has(
|
||||
> ha-automation-add-element-paste[clipboard-item]
|
||||
) {
|
||||
--ha-automation-add-items-padding-bottom: var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-icon-next {
|
||||
width: var(--ha-space-6);
|
||||
|
||||
+62
-32
@@ -1,64 +1,94 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiContentPaste } from "@mdi/js";
|
||||
import { mdiContentPaste, mdiPlus } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consume } from "../../../../common/decorators/consume";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
import "../../../../components/item/ha-list-item-button";
|
||||
import {
|
||||
internationalizationContext,
|
||||
narrowViewportContext,
|
||||
} from "../../../../data/context";
|
||||
import type { AddAutomationElementDialogParams } from "../show-add-automation-element-dialog";
|
||||
import { shortcutStyles } from "../styles";
|
||||
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
|
||||
|
||||
/**
|
||||
* @element ha-automation-add-element-paste
|
||||
*
|
||||
* @summary Paste bar at the bottom of the add automation element dialog's list pane.
|
||||
* Place it below the scrolling list.
|
||||
*
|
||||
* @attr clipboard-item - Type of the element on the clipboard. Nothing is shown when empty.
|
||||
*
|
||||
* @fires paste-element - Fired when the paste button is clicked.
|
||||
*/
|
||||
@customElement("ha-automation-add-element-paste")
|
||||
export class HaAutomationAddElementPaste extends LitElement {
|
||||
@property({ attribute: "clipboard-item", reflect: true })
|
||||
public clipboardItem?: string;
|
||||
@property({ attribute: "clipboard-item" }) public clipboardItem?;
|
||||
|
||||
@property({ attribute: "automation-element-type" })
|
||||
public automationElementType!: AddAutomationElementDialogParams["type"];
|
||||
|
||||
@property({ type: Boolean }) public divider = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
protected _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: narrowViewportContext, subscribe: true })
|
||||
protected _narrow!: ContextType<typeof narrowViewportContext>;
|
||||
|
||||
protected render() {
|
||||
if (!this.clipboardItem) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<ha-button appearance="filled" size="s" @click=${this._paste}>
|
||||
<ha-svg-icon slot="start" .path=${mdiContentPaste}></ha-svg-icon>
|
||||
${this._i18n.localize(
|
||||
`ui.panel.config.automation.editor.${this.automationElementType}s.paste_element`
|
||||
)}
|
||||
</ha-button>`;
|
||||
return html`<ha-list-item-button class="paste" @click=${this._paste}>
|
||||
<div slot="headline" class="label">
|
||||
${this._i18n.localize(
|
||||
`ui.panel.config.automation.editor.${this.automationElementType}s.paste`
|
||||
)}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this._i18n.localize(
|
||||
// @ts-ignore
|
||||
`ui.panel.config.automation.editor.${this.automationElementType}s.type.${this.clipboardItem}.label`
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
!this._narrow
|
||||
? html`<span slot="end" class="shortcut">
|
||||
<span>${renderCtrlOrCmd(this._i18n.localize)}</span>
|
||||
<span>+</span>
|
||||
<span>V</span>
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
<ha-svg-icon slot="start" .path=${mdiContentPaste}></ha-svg-icon
|
||||
><ha-svg-icon class="plus" slot="end" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-list-item-button>
|
||||
${this.divider ? html`<wa-divider></wa-divider>` : nothing}`;
|
||||
}
|
||||
|
||||
private _paste() {
|
||||
fireEvent(this, "paste-element");
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--ha-space-3);
|
||||
background-color: var(--ha-color-surface-low);
|
||||
}
|
||||
:host(:not([clipboard-item])) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
static styles = [
|
||||
shortcutStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ha-list-item-button {
|
||||
--ha-row-item-padding-block: var(--ha-space-1);
|
||||
--ha-row-item-padding-inline: var(--ha-space-3);
|
||||
--ha-row-item-min-height: 40px;
|
||||
}
|
||||
wa-divider {
|
||||
--spacing: 0;
|
||||
}
|
||||
|
||||
ha-svg-icon.plus {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
import type { HomeAssistant, HomeAssistantRegistries } from "../../../../types";
|
||||
import { brandsUrl } from "../../../../util/brands-url";
|
||||
import type { AddAutomationElementListItem } from "../add-automation-element-dialog";
|
||||
import type { AddAutomationElementDialogParams } from "../show-add-automation-element-dialog";
|
||||
import { getTargetIcon } from "../target/get_target_icon";
|
||||
import { getTargetText } from "../target/get_target_text";
|
||||
import {
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
parseRecentTarget,
|
||||
} from "../target/recent_targets";
|
||||
import { targetExists } from "../target/target_exists";
|
||||
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.
|
||||
@@ -125,6 +127,11 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public selectedGroup?: string;
|
||||
|
||||
@property({ attribute: false }) public clipboardItem?: string;
|
||||
|
||||
@property({ attribute: "automation-element-type" })
|
||||
public automationElementType!: AddAutomationElementDialogParams["type"];
|
||||
|
||||
@property({ attribute: false }) public recentTargets?: string[];
|
||||
|
||||
// #endregion properties
|
||||
@@ -235,6 +242,12 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
this.narrow && this.value
|
||||
? this._renderNarrow(this._entries, this.value)
|
||||
: html`
|
||||
<ha-list-base>
|
||||
<ha-automation-add-element-paste
|
||||
.automationElementType=${this.automationElementType}
|
||||
.clipboardItem=${this.clipboardItem}
|
||||
></ha-automation-add-element-paste>
|
||||
</ha-list-base>
|
||||
${this._renderRecentTargets(
|
||||
this.narrow,
|
||||
this._getRecentTargets(
|
||||
|
||||
@@ -25,15 +25,6 @@ import { getTargetIcon } from "../target/get_target_icon";
|
||||
|
||||
type Target = [string, string | undefined, string | undefined];
|
||||
|
||||
/**
|
||||
* @element ha-automation-add-items
|
||||
*
|
||||
* @summary List of automation elements to add, grouped by title.
|
||||
*
|
||||
* @slot - Footer below the scrolling items, e.g. an action bar.
|
||||
*
|
||||
* @cssprop --ha-automation-add-items-padding-bottom - Bottom padding of the items list.
|
||||
*/
|
||||
@customElement("ha-automation-add-items")
|
||||
export class HaAutomationAddItems extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -72,37 +63,36 @@ export class HaAutomationAddItems extends LitElement {
|
||||
|
||||
protected render() {
|
||||
return html`<div
|
||||
class=${classMap({
|
||||
items: true,
|
||||
blank: this.error || !this.items || !this.items.length,
|
||||
error: this.error,
|
||||
scrolled: this._itemsScrolled,
|
||||
"ha-scrollbar": this.scrollable,
|
||||
})}
|
||||
@scroll=${this._onItemsScroll}
|
||||
>
|
||||
${
|
||||
!this.items && !this.error
|
||||
? this.selectLabel
|
||||
: this.error
|
||||
? html`${this.error}
|
||||
<div>${this._renderTarget(this.target)}</div>`
|
||||
: this.items && !this.items.length
|
||||
? html`${this.emptyLabel}
|
||||
${
|
||||
this.target
|
||||
? html`<div>${this._renderTarget(this.target)}</div>`
|
||||
: nothing
|
||||
}`
|
||||
: repeat(
|
||||
this.items,
|
||||
(_, index) => `item-group-${index}`,
|
||||
(itemGroup) =>
|
||||
this._renderItemList(itemGroup.title, itemGroup.items)
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<slot></slot>`;
|
||||
class=${classMap({
|
||||
items: true,
|
||||
blank: this.error || !this.items || !this.items.length,
|
||||
error: this.error,
|
||||
scrolled: this._itemsScrolled,
|
||||
"ha-scrollbar": this.scrollable,
|
||||
})}
|
||||
@scroll=${this._onItemsScroll}
|
||||
>
|
||||
${
|
||||
!this.items && !this.error
|
||||
? this.selectLabel
|
||||
: this.error
|
||||
? html`${this.error}
|
||||
<div>${this._renderTarget(this.target)}</div>`
|
||||
: this.items && !this.items.length
|
||||
? html`${this.emptyLabel}
|
||||
${
|
||||
this.target
|
||||
? html`<div>${this._renderTarget(this.target)}</div>`
|
||||
: nothing
|
||||
}`
|
||||
: repeat(
|
||||
this.items,
|
||||
(_, index) => `item-group-${index}`,
|
||||
(itemGroup) =>
|
||||
this._renderItemList(itemGroup.title, itemGroup.items)
|
||||
)
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderItemList(title, items?: AddAutomationElementListItem[]) {
|
||||
@@ -225,7 +215,6 @@ export class HaAutomationAddItems extends LitElement {
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
:host([scrollable]) .items {
|
||||
@@ -236,7 +225,6 @@ export class HaAutomationAddItems extends LitElement {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.items.blank {
|
||||
border-radius: var(--ha-border-radius-xl);
|
||||
@@ -260,10 +248,7 @@ export class HaAutomationAddItems extends LitElement {
|
||||
--ha-list-gap: var(--ha-space-3);
|
||||
gap: var(--ha-space-2);
|
||||
padding: 0 var(--ha-space-4);
|
||||
padding-bottom: var(
|
||||
--ha-automation-add-items-padding-bottom,
|
||||
max(var(--safe-area-inset-bottom), var(--ha-space-3))
|
||||
);
|
||||
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3));
|
||||
}
|
||||
.items ha-list-base ha-list-item-button {
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
|
||||
@@ -1189,15 +1189,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
};
|
||||
}
|
||||
|
||||
protected supportedSingleKeyShortcuts(): SupportedShortcuts {
|
||||
if (!this._manualEditor?.sidebarOpen) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
Escape: () => this._manualEditor?.triggerCloseSidebar(),
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
private _collapseAll() {
|
||||
this._manualEditor?.collapseAll();
|
||||
|
||||
@@ -228,10 +228,6 @@ export const ManualEditorMixin = <TConfig>(
|
||||
);
|
||||
}
|
||||
|
||||
public get sidebarOpen(): boolean {
|
||||
return !!this.sidebarConfig;
|
||||
}
|
||||
|
||||
public triggerCloseSidebar() {
|
||||
if (this.sidebarConfig) {
|
||||
if (this.sidebarElement) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
mdiContentCut,
|
||||
mdiContentPaste,
|
||||
mdiDelete,
|
||||
mdiIdentifier,
|
||||
mdiPlayCircleOutline,
|
||||
mdiPlaylistEdit,
|
||||
mdiPlusCircleMultipleOutline,
|
||||
@@ -138,38 +137,23 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
${
|
||||
type !== "list"
|
||||
? html`<ha-dropdown-item
|
||||
slot="menu-items"
|
||||
value="edit_note"
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiCommentEditOutline}
|
||||
></ha-svg-icon>
|
||||
<div class="overflow-label">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.note.${(this.config.config as Exclude<Trigger, TriggerList>).note ? "edit" : "add"}`
|
||||
)}
|
||||
<span
|
||||
class="shortcut-placeholder ${isMac ? "mac" : ""}"
|
||||
></span>
|
||||
</div>
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item
|
||||
slot="menu-items"
|
||||
value="edit_id"
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiIdentifier}></ha-svg-icon>
|
||||
<div class="overflow-label">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.edit_id"
|
||||
)}
|
||||
<span
|
||||
class="shortcut-placeholder ${isMac ? "mac" : ""}"
|
||||
></span>
|
||||
</div>
|
||||
</ha-dropdown-item>`
|
||||
slot="menu-items"
|
||||
value="edit_note"
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiCommentEditOutline}
|
||||
></ha-svg-icon>
|
||||
<div class="overflow-label">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.note.${(this.config.config as Exclude<Trigger, TriggerList>).note ? "edit" : "add"}`
|
||||
)}
|
||||
<span
|
||||
class="shortcut-placeholder ${isMac ? "mac" : ""}"
|
||||
></span>
|
||||
</div>
|
||||
</ha-dropdown-item>`
|
||||
: nothing
|
||||
}
|
||||
<wa-divider slot="menu-items"></wa-divider>
|
||||
@@ -403,9 +387,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
case "edit_note":
|
||||
this.config.editNote();
|
||||
break;
|
||||
case "edit_id":
|
||||
this.config.editId();
|
||||
break;
|
||||
case "duplicate":
|
||||
this.config.duplicate();
|
||||
break;
|
||||
|
||||
@@ -377,3 +377,20 @@ export const overflowStyles = css`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const shortcutStyles = css`
|
||||
.shortcut {
|
||||
direction: ltr;
|
||||
--mdc-icon-size: var(--ha-space-3);
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-right: var(--ha-space-4);
|
||||
}
|
||||
.shortcut span {
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-family: var(--ha-font-family-code);
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
`;
|
||||
@@ -4,7 +4,6 @@ import memoizeOne from "memoize-one";
|
||||
import type {
|
||||
AutomationConfig,
|
||||
SidebarConfig,
|
||||
Trigger,
|
||||
TriggerCondition,
|
||||
} from "../../../../data/automation";
|
||||
import { ContextSubscriptionController } from "../../../../common/decorators/consume";
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
getExplicitTriggerIds,
|
||||
isGeneratedTriggerId,
|
||||
makeDuplicateTriggerIdsUnique,
|
||||
replaceLeafTrigger,
|
||||
updateTriggerCondition,
|
||||
} from "./automation-trigger-id";
|
||||
import { preserveAutomationRowKeys } from "../ha-automation-sortable-list-mixin";
|
||||
@@ -72,7 +70,6 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
showIndices: false,
|
||||
select: this._selectTriggerIds,
|
||||
fixDuplicateIds: this._fixDuplicateTriggerIds,
|
||||
updateTriggerId: this._updateTriggerId,
|
||||
},
|
||||
});
|
||||
_host.addController(this);
|
||||
@@ -182,23 +179,6 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Commit an explicit ID edit directly. Ordinary edits pass through cleanupRemovedIds,
|
||||
* which would treat a changed generated ID as a deleted trigger and strip its references.
|
||||
*/
|
||||
private _updateTriggerId = (original: Trigger, updated: Trigger): boolean => {
|
||||
const config = this._options.getConfig();
|
||||
if (!config || !this._options.canEdit()) {
|
||||
return false;
|
||||
}
|
||||
const triggers = replaceLeafTrigger(config.triggers, original, updated);
|
||||
if (triggers === config.triggers) {
|
||||
return false;
|
||||
}
|
||||
this._commit({ ...config, triggers });
|
||||
return true;
|
||||
};
|
||||
|
||||
private _commit(config: AutomationConfig) {
|
||||
const current = this._options.getConfig();
|
||||
if (config === current) {
|
||||
|
||||
@@ -47,11 +47,6 @@ export interface AutomationTriggerContext {
|
||||
select: (condition: TriggerCondition, ids: string[]) => void;
|
||||
/** Requests confirmation before migrating duplicate IDs throughout the automation. */
|
||||
fixDuplicateIds: () => Promise<void>;
|
||||
/**
|
||||
* Replace a leaf trigger after an explicit ID edit, leaving references to the old ID
|
||||
* dangling. Return false when the trigger is not one of the automation's triggers.
|
||||
*/
|
||||
updateTriggerId: (original: Trigger, updated: Trigger) => boolean;
|
||||
}
|
||||
|
||||
export const automationTriggerContext =
|
||||
@@ -168,16 +163,6 @@ const walkLeafTriggers = (
|
||||
return callback(triggers);
|
||||
};
|
||||
|
||||
/** Replace one leaf trigger by object identity; return the input unchanged if it is absent. */
|
||||
export const replaceLeafTrigger = (
|
||||
triggers: Trigger | Trigger[],
|
||||
original: Trigger,
|
||||
updated: Trigger
|
||||
): Trigger | Trigger[] =>
|
||||
walkLeafTriggers(triggers, (trigger) =>
|
||||
trigger === original ? updated : trigger
|
||||
);
|
||||
|
||||
/** Store only selected candidate IDs, matching options to their original trigger objects. */
|
||||
export const assignGeneratedTriggerIds = (
|
||||
triggers: Trigger | Trigger[],
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
mdiContentPaste,
|
||||
mdiDelete,
|
||||
mdiDotsVertical,
|
||||
mdiIdentifier,
|
||||
mdiPlayCircleOutline,
|
||||
mdiPlaylistEdit,
|
||||
mdiPlusCircleMultipleOutline,
|
||||
@@ -74,7 +73,6 @@ import {
|
||||
showPromptDialog,
|
||||
} from "../../../../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { isMac } from "../../../../util/is_mac";
|
||||
import { showEditorToast } from "../editor-toast";
|
||||
import "../ha-automation-editor-warning";
|
||||
@@ -408,24 +406,16 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
${
|
||||
type !== "list"
|
||||
? html`<ha-dropdown-item value="edit_note">
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiCommentEditOutline}
|
||||
></ha-svg-icon>
|
||||
${this._renderOverflowLabel(
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.note.${(this.trigger as Exclude<Trigger, TriggerList>).note ? "edit" : "add"}`
|
||||
)
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item value="edit_id" .disabled=${this.disabled}>
|
||||
<ha-svg-icon slot="icon" .path=${mdiIdentifier}></ha-svg-icon>
|
||||
${this._renderOverflowLabel(
|
||||
this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.edit_id"
|
||||
)
|
||||
)}
|
||||
</ha-dropdown-item>`
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiCommentEditOutline}
|
||||
></ha-svg-icon>
|
||||
${this._renderOverflowLabel(
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.note.${(this.trigger as Exclude<Trigger, TriggerList>).note ? "edit" : "add"}`
|
||||
)
|
||||
)}
|
||||
</ha-dropdown-item>`
|
||||
: nothing
|
||||
}
|
||||
<wa-divider></wa-divider>
|
||||
@@ -817,7 +807,6 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
this._renameTrigger();
|
||||
},
|
||||
editNote: this._editNoteTrigger,
|
||||
editId: this._editIdTrigger,
|
||||
toggleYamlMode: (yamlMode?: boolean) => {
|
||||
if (yamlMode === undefined || yamlMode !== this._yamlMode) {
|
||||
this._toggleYamlMode();
|
||||
@@ -962,69 +951,6 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
private _editIdTrigger = async (): Promise<void> => {
|
||||
if (isTriggerList(this.trigger)) return;
|
||||
const trigger = this.trigger;
|
||||
const result = await showPromptDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.edit_id_title"
|
||||
),
|
||||
textBelowInput: html`<ha-alert alert-type="info">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.edit_id_info",
|
||||
{
|
||||
link: html`<a
|
||||
href=${documentationUrl(
|
||||
this.hass,
|
||||
"/docs/automation/trigger/#trigger-id"
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.edit_id_learn_more"
|
||||
)}</a
|
||||
>`,
|
||||
}
|
||||
)}
|
||||
</ha-alert>`,
|
||||
inputLabel: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.id"
|
||||
),
|
||||
inputType: "string",
|
||||
placeholder: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.edit_id_placeholder"
|
||||
),
|
||||
defaultValue: trigger.id,
|
||||
showCloseButton: true,
|
||||
confirmText: this.hass.localize("ui.common.submit"),
|
||||
});
|
||||
if (result === null) {
|
||||
return;
|
||||
}
|
||||
const id = result.trim() === "" ? undefined : result;
|
||||
if (id === trigger.id) {
|
||||
return;
|
||||
}
|
||||
const value = { ...trigger };
|
||||
if (id === undefined) {
|
||||
delete value.id;
|
||||
} else {
|
||||
value.id = id;
|
||||
}
|
||||
// Bypass the editor's generated ID cleanup so references to the old ID dangle
|
||||
if (!this._triggers?.updateTriggerId(trigger, value)) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
if (this._selected && this.optionsInSidebar) {
|
||||
this.openSidebar(value); // refresh sidebar
|
||||
} else if (this._yamlMode) {
|
||||
this.triggerEditor?.yamlEditor?.setValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
private _editNoteTrigger = async (): Promise<void> => {
|
||||
if (isTriggerList(this.trigger)) return;
|
||||
const trigger = this.trigger;
|
||||
@@ -1179,9 +1105,6 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
case "edit_note":
|
||||
this._editNoteTrigger();
|
||||
break;
|
||||
case "edit_id":
|
||||
this._editIdTrigger();
|
||||
break;
|
||||
case "duplicate":
|
||||
this._duplicateTrigger();
|
||||
break;
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { live } from "lit/directives/live";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-blueprint-picker";
|
||||
import "../../../components/ha-card";
|
||||
import {
|
||||
combineVisible,
|
||||
getHiddenFields,
|
||||
} from "../../../components/ha-form/conditions";
|
||||
import type {
|
||||
HaFormDataContainer,
|
||||
HaFormVisible,
|
||||
} from "../../../components/ha-form/types";
|
||||
import "../../../components/ha-spinner";
|
||||
import "../../../components/ha-markdown";
|
||||
import "../../../components/ha-selector/ha-selector";
|
||||
@@ -22,7 +13,6 @@ import type { BlueprintAutomationConfig } from "../../../data/automation";
|
||||
import type {
|
||||
BlueprintInput,
|
||||
BlueprintInputSection,
|
||||
BlueprintMetaData,
|
||||
BlueprintOrError,
|
||||
Blueprints,
|
||||
} from "../../../data/blueprint";
|
||||
@@ -56,18 +46,19 @@ export abstract class HaBlueprintGenericEditor extends LitElement {
|
||||
}
|
||||
|
||||
// Values of all inputs, including those inside sections, falling back to
|
||||
// their defaults, so selector contexts and visible conditions can name any
|
||||
// other input
|
||||
// their defaults, so a selector context can name any other input
|
||||
private _inputValues = memoizeOne(
|
||||
(
|
||||
input: HaFormDataContainer | undefined,
|
||||
metadataInput: BlueprintMetaData["input"]
|
||||
): HaFormDataContainer => {
|
||||
const values: HaFormDataContainer = {};
|
||||
input: Record<string, unknown> | undefined,
|
||||
metadataInput:
|
||||
| Record<string, BlueprintInput | BlueprintInputSection | null>
|
||||
| undefined
|
||||
): Record<string, unknown> => {
|
||||
const values: Record<string, unknown> = {};
|
||||
const addInputs = (inputs: Record<string, BlueprintInput | null>) => {
|
||||
for (const [key, value] of Object.entries(inputs)) {
|
||||
values[key] =
|
||||
input?.[key] !== undefined ? input[key] : value?.default;
|
||||
input && key in input ? input[key] : (value?.default ?? undefined);
|
||||
}
|
||||
};
|
||||
for (const [key, value] of Object.entries(metadataInput ?? {})) {
|
||||
@@ -81,46 +72,6 @@ export abstract class HaBlueprintGenericEditor extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _hiddenInputs = memoizeOne(
|
||||
(
|
||||
values: HaFormDataContainer,
|
||||
metadataInput: BlueprintMetaData["input"]
|
||||
): Set<string> => {
|
||||
const fields: [string, HaFormVisible | undefined][] = [];
|
||||
for (const [key, value] of Object.entries(metadataInput ?? {})) {
|
||||
if (value && "input" in value) {
|
||||
for (const [inputKey, input] of Object.entries(value.input ?? {})) {
|
||||
fields.push([
|
||||
inputKey,
|
||||
combineVisible(value.visible, input?.visible),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
fields.push([key, value?.visible]);
|
||||
}
|
||||
}
|
||||
return getHiddenFields(fields, values);
|
||||
}
|
||||
);
|
||||
|
||||
private get _metadataInput() {
|
||||
return this._blueprint && "metadata" in this._blueprint
|
||||
? this._blueprint.metadata.input
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private get _values() {
|
||||
return this._inputValues(
|
||||
// The input holds the values of the inputs by key
|
||||
this._config.use_blueprint.input as HaFormDataContainer | undefined,
|
||||
this._metadataInput
|
||||
);
|
||||
}
|
||||
|
||||
private get _hidden() {
|
||||
return this._hiddenInputs(this._values, this._metadataInput);
|
||||
}
|
||||
|
||||
protected abstract get _config():
|
||||
BlueprintAutomationConfig | BlueprintScriptConfig;
|
||||
|
||||
@@ -179,25 +130,10 @@ export abstract class HaBlueprintGenericEditor extends LitElement {
|
||||
? Object.entries(blueprint.metadata.input).map(
|
||||
([key, value]) => {
|
||||
if (value && "input" in value) {
|
||||
const inputs = Object.entries(
|
||||
value.input ?? {}
|
||||
).filter(
|
||||
([inputKey]) => !this._hidden.has(inputKey)
|
||||
);
|
||||
if (!inputs.length) {
|
||||
return nothing;
|
||||
}
|
||||
const section = this._renderSection(
|
||||
key,
|
||||
value,
|
||||
inputs
|
||||
);
|
||||
const section = this._renderSection(key, value);
|
||||
border = false;
|
||||
return section;
|
||||
}
|
||||
if (this._hidden.has(key)) {
|
||||
return nothing;
|
||||
}
|
||||
const row = this._renderSettingRow(
|
||||
key,
|
||||
value,
|
||||
@@ -219,20 +155,18 @@ export abstract class HaBlueprintGenericEditor extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSection(
|
||||
sectionKey: string,
|
||||
section: BlueprintInputSection,
|
||||
inputs: [string, BlueprintInput | null][]
|
||||
) {
|
||||
private _renderSection(sectionKey: string, section: BlueprintInputSection) {
|
||||
const title = section?.name || sectionKey;
|
||||
const anyRequired = inputs.some(
|
||||
([, item]) => item === null || item.default === undefined
|
||||
);
|
||||
const anyRequired =
|
||||
section.input &&
|
||||
Object.values(section.input).some(
|
||||
(item) => item === null || item.default === undefined
|
||||
);
|
||||
const expanded = !section.collapsed || anyRequired;
|
||||
|
||||
return html`<ha-expansion-panel
|
||||
outlined
|
||||
.expanded=${anyRequired ? live(true) : expanded}
|
||||
.expanded=${expanded}
|
||||
.noCollapse=${anyRequired}
|
||||
>
|
||||
${
|
||||
@@ -255,7 +189,13 @@ export abstract class HaBlueprintGenericEditor extends LitElement {
|
||||
? html`<ha-markdown .content=${section.description}></ha-markdown>`
|
||||
: nothing
|
||||
}
|
||||
${inputs.map(([key, value]) => this._renderSettingRow(key, value, true))}
|
||||
${
|
||||
section.input
|
||||
? Object.entries(section.input).map(([key, value]) =>
|
||||
this._renderSettingRow(key, value, true)
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>`;
|
||||
}
|
||||
@@ -283,7 +223,17 @@ export abstract class HaBlueprintGenericEditor extends LitElement {
|
||||
.selector=${selector}
|
||||
.context=${
|
||||
value?.selector
|
||||
? resolveSelectorContext(value.selector, this._values)
|
||||
? resolveSelectorContext(
|
||||
value.selector,
|
||||
this._inputValues(
|
||||
// The input holds the values of the inputs by key
|
||||
this._config.use_blueprint.input as
|
||||
Record<string, unknown> | undefined,
|
||||
this._blueprint && "metadata" in this._blueprint
|
||||
? this._blueprint.metadata.input
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
.key=${key}
|
||||
|
||||
-11
@@ -12,8 +12,6 @@ import {
|
||||
type ESPHomeDeviceEntityRef,
|
||||
deriveESPHomeSetupStatus,
|
||||
getESPHomeAudioControls,
|
||||
getESPHomeBluetoothSwitch,
|
||||
hasESPHomeBluetoothSwitch,
|
||||
getESPHomeSetupCapabilityIds,
|
||||
hasESPHomeSetupCapabilities,
|
||||
isESPHomeSerialConfigured,
|
||||
@@ -191,21 +189,12 @@ export class ESPHomeSetupController implements ReactiveController {
|
||||
);
|
||||
if (
|
||||
!hasESPHomeSetupCapabilities(this._capabilities, {
|
||||
bluetoothSwitch: hasESPHomeBluetoothSwitch(
|
||||
deviceId,
|
||||
this._getEntities()
|
||||
),
|
||||
mediaPlayerSupported: audio.supported,
|
||||
})
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
let status = deriveESPHomeSetupStatus(this._capabilities, {
|
||||
bluetoothEnabled: getESPHomeBluetoothSwitch(
|
||||
deviceId,
|
||||
this._getEntities(),
|
||||
hass.states
|
||||
)?.on,
|
||||
mediaPlayerSupported: audio.supported,
|
||||
sendspinSupported: Boolean(audio.sendspinEntityId),
|
||||
sendspinEnabled: audio.sendspinOn,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { ifDefined } from "lit/directives/if-defined";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { ASSIST_ENTITIES, SENSOR_ENTITIES } from "../../../common/const";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
@@ -64,11 +65,6 @@ import {
|
||||
sortConfigEntries,
|
||||
} from "../../../data/config_entries";
|
||||
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
|
||||
import {
|
||||
DEVICE_ENTITY_CATEGORIES,
|
||||
computeDeviceEntityCategory,
|
||||
type DeviceEntityCategory,
|
||||
} from "../../../data/device/device_entity_category";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import {
|
||||
removeDeviceFromRegistry,
|
||||
@@ -309,11 +305,44 @@ export class HaConfigDevicePage extends LitElement {
|
||||
|
||||
private _entitiesByCategory = memoizeOne(
|
||||
(entities: EntityRegistryEntry[]) => {
|
||||
const result = groupBy(entities, computeDeviceEntityCategory) as Record<
|
||||
DeviceEntityCategory,
|
||||
const result = groupBy(entities, (entry) => {
|
||||
const domain = computeDomain(entry.entity_id);
|
||||
|
||||
if (ASSIST_ENTITIES.includes(domain)) {
|
||||
return "assist";
|
||||
}
|
||||
|
||||
if (domain === "event" || domain === "notify") {
|
||||
return domain;
|
||||
}
|
||||
|
||||
if (entry.entity_category) {
|
||||
return entry.entity_category;
|
||||
}
|
||||
|
||||
if (SENSOR_ENTITIES.includes(domain)) {
|
||||
return "sensor";
|
||||
}
|
||||
|
||||
return "control";
|
||||
}) as Record<
|
||||
| "control"
|
||||
| "event"
|
||||
| "sensor"
|
||||
| "assist"
|
||||
| "notify"
|
||||
| NonNullable<EntityRegistryEntry["entity_category"]>,
|
||||
EntityRegistryStateEntry[]
|
||||
>;
|
||||
for (const key of DEVICE_ENTITY_CATEGORIES) {
|
||||
for (const key of [
|
||||
"assist",
|
||||
"config",
|
||||
"control",
|
||||
"diagnostic",
|
||||
"event",
|
||||
"notify",
|
||||
"sensor",
|
||||
]) {
|
||||
if (!(key in result)) {
|
||||
result[key] = [];
|
||||
}
|
||||
@@ -856,7 +885,17 @@ export class HaConfigDevicePage extends LitElement {
|
||||
`;
|
||||
|
||||
const entitiesColumn = html`
|
||||
${DEVICE_ENTITY_CATEGORIES.map((category) =>
|
||||
${(
|
||||
[
|
||||
"control",
|
||||
"sensor",
|
||||
"notify",
|
||||
"event",
|
||||
"assist",
|
||||
"config",
|
||||
"diagnostic",
|
||||
] as const
|
||||
).map((category) =>
|
||||
// Make sure we render controls if no other cards will be rendered
|
||||
entitiesByCategory[category].length > 0 ||
|
||||
(entities.length === 0 && category === "control")
|
||||
|
||||
@@ -45,7 +45,6 @@ const defaultFullCalendarConfig: CalendarOptions = {
|
||||
locales: allLocales,
|
||||
firstDay: 1,
|
||||
dayHeaderFormat: { weekday: "short", month: undefined, day: undefined },
|
||||
dayLaneInnerClass: "ha-fc-lane",
|
||||
columnEventClass: "ha-fc-block",
|
||||
columnEventTimeClass: ({ isShort }) => (isShort ? "" : "ha-fc-block-time"),
|
||||
};
|
||||
@@ -461,15 +460,6 @@ class HaScheduleForm extends LitElement {
|
||||
.ha-fc-block-time {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Older browsers drop the inline logical insets that place blocks.
|
||||
Blocks never overlap, so each one fills its day. */
|
||||
@supports not (inset-inline-start: 0) {
|
||||
.ha-fc-lane > * {
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
|
||||
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
@@ -35,6 +36,7 @@ import type {
|
||||
import {
|
||||
helpersFilterQueryParamConfig,
|
||||
helpersFilterQueryParams,
|
||||
helpersFilterQueryString,
|
||||
helpersLinkFromUrl,
|
||||
} from "../../../common/url/helpers-query-params";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
@@ -46,7 +48,6 @@ import type {
|
||||
DataTableColumnContainer,
|
||||
RowClickedEvent,
|
||||
SelectionChangedEvent,
|
||||
SortingChangedEvent,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/data-table/ha-data-table-labels";
|
||||
import "../../../components/ha-button";
|
||||
@@ -193,11 +194,15 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ attribute: false }) public cloudStatus?: CloudStatus;
|
||||
|
||||
@storage({ key: "helpers-table-sort", state: false, subscribe: false })
|
||||
private _activeSorting?: SortingChangedEvent;
|
||||
|
||||
@storage({ key: "helpers-table-grouping", state: false, subscribe: false })
|
||||
private _activeGrouping?: string;
|
||||
private _tableSettings = new DataTableSettingsController(
|
||||
this,
|
||||
{
|
||||
search: "helpers-table-search",
|
||||
sorting: "helpers-table-sort",
|
||||
grouping: "helpers-table-grouping",
|
||||
},
|
||||
{ defaultGrouping: "category" }
|
||||
);
|
||||
|
||||
@storage({
|
||||
key: "helpers-table-collapsed",
|
||||
@@ -206,15 +211,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
})
|
||||
private _activeCollapsed?: string;
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
key: "helpers-table-search",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter = "";
|
||||
|
||||
@storage({
|
||||
key: "helpers-table-column-order",
|
||||
state: false,
|
||||
@@ -692,21 +688,21 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
.columns=${this._columns(this.hass.localize, helpers)}
|
||||
.data=${helpers}
|
||||
.initialGroupColumn=${this._activeGrouping ?? "category"}
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._activeSorting}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
.hiddenColumns=${this._activeHiddenColumns}
|
||||
@columns-changed=${this._handleColumnsChanged}
|
||||
@sorting-changed=${this._handleSortingChanged}
|
||||
@grouping-changed=${this._handleGroupingChanged}
|
||||
@sorting-changed=${this._tableSettings.sortingChanged}
|
||||
@grouping-changed=${this._tableSettings.groupingChanged}
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
.activeFilters=${this._activeFilters}
|
||||
@clear-filter=${this._clearFilter}
|
||||
@row-click=${this._openEditDialog}
|
||||
sync-url
|
||||
.filter=${this._filter}
|
||||
@search-changed=${this._handleSearchChange}
|
||||
.filter=${this._tableSettings.search}
|
||||
@search-changed=${this._tableSettings.searchChanged}
|
||||
has-fab
|
||||
clickable
|
||||
.noDataText=${this.hass.localize(
|
||||
@@ -874,10 +870,22 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _filterChanged(ev) {
|
||||
const type = ev.target.localName;
|
||||
const filters = { ...this._filters, [type]: ev.detail.value };
|
||||
|
||||
this._filters = { ...this._filters, [type]: ev.detail.value };
|
||||
// A filter also sends the value it was given, once it has found its items.
|
||||
// Only a change to what the URL holds is the user's, so a link isn't stored.
|
||||
if (
|
||||
helpersFilterQueryString(filters) !==
|
||||
helpersFilterQueryString(this._filters)
|
||||
) {
|
||||
this._storageFilters = {
|
||||
...this._storageFilters,
|
||||
[type]: ev.detail.value,
|
||||
};
|
||||
}
|
||||
|
||||
this._filters = filters;
|
||||
this._filteredItems = { ...this._filteredItems, [type]: ev.detail.items };
|
||||
this._storageFilters = this._filters;
|
||||
this._writeFilters();
|
||||
this._applyFilters();
|
||||
}
|
||||
@@ -1025,11 +1033,19 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
const searchParams = this._filterParams.searchParams();
|
||||
const link = searchParams ? helpersLinkFromUrl(searchParams) : undefined;
|
||||
|
||||
// Shown, but not stored as the user's choice
|
||||
if (link) {
|
||||
this._filter = link.search;
|
||||
this._tableSettings.showSearch(link.search);
|
||||
this._filters = link.filters;
|
||||
this._storageFilters = link.filters;
|
||||
this._applyFilters();
|
||||
} else if (searchParams?.toString() === "") {
|
||||
// Opened again without params, like from the sidebar. Written once the
|
||||
// table has seen the URL without params, so it restores what it stored
|
||||
this._filters = this._storageFilters;
|
||||
this._applyFilters();
|
||||
queueMicrotask(() => this._writeFilters());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this._writeFilters();
|
||||
@@ -1573,22 +1589,10 @@ ${rejected
|
||||
}
|
||||
};
|
||||
|
||||
private _handleSortingChanged(ev: CustomEvent) {
|
||||
this._activeSorting = ev.detail;
|
||||
}
|
||||
|
||||
private _handleGroupingChanged(ev: CustomEvent) {
|
||||
this._activeGrouping = ev.detail.value ?? "";
|
||||
}
|
||||
|
||||
private _handleCollapseChanged(ev: CustomEvent) {
|
||||
this._activeCollapsed = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleColumnsChanged(ev: CustomEvent) {
|
||||
this._activeColumnOrder = ev.detail.columnOrder;
|
||||
this._activeHiddenColumns = ev.detail.hiddenColumns;
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { mdiFileCodeOutline, mdiPackageVariant, mdiWeb } from "@mdi/js";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
import {
|
||||
devicesContext,
|
||||
internationalizationContext,
|
||||
} from "../../../data/context";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import type { HomeAssistantInternationalization } from "../../../types";
|
||||
import type { ConfigEntryExtended } from "./ha-config-integrations";
|
||||
|
||||
@customElement("ha-integration-card-footer")
|
||||
export class HaIntegrationCardFooter extends LitElement {
|
||||
@property({ attribute: false }) public manifest?: IntegrationManifest;
|
||||
|
||||
@property({ attribute: false }) public items!: ConfigEntryExtended[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public entityRegistryEntries!: EntityRegistryEntry[];
|
||||
|
||||
@property({ attribute: false }) public domainEntities: string[] = [];
|
||||
|
||||
@state()
|
||||
@consume({ context: devicesContext, subscribe: true })
|
||||
private _devices!: ContextType<typeof devicesContext>;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@transform<HomeAssistantInternationalization, boolean>({
|
||||
transformer: ({ language, translationMetadata }) =>
|
||||
computeRTL(language, translationMetadata.translations),
|
||||
})
|
||||
private _isRTL!: boolean;
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
const devices = this._getDevices(this.items, this._devices);
|
||||
const entitiesCount = devices.length
|
||||
? 0
|
||||
: this._getEntityCount(
|
||||
this.items,
|
||||
this.entityRegistryEntries,
|
||||
this.domainEntities
|
||||
);
|
||||
|
||||
const services = !devices.some((device) => device.entry_type !== "service");
|
||||
|
||||
const hasIcons =
|
||||
Boolean(this.manifest && !this.manifest.is_built_in) ||
|
||||
Boolean(this.manifest?.iot_class?.startsWith("cloud_")) ||
|
||||
Boolean(
|
||||
this.manifest &&
|
||||
!this.manifest.config_flow &&
|
||||
!this.items.every((itm) => itm.source === "system")
|
||||
);
|
||||
|
||||
let countBadge: TemplateResult | typeof nothing = nothing;
|
||||
if (devices.length > 0) {
|
||||
countBadge = html`<span class="count-badge"
|
||||
>${this._localize(
|
||||
`ui.panel.config.integrations.config_entry.${services ? "services" : "devices"}`,
|
||||
{ count: devices.length }
|
||||
)}</span
|
||||
>`;
|
||||
} else if (entitiesCount > 0) {
|
||||
countBadge = html`<span class="count-badge"
|
||||
>${this._localize(
|
||||
`ui.panel.config.integrations.config_entry.entities`,
|
||||
{ count: entitiesCount }
|
||||
)}</span
|
||||
>`;
|
||||
} else if (this.items.find((itm) => itm.source !== "yaml")) {
|
||||
countBadge = html`<span class="count-badge"
|
||||
>${this._localize(`ui.panel.config.integrations.config_entry.entries`, {
|
||||
count: this.items.filter((itm) => itm.source !== "yaml").length,
|
||||
})}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
if (countBadge === nothing && !hasIcons) return nothing;
|
||||
|
||||
const placement = this._isRTL ? "right" : "left";
|
||||
|
||||
return html`
|
||||
<div class="footer">
|
||||
${countBadge}
|
||||
<div class="icons">
|
||||
${
|
||||
this.manifest && !this.manifest.is_built_in
|
||||
? html`<span
|
||||
class="icon ${
|
||||
this.manifest.overwrites_built_in ? "overwrites" : "custom"
|
||||
}"
|
||||
>
|
||||
<ha-svg-icon
|
||||
id="icon-custom"
|
||||
.path=${mdiPackageVariant}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip for="icon-custom" .placement=${placement}>
|
||||
${this._localize(
|
||||
this.manifest.overwrites_built_in
|
||||
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
|
||||
: "ui.panel.config.integrations.config_entry.custom_integration"
|
||||
)}
|
||||
</ha-tooltip>
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.manifest && this.manifest.iot_class?.startsWith("cloud_")
|
||||
? html`<span class="icon cloud">
|
||||
<ha-svg-icon id="icon-cloud" .path=${mdiWeb}></ha-svg-icon>
|
||||
<ha-tooltip for="icon-cloud" .placement=${placement}>
|
||||
${this._localize(
|
||||
"ui.panel.config.integrations.config_entry.depends_on_cloud"
|
||||
)}
|
||||
</ha-tooltip>
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.manifest &&
|
||||
!this.manifest?.config_flow &&
|
||||
!this.items.every((itm) => itm.source === "system")
|
||||
? html`<span class="icon yaml">
|
||||
<ha-svg-icon
|
||||
id="icon-yaml"
|
||||
.path=${mdiFileCodeOutline}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip for="icon-yaml" .placement=${placement}>
|
||||
${this._localize(
|
||||
"ui.panel.config.integrations.config_entry.no_config_flow"
|
||||
)}
|
||||
</ha-tooltip>
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _getEntityCount = memoizeOne(
|
||||
(
|
||||
configEntry: ConfigEntryExtended[],
|
||||
entityRegistryEntries: EntityRegistryEntry[],
|
||||
domainEntities: string[]
|
||||
): number => {
|
||||
if (!entityRegistryEntries) {
|
||||
return domainEntities.length;
|
||||
}
|
||||
|
||||
const entryIds = configEntry
|
||||
.map((entry) => entry.entry_id)
|
||||
.filter(Boolean);
|
||||
|
||||
if (!entryIds.length) {
|
||||
return domainEntities.length;
|
||||
}
|
||||
|
||||
const entityRegEntities = entityRegistryEntries.filter(
|
||||
(entity) =>
|
||||
entity.config_entry_id && entryIds.includes(entity.config_entry_id)
|
||||
);
|
||||
|
||||
if (entityRegEntities.length === domainEntities.length) {
|
||||
return domainEntities.length;
|
||||
}
|
||||
|
||||
const entityIds = new Set<string>(
|
||||
entityRegEntities.map((reg) => reg.entity_id)
|
||||
);
|
||||
|
||||
for (const entity of domainEntities) {
|
||||
entityIds.add(entity);
|
||||
}
|
||||
|
||||
return entityIds.size;
|
||||
}
|
||||
);
|
||||
|
||||
private _getDevices = memoizeOne(
|
||||
(
|
||||
configEntry: ConfigEntryExtended[],
|
||||
deviceRegistryEntries: ContextType<typeof devicesContext>
|
||||
): DeviceRegistryEntry[] => {
|
||||
if (!deviceRegistryEntries) {
|
||||
return [];
|
||||
}
|
||||
const entryIds = configEntry.map((entry) => entry.entry_id);
|
||||
return Object.values(deviceRegistryEntries).filter((device) =>
|
||||
device.config_entries.some((entryId) => entryIds.includes(entryId))
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: var(--ha-space-2);
|
||||
min-height: var(--ha-space-8);
|
||||
}
|
||||
.count-badge {
|
||||
color: var(--ha-color-text-secondary);
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
.icons {
|
||||
display: flex;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.icon {
|
||||
color: var(--label-badge-grey);
|
||||
padding: var(--ha-space-1);
|
||||
margin-inline-start: var(--ha-space-2);
|
||||
}
|
||||
.icon.custom {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
.icon.overwrites {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.icon ha-svg-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-integration-card-footer": HaIntegrationCardFooter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { mdiAlertCircleOutline, mdiAlertOutline } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { configContext, uiContext } from "../../../data/context";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import type { HomeAssistantConfig, HomeAssistantUI } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
|
||||
@customElement("ha-integration-card-header")
|
||||
export class HaIntegrationCardHeader extends LitElement {
|
||||
@property() public error?: string;
|
||||
|
||||
@property() public warning?: string;
|
||||
|
||||
@property({ attribute: false }) public localizedDomainName?: string;
|
||||
|
||||
@property() public domain!: string;
|
||||
|
||||
@property({ attribute: false }) public manifest?: IntegrationManifest;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, boolean | undefined>({
|
||||
transformer: ({ themes }) => themes?.darkMode,
|
||||
})
|
||||
private _darkMode?: boolean;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<HomeAssistantConfig, string>({
|
||||
transformer: ({ auth }) => auth.data.hassUrl,
|
||||
})
|
||||
private _hassUrl!: string;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const domainName =
|
||||
this.localizedDomainName ||
|
||||
domainToName(this._localize, this.domain, this.manifest);
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<img
|
||||
alt=""
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: this.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this._darkMode,
|
||||
},
|
||||
this._hassUrl
|
||||
)}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
@error=${this._onImageError}
|
||||
@load=${this._onImageLoad}
|
||||
/>
|
||||
<div class="info">
|
||||
<div
|
||||
class="primary ${this.warning || this.error ? "has-secondary" : ""}"
|
||||
role="heading"
|
||||
aria-level="1"
|
||||
>
|
||||
${domainName}
|
||||
</div>
|
||||
${
|
||||
this.error
|
||||
? html`
|
||||
<div class="secondary error">
|
||||
<ha-svg-icon .path=${mdiAlertCircleOutline}></ha-svg-icon>
|
||||
<span>${this.error}</span>
|
||||
</div>
|
||||
`
|
||||
: this.warning
|
||||
? html`
|
||||
<div class="secondary warning">
|
||||
<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>
|
||||
<span>${this.warning}</span>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _onImageLoad(ev) {
|
||||
ev.target.style.visibility = "initial";
|
||||
}
|
||||
|
||||
private _onImageError(ev) {
|
||||
ev.target.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
flex: 1;
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
direction: var(--direction);
|
||||
min-width: 0;
|
||||
}
|
||||
.header img {
|
||||
margin-inline-start: initial;
|
||||
margin-inline-end: var(--ha-space-4);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
direction: var(--direction);
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.primary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.has-secondary {
|
||||
-webkit-line-clamp: 1;
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
.secondary {
|
||||
min-width: 0;
|
||||
--mdc-icon-size: 20px;
|
||||
font-size: var(--ha-font-size-s);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.secondary > span {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.secondary > ha-svg-icon {
|
||||
margin-inline-end: var(--ha-space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.error ha-svg-icon {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.warning ha-svg-icon {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-integration-card-header": HaIntegrationCardHeader;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,31 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiBugOutline,
|
||||
mdiFileCodeOutline,
|
||||
mdiPackageVariant,
|
||||
mdiWeb,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-ripple";
|
||||
import "../../../components/ha-spinner";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
import "../../../components/item/ha-row-item";
|
||||
import type { ConfigEntry } from "../../../data/config_entries";
|
||||
import { ERROR_STATES } from "../../../data/config_entries";
|
||||
import {
|
||||
configContext,
|
||||
devicesContext,
|
||||
uiContext,
|
||||
} from "../../../data/context";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type {
|
||||
IntegrationLogInfo,
|
||||
IntegrationManifest,
|
||||
} from "../../../data/integration";
|
||||
import { domainToName, LogSeverity } from "../../../data/integration";
|
||||
import { LogSeverity } from "../../../data/integration";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistantConfig, HomeAssistantUI } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import type { ConfigEntryExtended } from "./ha-config-integrations";
|
||||
import type {
|
||||
IntegrationCardStatusLevel,
|
||||
IntegrationCardSummary,
|
||||
} from "./integration-card-summary";
|
||||
import {
|
||||
CARD_STATE_LEVEL,
|
||||
computeIntegrationCardState,
|
||||
computeIntegrationCardSummary,
|
||||
} from "./integration-card-summary";
|
||||
|
||||
interface IntegrationCardStatus {
|
||||
level: IntegrationCardStatusLevel;
|
||||
icon?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface IntegrationCardIcon {
|
||||
id: string;
|
||||
path: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
import "./ha-integration-card-footer";
|
||||
import "./ha-integration-card-header";
|
||||
|
||||
@customElement("ha-integration-card")
|
||||
export class HaIntegrationCard extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@property() public domain!: string;
|
||||
|
||||
@property({ attribute: false }) public items!: ConfigEntryExtended[];
|
||||
@@ -77,54 +39,22 @@ export class HaIntegrationCard extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public domainEntities: string[] = [];
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: devicesContext, subscribe: true })
|
||||
private _devices!: ContextType<typeof devicesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, boolean | undefined>({
|
||||
transformer: ({ themes }) => themes?.darkMode,
|
||||
})
|
||||
private _darkMode?: boolean;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<HomeAssistantConfig, string>({
|
||||
transformer: ({ auth }) => auth.data.hassUrl,
|
||||
})
|
||||
private _hassUrl!: string;
|
||||
|
||||
// There is no hover on touch devices, so a tap on a tooltip anchor shows
|
||||
// the tooltip instead of following the card link.
|
||||
private _lastPointerType?: string;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const entryState = computeIntegrationCardState(this.items);
|
||||
const isError = ERROR_STATES.includes(entryState);
|
||||
const debugLoggingEnabled = this.logInfo?.level === LogSeverity.DEBUG;
|
||||
const entryState = this._getState(this.items);
|
||||
|
||||
const status = this._computeStatus(entryState, debugLoggingEnabled);
|
||||
|
||||
const domainName =
|
||||
this.items[0].localized_domain_name ||
|
||||
domainToName(this._localize, this.domain, this.manifest);
|
||||
const debugLoggingEnabled =
|
||||
this.logInfo && this.logInfo.level === LogSeverity.DEBUG;
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
class=${classMap({
|
||||
"state-loaded": entryState === "loaded",
|
||||
"state-not-loaded": entryState === "not_loaded",
|
||||
"all-not-loaded": this.items.every(
|
||||
(entry) => entry.state === "not_loaded"
|
||||
),
|
||||
"state-failed-unload": entryState === "failed_unload",
|
||||
"state-error": isError,
|
||||
"debug-logging": debugLoggingEnabled,
|
||||
"state-setup": entryState === "setup_in_progress",
|
||||
"state-error": ERROR_STATES.includes(entryState),
|
||||
"debug-logging": Boolean(debugLoggingEnabled),
|
||||
})}
|
||||
>
|
||||
<a
|
||||
@@ -132,277 +62,55 @@ export class HaIntegrationCard extends LitElement {
|
||||
class="ripple-anchor"
|
||||
>
|
||||
<ha-ripple></ha-ripple>
|
||||
<ha-row-item>
|
||||
<img
|
||||
slot="start"
|
||||
alt=""
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: this.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this._darkMode,
|
||||
},
|
||||
this._hassUrl
|
||||
)}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
@error=${this._onImageError}
|
||||
@load=${this._onImageLoad}
|
||||
/>
|
||||
<span slot="headline" class="name" role="heading" aria-level="1"
|
||||
>${domainName}</span
|
||||
>
|
||||
${this._renderSupportingText(status)}
|
||||
</ha-row-item>
|
||||
<div class="card-content">
|
||||
<ha-integration-card-header
|
||||
.domain=${this.domain}
|
||||
.localizedDomainName=${this.items[0].localized_domain_name}
|
||||
.error=${
|
||||
ERROR_STATES.includes(entryState)
|
||||
? this._localize(
|
||||
`ui.panel.config.integrations.config_entry.state.${entryState}`
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
.warning=${
|
||||
entryState !== "loaded" && !ERROR_STATES.includes(entryState)
|
||||
? this._localize(
|
||||
`ui.panel.config.integrations.config_entry.state.${entryState}`
|
||||
)
|
||||
: debugLoggingEnabled
|
||||
? this._localize(
|
||||
"ui.panel.config.integrations.config_entry.debug_logging_enabled"
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
.manifest=${this.manifest}
|
||||
></ha-integration-card-header>
|
||||
<ha-integration-card-footer
|
||||
.manifest=${this.manifest}
|
||||
.items=${this.items}
|
||||
.entityRegistryEntries=${this.entityRegistryEntries}
|
||||
.domainEntities=${this.domainEntities}
|
||||
></ha-integration-card-footer>
|
||||
</div>
|
||||
</a>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _computeStatus(
|
||||
entryState: ConfigEntry["state"],
|
||||
debugLoggingEnabled: boolean
|
||||
): IntegrationCardStatus | undefined {
|
||||
const level = CARD_STATE_LEVEL[entryState];
|
||||
if (level) {
|
||||
return {
|
||||
level,
|
||||
icon: level === "progress" ? undefined : mdiAlertCircle,
|
||||
message: this._localize(
|
||||
`ui.panel.config.integrations.config_entry.state.${entryState}`
|
||||
),
|
||||
};
|
||||
}
|
||||
if (debugLoggingEnabled) {
|
||||
return {
|
||||
level: "warning",
|
||||
icon: mdiBugOutline,
|
||||
message: this._localize(
|
||||
"ui.panel.config.integrations.config_entry.debug_logging_enabled"
|
||||
),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _computeSupportingText(
|
||||
summary?: IntegrationCardSummary,
|
||||
status?: IntegrationCardStatus
|
||||
): string {
|
||||
// Initializing, unloading and debug logging have no attention count,
|
||||
// so their message is shown instead of the plain count.
|
||||
if (status && !summary?.attention) {
|
||||
return status.message;
|
||||
}
|
||||
if (!summary) {
|
||||
return this._localize(
|
||||
"ui.panel.config.integrations.config_entry.no_devices_or_entities"
|
||||
);
|
||||
}
|
||||
if (!summary.attention) {
|
||||
return this._localize(
|
||||
`ui.panel.config.integrations.config_entry.${summary.unit}`,
|
||||
{ count: summary.count }
|
||||
);
|
||||
}
|
||||
const attentionState = summary.attentionState ?? "other";
|
||||
if (summary.attention >= summary.count) {
|
||||
return this._localize(
|
||||
"ui.panel.config.integrations.config_entry.attention.all",
|
||||
{ count: summary.count, state: attentionState }
|
||||
);
|
||||
}
|
||||
return this._localize(
|
||||
"ui.panel.config.integrations.config_entry.attention.some",
|
||||
{
|
||||
count: summary.count,
|
||||
attention: summary.attention,
|
||||
state: attentionState,
|
||||
private _getState = memoizeOne(
|
||||
(configEntry: ConfigEntry[]): ConfigEntry["state"] => {
|
||||
if (configEntry.length === 1) {
|
||||
return configEntry[0].state;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _renderSupportingText(
|
||||
status?: IntegrationCardStatus
|
||||
): TemplateResult {
|
||||
const devices = this._getDevices(this.items, this._devices);
|
||||
const summary = computeIntegrationCardSummary(
|
||||
this.items,
|
||||
devices,
|
||||
devices.length
|
||||
? 0
|
||||
: this._getEntityCount(
|
||||
this.items,
|
||||
this.entityRegistryEntries,
|
||||
this.domainEntities
|
||||
)
|
||||
);
|
||||
|
||||
const text = this._computeSupportingText(summary, status);
|
||||
|
||||
return html`<span slot="supporting-text" class="supporting-text">
|
||||
${
|
||||
status
|
||||
? html`<span class="status status-${status.level}">
|
||||
${
|
||||
status.icon
|
||||
? html`<ha-svg-icon
|
||||
.path=${status.icon}
|
||||
aria-hidden="true"
|
||||
></ha-svg-icon>`
|
||||
: html`<ha-spinner
|
||||
size="tiny"
|
||||
aria-hidden="true"
|
||||
></ha-spinner>`
|
||||
}
|
||||
<span class="text">${text}</span>
|
||||
</span>`
|
||||
: html`<span class="text">${text}</span>`
|
||||
let entryState: ConfigEntry["state"];
|
||||
for (const entry of configEntry) {
|
||||
if (ERROR_STATES.includes(entry.state)) {
|
||||
return entry.state;
|
||||
}
|
||||
entryState = entry.state;
|
||||
}
|
||||
${this._renderIcons()}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
private _renderIcons(): TemplateResult | typeof nothing {
|
||||
const manifest = this.manifest;
|
||||
if (!manifest) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const icons: IntegrationCardIcon[] = [];
|
||||
|
||||
if (!manifest.is_built_in) {
|
||||
icons.push({
|
||||
id: "icon-custom",
|
||||
path: mdiPackageVariant,
|
||||
label: this._localize(
|
||||
manifest.overwrites_built_in
|
||||
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
|
||||
: "ui.panel.config.integrations.config_entry.custom_integration"
|
||||
),
|
||||
className: manifest.overwrites_built_in ? "overwrites" : "custom",
|
||||
});
|
||||
}
|
||||
if (manifest.iot_class?.startsWith("cloud_")) {
|
||||
icons.push({
|
||||
id: "icon-cloud",
|
||||
path: mdiWeb,
|
||||
label: this._localize(
|
||||
"ui.panel.config.integrations.config_entry.depends_on_cloud"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (
|
||||
!manifest.config_flow &&
|
||||
!this.items.every((itm) => itm.source === "system")
|
||||
) {
|
||||
icons.push({
|
||||
id: "icon-yaml",
|
||||
path: mdiFileCodeOutline,
|
||||
label: this._localize(
|
||||
"ui.panel.config.integrations.config_entry.no_config_flow"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (!icons.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<span class="icons">
|
||||
${icons.map(
|
||||
({ id, path, label, className }) => html`
|
||||
<span
|
||||
id=${id}
|
||||
class="icon ${className ?? ""}"
|
||||
role="img"
|
||||
aria-label=${label}
|
||||
@pointerdown=${this._handleTooltipPointerDown}
|
||||
@click=${this._handleTooltipClick}
|
||||
>
|
||||
<ha-svg-icon .path=${path}></ha-svg-icon>
|
||||
</span>
|
||||
<ha-tooltip for=${id} trigger="hover focus click"
|
||||
>${label}</ha-tooltip
|
||||
>
|
||||
`
|
||||
)}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleTooltipPointerDown(ev: PointerEvent) {
|
||||
this._lastPointerType = ev.pointerType;
|
||||
}
|
||||
|
||||
private _handleTooltipClick(ev: MouseEvent) {
|
||||
if (this._lastPointerType !== "touch") {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
private _onImageLoad(ev: Event) {
|
||||
(ev.target as HTMLImageElement).style.visibility = "initial";
|
||||
}
|
||||
|
||||
private _onImageError(ev: Event) {
|
||||
(ev.target as HTMLImageElement).style.visibility = "hidden";
|
||||
}
|
||||
|
||||
private _getEntityCount = memoizeOne(
|
||||
(
|
||||
configEntry: ConfigEntryExtended[],
|
||||
entityRegistryEntries: EntityRegistryEntry[],
|
||||
domainEntities: string[]
|
||||
): number => {
|
||||
if (!entityRegistryEntries) {
|
||||
return domainEntities.length;
|
||||
}
|
||||
|
||||
const entryIds = configEntry
|
||||
.map((entry) => entry.entry_id)
|
||||
.filter(Boolean);
|
||||
|
||||
if (!entryIds.length) {
|
||||
return domainEntities.length;
|
||||
}
|
||||
|
||||
const entityRegEntities = entityRegistryEntries.filter(
|
||||
(entity) =>
|
||||
entity.config_entry_id && entryIds.includes(entity.config_entry_id)
|
||||
);
|
||||
|
||||
if (entityRegEntities.length === domainEntities.length) {
|
||||
return domainEntities.length;
|
||||
}
|
||||
|
||||
const entityIds = new Set<string>(
|
||||
entityRegEntities.map((reg) => reg.entity_id)
|
||||
);
|
||||
|
||||
for (const entity of domainEntities) {
|
||||
entityIds.add(entity);
|
||||
}
|
||||
|
||||
return entityIds.size;
|
||||
}
|
||||
);
|
||||
|
||||
private _getDevices = memoizeOne(
|
||||
(
|
||||
configEntry: ConfigEntryExtended[],
|
||||
deviceRegistryEntries: ContextType<typeof devicesContext>
|
||||
): DeviceRegistryEntry[] => {
|
||||
if (!deviceRegistryEntries) {
|
||||
return [];
|
||||
}
|
||||
const entryIds = configEntry.map((entry) => entry.entry_id);
|
||||
return Object.values(deviceRegistryEntries).filter((device) =>
|
||||
device.config_entries.some((entryId) => entryIds.includes(entryId))
|
||||
);
|
||||
return entryState!;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -417,6 +125,7 @@ export class HaIntegrationCard extends LitElement {
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
--state-color: var(--divider-color, #e0e0e0);
|
||||
--state-message-color: var(--state-color);
|
||||
}
|
||||
ha-card:hover {
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
@@ -439,104 +148,41 @@ export class HaIntegrationCard extends LitElement {
|
||||
background-color: var(--secondary-text-color);
|
||||
opacity: 0.08;
|
||||
}
|
||||
ha-row-item {
|
||||
--ha-row-item-padding-block: var(--ha-space-4);
|
||||
--ha-row-item-padding-inline: var(--ha-space-4);
|
||||
--ha-row-item-gap: var(--ha-space-4);
|
||||
}
|
||||
ha-row-item::part(headline) {
|
||||
font-size: var(--ha-font-size-l);
|
||||
}
|
||||
ha-row-item::part(supporting-text) {
|
||||
overflow: visible;
|
||||
}
|
||||
.name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.supporting-text {
|
||||
.card-content {
|
||||
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-1);
|
||||
min-height: calc(
|
||||
var(--ha-font-size-s) * var(--ha-line-height-normal)
|
||||
);
|
||||
}
|
||||
.supporting-text .text,
|
||||
.status .text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-1);
|
||||
min-width: 0;
|
||||
line-height: 16px;
|
||||
}
|
||||
.status ha-svg-icon,
|
||||
.status ha-spinner {
|
||||
flex-shrink: 0;
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
.status ha-spinner {
|
||||
--track-width: 2px;
|
||||
}
|
||||
.status-error ha-svg-icon {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.status-warning ha-svg-icon {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
.icons {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: calc(var(--ha-space-1) * -1);
|
||||
}
|
||||
.icon {
|
||||
display: flex;
|
||||
padding: var(--ha-space-1);
|
||||
/* 24px touch target around the 16px icon */
|
||||
margin-block: calc(var(--ha-space-1) * -1);
|
||||
color: var(--label-badge-grey);
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
.icon.custom {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
.icon.overwrites {
|
||||
color: var(--error-color);
|
||||
flex-direction: column;
|
||||
}
|
||||
.debug-logging {
|
||||
--state-color: var(--warning-color);
|
||||
--ha-card-border-color: var(--state-color);
|
||||
--text-on-state-color: var(--primary-text-color);
|
||||
}
|
||||
.state-error {
|
||||
--state-color: var(--error-color);
|
||||
--ha-card-border-color: var(--state-color);
|
||||
--text-on-state-color: var(--text-primary-color);
|
||||
}
|
||||
.state-failed-unload {
|
||||
--state-color: var(--warning-color);
|
||||
--ha-card-border-color: var(--state-color);
|
||||
--text-on-state-color: var(--primary-text-color);
|
||||
}
|
||||
.state-not-loaded {
|
||||
opacity: 0.8;
|
||||
--state-color: var(--warning-color);
|
||||
--ha-card-border-color: var(--state-color);
|
||||
--state-message-color: var(--primary-text-color);
|
||||
}
|
||||
.all-not-loaded {
|
||||
.state-setup {
|
||||
opacity: 0.8;
|
||||
--state-message-color: var(--secondary-text-color);
|
||||
}
|
||||
:host(.highlight) ha-card {
|
||||
--state-color: var(--primary-color);
|
||||
--ha-card-border-color: var(--state-color);
|
||||
--text-on-state-color: var(--text-primary-color);
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import type { ConfigEntry } from "../../../data/config_entries";
|
||||
import { ERROR_STATES } from "../../../data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
|
||||
export type IntegrationCardSummaryUnit =
|
||||
"devices" | "services" | "entities" | "entries";
|
||||
|
||||
export interface IntegrationCardSummary {
|
||||
unit: IntegrationCardSummaryUnit;
|
||||
count: number;
|
||||
/** Number of config entries needing attention, counted in `entries`. */
|
||||
attention: number;
|
||||
/**
|
||||
* The state shared by all config entries needing attention, or `undefined`
|
||||
* when they are in different states.
|
||||
*/
|
||||
attentionState?: ConfigEntry["state"];
|
||||
}
|
||||
|
||||
// Transient states (setup/unload in progress) and "loaded" don't need attention.
|
||||
const ATTENTION_STATES: ConfigEntry["state"][] = [
|
||||
...ERROR_STATES,
|
||||
"not_loaded",
|
||||
"failed_unload",
|
||||
];
|
||||
|
||||
// Card state priority, most important first.
|
||||
const CARD_STATE_PRIORITY: ConfigEntry["state"][] = [
|
||||
...ATTENTION_STATES,
|
||||
"setup_in_progress",
|
||||
"unload_in_progress",
|
||||
];
|
||||
|
||||
export type IntegrationCardStatusLevel = "error" | "warning" | "progress";
|
||||
|
||||
export const CARD_STATE_LEVEL: Partial<
|
||||
Record<ConfigEntry["state"], IntegrationCardStatusLevel>
|
||||
> = {
|
||||
setup_error: "error",
|
||||
setup_retry: "error",
|
||||
migration_error: "error",
|
||||
not_loaded: "warning",
|
||||
failed_unload: "warning",
|
||||
setup_in_progress: "progress",
|
||||
unload_in_progress: "progress",
|
||||
};
|
||||
|
||||
export const configEntryNeedsAttention = (entry: ConfigEntry): boolean =>
|
||||
ATTENTION_STATES.includes(entry.state);
|
||||
|
||||
/**
|
||||
* The state the integration card shows for a group of config entries: the
|
||||
* most important state of any entry, so the card never looks healthy while
|
||||
* one of its entries needs attention.
|
||||
*/
|
||||
export const computeIntegrationCardState = (
|
||||
entries: ConfigEntry[]
|
||||
): ConfigEntry["state"] => {
|
||||
for (const cardState of CARD_STATE_PRIORITY) {
|
||||
if (entries.some((entry) => entry.state === cardState)) {
|
||||
return cardState;
|
||||
}
|
||||
}
|
||||
return "loaded";
|
||||
};
|
||||
|
||||
/**
|
||||
* Entries needing attention are always counted as entries, not devices or
|
||||
* entities: a failing entry may own none of them, and only entries are what
|
||||
* the user retries or reconfigures.
|
||||
*/
|
||||
export const computeIntegrationCardSummary = (
|
||||
entries: ConfigEntry[],
|
||||
devices: DeviceRegistryEntry[],
|
||||
entityCount: number
|
||||
): IntegrationCardSummary | undefined => {
|
||||
const attentionEntries = entries.filter(configEntryNeedsAttention);
|
||||
const uiEntries = entries.filter((entry) => entry.source !== "yaml");
|
||||
|
||||
if (attentionEntries.length) {
|
||||
const attentionStates = new Set(
|
||||
attentionEntries.map((entry) => entry.state)
|
||||
);
|
||||
return {
|
||||
unit: "entries",
|
||||
count: Math.max(uiEntries.length, attentionEntries.length),
|
||||
attention: attentionEntries.length,
|
||||
attentionState:
|
||||
attentionStates.size === 1 ? [...attentionStates][0] : undefined,
|
||||
};
|
||||
}
|
||||
if (devices.length) {
|
||||
return {
|
||||
unit: devices.every((device) => device.entry_type === "service")
|
||||
? "services"
|
||||
: "devices",
|
||||
count: devices.length,
|
||||
attention: 0,
|
||||
};
|
||||
}
|
||||
if (entityCount > 0) {
|
||||
return { unit: "entities", count: entityCount, attention: 0 };
|
||||
}
|
||||
if (uiEntries.length) {
|
||||
return { unit: "entries", count: uiEntries.length, attention: 0 };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -1068,15 +1068,6 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
};
|
||||
}
|
||||
|
||||
protected supportedSingleKeyShortcuts(): SupportedShortcuts {
|
||||
if (!this._manualEditor?.sidebarOpen) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
Escape: () => this._manualEditor?.triggerCloseSidebar(),
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
private _collapseAll() {
|
||||
this._manualEditor?.collapseAll();
|
||||
|
||||
@@ -3,12 +3,12 @@ import type { PropertyValues } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
RowClickedEvent,
|
||||
SortingChangedEvent,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/data-table/ha-data-table-icon";
|
||||
import "../../../components/ha-button";
|
||||
@@ -44,11 +44,11 @@ export class HaConfigUsers extends LitElement {
|
||||
|
||||
@state() private _loadFailed = false;
|
||||
|
||||
@storage({ key: "users-table-sort", state: false, subscribe: false })
|
||||
private _activeSorting?: SortingChangedEvent;
|
||||
|
||||
@storage({ key: "users-table-grouping", state: false, subscribe: false })
|
||||
private _activeGrouping?: string;
|
||||
private _tableSettings = new DataTableSettingsController(this, {
|
||||
search: "users-table-search",
|
||||
sorting: "users-table-sort",
|
||||
grouping: "users-table-grouping",
|
||||
});
|
||||
|
||||
@storage({
|
||||
key: "users-table-column-order",
|
||||
@@ -64,14 +64,6 @@ export class HaConfigUsers extends LitElement {
|
||||
})
|
||||
private _activeHiddenColumns?: string[];
|
||||
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
key: "users-table-search",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter = "";
|
||||
|
||||
@storage({
|
||||
key: "users-table-collapsed",
|
||||
state: false,
|
||||
@@ -195,14 +187,14 @@ export class HaConfigUsers extends LitElement {
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
.hiddenColumns=${this._activeHiddenColumns}
|
||||
@columns-changed=${this._handleColumnsChanged}
|
||||
.initialGroupColumn=${this._activeGrouping}
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._activeSorting}
|
||||
@sorting-changed=${this._handleSortingChanged}
|
||||
@grouping-changed=${this._handleGroupingChanged}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
@sorting-changed=${this._tableSettings.sortingChanged}
|
||||
@grouping-changed=${this._tableSettings.groupingChanged}
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
.filter=${this._filter}
|
||||
@search-changed=${this._handleSearchChange}
|
||||
.filter=${this._tableSettings.search}
|
||||
@search-changed=${this._tableSettings.searchChanged}
|
||||
sync-url
|
||||
@row-click=${this._editUser}
|
||||
has-fab
|
||||
@@ -308,22 +300,10 @@ export class HaConfigUsers extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSortingChanged(ev: CustomEvent) {
|
||||
this._activeSorting = ev.detail;
|
||||
}
|
||||
|
||||
private _handleGroupingChanged(ev: CustomEvent) {
|
||||
this._activeGrouping = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleCollapseChanged(ev: CustomEvent) {
|
||||
this._activeCollapsed = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleColumnsChanged(ev: CustomEvent) {
|
||||
this._activeColumnOrder = ev.detail.columnOrder;
|
||||
this._activeHiddenColumns = ev.detail.hiddenColumns;
|
||||
|
||||
@@ -118,7 +118,9 @@ export class AssistPipelineRunDebug extends LitElement {
|
||||
appearance="filled"
|
||||
@click=${this._runAudioPipeline}
|
||||
.disabled=${
|
||||
!window.isSecureContext || !("AudioContext" in window)
|
||||
!window.isSecureContext ||
|
||||
// @ts-ignore-next-line
|
||||
!(window.AudioContext || window.webkitAudioContext)
|
||||
}
|
||||
>
|
||||
${this.hass.localize(
|
||||
@@ -129,7 +131,9 @@ export class AssistPipelineRunDebug extends LitElement {
|
||||
appearance="filled"
|
||||
@click=${this._runAudioWakeWordPipeline}
|
||||
.disabled=${
|
||||
!window.isSecureContext || !("AudioContext" in window)
|
||||
!window.isSecureContext ||
|
||||
// @ts-ignore-next-line
|
||||
!(window.AudioContext || window.webkitAudioContext)
|
||||
}
|
||||
>
|
||||
${this.hass.localize(
|
||||
|
||||
@@ -984,6 +984,7 @@ export class HuiEnergySourcesTableCard
|
||||
width: 100%;
|
||||
}
|
||||
.mdc-data-table__table-container {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -242,7 +242,8 @@ export class HuiCardEditMode extends LitElement {
|
||||
}
|
||||
|
||||
private _cutCard(): void {
|
||||
fireEvent(this, "ll-cut-card", { path: this.path! });
|
||||
fireEvent(this, "ll-copy-card", { path: this.path! });
|
||||
fireEvent(this, "ll-delete-card", { path: this.path!, silent: true });
|
||||
}
|
||||
|
||||
private _copyCard(): void {
|
||||
|
||||
@@ -297,7 +297,8 @@ export class HuiCardOptions extends LitElement {
|
||||
}
|
||||
|
||||
private _cutCard(): void {
|
||||
fireEvent(this, "ll-cut-card", { path: this.path! });
|
||||
fireEvent(this, "ll-copy-card", { path: this.path! });
|
||||
fireEvent(this, "ll-delete-card", { path: this.path!, silent: true });
|
||||
}
|
||||
|
||||
private _copyCard(): void {
|
||||
|
||||
@@ -105,7 +105,7 @@ export class HuiImage extends LitElement {
|
||||
this._loadState = LoadState.Loading;
|
||||
}
|
||||
if (this.cameraImage && this.cameraView !== "live") {
|
||||
this._startIntersectionObserver();
|
||||
this._startIntersectionObserverOrUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export class HuiImage extends LitElement {
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
|
||||
if (this._shouldStartCameraUpdates(oldHass)) {
|
||||
this._startIntersectionObserver();
|
||||
this._startIntersectionObserverOrUpdates();
|
||||
} else if (!this.hass!.connected) {
|
||||
this._stopUpdateCameraInterval();
|
||||
this._stopIntersectionObserver();
|
||||
@@ -356,13 +356,20 @@ export class HuiImage extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private _startIntersectionObserver(): void {
|
||||
if (!this._intersectionObserver) {
|
||||
this._intersectionObserver = new IntersectionObserver(
|
||||
this.handleIntersectionCallback.bind(this)
|
||||
);
|
||||
private _startIntersectionObserverOrUpdates(): void {
|
||||
if ("IntersectionObserver" in window) {
|
||||
if (!this._intersectionObserver) {
|
||||
this._intersectionObserver = new IntersectionObserver(
|
||||
this.handleIntersectionCallback.bind(this)
|
||||
);
|
||||
}
|
||||
this._intersectionObserver.observe(this);
|
||||
} else {
|
||||
// No support for IntersectionObserver
|
||||
// assume all images are visible
|
||||
this._imageVisible = true;
|
||||
this._startUpdateCameraInterval();
|
||||
}
|
||||
this._intersectionObserver.observe(this);
|
||||
}
|
||||
|
||||
private _stopIntersectionObserver(): void {
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { STRINGS_SEPARATOR_DOT } from "../../../../common/const";
|
||||
import { computeAreaName } from "../../../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { getDeviceArea } from "../../../../common/entity/context/get_device_context";
|
||||
import { computeEntityEntryName } from "../../../../common/entity/compute_entity_name_display";
|
||||
import { stringCompare } from "../../../../common/string/compare";
|
||||
import {
|
||||
computeDeviceEntityCategory,
|
||||
type DeviceEntityCategory,
|
||||
} from "../../../../data/device/device_entity_category";
|
||||
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
|
||||
import { findBatteryEntity } from "../../../../data/entity/entity_registry";
|
||||
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type {
|
||||
EntitiesCardConfig,
|
||||
HeadingCardConfig,
|
||||
MarkdownCardConfig,
|
||||
TileCardConfig,
|
||||
} from "../../cards/types";
|
||||
import type { EntityConfig, SectionConfig } from "../../entity-rows/types";
|
||||
import type {
|
||||
LovelaceStrategyDependency,
|
||||
LovelaceStrategyEditor,
|
||||
} from "../types";
|
||||
|
||||
export const DEVICE_SECTION_CATEGORIES = [
|
||||
"control",
|
||||
"sensor",
|
||||
"event",
|
||||
"config",
|
||||
"diagnostic",
|
||||
] as const satisfies readonly DeviceEntityCategory[];
|
||||
|
||||
export type DeviceSectionCategory = (typeof DEVICE_SECTION_CATEGORIES)[number];
|
||||
|
||||
export const DEFAULT_DEVICE_SECTION_CATEGORIES: DeviceSectionCategory[] = [
|
||||
"control",
|
||||
"sensor",
|
||||
];
|
||||
|
||||
export const DEVICE_SECTION_HEADINGS = ["none", "device", "area"] as const;
|
||||
|
||||
export type DeviceSectionHeading = (typeof DEVICE_SECTION_HEADINGS)[number];
|
||||
|
||||
export interface DeviceSectionStrategyConfig {
|
||||
type: "device";
|
||||
device_id?: string;
|
||||
categories?: DeviceSectionCategory[];
|
||||
display?: "tiles" | "list";
|
||||
group_by_category?: boolean;
|
||||
heading?: DeviceSectionHeading;
|
||||
exclude_entities?: string[];
|
||||
}
|
||||
|
||||
const computeTileConfig = (entityId: string): TileCardConfig => ({
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
name: { type: "entity" },
|
||||
});
|
||||
|
||||
const computeRowConfig = (entityId: string): EntityConfig => ({
|
||||
entity: entityId,
|
||||
name: { type: "entity" },
|
||||
});
|
||||
|
||||
const computeDeviceSectionHeading = (
|
||||
device: DeviceRegistryEntry,
|
||||
heading: Exclude<DeviceSectionHeading, "none">,
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
localize: HomeAssistant["localize"]
|
||||
): string => {
|
||||
const area =
|
||||
heading === "area" ? getDeviceArea(device, areas, devices) : undefined;
|
||||
const parentDevice =
|
||||
device.next_name_part === "parent_device" && device.parent_device_id
|
||||
? devices[device.parent_device_id]
|
||||
: undefined;
|
||||
|
||||
const parts = [
|
||||
area ? computeAreaName(area) : undefined,
|
||||
parentDevice ? computeDeviceName(parentDevice) : undefined,
|
||||
computeDeviceName(device) ||
|
||||
localize("ui.components.device-picker.unnamed_device"),
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join(STRINGS_SEPARATOR_DOT);
|
||||
};
|
||||
|
||||
@customElement("device-section-strategy")
|
||||
export class DeviceSectionStrategy extends ReactiveElement {
|
||||
static registryDependencies: readonly LovelaceStrategyDependency[] = [
|
||||
"entities",
|
||||
"devices",
|
||||
"areas",
|
||||
];
|
||||
|
||||
public static async getConfigElement(): Promise<LovelaceStrategyEditor> {
|
||||
await import("./hui-device-section-strategy-editor");
|
||||
return document.createElement("hui-device-section-strategy-editor");
|
||||
}
|
||||
|
||||
public static getStubConfig(
|
||||
hass: HomeAssistant
|
||||
): DeviceSectionStrategyConfig {
|
||||
const categories: readonly DeviceEntityCategory[] =
|
||||
DEFAULT_DEVICE_SECTION_CATEGORIES;
|
||||
const shownEntries = Object.values(hass.entities).filter(
|
||||
(entry) =>
|
||||
entry.device_id &&
|
||||
!entry.hidden &&
|
||||
hass.devices[entry.device_id]?.entry_type !== "service" &&
|
||||
categories.includes(computeDeviceEntityCategory(entry))
|
||||
);
|
||||
const climateSensor = shownEntries.find((entry) => {
|
||||
if (computeDomain(entry.entity_id) !== "sensor") {
|
||||
return false;
|
||||
}
|
||||
const deviceClass = hass.states[entry.entity_id]?.attributes.device_class;
|
||||
return deviceClass === "temperature" || deviceClass === "humidity";
|
||||
});
|
||||
const previewEntry = climateSensor ?? shownEntries[0];
|
||||
|
||||
return { type: "device", device_id: previewEntry?.device_id };
|
||||
}
|
||||
|
||||
static async generate(
|
||||
config: DeviceSectionStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceSectionConfig> {
|
||||
const section: LovelaceSectionConfig = {
|
||||
type: "grid",
|
||||
cards: [],
|
||||
};
|
||||
|
||||
const device = config.device_id
|
||||
? hass.devices[config.device_id]
|
||||
: undefined;
|
||||
|
||||
if (!device) {
|
||||
section.cards!.push({
|
||||
type: "markdown",
|
||||
content: hass.localize(
|
||||
config.device_id
|
||||
? "ui.panel.lovelace.strategy.device.device_not_found"
|
||||
: "ui.panel.lovelace.strategy.device.no_device"
|
||||
),
|
||||
} satisfies MarkdownCardConfig);
|
||||
return section;
|
||||
}
|
||||
|
||||
const heading = config.heading ?? "device";
|
||||
|
||||
const categories: readonly DeviceEntityCategory[] =
|
||||
config.categories ?? DEFAULT_DEVICE_SECTION_CATEGORIES;
|
||||
|
||||
const entries = Object.values(hass.entities)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.device_id === device.id &&
|
||||
!entry.hidden &&
|
||||
!config.exclude_entities?.includes(entry.entity_id)
|
||||
)
|
||||
.sort((entry1, entry2) => {
|
||||
const name1 = computeEntityEntryName(entry1);
|
||||
const name2 = computeEntityEntryName(entry2);
|
||||
if (!name1 || !name2) {
|
||||
return Number(!!name1) - Number(!!name2);
|
||||
}
|
||||
return stringCompare(name1, name2, hass.locale.language);
|
||||
});
|
||||
|
||||
const batteryEntry =
|
||||
heading !== "none" ? findBatteryEntity(hass.states, entries) : undefined;
|
||||
|
||||
if (heading !== "none") {
|
||||
section.cards!.push({
|
||||
type: "heading",
|
||||
heading: computeDeviceSectionHeading(
|
||||
device,
|
||||
heading,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.localize
|
||||
),
|
||||
tap_action: hass.user?.is_admin
|
||||
? {
|
||||
action: "navigate",
|
||||
navigation_path: `/config/devices/device/${device.id}`,
|
||||
}
|
||||
: { action: "none" },
|
||||
badges: batteryEntry
|
||||
? [
|
||||
{
|
||||
type: "entity",
|
||||
entity: batteryEntry.entity_id,
|
||||
tap_action: { action: "more-info" },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
} satisfies HeadingCardConfig);
|
||||
}
|
||||
|
||||
const entitiesByCategory = new Map<DeviceEntityCategory, string[]>();
|
||||
for (const entry of entries) {
|
||||
if (entry === batteryEntry) continue;
|
||||
const category = computeDeviceEntityCategory(entry);
|
||||
if (!categories.includes(category)) continue;
|
||||
if (!entitiesByCategory.has(category)) {
|
||||
entitiesByCategory.set(category, []);
|
||||
}
|
||||
entitiesByCategory.get(category)!.push(entry.entity_id);
|
||||
}
|
||||
|
||||
const shownCategories = DEVICE_SECTION_CATEGORIES.filter((category) =>
|
||||
entitiesByCategory.has(category)
|
||||
);
|
||||
const showCategoryNames =
|
||||
config.group_by_category && shownCategories.length > 1;
|
||||
const computeCategoryName = (category: DeviceSectionCategory) =>
|
||||
hass.localize(`ui.panel.lovelace.strategy.device.categories.${category}`);
|
||||
|
||||
if (config.display === "list") {
|
||||
if (shownCategories.length === 0) {
|
||||
return section;
|
||||
}
|
||||
section.cards!.push({
|
||||
type: "entities",
|
||||
entities: shownCategories.flatMap((category) => [
|
||||
...(showCategoryNames
|
||||
? [
|
||||
{
|
||||
type: "section",
|
||||
label: computeCategoryName(category),
|
||||
} satisfies SectionConfig,
|
||||
]
|
||||
: []),
|
||||
...entitiesByCategory.get(category)!.map(computeRowConfig),
|
||||
]),
|
||||
} satisfies EntitiesCardConfig);
|
||||
return section;
|
||||
}
|
||||
|
||||
for (const category of shownCategories) {
|
||||
if (showCategoryNames) {
|
||||
section.cards!.push({
|
||||
type: "heading",
|
||||
heading_style: "subtitle",
|
||||
heading: computeCategoryName(category),
|
||||
} satisfies HeadingCardConfig);
|
||||
}
|
||||
section.cards!.push(
|
||||
...entitiesByCategory.get(category)!.map(computeTileConfig)
|
||||
);
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"device-section-strategy": DeviceSectionStrategy;
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { mdiFormatListBulleted } from "@mdi/js";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { LovelaceStrategyEditor } from "../types";
|
||||
import {
|
||||
DEFAULT_DEVICE_SECTION_CATEGORIES,
|
||||
DEVICE_SECTION_CATEGORIES,
|
||||
DEVICE_SECTION_HEADINGS,
|
||||
type DeviceSectionStrategyConfig,
|
||||
} from "./device-section-strategy";
|
||||
|
||||
@customElement("hui-device-section-strategy-editor")
|
||||
export class HuiDeviceSectionStrategyEditor
|
||||
extends LitElement
|
||||
implements LovelaceStrategyEditor
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() @consumeLocalize() private _localize!: LocalizeFunc;
|
||||
|
||||
@state() private _config?: DeviceSectionStrategyConfig;
|
||||
|
||||
public setConfig(config: DeviceSectionStrategyConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _deviceEntities = memoizeOne(
|
||||
(deviceId: string | undefined, entities: HomeAssistant["entities"]) =>
|
||||
deviceId
|
||||
? Object.values(entities)
|
||||
.filter((entry) => entry.device_id === deviceId && !entry.hidden)
|
||||
.map((entry) => entry.entity_id)
|
||||
: []
|
||||
);
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc, deviceEntities: string[]) =>
|
||||
[
|
||||
{
|
||||
name: "device_id",
|
||||
required: true,
|
||||
selector: { device: {} },
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: DEVICE_SECTION_HEADINGS.map((heading) => ({
|
||||
value: heading,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.strategy.device.heading_options.${heading}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "categories",
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
mode: "list",
|
||||
options: DEVICE_SECTION_CATEGORIES.map((category) => ({
|
||||
value: category,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.strategy.device.categories.${category}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "display",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "box",
|
||||
options: (["tiles", "list"] as const).map((display) => ({
|
||||
value: display,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.strategy.device.display_options.${display}`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.strategy.device.display_options.${display}_description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "group_by_category",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
...(deviceEntities.length
|
||||
? ([
|
||||
{
|
||||
name: "entities",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiFormatListBulleted,
|
||||
schema: [
|
||||
{
|
||||
name: "exclude_entities",
|
||||
selector: {
|
||||
entity: {
|
||||
multiple: true,
|
||||
include_entities: deviceEntities,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const)
|
||||
: []),
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this._config) return nothing;
|
||||
|
||||
const data = {
|
||||
categories: DEFAULT_DEVICE_SECTION_CATEGORIES,
|
||||
display: "tiles",
|
||||
heading: "device",
|
||||
...this._config,
|
||||
};
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${this._schema(
|
||||
this._localize,
|
||||
this._deviceEntities(
|
||||
this._config.device_id,
|
||||
this.hass?.entities ?? {}
|
||||
)
|
||||
)}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _computeLabel = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) =>
|
||||
this._localize(`ui.panel.lovelace.editor.strategy.device.${schema.name}`);
|
||||
|
||||
private _computeHelper = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) =>
|
||||
schema.name === "group_by_category" ||
|
||||
schema.name === "heading" ||
|
||||
schema.name === "exclude_entities"
|
||||
? this._localize(
|
||||
`ui.panel.lovelace.editor.strategy.device.${schema.name}_helper`
|
||||
)
|
||||
: "";
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<DeviceSectionStrategyConfig>) {
|
||||
ev.stopPropagation();
|
||||
const config = { ...this._config, ...ev.detail.value };
|
||||
if (config.device_id !== this._config?.device_id) {
|
||||
delete config.exclude_entities;
|
||||
}
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-device-section-strategy-editor": HuiDeviceSectionStrategyEditor;
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,6 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
|
||||
section: {
|
||||
"common-controls": () =>
|
||||
import("./usage_prediction/common-controls-section-strategy"),
|
||||
device: () => import("./device/device-section-strategy"),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._mqls?.forEach((mql) => {
|
||||
mql.removeEventListener("change", this._mqlListenerRef!);
|
||||
mql.removeListener(this._mqlListenerRef!);
|
||||
});
|
||||
this._mqlListenerRef = undefined;
|
||||
this._mqls = undefined;
|
||||
@@ -112,7 +112,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
if (!this._mqlListenerRef) {
|
||||
this._mqlListenerRef = this._updateColumns.bind(this);
|
||||
}
|
||||
mql.addEventListener("change", this._mqlListenerRef);
|
||||
mql.addListener(this._mqlListenerRef);
|
||||
return mql;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,12 +45,12 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
|
||||
super.connectedCallback();
|
||||
this._mql = window.matchMedia("(min-width: 760px)");
|
||||
this._mqlListenerRef = this._createCards.bind(this);
|
||||
this._mql.addEventListener("change", this._mqlListenerRef);
|
||||
this._mql.addListener(this._mqlListenerRef);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._mql?.removeEventListener("change", this._mqlListenerRef!);
|
||||
this._mql?.removeListener(this._mqlListenerRef!);
|
||||
this._mqlListenerRef = undefined;
|
||||
this._mql = undefined;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import { deepEqual } from "../../../common/util/deep-equal";
|
||||
@@ -69,8 +68,7 @@ declare global {
|
||||
"ll-edit-card": { path: LovelacePath };
|
||||
"ll-delete-card": DeleteCardParams;
|
||||
"ll-duplicate-card": { path: LovelacePath };
|
||||
"ll-copy-card": { path: LovelacePath; silent?: boolean };
|
||||
"ll-cut-card": { path: LovelacePath };
|
||||
"ll-copy-card": { path: LovelacePath };
|
||||
"ll-create-badge": { path: LovelacePath };
|
||||
"ll-edit-badge": { path: LovelacePath };
|
||||
"ll-delete-badge": DeleteBadgeParams;
|
||||
@@ -81,7 +79,6 @@ declare global {
|
||||
"ll-delete-card": HASSDomEvent<HASSDomEvents["ll-delete-card"]>;
|
||||
"ll-duplicate-card": HASSDomEvent<HASSDomEvents["ll-duplicate-card"]>;
|
||||
"ll-copy-card": HASSDomEvent<HASSDomEvents["ll-copy-card"]>;
|
||||
"ll-cut-card": HASSDomEvent<HASSDomEvents["ll-cut-card"]>;
|
||||
"ll-create-badge": HASSDomEvent<HASSDomEvents["ll-create-badge"]>;
|
||||
"ll-edit-badge": HASSDomEvent<HASSDomEvents["ll-edit-badge"]>;
|
||||
"ll-delete-badge": HASSDomEvent<HASSDomEvents["ll-delete-badge"]>;
|
||||
@@ -219,42 +216,6 @@ export class HUIView extends ReactiveElement {
|
||||
return;
|
||||
}
|
||||
this._clipboard = deepClone(cardConfig);
|
||||
|
||||
if (ev.detail.silent) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lovelace.showToast({
|
||||
message: this.hass.localize(
|
||||
"ui.panel.lovelace.editor.edit_card.copied_to_clipboard"
|
||||
),
|
||||
});
|
||||
}
|
||||
);
|
||||
this.addEventListener(
|
||||
"ll-cut-card",
|
||||
async (ev: HASSDomEvent<HASSDomEvents["ll-cut-card"]>) => {
|
||||
fireEvent(this, "ll-copy-card", {
|
||||
path: ev.detail.path!,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
await performDeleteCard(this.hass, this.lovelace, {
|
||||
...ev.detail,
|
||||
silent: true,
|
||||
path: normalizeCardPath(ev.detail.path),
|
||||
});
|
||||
|
||||
this.lovelace.showToast({
|
||||
message: this.hass.localize(
|
||||
"ui.panel.lovelace.editor.edit_card.cut_to_clipboard"
|
||||
),
|
||||
duration: 8000,
|
||||
action: {
|
||||
action: () => fireEvent(window, "undo-change"),
|
||||
text: this.hass.localize("ui.common.undo"),
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
this.addEventListener(
|
||||
|
||||
@@ -25,15 +25,18 @@ import { consume } from "../../../common/decorators/consume";
|
||||
import { consumeEntityStates } from "../../../common/decorators/consume-context-entry";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
|
||||
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { formatNumber } from "../../../common/number/format_number";
|
||||
import { marketplaceFilterQueryParamConfig } from "../../../common/url/marketplace-query-params";
|
||||
import {
|
||||
createMarketplaceFilterQueryString,
|
||||
marketplaceFilterQueryParamConfig,
|
||||
} from "../../../common/url/marketplace-query-params";
|
||||
import { shallowEqual } from "../../../common/util/shallow-equal";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
SelectionChangedEvent,
|
||||
SortingDirection,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../layouts/hass-tabs-subpage";
|
||||
import "../../../layouts/hass-tabs-subpage-data-table";
|
||||
@@ -201,27 +204,25 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
|
||||
@state() private _marketplaceUpdateEntityIds: string[] = [];
|
||||
|
||||
@state() private _filters: RepositoryFilters = {};
|
||||
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
key: "marketplace-dashboard-table-filters",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filters: RepositoryFilters = {};
|
||||
|
||||
@storage({
|
||||
key: "marketplace-dashboard-table-sorting",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _activeSorting?: { column: string; direction: SortingDirection };
|
||||
private _storageFilters: RepositoryFilters = {};
|
||||
|
||||
@storage({
|
||||
key: "marketplace-dashboard-table-grouping",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _activeGrouping?: string = "translated_status";
|
||||
private _tableSettings = new DataTableSettingsController(
|
||||
this,
|
||||
{
|
||||
search: "marketplace-dashboard-table-search",
|
||||
sorting: "marketplace-dashboard-table-sorting",
|
||||
grouping: "marketplace-dashboard-table-grouping",
|
||||
},
|
||||
{ defaultGrouping: "translated_status" }
|
||||
);
|
||||
|
||||
@storage({
|
||||
key: "marketplace-dashboard-table-collapsed",
|
||||
@@ -230,14 +231,6 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
})
|
||||
private _activeCollapsed: string[] = [];
|
||||
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
key: "marketplace-dashboard-table-search",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _activeSearch?: string;
|
||||
|
||||
@storage({
|
||||
key: "marketplace-dashboard-table-hidden-columns",
|
||||
state: true,
|
||||
@@ -281,6 +274,7 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
// Stored settings ignore what is set before the first update
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
if (!this.hasUpdated) {
|
||||
this._filters = this._storageFilters;
|
||||
this._applyLink();
|
||||
}
|
||||
|
||||
@@ -299,8 +293,9 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
const searchParams = this._filterParams.searchParams();
|
||||
const link = searchParams ? browseLinkFromUrl(searchParams) : undefined;
|
||||
|
||||
// Shown, but not stored as the user's choice
|
||||
if (link) {
|
||||
this._activeSearch = link.search;
|
||||
this._tableSettings.showSearch(link.search);
|
||||
this._filters = {
|
||||
...link.filters,
|
||||
[STATUS_FILTER]: statusFilterOfTab(
|
||||
@@ -309,6 +304,13 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
link.filters[STATUS_FILTER]
|
||||
),
|
||||
};
|
||||
} else if (searchParams?.toString() === "") {
|
||||
// Opened again without params, like from the sidebar. Written once the
|
||||
// table has seen the URL without params, so it restores what it stored
|
||||
this._filters = this._storageFilters;
|
||||
queueMicrotask(() => this._writeFilters());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this._writeFilters();
|
||||
@@ -377,32 +379,32 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
back-path="/config"
|
||||
clickable
|
||||
sync-url
|
||||
.filter=${this._activeSearch || ""}
|
||||
.filter=${this._tableSettings.search}
|
||||
has-filters
|
||||
.filters=${
|
||||
Object.values(filters).filter((values) => values?.length).length
|
||||
}
|
||||
.noDataText=${this._i18n.localize("ui.panel.marketplace.dashboard.no_data")}
|
||||
.empty=${!this.marketplace.repositories.length}
|
||||
.initialSorting=${this._activeSorting}
|
||||
.initialGroupColumn=${this._activeGrouping}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.groupOrder=${this._groupOrder(
|
||||
this._activeGrouping,
|
||||
this._tableSettings.grouping,
|
||||
this._i18n.localize
|
||||
)}
|
||||
.columnOrder=${this._orderTableColumns}
|
||||
.hiddenColumns=${this._hiddenTableColumns}
|
||||
@columns-changed=${this._handleColumnsChanged}
|
||||
@grouping-changed=${this._handleGroupingChanged}
|
||||
@grouping-changed=${this._tableSettings.groupingChanged}
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
@row-click=${this._handleRowClicked}
|
||||
?selectable=${this.tab === "installed"}
|
||||
.selected=${this._selected.length}
|
||||
@selection-changed=${this._handleSelectionChanged}
|
||||
@clear-filter=${this._handleClearFilter}
|
||||
@search-changed=${this._handleSearchFilterChanged}
|
||||
@sorting-changed=${this._handleSortingChanged}
|
||||
@search-changed=${this._tableSettings.searchChanged}
|
||||
@sorting-changed=${this._tableSettings.sortingChanged}
|
||||
>
|
||||
${this._renderToolbar()}
|
||||
${
|
||||
@@ -1002,9 +1004,18 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
navigate(`/marketplace/repository/${ev.detail.id}`);
|
||||
}
|
||||
|
||||
// A filter can also send the value it was given. Only a change to what the
|
||||
// URL holds is the user's, so what a link applied isn't stored.
|
||||
private _isUserChange(filters: RepositoryFilters): boolean {
|
||||
return (
|
||||
createMarketplaceFilterQueryString(filtersOfTab(filters, this.tab)) !==
|
||||
createMarketplaceFilterQueryString(filtersOfTab(this._filters, this.tab))
|
||||
);
|
||||
}
|
||||
|
||||
// The pane's own clear button sends no value
|
||||
private _statusFilterChanged(ev: HASSDomEvent<{ value?: string[] }>) {
|
||||
this._filters = {
|
||||
const filters = {
|
||||
...this._filters,
|
||||
[STATUS_FILTER]: statusFilterOfTab(
|
||||
this._filters,
|
||||
@@ -1012,33 +1023,50 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
ev.detail.value
|
||||
),
|
||||
};
|
||||
|
||||
if (this._isUserChange(filters)) {
|
||||
this._storageFilters = {
|
||||
...this._storageFilters,
|
||||
[STATUS_FILTER]: statusFilterOfTab(
|
||||
this._storageFilters,
|
||||
this.tab,
|
||||
ev.detail.value
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
this._filters = filters;
|
||||
this._writeFilters();
|
||||
}
|
||||
|
||||
private _typeFilterChanged(ev: CustomEvent<{ value: string[] }>) {
|
||||
this._filters = { ...this._filters, [TYPE_FILTER]: ev.detail.value };
|
||||
const filters = { ...this._filters, [TYPE_FILTER]: ev.detail.value };
|
||||
|
||||
if (this._isUserChange(filters)) {
|
||||
this._storageFilters = {
|
||||
...this._storageFilters,
|
||||
[TYPE_FILTER]: ev.detail.value,
|
||||
};
|
||||
}
|
||||
|
||||
this._filters = filters;
|
||||
this._writeFilters();
|
||||
}
|
||||
|
||||
private _sourceFilterChanged(ev: CustomEvent<{ value: string[] }>) {
|
||||
this._filters = { ...this._filters, [SOURCE_FILTER]: ev.detail.value };
|
||||
const filters = { ...this._filters, [SOURCE_FILTER]: ev.detail.value };
|
||||
|
||||
if (this._isUserChange(filters)) {
|
||||
this._storageFilters = {
|
||||
...this._storageFilters,
|
||||
[SOURCE_FILTER]: ev.detail.value,
|
||||
};
|
||||
}
|
||||
|
||||
this._filters = filters;
|
||||
this._writeFilters();
|
||||
}
|
||||
|
||||
private _handleSearchFilterChanged(ev: CustomEvent) {
|
||||
this._activeSearch = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleSortingChanged(ev: CustomEvent) {
|
||||
this._activeSorting = ev.detail;
|
||||
}
|
||||
|
||||
private _handleGroupingChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["grouping-changed"]>
|
||||
) {
|
||||
this._activeGrouping = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleCollapseChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["collapsed-changed"]>
|
||||
) {
|
||||
@@ -1052,6 +1080,7 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
|
||||
private _handleClearFilter() {
|
||||
this._filters = {};
|
||||
this._storageFilters = {};
|
||||
this._writeFilters();
|
||||
}
|
||||
|
||||
|
||||
@@ -54,9 +54,17 @@ const helpersTableParams = {
|
||||
assistants: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
// The application credentials table reads these from the link
|
||||
const applicationCredentialsTableParams = {
|
||||
search: "string?",
|
||||
sort: "string?",
|
||||
direction: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
export const getMyRedirects = (): Redirects => ({
|
||||
application_credentials: {
|
||||
redirect: "/config/application_credentials",
|
||||
params: applicationCredentialsTableParams,
|
||||
},
|
||||
tools_assist: {
|
||||
redirect: "/config/tools/assist",
|
||||
|
||||
@@ -83,13 +83,13 @@ class PanelTodo extends LitElement {
|
||||
this._mql = window.matchMedia(
|
||||
"(max-width: 450px), all and (max-height: 500px)"
|
||||
);
|
||||
this._mql.addEventListener("change", this._setIsMobile);
|
||||
this._mql.addListener(this._setIsMobile);
|
||||
this.mobile = this._mql.matches;
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._mql?.removeEventListener("change", this._setIsMobile!);
|
||||
this._mql?.removeListener(this._setIsMobile!);
|
||||
this._mql = undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// @ts-ignore
|
||||
import skeletonStyles from "fullcalendar/skeleton.css";
|
||||
// @ts-ignore
|
||||
import classicThemeStyles from "fullcalendar/themes/classic/theme.css";
|
||||
import { unsafeCSS } from "lit";
|
||||
|
||||
// Apart from css`` templates, as the minifier that compiles those for legacy
|
||||
// browsers skips every file that calls unsafeCSS
|
||||
export const fullCalendarBaseStyles = [
|
||||
unsafeCSS(skeletonStyles),
|
||||
unsafeCSS(classicThemeStyles),
|
||||
];
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { PluginInput } from "fullcalendar";
|
||||
// @ts-ignore
|
||||
import skeletonStyles from "fullcalendar/skeleton.css";
|
||||
import classicThemePlugin from "fullcalendar/themes/classic";
|
||||
import { css } from "lit";
|
||||
import { fullCalendarBaseStyles } from "./fullcalendar-base-styles";
|
||||
// @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 = {
|
||||
@@ -16,7 +19,8 @@ export const fullCalendarTheme: PluginInput = {
|
||||
};
|
||||
|
||||
export const fullCalendarStyles = [
|
||||
...fullCalendarBaseStyles,
|
||||
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,
|
||||
@@ -86,14 +90,5 @@ export const fullCalendarStyles = [
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Margins instead of flex gap, which needs Safari 14.1 and Chrome 84 */
|
||||
.ha-fc-list-event {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.ha-fc-list-event > .ha-fc-dot {
|
||||
margin-inline: 12px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
// Source: https://developer.mozilla.org/en-US/docs/Web/API/Element/getAttributeNames
|
||||
if (Element.prototype.getAttributeNames === undefined) {
|
||||
Element.prototype.getAttributeNames = function () {
|
||||
const attributes = this.attributes;
|
||||
const length = attributes.length;
|
||||
const result = new Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
result[i] = attributes[i].name;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -21,31 +21,6 @@ const _polyfillTimeZoneData = polyfillTimeZoneData;
|
||||
// The DateTimeFormat and DurationFormat polyfills call BigInt() when formatting
|
||||
const hasBigInt = typeof BigInt !== "undefined";
|
||||
|
||||
// The FormatJS DateTimeFormat polyfill defaults to UTC instead of the
|
||||
// device's time zone. Both legacy bundles run this file, so the zone is read
|
||||
// once, while Intl.DateTimeFormat is still native.
|
||||
const readNativeTimeZone = (): string | undefined => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch (_err) {
|
||||
// Replaced by a polyfill that has no locale data yet
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const nativeTimeZone = (globalThis.__haNativeTimeZone ??= readNativeTimeZone());
|
||||
|
||||
const setDefaultTimeZone = () => {
|
||||
if (!nativeTimeZone) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
(Intl.DateTimeFormat as any).__setDefaultTimeZone(nativeTimeZone);
|
||||
} catch (_err) {
|
||||
// No time zone data for it, so keep UTC
|
||||
}
|
||||
};
|
||||
|
||||
const polyfillIntl = async () => {
|
||||
if (polyfilled) {
|
||||
return;
|
||||
@@ -61,9 +36,9 @@ const polyfillIntl = async () => {
|
||||
}
|
||||
if (hasBigInt && shouldPolyfillDateTimeFormat(locale)) {
|
||||
polyfills.push(
|
||||
import("@formatjs/intl-datetimeformat/polyfill-force")
|
||||
.then(() => _polyfillTimeZoneData())
|
||||
.then(setDefaultTimeZone)
|
||||
import("@formatjs/intl-datetimeformat/polyfill-force").then(() =>
|
||||
_polyfillTimeZoneData()
|
||||
)
|
||||
);
|
||||
}
|
||||
if (hasBigInt && shouldPolyfillDurationFormat()) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
if (typeof window.ResizeObserver !== "function") {
|
||||
window.ResizeObserver = (
|
||||
await import("@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver")
|
||||
).default;
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -49,7 +49,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
|
||||
}
|
||||
);
|
||||
});
|
||||
mql.addEventListener("change", (ev) => this._applyTheme(ev.matches));
|
||||
mql.addListener((ev) => this._applyTheme(ev.matches));
|
||||
if (!this._themeApplied && mql.matches) {
|
||||
applyThemesOnElement(
|
||||
document.documentElement,
|
||||
|
||||
@@ -2750,7 +2750,7 @@
|
||||
"dismiss": "Dismiss",
|
||||
"no_matching_link_found": "No matching My link found for {path}",
|
||||
"new_version_available": "A new version of the frontend is available. This page will update in {seconds, plural, one {# second} other {# seconds}}.",
|
||||
"new_version_available_reload": "A new version of the frontend is available. It will be applied once you have finished what you are working on.",
|
||||
"new_version_available_reload": "A new version of the frontend is available. It will be applied once you have saved or discarded your changes.",
|
||||
"update_now": "Update now",
|
||||
"theme_save_failed": "Unable to save theme settings to your user profile.",
|
||||
"theme_preferences_unavailable": "Unable to load user profile theme settings.",
|
||||
@@ -5646,17 +5646,12 @@
|
||||
"id": "Trigger ID",
|
||||
"optional": "Optional",
|
||||
"edit_id": "Edit ID",
|
||||
"edit_id_title": "Edit trigger ID",
|
||||
"edit_id_info": "Trigger IDs are generated automatically, so you usually don't need to set one yourself. {link}",
|
||||
"edit_id_learn_more": "Learn more about trigger IDs",
|
||||
"edit_id_placeholder": "e.g. sun_has_set",
|
||||
"duplicate": "[%key:ui::common::duplicate%]",
|
||||
"re_order": "Re-order",
|
||||
"rename": "Rename",
|
||||
"cut": "Cut",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"paste_element": "Paste trigger",
|
||||
"change_alias": "Rename trigger",
|
||||
"alias": "Trigger name",
|
||||
"delete": "[%key:ui::common::delete%]",
|
||||
@@ -5969,7 +5964,6 @@
|
||||
"cut": "[%key:ui::panel::config::automation::editor::triggers::cut%]",
|
||||
"copy": "[%key:ui::panel::config::automation::editor::triggers::copy%]",
|
||||
"paste": "[%key:ui::panel::config::automation::editor::triggers::paste%]",
|
||||
"paste_element": "Paste condition",
|
||||
"change_alias": "Rename condition",
|
||||
"alias": "Condition name",
|
||||
"delete": "[%key:ui::common::delete%]",
|
||||
@@ -6158,7 +6152,6 @@
|
||||
"cut": "[%key:ui::panel::config::automation::editor::triggers::cut%]",
|
||||
"copy": "[%key:ui::panel::config::automation::editor::triggers::copy%]",
|
||||
"paste": "[%key:ui::panel::config::automation::editor::triggers::paste%]",
|
||||
"paste_element": "Paste action",
|
||||
"change_alias": "Rename action",
|
||||
"alias": "Action name",
|
||||
"enable": "Enable",
|
||||
@@ -7373,8 +7366,6 @@
|
||||
"setup_capability_serial_title": "Control a device over serial",
|
||||
"setup_capability_serial_short": "AV receivers, amplifiers, and more",
|
||||
"setup_capability_serial_description": "Add serial devices into Home Assistant, allowing for control of A/V receivers and multi-zone amplifiers.",
|
||||
"setup_bluetooth_turn_on": "Turn on",
|
||||
"setup_bluetooth_turn_off": "Turn off",
|
||||
"setup_audio_music_assistant": "Music Assistant",
|
||||
"setup_audio_byline": "By the Open Home Foundation",
|
||||
"setup_audio_offer_title": "Enhance your music experience",
|
||||
@@ -7642,10 +7633,6 @@
|
||||
"entities": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
|
||||
"services": "{count} {count, plural,\n one {service}\n other {services}\n}",
|
||||
"entries": "{count} {count, plural,\n one {entry}\n other {entries}\n}",
|
||||
"attention": {
|
||||
"some": "{attention} of {count} {count, plural,\n one {entry}\n other {entries}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up, retrying}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{attention, plural,\n one {is}\n other {are}\n } not loaded}\n other {{attention, plural,\n one {needs}\n other {need}\n } attention}\n}",
|
||||
"all": "{count} {count, plural,\n one {entry}\n other {entries}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up, retrying}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{count, plural,\n one {is}\n other {are}\n } not loaded}\n other {{count, plural,\n one {needs}\n other {need}\n } attention}\n}"
|
||||
},
|
||||
"no_devices_or_entities": "No devices or entities",
|
||||
"devices_without_subentry": "Devices that don't belong to a sub-entry",
|
||||
"copy": "Copy entry ID",
|
||||
@@ -9759,17 +9746,6 @@
|
||||
"not_loaded": "Usage Prediction integration is not loaded.",
|
||||
"no_data": "This place will soon fill up with the entities you use most often, based on your activity."
|
||||
},
|
||||
"device": {
|
||||
"no_device": "Select a device to show its entities.",
|
||||
"device_not_found": "This device no longer exists.",
|
||||
"categories": {
|
||||
"control": "[%key:ui::panel::config::devices::entities::control%]",
|
||||
"sensor": "[%key:ui::panel::config::devices::entities::sensor%]",
|
||||
"event": "[%key:ui::panel::config::devices::entities::event%]",
|
||||
"config": "[%key:ui::panel::config::devices::entities::config%]",
|
||||
"diagnostic": "[%key:ui::panel::config::devices::entities::diagnostic%]"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"lights": "Lights",
|
||||
"other_lights": "Other lights",
|
||||
@@ -10239,8 +10215,6 @@
|
||||
"delete": "Delete",
|
||||
"copy": "Copy",
|
||||
"cut": "Cut",
|
||||
"copied_to_clipboard": "Card copied to clipboard",
|
||||
"cut_to_clipboard": "Card cut to clipboard",
|
||||
"duplicate": "Duplicate",
|
||||
"move": "Move to view",
|
||||
"move_before": "Move card before",
|
||||
@@ -11796,29 +11770,6 @@
|
||||
"exclude_entities_helper": "These entities will not be suggested based on your activity",
|
||||
"hide_empty": "Hide when empty"
|
||||
},
|
||||
"device": {
|
||||
"device_id": "Device",
|
||||
"categories": "Categories",
|
||||
"display": "Display",
|
||||
"display_options": {
|
||||
"tiles": "Tiles",
|
||||
"tiles_description": "One card per entity",
|
||||
"list": "List",
|
||||
"list_description": "All entities in a single card"
|
||||
},
|
||||
"group_by_category": "Group by category",
|
||||
"group_by_category_helper": "Adds a subtitle above each category",
|
||||
"heading": "Heading",
|
||||
"heading_helper": "When shown, the heading includes the battery level",
|
||||
"heading_options": {
|
||||
"none": "No heading",
|
||||
"device": "Device name",
|
||||
"area": "Area and device name"
|
||||
},
|
||||
"entities": "Entities",
|
||||
"exclude_entities": "Exclude entities",
|
||||
"exclude_entities_helper": "Not shown in this section, even if their category is selected"
|
||||
},
|
||||
"original-states": {
|
||||
"areas": "Areas to display",
|
||||
"hide_entities_without_area": "Hide entities without area",
|
||||
|
||||
@@ -39,8 +39,6 @@ declare global {
|
||||
var __MAPLIBRE_WORKER_URL__: string;
|
||||
var __BACKWARDS_COMPAT__: boolean;
|
||||
var __HASS_URL__: string;
|
||||
// Read by intl-polyfill before a polyfill replaces Intl.DateTimeFormat
|
||||
var __haNativeTimeZone: string | undefined;
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
||||
interface Window {
|
||||
|
||||
@@ -42,7 +42,11 @@ export class AudioRecorder {
|
||||
}
|
||||
|
||||
public static get isSupported() {
|
||||
return window.isSecureContext && "AudioContext" in window;
|
||||
return (
|
||||
window.isSecureContext &&
|
||||
// @ts-ignore-next-line
|
||||
(window.AudioContext || window.webkitAudioContext)
|
||||
);
|
||||
}
|
||||
|
||||
public async start(): Promise<MicrophoneError | undefined> {
|
||||
@@ -88,7 +92,8 @@ export class AudioRecorder {
|
||||
}
|
||||
|
||||
private async _createContext() {
|
||||
const context = new AudioContext();
|
||||
// @ts-expect-error webkitAudioContext is not recognized
|
||||
const context = new (AudioContext || webkitAudioContext)();
|
||||
this._context = context;
|
||||
this._stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
// Syntax here must match an item of `parser.worker` in Webpack config in
|
||||
|
||||
@@ -125,7 +125,7 @@ const referenceUrl = (): string | undefined => {
|
||||
*/
|
||||
const askServer = async (
|
||||
url: string,
|
||||
signal: AbortSignal
|
||||
signal?: AbortSignal
|
||||
): Promise<ChunkVerdict> => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
@@ -145,17 +145,20 @@ const askServer = async (
|
||||
};
|
||||
|
||||
const probeChunk = async (url: string): Promise<ChunkVerdict> => {
|
||||
const controller = new AbortController();
|
||||
const controller =
|
||||
typeof AbortController === "undefined" ? undefined : new AbortController();
|
||||
try {
|
||||
// Timed out as well as aborted: the timeout settles the probe even if the
|
||||
// request never does, the abort then frees the connection.
|
||||
// Timed out rather than relying on the abort alone: without
|
||||
// AbortController a stalled request would never settle, pinning the probe.
|
||||
const verdict: ChunkVerdict = await promiseTimeout(
|
||||
PROBE_TIMEOUT,
|
||||
askServer(url, controller.signal)
|
||||
askServer(url, controller?.signal)
|
||||
);
|
||||
return verdict;
|
||||
} catch (_err) {
|
||||
controller.abort();
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,33 +15,14 @@ export const registerServiceWorker = async (
|
||||
return;
|
||||
}
|
||||
|
||||
// Set when the active worker changed while this page could not reload.
|
||||
let reloadPending = false;
|
||||
|
||||
// If the active service worker changes, refresh the page because the cache has changed.
|
||||
// Another tab can activate the new worker, so wait until this page has no unsaved work.
|
||||
// Until then this page runs the old build under the new worker, so its lazy imports can
|
||||
// fail and go through stale build recovery instead.
|
||||
// If the active service worker changes, refresh the page because the cache has changed
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
if (!window.isDirtyState) {
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
reloadPending = true;
|
||||
location.reload();
|
||||
});
|
||||
|
||||
const reg = await navigator.serviceWorker.register(`/sw-${__BUILD__}.js`);
|
||||
|
||||
if (!notifyUpdate || __DEV__ || __DEMO__) {
|
||||
// No update toast here, so reload as soon as the page is clean.
|
||||
const reloadWhenClean = () => {
|
||||
if (reloadPending && !window.isDirtyState) {
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
window.addEventListener("dirty-state-changed", reloadWhenClean);
|
||||
// The page may have become clean while registering.
|
||||
reloadWhenClean();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,12 +43,6 @@ export const registerServiceWorker = async (
|
||||
};
|
||||
|
||||
const activateUpdate = () => {
|
||||
if (reloadPending) {
|
||||
clearUpdateCountdown();
|
||||
hideUpdateToast();
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
if (!pendingWorker) {
|
||||
return;
|
||||
}
|
||||
@@ -103,7 +78,6 @@ export const registerServiceWorker = async (
|
||||
secondaryAction: {
|
||||
action: () => {
|
||||
pendingWorker = undefined;
|
||||
reloadPending = false;
|
||||
clearUpdateCountdown();
|
||||
},
|
||||
text: { translationKey: "ui.common.cancel" },
|
||||
@@ -119,7 +93,7 @@ export const registerServiceWorker = async (
|
||||
};
|
||||
|
||||
window.addEventListener("dirty-state-changed", () => {
|
||||
if (!pendingWorker && !reloadPending) {
|
||||
if (!pendingWorker) {
|
||||
return;
|
||||
}
|
||||
if (window.isDirtyState) {
|
||||
@@ -158,8 +132,5 @@ export const registerServiceWorker = async (
|
||||
|
||||
if (reg.waiting && navigator.serviceWorker.controller) {
|
||||
updateReady(reg.waiting);
|
||||
} else if (reloadPending && !window.isDirtyState) {
|
||||
// The page became clean while registering, before the listener above.
|
||||
startUpdateCountdown();
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { expect, test } from "vitest";
|
||||
import {
|
||||
LOCAL_TIME_ZONE,
|
||||
resolveTimeZone,
|
||||
@@ -10,20 +10,3 @@ test("resolveTimeZone", () => {
|
||||
expect(resolveTimeZone(TimeZone.local, serverTimeZone)).toBe(LOCAL_TIME_ZONE);
|
||||
expect(resolveTimeZone(TimeZone.server, serverTimeZone)).toBe(serverTimeZone);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["Europe/Amsterdam", "Europe/Amsterdam"],
|
||||
// An alias the zone list lacks, so Intl's own name is used
|
||||
["Asia/Calcutta", LOCAL_TIME_ZONE],
|
||||
])("resolves the native time zone %s", async (native, expected) => {
|
||||
globalThis.__haNativeTimeZone = native;
|
||||
vi.resetModules();
|
||||
try {
|
||||
const { LOCAL_TIME_ZONE: zone, HAS_RESOLVED_IANA_TIME_ZONE: resolved } =
|
||||
await import("../../../src/common/datetime/resolve-time-zone");
|
||||
expect(zone).toBe(expected);
|
||||
expect(resolved).toBe(true);
|
||||
} finally {
|
||||
globalThis.__haNativeTimeZone = undefined;
|
||||
}
|
||||
});
|
||||
@@ -227,6 +227,18 @@ describe("createBaseLayer", () => {
|
||||
expect(options.referrerPolicy).toBe("origin");
|
||||
});
|
||||
|
||||
// MapLibre's code has BigInt literals in it, which Babel cannot transpile
|
||||
// away, and a worker that fails to parse is silent - so no BigInt, no vector.
|
||||
it("falls back to raster tiles without BigInt", async () => {
|
||||
vi.stubGlobal("BigInt", undefined);
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
|
||||
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
expect(maplibreGL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The worker is an entry of the build, and the build hands over its URL.
|
||||
// Absolute, because on Cast the page is not served from the instance.
|
||||
it("points MapLibre at the built worker once, before the first map", async () => {
|
||||
|
||||
@@ -2,111 +2,106 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getHiddenFields,
|
||||
getHiddenSchemaFields,
|
||||
isVisible,
|
||||
isFieldVisible,
|
||||
} from "../../../src/components/ha-form/conditions";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
HaFormVisible,
|
||||
} from "../../../src/components/ha-form/types";
|
||||
import type { HaFormSchema } from "../../../src/components/ha-form/types";
|
||||
|
||||
describe("isVisible", () => {
|
||||
const field = (visible: HaFormSchema["visible"]): HaFormSchema =>
|
||||
({ name: "field", selector: { text: {} }, visible }) as HaFormSchema;
|
||||
|
||||
describe("isFieldVisible", () => {
|
||||
it("shows a field without a visible condition", () => {
|
||||
expect(isVisible(undefined, { a: 1 })).toBe(true);
|
||||
expect(isFieldVisible(field(undefined), { a: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("honors a boolean visible", () => {
|
||||
expect(isVisible(true, {})).toBe(true);
|
||||
expect(isVisible(false, {})).toBe(false);
|
||||
expect(isFieldVisible(field(true), {})).toBe(true);
|
||||
expect(isFieldVisible(field(false), {})).toBe(false);
|
||||
});
|
||||
|
||||
describe("operators", () => {
|
||||
it("eq (default) matches equal values", () => {
|
||||
expect(isVisible({ field: "a", value: 1 }, { a: 1 })).toBe(true);
|
||||
expect(isVisible({ field: "a", value: 1 }, { a: 2 })).toBe(false);
|
||||
expect(isFieldVisible(field({ field: "a", value: 1 }), { a: 1 })).toBe(
|
||||
true
|
||||
);
|
||||
expect(isFieldVisible(field({ field: "a", value: 1 }), { a: 2 })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("not_eq matches different values", () => {
|
||||
const visible: HaFormVisible = {
|
||||
field: "a",
|
||||
operator: "not_eq",
|
||||
value: 1,
|
||||
};
|
||||
expect(isVisible(visible, { a: 2 })).toBe(true);
|
||||
expect(isVisible(visible, { a: 1 })).toBe(false);
|
||||
const schema = field({ field: "a", operator: "not_eq", value: 1 });
|
||||
expect(isFieldVisible(schema, { a: 2 })).toBe(true);
|
||||
expect(isFieldVisible(schema, { a: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("in matches membership", () => {
|
||||
const visible: HaFormVisible = {
|
||||
field: "a",
|
||||
operator: "in",
|
||||
value: ["x", "y"],
|
||||
};
|
||||
expect(isVisible(visible, { a: "y" })).toBe(true);
|
||||
expect(isVisible(visible, { a: "z" })).toBe(false);
|
||||
const schema = field({ field: "a", operator: "in", value: ["x", "y"] });
|
||||
expect(isFieldVisible(schema, { a: "y" })).toBe(true);
|
||||
expect(isFieldVisible(schema, { a: "z" })).toBe(false);
|
||||
});
|
||||
|
||||
it("not_in matches non-membership", () => {
|
||||
const visible: HaFormVisible = {
|
||||
const schema = field({
|
||||
field: "a",
|
||||
operator: "not_in",
|
||||
value: ["x", "y"],
|
||||
};
|
||||
expect(isVisible(visible, { a: "z" })).toBe(true);
|
||||
expect(isVisible(visible, { a: "x" })).toBe(false);
|
||||
});
|
||||
expect(isFieldVisible(schema, { a: "z" })).toBe(true);
|
||||
expect(isFieldVisible(schema, { a: "x" })).toBe(false);
|
||||
});
|
||||
|
||||
it("exists matches a defined non-empty value", () => {
|
||||
const visible: HaFormVisible = { field: "a", operator: "exists" };
|
||||
expect(isVisible(visible, { a: "x" })).toBe(true);
|
||||
expect(isVisible(visible, { a: "" })).toBe(false);
|
||||
expect(isVisible(visible, {})).toBe(false);
|
||||
const schema = field({ field: "a", operator: "exists" });
|
||||
expect(isFieldVisible(schema, { a: "x" })).toBe(true);
|
||||
expect(isFieldVisible(schema, { a: "" })).toBe(false);
|
||||
expect(isFieldVisible(schema, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("not_exists matches a missing or empty value", () => {
|
||||
const visible: HaFormVisible = { field: "a", operator: "not_exists" };
|
||||
expect(isVisible(visible, {})).toBe(true);
|
||||
expect(isVisible(visible, { a: null } as any)).toBe(true);
|
||||
expect(isVisible(visible, { a: "x" })).toBe(false);
|
||||
const schema = field({ field: "a", operator: "not_exists" });
|
||||
expect(isFieldVisible(schema, {})).toBe(true);
|
||||
expect(isFieldVisible(schema, { a: null } as any)).toBe(true);
|
||||
expect(isFieldVisible(schema, { a: "x" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combinators", () => {
|
||||
it("and requires every condition", () => {
|
||||
const visible: HaFormVisible = {
|
||||
const schema = field({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2 },
|
||||
],
|
||||
};
|
||||
expect(isVisible(visible, { a: 1, b: 2 })).toBe(true);
|
||||
expect(isVisible(visible, { a: 1, b: 9 })).toBe(false);
|
||||
});
|
||||
expect(isFieldVisible(schema, { a: 1, b: 2 })).toBe(true);
|
||||
expect(isFieldVisible(schema, { a: 1, b: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("or requires any condition", () => {
|
||||
const visible: HaFormVisible = {
|
||||
const schema = field({
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2 },
|
||||
],
|
||||
};
|
||||
expect(isVisible(visible, { a: 9, b: 2 })).toBe(true);
|
||||
expect(isVisible(visible, { a: 9, b: 9 })).toBe(false);
|
||||
});
|
||||
expect(isFieldVisible(schema, { a: 9, b: 2 })).toBe(true);
|
||||
expect(isFieldVisible(schema, { a: 9, b: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("not negates its conditions", () => {
|
||||
const visible: HaFormVisible = {
|
||||
const schema = field({
|
||||
condition: "not",
|
||||
conditions: [{ field: "a", value: 1 }],
|
||||
};
|
||||
expect(isVisible(visible, { a: 2 })).toBe(true);
|
||||
expect(isVisible(visible, { a: 1 })).toBe(false);
|
||||
});
|
||||
expect(isFieldVisible(schema, { a: 2 })).toBe(true);
|
||||
expect(isFieldVisible(schema, { a: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("nests combinators", () => {
|
||||
const visible: HaFormVisible = {
|
||||
const schema = field({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ field: "a", value: 1 },
|
||||
@@ -118,65 +113,57 @@ describe("isVisible", () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(isVisible(visible, { a: 1, b: 9, c: 3 })).toBe(true);
|
||||
expect(isVisible(visible, { a: 1, b: 9, c: 9 })).toBe(false);
|
||||
expect(isVisible(visible, { a: 9, b: 2, c: 3 })).toBe(false);
|
||||
});
|
||||
expect(isFieldVisible(schema, { a: 1, b: 9, c: 3 })).toBe(true);
|
||||
expect(isFieldVisible(schema, { a: 1, b: 9, c: 9 })).toBe(false);
|
||||
expect(isFieldVisible(schema, { a: 9, b: 2, c: 3 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an array of conditions as AND", () => {
|
||||
const visible: HaFormVisible = [
|
||||
const schema = field([
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2 },
|
||||
];
|
||||
expect(isVisible(visible, { a: 1, b: 2 })).toBe(true);
|
||||
expect(isVisible(visible, { a: 1, b: 9 })).toBe(false);
|
||||
]);
|
||||
expect(isFieldVisible(schema, { a: 1, b: 2 })).toBe(true);
|
||||
expect(isFieldVisible(schema, { a: 1, b: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles missing data", () => {
|
||||
expect(isVisible({ field: "a", value: 1 }, undefined)).toBe(false);
|
||||
expect(isFieldVisible(field({ field: "a", value: 1 }), undefined)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHiddenFields", () => {
|
||||
const named = (
|
||||
name: string,
|
||||
visible?: HaFormSchema["visible"]
|
||||
): HaFormSchema =>
|
||||
({ name, selector: { text: {} }, visible }) as HaFormSchema;
|
||||
|
||||
it("collects the fields that are not visible", () => {
|
||||
const fields = [
|
||||
["mode", undefined],
|
||||
["token", { field: "mode", value: "advanced" }],
|
||||
] as const;
|
||||
expect([...getHiddenFields(fields, { mode: "simple" })]).toEqual(["token"]);
|
||||
expect([...getHiddenFields(fields, { mode: "advanced" })]).toEqual([]);
|
||||
const schema = [
|
||||
named("mode"),
|
||||
named("token", { field: "mode", value: "advanced" }),
|
||||
];
|
||||
expect([...getHiddenFields(schema, { mode: "simple" })]).toEqual(["token"]);
|
||||
expect([...getHiddenFields(schema, { mode: "advanced" })]).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats a hidden field as absent to the other conditions", () => {
|
||||
// "token" is hidden, so its value must not satisfy the condition on "extra"
|
||||
const fields = [
|
||||
["mode", undefined],
|
||||
["token", { field: "mode", value: "advanced" }],
|
||||
["extra", { field: "token", operator: "exists" }],
|
||||
] as const;
|
||||
const schema = [
|
||||
named("mode"),
|
||||
named("token", { field: "mode", value: "advanced" }),
|
||||
named("extra", { field: "token", operator: "exists" }),
|
||||
];
|
||||
expect([
|
||||
...getHiddenFields(fields, { mode: "simple", token: "stale" }),
|
||||
...getHiddenFields(schema, { mode: "simple", token: "stale" }),
|
||||
]).toEqual(["token", "extra"]);
|
||||
expect([
|
||||
...getHiddenFields(fields, { mode: "advanced", token: "kept" }),
|
||||
...getHiddenFields(schema, { mode: "advanced", token: "kept" }),
|
||||
]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHiddenSchemaFields", () => {
|
||||
it("reads the visible condition of each schema field", () => {
|
||||
const schema = [
|
||||
{ name: "mode", selector: { text: {} } },
|
||||
{
|
||||
name: "token",
|
||||
selector: { text: {} },
|
||||
visible: { field: "mode", value: "advanced" },
|
||||
},
|
||||
] as HaFormSchema[];
|
||||
expect([...getHiddenSchemaFields(schema, { mode: "simple" })]).toEqual([
|
||||
"token",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -11,10 +11,8 @@ import {
|
||||
deviceHasMediaPlayerEntity,
|
||||
findESPHomeZWaveFlow,
|
||||
getESPHomeAudioControls,
|
||||
getESPHomeBluetoothSwitch,
|
||||
getESPHomeSetupBannerState,
|
||||
getESPHomeSetupCapabilityIds,
|
||||
hasESPHomeBluetoothSwitch,
|
||||
hasESPHomeSetupCapabilities,
|
||||
hasStartedNonBluetoothESPHomeSetup,
|
||||
isESPHomeSerialConfigured,
|
||||
@@ -91,23 +89,15 @@ describe("hasESPHomeSetupCapabilities", () => {
|
||||
expect(hasESPHomeSetupCapabilities(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, true, true],
|
||||
[false, true, false],
|
||||
[false, false, true],
|
||||
])(
|
||||
"is %s for Bluetooth alone with capability %s and switch %s",
|
||||
(expected, supported, bluetoothSwitch) => {
|
||||
expect(
|
||||
hasESPHomeSetupCapabilities(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported },
|
||||
}),
|
||||
{ bluetoothSwitch }
|
||||
)
|
||||
).toBe(expected);
|
||||
}
|
||||
);
|
||||
it("is false for Bluetooth alone", () => {
|
||||
expect(
|
||||
hasESPHomeSetupCapabilities(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for a device with no advertised capabilities", () => {
|
||||
expect(hasESPHomeSetupCapabilities(capabilities())).toBe(false);
|
||||
@@ -189,18 +179,6 @@ describe("deriveESPHomeSetupStatus", () => {
|
||||
expect(status.serial).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks Bluetooth not started when its switch is off", () => {
|
||||
const status = deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
}),
|
||||
{ ...deriveOptions(), bluetoothEnabled: false }
|
||||
);
|
||||
|
||||
expect(status.bluetooth).toBe("not-started");
|
||||
expect(countRemainingESPHomeCapabilities(status)).toBe(1);
|
||||
});
|
||||
|
||||
it("marks audio from media_player entities, not capabilities", () => {
|
||||
const caps = capabilities();
|
||||
|
||||
@@ -420,7 +398,7 @@ describe("isESPHomeSerialConfigured", () => {
|
||||
});
|
||||
|
||||
describe("remaining capabilities and continue-setup", () => {
|
||||
it("counts incomplete rows, with Bluetooth completed when it has no switch", () => {
|
||||
it("counts incomplete rows and ignores Bluetooth", () => {
|
||||
const status = deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
@@ -562,57 +540,6 @@ describe("remaining capabilities and continue-setup", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getESPHomeBluetoothSwitch", () => {
|
||||
// Another device's switch comes first, so only the device filter skips it
|
||||
const entities = [
|
||||
{
|
||||
entity_id: "switch.other_bluetooth",
|
||||
device_id: "dev-2",
|
||||
platform: "esphome",
|
||||
},
|
||||
{
|
||||
entity_id: "switch.garage_proxy_bluetooth",
|
||||
device_id: "dev-1",
|
||||
platform: "esphome",
|
||||
},
|
||||
];
|
||||
|
||||
it("finds the device's Bluetooth switch and its state", () => {
|
||||
expect(
|
||||
getESPHomeBluetoothSwitch("dev-1", entities, {
|
||||
"switch.garage_proxy_bluetooth": { state: "off" },
|
||||
"switch.other_bluetooth": { state: "on" },
|
||||
})
|
||||
).toEqual({ entityId: "switch.garage_proxy_bluetooth", on: false });
|
||||
});
|
||||
|
||||
it("counts an offline switch as present, but not a disabled one", () => {
|
||||
expect(hasESPHomeBluetoothSwitch("dev-1", entities)).toBe(true);
|
||||
expect(
|
||||
hasESPHomeBluetoothSwitch("dev-3", [
|
||||
{
|
||||
entity_id: "switch.disabled_bluetooth",
|
||||
device_id: "dev-3",
|
||||
platform: "esphome",
|
||||
disabled_by: "integration",
|
||||
},
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a disabled switch without a state", {}],
|
||||
[
|
||||
"an unavailable switch",
|
||||
{ "switch.garage_proxy_bluetooth": { state: "unavailable" } },
|
||||
],
|
||||
])("ignores %s", (_name, states) => {
|
||||
expect(
|
||||
getESPHomeBluetoothSwitch("dev-1", entities, states)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getESPHomeAudioControls", () => {
|
||||
it("maps the Sendspin Enabled switch without inverting it", () => {
|
||||
expect(
|
||||
|
||||
@@ -49,24 +49,10 @@ class TestDirtyStateProvider extends DirtyStateProviderMixin<TestState>()(
|
||||
|
||||
customElements.define("test-dirty-state-provider", TestDirtyStateProvider);
|
||||
|
||||
class TestReloadBlockingProvider extends TestDirtyStateProvider {
|
||||
@state() public blocking = false;
|
||||
|
||||
protected override get _blocksReload(): boolean {
|
||||
return this.isDirtyState || this.blocking;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define(
|
||||
"test-reload-blocking-provider",
|
||||
TestReloadBlockingProvider
|
||||
);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-dirty-state-consumer": TestDirtyStateConsumer;
|
||||
"test-dirty-state-provider": TestDirtyStateProvider;
|
||||
"test-reload-blocking-provider": TestReloadBlockingProvider;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,13 +68,9 @@ const mountProvider = async () => {
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document
|
||||
.querySelectorAll(
|
||||
"test-dirty-state-provider, test-reload-blocking-provider"
|
||||
)
|
||||
.forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
document.querySelectorAll("test-dirty-state-provider").forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
provider = undefined;
|
||||
window.isDirtyState = false;
|
||||
});
|
||||
@@ -292,26 +274,6 @@ describe("DirtyStateProviderMixin", () => {
|
||||
window.removeEventListener("dirty-state-changed", listener);
|
||||
});
|
||||
|
||||
it("publishes a reload block without marking its own state dirty", async () => {
|
||||
const element = document.createElement("test-reload-blocking-provider");
|
||||
document.body.append(element);
|
||||
element.initialize(
|
||||
{ type: "shallow" },
|
||||
{ name: "Kitchen", enabled: false }
|
||||
);
|
||||
await element.updateComplete;
|
||||
expect(window.isDirtyState).toBe(false);
|
||||
|
||||
element.blocking = true;
|
||||
await element.updateComplete;
|
||||
expect(window.isDirtyState).toBe(true);
|
||||
expect(element.isDirtyState).toBe(false);
|
||||
|
||||
element.blocking = false;
|
||||
await element.updateComplete;
|
||||
expect(window.isDirtyState).toBe(false);
|
||||
});
|
||||
|
||||
it("hands global dirty state to the next provider after disconnecting", async () => {
|
||||
const firstProvider = await mountProvider();
|
||||
firstProvider.initialize(
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ConfigEntry } from "../../../../src/data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import {
|
||||
computeIntegrationCardState,
|
||||
computeIntegrationCardSummary,
|
||||
} from "../../../../src/panels/config/integrations/integration-card-summary";
|
||||
|
||||
const entry = (
|
||||
entry_id: string,
|
||||
state: ConfigEntry["state"] = "loaded",
|
||||
source = "user"
|
||||
) => ({ entry_id, state, source }) as ConfigEntry;
|
||||
|
||||
const device = (
|
||||
config_entries: string[],
|
||||
entry_type: DeviceRegistryEntry["entry_type"] = null
|
||||
) => ({ config_entries, entry_type }) as DeviceRegistryEntry;
|
||||
|
||||
describe("computeIntegrationCardState", () => {
|
||||
it("returns the most important state regardless of entry order", () => {
|
||||
expect(
|
||||
computeIntegrationCardState([entry("a", "not_loaded"), entry("b")])
|
||||
).toBe("not_loaded");
|
||||
expect(
|
||||
computeIntegrationCardState([
|
||||
entry("a", "setup_in_progress"),
|
||||
entry("b", "setup_retry"),
|
||||
])
|
||||
).toBe("setup_retry");
|
||||
expect(computeIntegrationCardState([entry("a"), entry("b")])).toBe(
|
||||
"loaded"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeIntegrationCardSummary", () => {
|
||||
it("counts devices, services, entities or entries when all are healthy", () => {
|
||||
expect(
|
||||
computeIntegrationCardSummary([entry("a")], [device(["a"])], 0)
|
||||
).toEqual({ unit: "devices", count: 1, attention: 0 });
|
||||
expect(
|
||||
computeIntegrationCardSummary([entry("a")], [device(["a"], "service")], 0)
|
||||
).toEqual({ unit: "services", count: 1, attention: 0 });
|
||||
expect(computeIntegrationCardSummary([entry("a")], [], 3)).toEqual({
|
||||
unit: "entities",
|
||||
count: 3,
|
||||
attention: 0,
|
||||
});
|
||||
expect(
|
||||
computeIntegrationCardSummary(
|
||||
[entry("a", "loaded", "yaml"), entry("b")],
|
||||
[],
|
||||
0
|
||||
)
|
||||
).toEqual({ unit: "entries", count: 1, attention: 0 });
|
||||
expect(
|
||||
computeIntegrationCardSummary([entry("a", "loaded", "yaml")], [], 0)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("counts entries needing attention instead of devices", () => {
|
||||
expect(
|
||||
computeIntegrationCardSummary(
|
||||
[entry("a"), entry("b", "setup_error"), entry("c", "setup_error")],
|
||||
[device(["a"]), device(["b"])],
|
||||
0
|
||||
)
|
||||
).toEqual({
|
||||
unit: "entries",
|
||||
count: 3,
|
||||
attention: 2,
|
||||
attentionState: "setup_error",
|
||||
});
|
||||
expect(
|
||||
computeIntegrationCardSummary(
|
||||
[entry("a", "setup_error"), entry("b", "not_loaded")],
|
||||
[],
|
||||
0
|
||||
)?.attentionState
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -316,7 +316,10 @@ describe("recover-stale-build", () => {
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("gives up on a stalled request", async () => {
|
||||
it("gives up on a stalled request without AbortController", async () => {
|
||||
const abortController = globalThis.AbortController;
|
||||
// @ts-expect-error — emulate a browser at the legacy floor
|
||||
delete globalThis.AbortController;
|
||||
// Never settles on its own; the probe's timeout has to end it.
|
||||
fetchMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
@@ -331,8 +334,6 @@ describe("recover-stale-build", () => {
|
||||
|
||||
await expect(recovery).resolves.toBe(false);
|
||||
expect(reloadMarker()).toBeNull();
|
||||
// The stalled request was aborted rather than left holding a connection.
|
||||
expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true);
|
||||
|
||||
// The probe was released, so a later failure is not swallowed.
|
||||
fetchMock.mockResolvedValue(httpResponse(404));
|
||||
@@ -342,6 +343,7 @@ describe("recover-stale-build", () => {
|
||||
).resolves.toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
globalThis.AbortController = abortController;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ describe("registerServiceWorker", () => {
|
||||
let registration: FakeRegistration;
|
||||
let serviceWorker: FakeServiceWorkerContainer;
|
||||
let notifications: ShowToastParams[];
|
||||
let windowListeners: Parameters<typeof window.addEventListener>[];
|
||||
|
||||
const latestNotification = () => notifications[notifications.length - 1];
|
||||
|
||||
@@ -86,12 +85,6 @@ describe("registerServiceWorker", () => {
|
||||
globalThis.__DEMO__ = false;
|
||||
window.isDirtyState = false;
|
||||
notifications = [];
|
||||
windowListeners = [];
|
||||
const addWindowListener = window.addEventListener.bind(window);
|
||||
vi.spyOn(window, "addEventListener").mockImplementation((...args) => {
|
||||
windowListeners.push(args);
|
||||
addWindowListener(...args);
|
||||
});
|
||||
root = document.createElement("div");
|
||||
root.addEventListener("hass-notification", (event) => {
|
||||
notifications.push((event as CustomEvent<ShowToastParams>).detail);
|
||||
@@ -109,11 +102,6 @@ describe("registerServiceWorker", () => {
|
||||
|
||||
afterEach(() => {
|
||||
latestNotification()?.secondaryAction?.action();
|
||||
for (const [type, listener] of windowListeners) {
|
||||
window.removeEventListener(type, listener);
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
window.isDirtyState = false;
|
||||
@@ -151,111 +139,6 @@ describe("registerServiceWorker", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reloads when the controller changes on a clean page", async () => {
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload });
|
||||
await registerServiceWorker(root);
|
||||
|
||||
serviceWorker.dispatchEvent(new Event("controllerchange"));
|
||||
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("offers the deferred controller change reload once the page is clean", async () => {
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload });
|
||||
await registerServiceWorker(root);
|
||||
setDirtyState(true);
|
||||
|
||||
serviceWorker.dispatchEvent(new Event("controllerchange"));
|
||||
serviceWorker.dispatchEvent(new Event("controllerchange"));
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(notifications).toHaveLength(0);
|
||||
|
||||
setDirtyState(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(latestNotification().message).toMatchObject({
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 60 },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
expect(latestNotification()).toMatchObject({ message: "", duration: 0 });
|
||||
});
|
||||
|
||||
it("reloads on request after a deferred controller change", async () => {
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload });
|
||||
await registerServiceWorker(root);
|
||||
setDirtyState(true);
|
||||
serviceWorker.dispatchEvent(new Event("controllerchange"));
|
||||
setDirtyState(false);
|
||||
|
||||
latestNotification().action!.action();
|
||||
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
expect(worker.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels a deferred controller change reload", async () => {
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload });
|
||||
await registerServiceWorker(root);
|
||||
setDirtyState(true);
|
||||
serviceWorker.dispatchEvent(new Event("controllerchange"));
|
||||
setDirtyState(false);
|
||||
|
||||
latestNotification().secondaryAction!.action();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
setDirtyState(true);
|
||||
setDirtyState(false);
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads once clean after a deferred controller change without the update toast", async () => {
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload });
|
||||
await registerServiceWorker(root, false);
|
||||
setDirtyState(true);
|
||||
|
||||
serviceWorker.dispatchEvent(new Event("controllerchange"));
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
setDirtyState(false);
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ notifyUpdate: true, toast: true },
|
||||
{ notifyUpdate: false, toast: false },
|
||||
])(
|
||||
"handles a page that became clean while registering (notifyUpdate=$notifyUpdate)",
|
||||
async ({ notifyUpdate, toast }) => {
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload });
|
||||
let resolveRegistration!: (value: FakeRegistration) => void;
|
||||
serviceWorker.register.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveRegistration = resolve;
|
||||
})
|
||||
);
|
||||
setDirtyState(true);
|
||||
|
||||
const registered = registerServiceWorker(root, notifyUpdate);
|
||||
serviceWorker.dispatchEvent(new Event("controllerchange"));
|
||||
setDirtyState(false);
|
||||
resolveRegistration(registration);
|
||||
await registered;
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(toast ? 0 : 1);
|
||||
expect(notifications.length > 0).toBe(toast);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ notifyUpdate: false, dev: false, demo: false },
|
||||
{ notifyUpdate: true, dev: true, demo: false },
|
||||
|
||||
@@ -73,6 +73,9 @@
|
||||
"@lit-labs/virtualizer/layouts/grid": [
|
||||
"./node_modules/@lit-labs/virtualizer/layouts/grid.js"
|
||||
],
|
||||
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver": [
|
||||
"./node_modules/@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver.js"
|
||||
],
|
||||
"@lit-labs/observers/resize-controller": [
|
||||
"./node_modules/@lit-labs/observers/resize-controller.js"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user