Compare commits

..
97 changed files with 2064 additions and 3212 deletions

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
View File
@@ -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: {
+8 -8
View File
@@ -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
}
}
+3 -3
View File
@@ -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}/`)),
+2 -17
View File
@@ -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
View File
@@ -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",
+19 -19
View File
@@ -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
);
}
}
+9 -11
View File
@@ -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;
+21 -12
View File
@@ -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);
+2 -2
View File
@@ -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);
};
+7 -4
View File
@@ -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.
+11
View File
@@ -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
+1 -1
View File
@@ -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;
+10 -34
View File
@@ -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 -2
View File
@@ -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
+3 -3
View File
@@ -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">
+1 -3
View File
@@ -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,
},
});
-1
View File
@@ -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;
-3
View File
@@ -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>;
}
-40
View File
@@ -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";
};
+1 -1
View File
@@ -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
View File
@@ -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 ||
+3 -3
View File
@@ -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 {
+1 -11
View File
@@ -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;
-3
View File
@@ -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
+5 -14
View File
@@ -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);
}
+4 -17
View File
@@ -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>
+1
View File
@@ -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;
+53 -9
View File
@@ -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;
+1
View File
@@ -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);
+2 -11
View File
@@ -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();
}
+2 -2
View File
@@ -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);
@@ -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;
+17
View File
@@ -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}
@@ -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;
}
}
`,
];
}
+41 -37
View File
@@ -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();
+12 -32
View File
@@ -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 {
+15 -8
View File
@@ -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;
}
+1 -40
View File
@@ -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();
}
+8
View File
@@ -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",
+2 -2
View File
@@ -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;
}
-12
View File
@@ -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),
];
+7 -12
View File
@@ -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;
};
}
+3 -28
View File
@@ -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 {};
+1 -1
View File
@@ -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,
+1 -50
View File
@@ -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",
-2
View File
@@ -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 {
+7 -2
View File
@@ -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
+9 -6
View File
@@ -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";
}
};
+3 -32
View File
@@ -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 -18
View File
@@ -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;
}
});
+12
View File
@@ -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",
]);
});
});
+10 -83
View File
@@ -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(
+3 -41
View File
@@ -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();
});
});
+5 -3
View File
@@ -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;
}
});
-117
View File
@@ -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 },
+3
View File
@@ -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"
],