mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-21 13:13:39 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b2e75169 | ||
|
|
d2eec5ed07 | ||
|
|
c608ceee21 | ||
|
|
fb9e9ed01d | ||
|
|
fc8a8702b9 | ||
|
|
caa15c26d5 | ||
|
|
45ee91aed2 | ||
|
|
22164c336d | ||
|
|
88b2b434b4 | ||
|
|
5c80d18f86 | ||
|
|
87f016f451 | ||
|
|
624865b2a9 | ||
|
|
a321b07bba | ||
|
|
cfd5b4fd8d | ||
|
|
2944efe1b7 | ||
|
|
9ec5e94c29 | ||
|
|
bcb123850d | ||
|
|
af4959b57a | ||
|
|
00ef6bf8b3 | ||
|
|
b94a4f947c |
No files matched your search
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: ha-frontend-query-params
|
||||
description: Home Assistant frontend URL query param patterns. Use when keeping page state such as filters, search, sorting or grouping in the URL, adding or reading typed query params, using QueryParamsController or the data table's sync-url and column urlName, building in-app links with a query string, or adding params to My Home Assistant redirects.
|
||||
---
|
||||
|
||||
# HA Frontend Query Params
|
||||
|
||||
Use this skill when implementing or reviewing state kept in the URL's query string, or links that set it. Cross-load `ha-frontend-lit` for the controller and lifecycle work, `ha-frontend-testing` for running tests, and `ha-frontend-review` when reviewing.
|
||||
|
||||
## Typed Params
|
||||
|
||||
`src/common/url/query-params.ts` turns a typed config into values and back:
|
||||
|
||||
- `QueryParamConfig` lists keys by type: `string`, `list` (one comma-separated param, `?label=a,b`), `date` (ISO string) and `boolean` (`{ key, trueValue }`, true only when the param equals `trueValue`).
|
||||
- `QueryParamValues<typeof config>` is the matching partial values type.
|
||||
- `decodeQueryParams(source, config)` reads a `SearchParamsSource` (a `URLSearchParams`, a plain record or a query string). Empty values are skipped.
|
||||
- `createQueryString(values, config)` writes the values, leaving out empty strings, empty lists and false booleans.
|
||||
- `updateQueryString(current, values, config)` replaces only the config's params in an existing query string and keeps every other param exactly as written.
|
||||
- `hasQueryParams(source, config)` tells whether a URL has any of the config's params, which is how a page knows whether a link asked for anything.
|
||||
- `serviceTargetFromQueryParams` and `queryParamsFromServiceTarget` map `HassServiceTarget` keys to list params.
|
||||
|
||||
Give each feature its own `*-query-params.ts` in `src/common/url/` holding its config (`as const satisfies QueryParamConfig`), its values type, and the helpers that turn params into page state and back. Follow the existing ones: `data-table-query-params.ts`, `marketplace-query-params.ts`, `history-logbook-query-params.ts` and `todo-query-params.ts`. Helpers in `src/data/` that build outside links use a local config the same way, as `src/data/integration.ts` and `src/data/system_log_report.ts` do. In new code, prefer these over hand-built `URLSearchParams`, string concatenation or `extractSearchParam` reads from `src/common/url/search-params.ts`.
|
||||
|
||||
## QueryParamsController
|
||||
|
||||
`src/common/controllers/query-params-controller.ts` keeps part of a page's state in the URL. Construct it with the host, the feature's config and options:
|
||||
|
||||
- `enabled()`: whether the host syncs right now. It can change, as on the Marketplace dashboard, which turns it off on the Discover tab.
|
||||
- `navigated()`: called when the page's URL changes while the host is connected (`location-changed` or `popstate`), and when the host reconnects after its first update. Read the URL with `searchParams()`, apply it, then `write()` the state back.
|
||||
|
||||
Methods:
|
||||
|
||||
- `searchParams()` returns the current params, or `undefined` when the controller is disabled or the URL is on another path.
|
||||
- `write(values, debounced?)` updates only the config's params with `replaceCurrentUrl`, so it replaces the current history entry. Pass `debounced` for values that change while typing; the write lands 300 ms after the last one and any later write replaces it.
|
||||
- `flush()` writes a pending debounced write now. The data table calls it when the search box loses focus. Call it before anything that adds its own history entry, such as a dialog.
|
||||
|
||||
The controller binds to `currentPath()` when the host connects and does nothing while the URL is on another path. Navigating away drops a pending write. When a page is reached through a router `defaultPage` (such as `/marketplace` landing on `/marketplace/browse`), check that a link to the bare path still syncs, since the redirect may land after the host connects.
|
||||
|
||||
Read the URL yourself on the first render, after the initial state is set (in `willUpdate` before `hasUpdated`, or `firstUpdated`), and leave later navigations to `navigated()`. `ha-marketplace-dashboard.ts` and `lorawan-devices.ts` show both. When moving a page to the controller, remove its own `location-changed` and `popstate` listeners and any search kept in history state, since the controller replaces them.
|
||||
|
||||
When a router reuses one element for different items, such as one app's tab after another's, key it on the item so the controller binds again to the new path.
|
||||
|
||||
## URL Rules
|
||||
|
||||
- Writes replace the current history entry, so Back leaves the page instead of undoing each change. Push a new entry only for a deliberate navigation, such as picking another log provider.
|
||||
- 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.
|
||||
- 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.
|
||||
- Use the names other pages already use for the same thing: `search`, `sort`, `direction` and `group` for the table, and `floor`, `area`, `device`, `label`, `config_entry`, `sub_entry` and `assistants` for the shared filters.
|
||||
- Keep existing param names so existing links and My Home Assistant links keep working. When renaming, read the old name as well for about six months, with a code comment naming the release it can be removed in, and write only the new name. Keep the old name in the config, use it only as a fallback when reading, and leave it out of the values you write, so the next write removes it from the URL.
|
||||
- Pages opt in. Some use params for other things, such as `?domain=` starting the add integration flow on the integrations dashboard, so check what a page already reads before syncing it.
|
||||
- Collapsed groups, column order and hidden columns stay in storage (`@storage`), not the URL.
|
||||
- Link to a page by its main path, not an alias, so a link to an open page lands on the same path and applies.
|
||||
|
||||
## 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.
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Coverage Check
|
||||
|
||||
Before calling a page done:
|
||||
|
||||
1. List every filter, sortable column, groupable column, the search box and any view toggle (such as a tab, raw view or provider). Confirm each one is written to the URL and read back from a link, both on first render and on a page that is already open.
|
||||
2. Search the codebase for in-app links and `navigate()` calls into the page with a query string, and move them to the current param names and the page's main path. `lorawan-connections.ts` and `lorawan-overview.ts` link into `lorawan-devices.ts` this way.
|
||||
|
||||
## My Home Assistant Links
|
||||
|
||||
When a page gains params, add them to its redirect in `getMyRedirects()` in `src/panels/my/ha-panel-my.ts`:
|
||||
|
||||
- List them under `params` as `"string?"`, matching what the page reads. For a data table page that includes `search`, `sort`, `direction` and `group` as well as its filters. Keep the list in a const next to the others, such as `marketplaceTableParams`, which several redirects share.
|
||||
- Point the redirect at the page's main path, the one the page's controller binds to.
|
||||
- Don't add legacy param names.
|
||||
- Add the same params to the redirect's entry in `redirect.json` in `home-assistant/my.home-assistant.io`, in a separate pull request that links the frontend pull request. Merge it only once the frontend pull request is in `dev`.
|
||||
- In the frontend pull request, link the My Home Assistant pull request under "Additional information": `- Link to My Home Assistant pull request: home-assistant/my.home-assistant.io#<n>`.
|
||||
|
||||
## Tests
|
||||
|
||||
Follow `test/common/url/query-params.test.ts`, `test/common/url/data-table-query-params.test.ts` and `test/common/controllers/query-params-controller.test.ts`. For a feature's params file, cover the encode and decode round trip, defaults being left out, and legacy names still being read. Test the helpers, not the page rendering, and only where the logic could break without anyone noticing.
|
||||
@@ -46,6 +46,7 @@ Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching
|
||||
- `ha-frontend-lit`: reactive fields, DOM queries, lifecycle behavior, and render-derived state.
|
||||
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
|
||||
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
|
||||
- `ha-frontend-query-params`: typed URL query params, `QueryParamsController`, data table URL sync, in-app links, and My Home Assistant redirect params.
|
||||
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
|
||||
- `ha-frontend-review`: PR template use, existing review feedback, review checklist, recurring issues, and UI/UX evidence including gallery specifications.
|
||||
- `ha-frontend-ux-readiness`: UI/UX routing that complements frontend review for new user experiences.
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const npmPath = (...parts) =>
|
||||
path.resolve(paths.root_dir, "node_modules", ...parts);
|
||||
const polyPath = (...parts) => path.resolve(paths.root_dir, ...parts);
|
||||
@@ -83,9 +87,24 @@ async function copyMapPanel(staticDir) {
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
copyFileDir(
|
||||
npmPath("zxing-wasm/dist/reader/zxing_reader.wasm"),
|
||||
staticPath("js")
|
||||
// Resolve through barcode-detector, the wasm must match the zxing-wasm JS it bundles
|
||||
const zxingWasm = createRequire(require.resolve("barcode-detector")).resolve(
|
||||
"zxing-wasm/reader/zxing_reader.wasm"
|
||||
);
|
||||
const {
|
||||
ZXING_WASM_SHA256,
|
||||
ZXING_WASM_VERSION,
|
||||
} = require("barcode-detector/pure");
|
||||
const wasm = fs.readFileSync(zxingWasm);
|
||||
if (createHash("sha256").update(wasm).digest("hex") !== ZXING_WASM_SHA256) {
|
||||
throw new Error(
|
||||
`${zxingWasm} does not match the zxing-wasm ${ZXING_WASM_VERSION} bundled in barcode-detector`
|
||||
);
|
||||
}
|
||||
// Versioned file name, see locateFile in ha-qr-scanner
|
||||
fs.outputFileSync(
|
||||
staticPath("js", `zxing_reader-${ZXING_WASM_VERSION}.wasm`),
|
||||
wasm
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,9 @@ gulp.task("rspack-watch-app", () => {
|
||||
: createAppConfig({ isProdBuild: false, latestBuild: true })
|
||||
).watch({ poll: isWsl }, doneHandler());
|
||||
gulp.watch(
|
||||
path.join(paths.translations_src, "en.json"),
|
||||
// Watch the folder via a glob; git replaces en.json on checkout/stash,
|
||||
// which can permanently drop a watch on the file itself
|
||||
path.join(paths.translations_src, "*.json"),
|
||||
gulp.series("build-translations", "copy-translations-app")
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,6 +40,21 @@ const TOOLCHAIN_PACKAGES = [
|
||||
"caniuse-lite",
|
||||
];
|
||||
|
||||
// Not direct dependencies: read the copy the package that loads them uses.
|
||||
const TOOLCHAIN_PACKAGE_PARENTS = {
|
||||
terser: "terser-webpack-plugin",
|
||||
"caniuse-lite": "browserslist",
|
||||
};
|
||||
|
||||
const toolchainVersion = (pkg) => {
|
||||
const parent = TOOLCHAIN_PACKAGE_PARENTS[pkg];
|
||||
const lookup = parent && [
|
||||
path.dirname(require.resolve(`${parent}/package.json`)),
|
||||
];
|
||||
return require(require.resolve(`${pkg}/package.json`, { paths: lookup }))
|
||||
.version;
|
||||
};
|
||||
|
||||
// Our own build logic — the config, loaders and babel plugins. Their contents
|
||||
// (not their paths) go into the cache version, so a change invalidates the
|
||||
// cache the same way `buildDependencies` would, but without tying validity to
|
||||
@@ -63,9 +78,7 @@ const PATCHES_DIR = path.join(paths.root_dir, "patches");
|
||||
// stays valid when reused on a different machine or checkout path.
|
||||
const cacheVersion = () => {
|
||||
const parts = [
|
||||
...TOOLCHAIN_PACKAGES.map(
|
||||
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
|
||||
),
|
||||
...TOOLCHAIN_PACKAGES.map((pkg) => `${pkg}@${toolchainVersion(pkg)}`),
|
||||
...CONFIG_FILES.map(
|
||||
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
|
||||
),
|
||||
|
||||
+3
-2
@@ -133,14 +133,15 @@
|
||||
"workbox-routing": "7.4.1",
|
||||
"workbox-strategies": "7.4.1",
|
||||
"xss": "1.0.15",
|
||||
"zrender": "6.1.0",
|
||||
"zxing-wasm": "3.1.5"
|
||||
"zrender": "6.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.7",
|
||||
"@babel/helper-compilation-targets": "8.0.7",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-class-properties": "8.0.1",
|
||||
"@babel/plugin-transform-private-methods": "8.0.1",
|
||||
"@babel/plugin-transform-runtime": "8.0.6",
|
||||
"@babel/preset-env": "8.0.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
|
||||
Generated
+6
-11
@@ -462,9 +462,6 @@ importers:
|
||||
zrender:
|
||||
specifier: 6.1.0
|
||||
version: 6.1.0
|
||||
zxing-wasm:
|
||||
specifier: 3.1.5
|
||||
version: 3.1.5
|
||||
devDependencies:
|
||||
'@ampproject/remapping':
|
||||
specifier: 2.3.0
|
||||
@@ -478,6 +475,12 @@ importers:
|
||||
'@babel/helper-define-polyfill-provider':
|
||||
specifier: 1.0.0
|
||||
version: 1.0.0(@babel/[email protected])
|
||||
'@babel/plugin-transform-class-properties':
|
||||
specifier: 8.0.1
|
||||
version: 8.0.1(@babel/[email protected])
|
||||
'@babel/plugin-transform-private-methods':
|
||||
specifier: 8.0.1
|
||||
version: 8.0.1(@babel/[email protected])
|
||||
'@babel/plugin-transform-runtime':
|
||||
specifier: 8.0.6
|
||||
version: 8.0.6(@babel/[email protected])
|
||||
@@ -7836,9 +7839,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3lC9BJk4fR5ZJxcGjb0hVnDFOW7KpLXHIebiBmVd4FDRQZVeObztoTKxRUPxlWwSgxrMRONK0u50hBD1aTYEKg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-jmxXvTCR/qZxMz2XwR6V+nL2BlDTD1aux7ZWSH9BV77TG9u4k0VyHba/NCCHBHJ60Wg6VV0QL/SMlJczKhJI4w==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@ampproject/[email protected]':
|
||||
@@ -15838,8 +15838,3 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/emscripten': 1.41.6
|
||||
type-fest: 5.10.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/emscripten': 1.41.6
|
||||
type-fest: 5.10.0
|
||||
@@ -98,6 +98,11 @@
|
||||
"groupName": "date-fns",
|
||||
"matchPackageNames": ["date-fns", "date-fns-tz"]
|
||||
},
|
||||
{
|
||||
"description": "Group ECharts with zrender, which we import directly and must match the version echarts depends on",
|
||||
"groupName": "echarts",
|
||||
"matchPackageNames": ["echarts", "zrender"]
|
||||
},
|
||||
{
|
||||
"description": "Group formatjs monorepo package",
|
||||
"groupName": "formatjs",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Add a value to a most recently used list. The value goes first, also when it
|
||||
* was already in the list, and the list is capped at `max`.
|
||||
* Entries rejected by `isValid` are dropped first, so they don't take up slots.
|
||||
*/
|
||||
export const addRecent = <T>(
|
||||
list: readonly T[] | undefined,
|
||||
value: T,
|
||||
max: number,
|
||||
isValid?: (item: T) => boolean
|
||||
): T[] => {
|
||||
const current = Array.isArray(list)
|
||||
? list.filter((item) => item !== value && (!isValid || isValid(item)))
|
||||
: [];
|
||||
return [value, ...current].slice(0, max);
|
||||
};
|
||||
@@ -14,7 +14,6 @@ export type LocalizeKeys =
|
||||
| `ui.card.weather.attributes.${string}`
|
||||
| `ui.card.weather.cardinal_direction.${string}`
|
||||
| `ui.card.lawn_mower.actions.${string}`
|
||||
| `ui.common.${string}`
|
||||
| `ui.components.calendar.event.rrule.${string}`
|
||||
| `ui.components.selectors.file.${string}`
|
||||
| `ui.dialogs.entity_registry.editor.${string}`
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
DataTableFiltersValue,
|
||||
DataTableFiltersValues,
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
decodeQueryParams,
|
||||
hasQueryParams,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
type SearchParamsSource,
|
||||
} from "./query-params";
|
||||
|
||||
// The filters of the helpers table. Search, sorting and grouping are kept by
|
||||
// the data table, see data-table-query-params.
|
||||
export const helpersFilterQueryParamConfig = {
|
||||
list: [
|
||||
"floor",
|
||||
"area",
|
||||
"device",
|
||||
"label",
|
||||
"category",
|
||||
"assistants",
|
||||
// Read until 2027.5, replaced with `assistants` on the next write
|
||||
"voice_assistant",
|
||||
],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type HelpersFilterQueryParams = QueryParamValues<
|
||||
typeof helpersFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
const list = (value: DataTableFiltersValue): string[] | undefined =>
|
||||
Array.isArray(value) ? value : undefined;
|
||||
|
||||
const floorAreas = (
|
||||
value: DataTableFiltersValue
|
||||
): Record<string, string[]> | undefined =>
|
||||
value && !Array.isArray(value) ? value : undefined;
|
||||
|
||||
export const helpersFilterQueryParams = (
|
||||
filters: DataTableFiltersValues
|
||||
): HelpersFilterQueryParams => ({
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
category: list(filters["ha-filter-categories"]),
|
||||
assistants: list(filters["ha-filter-voice-assistants"]),
|
||||
});
|
||||
|
||||
// What a link says to show, undefined when it has no table params so what was
|
||||
// picked before stays. A link replaces the search and the filters, ones left
|
||||
// behind would hide part of what it shows. The table applies its sorting and
|
||||
// grouping.
|
||||
export interface HelpersLink {
|
||||
search: string;
|
||||
filters: DataTableFiltersValues;
|
||||
}
|
||||
|
||||
export const helpersLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): HelpersLink | undefined => {
|
||||
if (
|
||||
!hasQueryParams(searchParams, helpersFilterQueryParamConfig) &&
|
||||
!hasQueryParams(searchParams, dataTableQueryParamConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = decodeQueryParams(searchParams, helpersFilterQueryParamConfig);
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof HelpersFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
const assistants = values("assistants");
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas":
|
||||
floors.length || areas.length ? { floors, areas } : undefined,
|
||||
"ha-filter-devices": values("device"),
|
||||
"ha-filter-labels": values("label"),
|
||||
"ha-filter-categories": values("category"),
|
||||
"ha-filter-voice-assistants": assistants.length
|
||||
? assistants
|
||||
: values("voice_assistant"),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import { STATES_OFF } from "../../common/const";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { apiContext, formattersContext } from "../../data/context";
|
||||
@@ -13,6 +14,7 @@ import { forwardHaptic } from "../../data/haptics";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-switch";
|
||||
import { getToggleAction } from "../../common/entity/get_toggle_action";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
|
||||
const isOn = (stateObj?: HassEntity) =>
|
||||
stateObj !== undefined &&
|
||||
@@ -37,6 +39,10 @@ export class HaEntityToggle extends LitElement {
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
private _formatters?: ContextType<typeof formattersContext>;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@property({ attribute: false }) public stateObj?: HassEntity;
|
||||
|
||||
@property() public label?: string;
|
||||
@@ -49,6 +55,14 @@ export class HaEntityToggle extends LitElement {
|
||||
}
|
||||
|
||||
const name = this._formatters?.formatEntityName(this.stateObj) ?? "";
|
||||
const turnOnLabel = this._localize(
|
||||
"ui.components.entity.entity-toggle.turn_on",
|
||||
{ name }
|
||||
);
|
||||
const turnOffLabel = this._localize(
|
||||
"ui.components.entity.entity-toggle.turn_off",
|
||||
{ name }
|
||||
);
|
||||
|
||||
if (
|
||||
this.stateObj.attributes.assumed_state ||
|
||||
@@ -56,7 +70,7 @@ export class HaEntityToggle extends LitElement {
|
||||
) {
|
||||
return html`
|
||||
<ha-icon-button
|
||||
.label=${`Turn ${name} off`}
|
||||
.label=${turnOffLabel}
|
||||
.path=${mdiFlashOff}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@click=${this._turnOff}
|
||||
@@ -65,7 +79,7 @@ export class HaEntityToggle extends LitElement {
|
||||
}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${`Turn ${name} on`}
|
||||
.label=${turnOnLabel}
|
||||
.path=${mdiFlash}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@click=${this._turnOn}
|
||||
@@ -75,7 +89,10 @@ export class HaEntityToggle extends LitElement {
|
||||
}
|
||||
|
||||
return html`<ha-switch
|
||||
aria-label=${`Toggle ${name} ${this._isOn ? "off" : "on"}`}
|
||||
.accessibleLabel=${this._localize(
|
||||
"ui.components.entity.entity-toggle.toggle",
|
||||
{ name }
|
||||
)}
|
||||
.checked=${this._isOn}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@change=${this._toggleChanged}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mdiUnfoldMoreHorizontal } from "@mdi/js";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
@@ -13,6 +14,10 @@ import {
|
||||
state as litState,
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "./ha-button";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
const LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/;
|
||||
|
||||
interface State {
|
||||
bold: boolean;
|
||||
@@ -30,12 +35,27 @@ export class HaAnsiToHtml extends LitElement {
|
||||
@property({ type: Boolean, attribute: "wrap-disabled" }) public wrapDisabled =
|
||||
false;
|
||||
|
||||
// Lines to show before and after each line that matches the filter.
|
||||
@property({ type: Number, attribute: "context-lines" }) public contextLines =
|
||||
0;
|
||||
|
||||
// Text for the button that shows a run of hidden lines between matches.
|
||||
@property({ attribute: false }) public expandLabel?: (
|
||||
count: number
|
||||
) => string;
|
||||
|
||||
@query("pre") private _pre?: HTMLPreElement;
|
||||
|
||||
@queryAll("div") private _divs!: NodeListOf<HTMLDivElement>;
|
||||
|
||||
@litState() private _filter = "";
|
||||
|
||||
// Hidden lines the user chose to show while a filter is set.
|
||||
private _expandedLines = new WeakSet<HTMLDivElement>();
|
||||
|
||||
// The hidden lines each expand row shows when clicked.
|
||||
private _expandGaps = new WeakMap<HTMLElement, HTMLDivElement[]>();
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
|
||||
}
|
||||
@@ -57,6 +77,15 @@ export class HaAnsiToHtml extends LitElement {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.context {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.expand {
|
||||
display: block;
|
||||
margin: var(--ha-space-1) 0;
|
||||
font-family: var(--ha-font-family-body);
|
||||
white-space: normal;
|
||||
}
|
||||
.bold {
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
}
|
||||
@@ -119,8 +148,7 @@ export class HaAnsiToHtml extends LitElement {
|
||||
}
|
||||
|
||||
::highlight(search-results) {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary-color);
|
||||
background-color: var(--ha-color-fill-primary-normal-resting);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -322,12 +350,16 @@ export class HaAnsiToHtml extends LitElement {
|
||||
* @returns true if there are lines to display
|
||||
*/
|
||||
filterLines(filter: string): boolean {
|
||||
if (filter !== this._filter) {
|
||||
this._expandedLines = new WeakSet();
|
||||
}
|
||||
this._filter = filter;
|
||||
const lines = this._divs;
|
||||
let numberOfFoundLines = 0;
|
||||
if (!filter) {
|
||||
lines.forEach((line) => {
|
||||
line.style.display = "";
|
||||
line.classList.remove("context");
|
||||
});
|
||||
numberOfFoundLines = lines.length;
|
||||
if (CSS.highlights) {
|
||||
@@ -335,9 +367,59 @@ export class HaAnsiToHtml extends LitElement {
|
||||
}
|
||||
} else {
|
||||
const highlightRanges: Range[] = [];
|
||||
lines.forEach((line) => {
|
||||
if (!line.textContent?.toLowerCase().includes(filter.toLowerCase())) {
|
||||
|
||||
const lineList = Array.from(lines);
|
||||
|
||||
const matches = lineList.map(
|
||||
(line) =>
|
||||
line.textContent?.toLowerCase().includes(filter.toLowerCase()) ??
|
||||
false
|
||||
);
|
||||
|
||||
// A line without a timestamp continues the entry above it, such as a
|
||||
// build's output, so a matching entry shows all of its lines.
|
||||
let entryStart = -1;
|
||||
|
||||
const entryOf = lineList.map((line, lineIndex) => {
|
||||
if (LINE_TIMESTAMP.test(line.textContent ?? "")) {
|
||||
entryStart = lineIndex;
|
||||
}
|
||||
|
||||
return entryStart === -1 ? lineIndex : entryStart;
|
||||
});
|
||||
|
||||
const matchedEntries = new Set(
|
||||
entryOf.filter((_entry, lineIndex) => matches[lineIndex])
|
||||
);
|
||||
|
||||
const inMatch = entryOf.map(
|
||||
(entry, lineIndex) =>
|
||||
matches[lineIndex] ||
|
||||
(this.contextLines > 0 && matchedEntries.has(entry))
|
||||
);
|
||||
|
||||
// Like a diff, also show the lines around each match.
|
||||
const visible = inMatch.map(
|
||||
(_match, lineIndex) =>
|
||||
this._expandedLines.has(lineList[lineIndex]) ||
|
||||
inMatch
|
||||
.slice(
|
||||
Math.max(0, lineIndex - this.contextLines),
|
||||
lineIndex + this.contextLines + 1
|
||||
)
|
||||
.some(Boolean)
|
||||
);
|
||||
|
||||
lineList.forEach((line, lineIndex) => {
|
||||
line.classList.toggle(
|
||||
"context",
|
||||
visible[lineIndex] && !inMatch[lineIndex]
|
||||
);
|
||||
|
||||
if (!visible[lineIndex]) {
|
||||
line.style.display = "none";
|
||||
} else if (!matches[lineIndex]) {
|
||||
line.style.display = "";
|
||||
} else {
|
||||
line.style.display = "";
|
||||
numberOfFoundLines++;
|
||||
@@ -367,18 +449,104 @@ export class HaAnsiToHtml extends LitElement {
|
||||
if (CSS.highlights) {
|
||||
CSS.highlights.set("search-results", new Highlight(...highlightRanges));
|
||||
}
|
||||
|
||||
if (this.contextLines && numberOfFoundLines) {
|
||||
this._updateExpandButtons(lineList, visible);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
this._updateExpandButtons([], []);
|
||||
|
||||
return !!numberOfFoundLines;
|
||||
}
|
||||
|
||||
// Filtering runs again for every new log line, so rows for unchanged gaps
|
||||
// are kept rather than recreated, which would drop keyboard focus.
|
||||
private _updateExpandButtons(lines: HTMLDivElement[], visible: boolean[]) {
|
||||
const rows = new Map<Element | null, HTMLSpanElement>();
|
||||
this._pre?.querySelectorAll(".expand").forEach((row) => {
|
||||
if (row instanceof HTMLSpanElement) {
|
||||
rows.set(row.nextElementSibling, row);
|
||||
}
|
||||
});
|
||||
|
||||
let hidden: HTMLDivElement[] = [];
|
||||
|
||||
lines.forEach((line, lineIndex) => {
|
||||
if (!visible[lineIndex]) {
|
||||
hidden.push(line);
|
||||
}
|
||||
|
||||
if (
|
||||
hidden.length &&
|
||||
(visible[lineIndex] || lineIndex === lines.length - 1)
|
||||
) {
|
||||
const label = this.expandLabel?.(hidden.length) ?? `${hidden.length}`;
|
||||
let row = rows.get(hidden[0]);
|
||||
|
||||
if (row) {
|
||||
rows.delete(hidden[0]);
|
||||
const text = row.querySelector("ha-button")?.lastChild;
|
||||
|
||||
if (text instanceof Text && text.data !== label) {
|
||||
text.data = label;
|
||||
}
|
||||
} else {
|
||||
row = this._createExpandRow(label);
|
||||
hidden[0].before(row);
|
||||
}
|
||||
|
||||
this._expandGaps.set(row, hidden);
|
||||
hidden = [];
|
||||
}
|
||||
});
|
||||
|
||||
rows.forEach((row) => row.remove());
|
||||
}
|
||||
|
||||
private _createExpandRow(label: string): HTMLSpanElement {
|
||||
const row = document.createElement("span");
|
||||
row.className = "expand";
|
||||
const button = document.createElement("ha-button");
|
||||
button.setAttribute("appearance", "filled");
|
||||
button.setAttribute("variant", "neutral");
|
||||
button.setAttribute("size", "s");
|
||||
const icon = document.createElement("ha-svg-icon");
|
||||
icon.slot = "start";
|
||||
icon.path = mdiUnfoldMoreHorizontal;
|
||||
button.append(icon, label);
|
||||
button.addEventListener("click", () => {
|
||||
const gap = this._expandGaps.get(row) ?? [];
|
||||
const hadFocus = row.matches(":focus-within");
|
||||
gap.forEach((hiddenLine) => this._expandedLines.add(hiddenLine));
|
||||
this.filterLines(this._filter);
|
||||
|
||||
// The row is gone once its lines show, so keep focus on the first of them.
|
||||
if (hadFocus && gap[0]) {
|
||||
gap[0].tabIndex = -1;
|
||||
gap[0].focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
row.append(button);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
public trimLines(maxLines: number) {
|
||||
const pre = this._pre;
|
||||
if (!pre) {
|
||||
const lines = this._divs;
|
||||
|
||||
if (lines.length <= maxLines) {
|
||||
return;
|
||||
}
|
||||
while (pre.childElementCount > maxLines && pre.firstElementChild) {
|
||||
pre.firstElementChild.remove();
|
||||
|
||||
Array.from(lines)
|
||||
.slice(0, lines.length - maxLines)
|
||||
.forEach((line) => line.remove());
|
||||
|
||||
if (this._filter) {
|
||||
this.filterLines(this._filter);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ export class HaBaseTimeInput extends LitElement {
|
||||
*/
|
||||
@property() label?: string;
|
||||
|
||||
/** Accessible name for the input when it has no visible label. */
|
||||
@property({ attribute: "accessible-label" })
|
||||
public accessibleLabel?: string;
|
||||
|
||||
/**
|
||||
* Helper for the input
|
||||
*/
|
||||
@@ -178,6 +182,7 @@ export class HaBaseTimeInput extends LitElement {
|
||||
class="time-input-wrap"
|
||||
role="group"
|
||||
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
|
||||
aria-label=${ifDefined(this.label ? undefined : this.accessibleLabel)}
|
||||
>
|
||||
${
|
||||
this.enableSign
|
||||
|
||||
@@ -50,6 +50,10 @@ export class HaDateInput extends LitElement {
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
/** Accessible name for the input when it has no visible label. */
|
||||
@property({ attribute: "accessible-label" })
|
||||
public accessibleLabel?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: "can-clear", type: Boolean }) public canClear = false;
|
||||
@@ -65,6 +69,7 @@ export class HaDateInput extends LitElement {
|
||||
.label=${this.label ?? ""}
|
||||
.hint=${this.helper ?? ""}
|
||||
.disabled=${this.disabled}
|
||||
.accessibleLabel=${this.accessibleLabel}
|
||||
readonly
|
||||
@click=${this._openDialog}
|
||||
@keydown=${this._keyDown}
|
||||
|
||||
@@ -44,7 +44,7 @@ export class HaIconButton extends LitElement {
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
variant="neutral"
|
||||
.ariaLabel=${this.label ?? null}
|
||||
.accessibleLabel=${this.label ?? ""}
|
||||
.title=${this.hideTitle ? "" : (this.label ?? "")}
|
||||
aria-haspopup=${ifDefined(this.ariaHasPopup)}
|
||||
.disabled=${this.disabled}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
// and "qr-scanner" defaults to a suboptimal implementation if it is not available.
|
||||
// The following import makes a better implementation available that is based on a
|
||||
// WebAssembly port of ZXing:
|
||||
import { prepareZXingModule } from "barcode-detector";
|
||||
import { prepareZXingModule, ZXING_WASM_VERSION } from "barcode-detector";
|
||||
import type QrScanner from "qr-scanner";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
@@ -29,7 +29,8 @@ prepareZXingModule({
|
||||
overrides: {
|
||||
locateFile: (path: string, prefix: string) => {
|
||||
if (path.endsWith(".wasm")) {
|
||||
return "/static/js/zxing_reader.wasm";
|
||||
// Versioned, /static is cached for a month and the glue only works with its own wasm
|
||||
return `/static/js/zxing_reader-${ZXING_WASM_VERSION}.wasm`;
|
||||
}
|
||||
return prefix + path;
|
||||
},
|
||||
|
||||
@@ -15,6 +15,10 @@ export class HaTimeInput extends LitElement {
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
/** Accessible name for the input when it has no visible label. */
|
||||
@property({ attribute: "accessible-label" })
|
||||
public accessibleLabel?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
@@ -61,6 +65,7 @@ export class HaTimeInput extends LitElement {
|
||||
return html`
|
||||
<ha-base-time-input
|
||||
.label=${this.label}
|
||||
.accessibleLabel=${this.accessibleLabel}
|
||||
.hours=${hours}
|
||||
.minutes=${minutes}
|
||||
.seconds=${seconds}
|
||||
|
||||
@@ -126,6 +126,10 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
@property({ type: Boolean, attribute: "inset-label" })
|
||||
public insetLabel = false;
|
||||
|
||||
/** Accessible name for the input when it has no visible label. */
|
||||
@property({ attribute: "accessible-label" })
|
||||
public accessibleLabel?: string;
|
||||
|
||||
/** Sets the native input id (and label `for`) on the inner `wa-input`. */
|
||||
@property({ attribute: "input-id" })
|
||||
public inputId?: string;
|
||||
@@ -276,6 +280,7 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
.name=${this.name}
|
||||
.disabled=${this.disabled}
|
||||
.inputId=${this.inputId || "input"}
|
||||
.accessibleLabel=${this.accessibleLabel ?? ""}
|
||||
class=${classMap({
|
||||
input: true,
|
||||
invalid: this.invalid || this._invalid,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { getShadowRootHost } from "../../common/dom/get-shadow-root-host";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import { slugify } from "../../common/string/slugify";
|
||||
import { stripDiacritics } from "../../common/string/strip-diacritics";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { UNAVAILABLE } from "../../data/entity/entity";
|
||||
import type {
|
||||
@@ -94,6 +95,13 @@ export interface MediaPlayerItemId {
|
||||
|
||||
type MediaClass = MediaPlayerItem["media_class"];
|
||||
|
||||
/** Folders with at least this many items get a filter field when their source cannot search. */
|
||||
const FILTER_MIN_CHILDREN = 10;
|
||||
|
||||
/** Case and accent insensitive, so "stefan" finds "Ștefan" and "DIGI" finds "Digi". */
|
||||
const normalizeForFilter = (value: string): string =>
|
||||
stripDiacritics(value).toLowerCase().trim();
|
||||
|
||||
const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
|
||||
can_expand: true,
|
||||
can_play: false,
|
||||
@@ -144,6 +152,8 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
@state() private _searchQuery = "";
|
||||
|
||||
@state() private _filterQuery = "";
|
||||
|
||||
@state() private _searchResults?: MediaPlayerItem[];
|
||||
|
||||
@state() private _searching = false;
|
||||
@@ -233,6 +243,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
this._searchQuery = "";
|
||||
this._searchResults = undefined;
|
||||
this._mediaClassFilter = [];
|
||||
this._filterQuery = "";
|
||||
const currentId = navigateIds[navigateIds.length - 1];
|
||||
const parentId =
|
||||
navigateIds.length > 1 ? navigateIds[navigateIds.length - 2] : undefined;
|
||||
@@ -447,13 +458,24 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
// Search is available on non-root pages that opt in via can_search, aside
|
||||
// from the manual-entry and TTS pseudo-sources.
|
||||
const showSearch =
|
||||
// Search and filter are available on non-root pages, aside from the
|
||||
// manual-entry and TTS pseudo-sources.
|
||||
const allowsSearchOrFilter =
|
||||
this.navigateIds.length > 1 &&
|
||||
!isManualMediaSourceContentId(currentItem.media_content_id) &&
|
||||
!isTTSMediaSource(currentItem.media_content_id) &&
|
||||
currentItem.can_search;
|
||||
!isTTSMediaSource(currentItem.media_content_id);
|
||||
const showSearch = allowsSearchOrFilter && currentItem.can_search;
|
||||
const showFilter =
|
||||
allowsSearchOrFilter &&
|
||||
!currentItem.can_search &&
|
||||
children.length >= FILTER_MIN_CHILDREN;
|
||||
const filterQuery = showFilter ? normalizeForFilter(this._filterQuery) : "";
|
||||
const isFiltering = filterQuery !== "";
|
||||
if (isFiltering) {
|
||||
children = children.filter((child) =>
|
||||
normalizeForFilter(child.title).includes(filterQuery)
|
||||
);
|
||||
}
|
||||
// The backend reports which media classes are worth filtering by for this
|
||||
// item; without them we still allow searching, just without the filter.
|
||||
const mediaClassFilterOptions =
|
||||
@@ -478,7 +500,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
return html`
|
||||
${
|
||||
canPickCurrent || showSearch
|
||||
canPickCurrent || showSearch || showFilter
|
||||
? html`
|
||||
<div
|
||||
class="header ${classMap({
|
||||
@@ -494,7 +516,9 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
currentItem,
|
||||
mediaClassFilterOptions
|
||||
)
|
||||
: nothing
|
||||
: showFilter
|
||||
? this._renderFilterRow(currentItem)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
canPickCurrent
|
||||
@@ -617,7 +641,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
@tts-picked=${this._ttsPicked}
|
||||
></ha-browse-media-tts>
|
||||
`
|
||||
: !children.length && !notShown
|
||||
: !children.length && (!notShown || isFiltering)
|
||||
? html`
|
||||
<div class="container no-items">
|
||||
${
|
||||
@@ -625,25 +649,29 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
? this.hass.localize(
|
||||
"ui.components.media-browser.search.no_results"
|
||||
)
|
||||
: currentItem.media_content_id ===
|
||||
"media-source://media_source/local/."
|
||||
? html`
|
||||
<div class="highlight-add-button">
|
||||
<span>
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowUpRight}
|
||||
></ha-svg-icon>
|
||||
</span>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.file_management.highlight_button"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: this.hass.localize(
|
||||
"ui.components.media-browser.no_items"
|
||||
: isFiltering
|
||||
? this.hass.localize(
|
||||
"ui.components.media-browser.filter.no_matches"
|
||||
)
|
||||
: currentItem.media_content_id ===
|
||||
"media-source://media_source/local/."
|
||||
? html`
|
||||
<div class="highlight-add-button">
|
||||
<span>
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowUpRight}
|
||||
></ha-svg-icon>
|
||||
</span>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.file_management.highlight_button"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: this.hass.localize(
|
||||
"ui.components.media-browser.no_items"
|
||||
)
|
||||
}
|
||||
</div>
|
||||
`
|
||||
@@ -773,6 +801,23 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderFilterRow(currentItem: MediaPlayerItem): TemplateResult {
|
||||
return html`
|
||||
<div class="search-row">
|
||||
<ha-input-search
|
||||
class="search-input"
|
||||
appearance="outlined"
|
||||
.value=${this._filterQuery}
|
||||
.placeholder=${this.hass.localize(
|
||||
"ui.components.media-browser.filter.filter_placeholder",
|
||||
{ name: currentItem.title }
|
||||
)}
|
||||
@input=${this._handleFilterInput}
|
||||
></ha-input-search>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMediaClassFilter(
|
||||
mediaClassFilterOptions: MediaClass[]
|
||||
): TemplateResult {
|
||||
@@ -846,6 +891,12 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleFilterInput(ev: InputEvent): void {
|
||||
this._filterQuery = (ev.target as HaInputSearch).value ?? "";
|
||||
this._virtualizer?.scrollTo(0, 0);
|
||||
this._content?.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
private _handleSearchKeydown(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -13,6 +13,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
@@ -28,9 +29,12 @@ import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-list-item";
|
||||
import "../../../components/ha-marquee-text";
|
||||
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
|
||||
import type { HaSlider } from "../../../components/ha-slider";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
@@ -59,8 +63,21 @@ import type {
|
||||
HomeAssistantApi,
|
||||
HomeAssistantConnection,
|
||||
HomeAssistantFormatters,
|
||||
ValueChangedEvent,
|
||||
} from "../../../types";
|
||||
|
||||
const DROPDOWN_MAX_ITEMS = 4;
|
||||
|
||||
const computeAttributeItems = (
|
||||
stateObj: MediaPlayerEntity,
|
||||
attribute: "source" | "sound_mode",
|
||||
formatters: HomeAssistantFormatters
|
||||
): PickerComboBoxItem[] =>
|
||||
(stateObj.attributes[`${attribute}_list`] ?? []).map((option) => ({
|
||||
id: option,
|
||||
primary: formatters.formatEntityAttributeValue(stateObj, attribute, option),
|
||||
}));
|
||||
|
||||
@customElement("more-info-media_player")
|
||||
class MoreInfoMediaPlayer extends LitElement {
|
||||
@state()
|
||||
@@ -87,6 +104,12 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
@query(".volume-slider")
|
||||
private _volumeSlider?: HaSlider;
|
||||
|
||||
@query("#source-picker")
|
||||
private _sourcePicker?: HaGenericPicker;
|
||||
|
||||
@query("#sound-mode-picker")
|
||||
private _soundModePicker?: HaGenericPicker;
|
||||
|
||||
private _progressController = new MediaProgressController(this, {
|
||||
getStateObj: () => this.stateObj,
|
||||
getSlider: () => this._positionSlider,
|
||||
@@ -231,6 +254,10 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
|
||||
const sourceList = this.stateObj.attributes.source_list || [];
|
||||
|
||||
if (sourceList.length > DROPDOWN_MAX_ITEMS) {
|
||||
return this._renderSourcePicker();
|
||||
}
|
||||
|
||||
return html`<ha-tooltip for="source-button">
|
||||
${this._localize(`ui.card.media_player.source`)}
|
||||
</ha-tooltip>
|
||||
@@ -270,6 +297,10 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (this.stateObj.attributes.sound_mode_list.length > DROPDOWN_MAX_ITEMS) {
|
||||
return this._renderSoundModePicker();
|
||||
}
|
||||
|
||||
return html`<ha-tooltip for="sound-mode-button">
|
||||
${this._localize(`ui.card.media_player.sound_mode`)}
|
||||
</ha-tooltip>
|
||||
@@ -297,6 +328,63 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
</ha-dropdown>`;
|
||||
}
|
||||
|
||||
private _renderSourcePicker() {
|
||||
return html`<ha-tooltip for="source-button">
|
||||
${this._localize(`ui.card.media_player.source`)}
|
||||
</ha-tooltip>
|
||||
<ha-generic-picker
|
||||
id="source-picker"
|
||||
.label=${this._localize(`ui.card.media_player.source`)}
|
||||
.value=${this.stateObj!.attributes.source}
|
||||
.getItems=${this._getSourceItems}
|
||||
no-sort
|
||||
@value-changed=${this._handleSourcePicked}
|
||||
>
|
||||
<ha-icon-button
|
||||
id="source-button"
|
||||
slot="field"
|
||||
.label=${this._localize(`ui.card.media_player.source`)}
|
||||
.path=${mdiLoginVariant}
|
||||
@click=${this._openSourcePicker}
|
||||
>
|
||||
</ha-icon-button>
|
||||
</ha-generic-picker>`;
|
||||
}
|
||||
|
||||
private _renderSoundModePicker() {
|
||||
return html`<ha-tooltip for="sound-mode-button">
|
||||
${this._localize(`ui.card.media_player.sound_mode`)}
|
||||
</ha-tooltip>
|
||||
<ha-generic-picker
|
||||
id="sound-mode-picker"
|
||||
.label=${this._localize(`ui.card.media_player.sound_mode`)}
|
||||
.value=${this.stateObj!.attributes.sound_mode}
|
||||
.getItems=${this._getSoundModeItems}
|
||||
no-sort
|
||||
@value-changed=${this._handleSoundModePicked}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="field"
|
||||
id="sound-mode-button"
|
||||
.label=${this._localize(`ui.card.media_player.sound_mode`)}
|
||||
.path=${mdiMusicNoteEighth}
|
||||
hide-title
|
||||
@click=${this._openSoundModePicker}
|
||||
>
|
||||
</ha-icon-button>
|
||||
</ha-generic-picker>`;
|
||||
}
|
||||
|
||||
private _computeSourceItems = memoizeOne(computeAttributeItems);
|
||||
|
||||
private _computeSoundModeItems = memoizeOne(computeAttributeItems);
|
||||
|
||||
private _getSourceItems = (): PickerComboBoxItem[] =>
|
||||
this._computeSourceItems(this.stateObj!, "source", this._formatters);
|
||||
|
||||
private _getSoundModeItems = (): PickerComboBoxItem[] =>
|
||||
this._computeSoundModeItems(this.stateObj!, "sound_mode", this._formatters);
|
||||
|
||||
protected _renderGrouping() {
|
||||
if (
|
||||
!this.stateObj ||
|
||||
@@ -424,7 +512,7 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
min="0"
|
||||
max=${duration}
|
||||
step="1"
|
||||
aria-label=${this._localize(
|
||||
.accessibleLabel=${this._localize(
|
||||
"ui.card.media_player.track_position"
|
||||
)}
|
||||
@change=${this._handleMediaSeekChanged}
|
||||
@@ -801,6 +889,40 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _openSourcePicker(ev: Event) {
|
||||
this._sourcePicker?.open(ev);
|
||||
}
|
||||
|
||||
private _openSoundModePicker(ev: Event) {
|
||||
this._soundModePicker?.open(ev);
|
||||
}
|
||||
|
||||
private _handleSourcePicked(ev: ValueChangedEvent<string | undefined>) {
|
||||
ev.stopPropagation();
|
||||
const source = ev.detail.value;
|
||||
if (!source || this.stateObj!.attributes.source === source) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._api.callService("media_player", "select_source", {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSoundModePicked(ev: ValueChangedEvent<string | undefined>) {
|
||||
ev.stopPropagation();
|
||||
const soundMode = ev.detail.value;
|
||||
if (!soundMode || this.stateObj!.attributes.sound_mode === soundMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._api.callService("media_player", "select_sound_mode", {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
sound_mode: soundMode,
|
||||
});
|
||||
}
|
||||
|
||||
private _showBrowseMedia(): void {
|
||||
showMediaBrowserDialog(this, {
|
||||
action: "play",
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
apiContext,
|
||||
configContext,
|
||||
connectionContext,
|
||||
devicesContext,
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
} from "../../../data/context";
|
||||
@@ -53,6 +54,8 @@ import type { HaMoreInfoUpdateBackup } from "../components/update/ha-more-info-u
|
||||
// Dialogs that reflect their open state to the `open` attribute.
|
||||
const DIALOG_ELEMENTS = new Set(["ha-adaptive-dialog", "ha-dialog"]);
|
||||
|
||||
const NON_APP_HASSIO_DEVICES = new Set(["core", "supervisor", "OS", "host"]);
|
||||
|
||||
@customElement("more-info-update")
|
||||
class MoreInfoUpdate extends LitElement {
|
||||
@property({ attribute: false }) public stateObj?: UpdateEntity;
|
||||
@@ -75,6 +78,9 @@ class MoreInfoUpdate extends LitElement {
|
||||
@consumeEntityRegistryEntry({ entityIdPath: ["stateObj", "entity_id"] })
|
||||
private _entityEntry?: EntityRegistryDisplayEntry;
|
||||
|
||||
@consume({ context: devicesContext, subscribe: true })
|
||||
private _devices!: ContextType<typeof devicesContext>;
|
||||
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection!: ContextType<typeof connectionContext>;
|
||||
|
||||
@@ -432,7 +438,9 @@ class MoreInfoUpdate extends LitElement {
|
||||
// The dialog was closed, or is closing, while the update was installing.
|
||||
const toastHost = document.querySelector("home-assistant");
|
||||
|
||||
if (toastHost) {
|
||||
if (toastHost instanceof HTMLElement) {
|
||||
const filter = this._appSlug();
|
||||
|
||||
showToast(toastHost, {
|
||||
message: this._localize(
|
||||
"ui.dialogs.more_info_control.update.install_failed",
|
||||
@@ -444,8 +452,7 @@ class MoreInfoUpdate extends LitElement {
|
||||
text: this._localize(
|
||||
"ui.dialogs.more_info_control.update.supervisor_logs"
|
||||
),
|
||||
action: () =>
|
||||
this._showSupervisorLogs(toastHost as HTMLElement),
|
||||
action: () => this._showSupervisorLogs(toastHost, filter),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@@ -460,17 +467,37 @@ class MoreInfoUpdate extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
// Supervisor names the app by its slug in log lines; other hassio devices aren't apps.
|
||||
private _appSlug(): string | undefined {
|
||||
const deviceId = this._entityEntry?.device_id;
|
||||
|
||||
if (!deviceId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const id = this._devices[deviceId]?.identifiers.find(
|
||||
([domain]) => domain === "hassio"
|
||||
)?.[1];
|
||||
|
||||
if (!id || NON_APP_HASSIO_DEVICES.has(id) || id.startsWith("mount_")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
private _handleShowSupervisorLogs(): void {
|
||||
this._showSupervisorLogs(this);
|
||||
this._showSupervisorLogs(this, this._appSlug());
|
||||
}
|
||||
|
||||
// The toast outlives this element, so it opens the dialog from its own host.
|
||||
private _showSupervisorLogs(host: HTMLElement): void {
|
||||
private _showSupervisorLogs(host: HTMLElement, filter?: string): void {
|
||||
showLogsDialog(host, {
|
||||
provider: "supervisor",
|
||||
title: this._localize(
|
||||
"ui.dialogs.more_info_control.update.supervisor_logs"
|
||||
),
|
||||
filter,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,28 @@ type EventTimeFormatter = Extract<
|
||||
(...args: any[]) => string
|
||||
>;
|
||||
|
||||
// FullCalendar sorts events by these fields, with times in calendar time
|
||||
interface EventSortFields {
|
||||
allDay: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// FullCalendar draws a timed event within one day as a dot
|
||||
const isDotEvent = ({ allDay, start, end }: EventSortFields) =>
|
||||
!allDay &&
|
||||
Math.floor(start / DAY_MS) === Math.floor(Math.max(start, end - 1) / DAY_MS);
|
||||
|
||||
const defaultEventOrder = "start,-duration,allDay,title";
|
||||
|
||||
const narrowMonthEventOrder = [
|
||||
(a: EventSortFields, b: EventSortFields) =>
|
||||
Number(isDotEvent(a)) - Number(isDotEvent(b)),
|
||||
...defaultEventOrder.split(","),
|
||||
] as CalendarOptions["eventOrder"];
|
||||
|
||||
const weekTitleFormat: CalendarOptions["titleFormat"] = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
@@ -86,6 +108,7 @@ const defaultFullCalendarConfig: CalendarOptions = {
|
||||
dayCellTopClass: "ha-fc-day-top",
|
||||
dayCellTopInnerClass: ({ isToday }) =>
|
||||
joinClassNames("ha-fc-day-number", isToday && "ha-fc-day-number-today"),
|
||||
dayCellInnerClass: "ha-fc-day-events",
|
||||
eventClass: "ha-fc-event",
|
||||
rowEventClass: "ha-fc-row-event",
|
||||
rowEventInnerClass: "ha-fc-row-event-inner",
|
||||
@@ -232,7 +255,7 @@ export class HAFullCalendar extends LitElement {
|
||||
? html`<ha-button
|
||||
size="s"
|
||||
class="fab-header"
|
||||
aria-label=${this.hass.localize(
|
||||
.accessibleLabel=${this.hass.localize(
|
||||
"ui.components.calendar.event.add"
|
||||
)}
|
||||
@click=${this._createEvent}
|
||||
@@ -289,7 +312,7 @@ export class HAFullCalendar extends LitElement {
|
||||
? html`<ha-button
|
||||
size="s"
|
||||
class="fab-header"
|
||||
aria-label=${this.hass.localize(
|
||||
.accessibleLabel=${this.hass.localize(
|
||||
"ui.components.calendar.event.add"
|
||||
)}
|
||||
@click=${this._createEvent}
|
||||
@@ -354,6 +377,15 @@ export class HAFullCalendar extends LitElement {
|
||||
this.calendar!.setOption("eventDisplay", this.eventDisplay);
|
||||
}
|
||||
|
||||
const eventOrder = this._eventOrderOptions(this._activeView!);
|
||||
if (
|
||||
this.calendar.getOption("eventOrderStrict") !==
|
||||
eventOrder.eventOrderStrict
|
||||
) {
|
||||
this.calendar.setOption("eventOrder", eventOrder.eventOrder);
|
||||
this.calendar.setOption("eventOrderStrict", eventOrder.eventOrderStrict);
|
||||
}
|
||||
|
||||
if (changedProps.has("autoHeight")) {
|
||||
this.calendar.setOption("height", this._height);
|
||||
this.calendar.setOption("dayMaxEventRows", !this.autoHeight);
|
||||
@@ -386,6 +418,7 @@ export class HAFullCalendar extends LitElement {
|
||||
dayMaxEventRows: !this.autoHeight,
|
||||
eventDisplay: this.eventDisplay,
|
||||
eventTimeFormat: this._formatEventTime,
|
||||
...this._eventOrderOptions(initialView),
|
||||
};
|
||||
|
||||
config.dateClick = (info) => this._handleDateClick(info);
|
||||
@@ -403,6 +436,16 @@ export class HAFullCalendar extends LitElement {
|
||||
return this.autoHeight ? "auto" : defaultFullCalendarConfig.height;
|
||||
}
|
||||
|
||||
// Narrow month dots wrap below the bars, so every bar is placed before them
|
||||
// and no event fills a gap above an earlier one
|
||||
private _eventOrderOptions(view: FullCalendarView) {
|
||||
const narrowMonth = this.narrow && view === "dayGridMonth";
|
||||
return {
|
||||
eventOrder: narrowMonth ? narrowMonthEventOrder : defaultEventOrder,
|
||||
eventOrderStrict: narrowMonth,
|
||||
};
|
||||
}
|
||||
|
||||
// Return if there are calendars that support creating events
|
||||
private get _hasMutableCalendars(): boolean {
|
||||
return this.calendars.some((selCal) => {
|
||||
@@ -891,6 +934,78 @@ export class HAFullCalendar extends LitElement {
|
||||
:host([narrow]) .ha-fc-dayGridMonth .ha-fc-dot-event-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:host([narrow]) .ha-fc-dayGridMonth .ha-fc-more-link {
|
||||
justify-content: center;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Dots sit 4 to a 12px line. FullCalendar stacks each dot 3px below
|
||||
the one before it, so a line takes the height it reserves for 4
|
||||
dots, and a margin lifts each dot back up to the start of its line.
|
||||
Without these selectors the dots stay stacked. Print lays events
|
||||
out in normal flow, so this is for screens only. */
|
||||
@media screen {
|
||||
@supports selector(:nth-child(1 of :has(*))) {
|
||||
:host([narrow]) .ha-fc-dayGridMonth .ha-fc-day-events {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
:host([narrow])
|
||||
.ha-fc-dayGridMonth
|
||||
.ha-fc-day-events
|
||||
> :has(> .ha-fc-dot-event) {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
/* FullCalendar hides a dot it has not placed or that does not fit */
|
||||
:host([narrow])
|
||||
.ha-fc-dayGridMonth
|
||||
.ha-fc-day-events
|
||||
> :has(> .ha-fc-dot-event):not([style*="hidden"]) {
|
||||
position: relative !important;
|
||||
width: 25% !important;
|
||||
margin-top: calc(var(--dot-column, 0) * -3px);
|
||||
margin-bottom: -3px;
|
||||
}
|
||||
|
||||
:host([narrow])
|
||||
.ha-fc-dayGridMonth
|
||||
.ha-fc-day-events
|
||||
> :nth-child(4n + 2 of :has(> .ha-fc-dot-event)) {
|
||||
--dot-column: 1;
|
||||
}
|
||||
|
||||
:host([narrow])
|
||||
.ha-fc-dayGridMonth
|
||||
.ha-fc-day-events
|
||||
> :nth-child(4n + 3 of :has(> .ha-fc-dot-event)) {
|
||||
--dot-column: 2;
|
||||
}
|
||||
|
||||
:host([narrow])
|
||||
.ha-fc-dayGridMonth
|
||||
.ha-fc-day-events
|
||||
> :nth-child(4n of :has(> .ha-fc-dot-event)) {
|
||||
--dot-column: 3;
|
||||
}
|
||||
|
||||
/* A last line that is not full still needs the room of 3 dots */
|
||||
:host([narrow])
|
||||
.ha-fc-dayGridMonth
|
||||
.ha-fc-day-events
|
||||
> :nth-last-child(1 of :has(> .ha-fc-dot-event)) {
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
:host([narrow]) .ha-fc-dayGridMonth .ha-fc-more-link {
|
||||
padding-top: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
urlName: "integration",
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
@@ -182,6 +183,7 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
@sorting-changed=${this._handleSortingChanged}
|
||||
.filter=${this._filter}
|
||||
@search-changed=${this._handleSearchChange}
|
||||
sync-url
|
||||
>
|
||||
<div class="header-btns" slot="selection-bar">
|
||||
${
|
||||
|
||||
@@ -441,7 +441,7 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.confirm.reset_options.text"
|
||||
),
|
||||
confirmText: this.hass.localize("ui.common.reset_options"),
|
||||
confirmText: this.hass.localize("ui.common.reset"),
|
||||
dismissText: this.hass.localize("ui.common.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
@@ -1574,6 +1574,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
showLogsDialog(this, {
|
||||
provider: "supervisor",
|
||||
title: this.i18n.localize("ui.panel.config.logs.supervisor_logs"),
|
||||
filter: this.addon.slug,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,14 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { addRecent } from "../../../common/array/add-recent";
|
||||
import { ensureArray } from "../../../common/array/ensure-array";
|
||||
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type {
|
||||
HASSDomCurrentTargetEvent,
|
||||
HASSDomTargetEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { mainWindow } from "../../../common/dom/get_main_window";
|
||||
import { computeAreaName } from "../../../common/entity/compute_area_name";
|
||||
@@ -123,6 +128,11 @@ import "./add-automation-element/ha-automation-add-element-paste";
|
||||
import "./add-automation-element/ha-automation-add-from-target";
|
||||
import "./add-automation-element/ha-automation-add-items";
|
||||
import "./add-automation-element/ha-automation-add-search";
|
||||
import {
|
||||
addRecentTarget,
|
||||
RECENT_TARGETS_STORAGE_KEY,
|
||||
} from "./target/recent_targets";
|
||||
import { findElementGroupKey } from "./add-automation-element/element-group";
|
||||
import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog";
|
||||
import {
|
||||
ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM,
|
||||
@@ -184,6 +194,8 @@ const DYNAMIC_KEYWORDS = ["dynamicGroups", "helpers", "integrationGroups"];
|
||||
|
||||
const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
|
||||
|
||||
const MAX_RECENT_ELEMENTS = 4;
|
||||
|
||||
// Group keys surfaced in the "Other" section of the "by target" tab because
|
||||
// their elements have no target (time/calendar/schedule, sun). Picking one
|
||||
// drills into its items, like selecting the matching group in the "by type" tab.
|
||||
@@ -246,6 +258,14 @@ class DialogAddAutomationElement
|
||||
|
||||
@state() private _openedFromQuery = false;
|
||||
|
||||
@storage({ key: RECENT_TARGETS_STORAGE_KEY, state: true, subscribe: false })
|
||||
private _recentTargets?: string[];
|
||||
|
||||
@storage({ key: "automation-recent-elements", state: true, subscribe: false })
|
||||
private _recentElements?: Partial<
|
||||
Record<AddAutomationElementDialogParams["type"], string[]>
|
||||
>;
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
private _labelRegistry!: LabelRegistryEntry[];
|
||||
@@ -625,6 +645,15 @@ class DialogAddAutomationElement
|
||||
this._manifests
|
||||
);
|
||||
|
||||
const recentElements = hideCollections
|
||||
? []
|
||||
: this._getRecentElements(
|
||||
automationElementType,
|
||||
this._recentElements?.[automationElementType],
|
||||
this._currentItems(),
|
||||
collections
|
||||
);
|
||||
|
||||
return html`
|
||||
<div slot="header">
|
||||
${this._renderHeader()}
|
||||
@@ -711,6 +740,8 @@ class DialogAddAutomationElement
|
||||
: this._triggerDescriptions
|
||||
)}
|
||||
.selectedGroup=${this._selectedGroup}
|
||||
.recentTargets=${this._recentTargets}
|
||||
@clear-recent-targets=${this._clearRecentTargets}
|
||||
class=${classMap({
|
||||
"ha-scrollbar": true,
|
||||
hidden:
|
||||
@@ -739,12 +770,46 @@ class DialogAddAutomationElement
|
||||
@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.titleKey && collection.groups.length
|
||||
collection.groups.length > 0 &&
|
||||
(!!collection.titleKey || recentElements.length > 0)
|
||||
? html`<ha-section-title>
|
||||
${this.hass.localize(collection.titleKey)}
|
||||
${
|
||||
collection.titleKey
|
||||
? this.hass.localize(collection.titleKey)
|
||||
: this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${automationElementType}s.name`
|
||||
)
|
||||
}
|
||||
</ha-section-title>`
|
||||
: nothing
|
||||
}
|
||||
@@ -1910,6 +1975,125 @@ class DialogAddAutomationElement
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _renderRecentButton(
|
||||
item: AddAutomationElementListItem,
|
||||
groupName?: string
|
||||
) {
|
||||
return html`
|
||||
<ha-list-item-button .value=${item.key} @click=${this._recentSelected}>
|
||||
<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
|
||||
}
|
||||
${
|
||||
groupName
|
||||
? html`<span slot="end" class="recent-group">${groupName}</span>`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-item-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _recentSelected = (
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElement & { value: string }>
|
||||
) => {
|
||||
const key = ev.currentTarget.value;
|
||||
this._rememberSelection(key);
|
||||
this._params!.add(key);
|
||||
this.closeDialog();
|
||||
};
|
||||
|
||||
private _currentItems() {
|
||||
return this._items(
|
||||
this._params!.type,
|
||||
this.hass.localize,
|
||||
this.hass.services,
|
||||
this._triggerDescriptions,
|
||||
this._conditionDescriptions,
|
||||
this._manifests
|
||||
);
|
||||
}
|
||||
|
||||
private _getRecentElements = memoizeOne(
|
||||
(
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
recents: string[] | undefined,
|
||||
items: AddAutomationElementListItem[],
|
||||
collections: CollectionGroup[]
|
||||
) => {
|
||||
if (!Array.isArray(recents)) {
|
||||
return [];
|
||||
}
|
||||
return recents
|
||||
.map((key) => items.find((item) => item.key === key))
|
||||
.filter((item) => item !== undefined)
|
||||
.slice(0, MAX_RECENT_ELEMENTS)
|
||||
.map((item) => ({
|
||||
item,
|
||||
groupName: this._elementGroup(type, item.key, collections)?.name,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
private _elementGroup(
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
key: string,
|
||||
collections: CollectionGroup[]
|
||||
) {
|
||||
const groupKey = findElementGroupKey(type, TYPES[type].collections, key);
|
||||
// Elements that are a row of their own resolve to a group that isn't rendered
|
||||
return collections
|
||||
.flatMap((collection) => collection.groups)
|
||||
.find((group) => group.key === groupKey);
|
||||
}
|
||||
|
||||
private _clearRecentElements = () => {
|
||||
this._recentElements = {
|
||||
...this._recentElements,
|
||||
[this._params!.type]: [],
|
||||
};
|
||||
};
|
||||
|
||||
private _clearRecentTargets = () => {
|
||||
this._recentTargets = [];
|
||||
};
|
||||
|
||||
// Existence is only checked when showing recents, data that is still
|
||||
// loading must not remove valid ones
|
||||
private _rememberSelection(key: string, target?: SingleHassServiceTarget) {
|
||||
const type = this._params!.type;
|
||||
|
||||
// Building blocks are rarely reused
|
||||
const blocks =
|
||||
type === "action"
|
||||
? ACTION_BUILDING_BLOCKS_GROUP
|
||||
: type === "condition"
|
||||
? CONDITION_BUILDING_BLOCKS_GROUP
|
||||
: {};
|
||||
if (!(key in blocks)) {
|
||||
this._recentElements = {
|
||||
...this._recentElements,
|
||||
[type]: addRecent(
|
||||
this._recentElements?.[type],
|
||||
key,
|
||||
MAX_RECENT_ELEMENTS,
|
||||
(item) => typeof item === "string"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (target) {
|
||||
this._recentTargets = addRecentTarget(this._recentTargets, target);
|
||||
}
|
||||
}
|
||||
|
||||
private _selected(ev: ValueChangedEvent<string>) {
|
||||
let target: HassServiceTarget | undefined;
|
||||
if (
|
||||
@@ -1919,6 +2103,10 @@ class DialogAddAutomationElement
|
||||
) {
|
||||
target = this._selectedTarget;
|
||||
}
|
||||
this._rememberSelection(
|
||||
ev.detail.value,
|
||||
target ? this._selectedTarget : undefined
|
||||
);
|
||||
this._params!.add(ev.detail.value, target);
|
||||
this.closeDialog();
|
||||
}
|
||||
@@ -2160,6 +2348,7 @@ class DialogAddAutomationElement
|
||||
(item as AutomationItemComboBoxItem).type &&
|
||||
!["floor", "area"].includes((item as AutomationItemComboBoxItem).type)
|
||||
) {
|
||||
this._rememberSelection(item.id);
|
||||
this._params!.add(item.id);
|
||||
this.closeDialog();
|
||||
return;
|
||||
@@ -2436,6 +2625,19 @@ class DialogAddAutomationElement
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
ha-button.clear-recent {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: calc(-1 * var(--ha-space-2));
|
||||
--ha-button-height: var(--ha-space-6);
|
||||
--wa-form-control-padding-inline: var(--ha-space-2);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
|
||||
.recent-group {
|
||||
color: var(--ha-color-on-neutral-quiet);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
|
||||
ha-automation-add-items {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import type { AutomationElementGroupCollection } from "../../../../data/automation";
|
||||
import {
|
||||
DYNAMIC_PREFIX,
|
||||
getValueFromDynamic,
|
||||
isDynamic,
|
||||
} from "../../../../data/automation";
|
||||
import { getConditionDomain } from "../../../../data/condition";
|
||||
import { getTriggerDomain } from "../../../../data/trigger";
|
||||
|
||||
/**
|
||||
* Find the key of the group an element belongs to, independent of how it was
|
||||
* reached (category, target or search). The group may not be rendered, for
|
||||
* example when the integration behind it is not set up.
|
||||
*/
|
||||
export const findElementGroupKey = (
|
||||
type: "trigger" | "condition" | "action",
|
||||
collections: AutomationElementGroupCollection[],
|
||||
key: string
|
||||
): string => {
|
||||
const value = isDynamic(key) ? getValueFromDynamic(key) : key;
|
||||
|
||||
const domain =
|
||||
type === "trigger"
|
||||
? getTriggerDomain(value)
|
||||
: type === "condition"
|
||||
? getConditionDomain(value)
|
||||
: computeDomain(value);
|
||||
|
||||
for (const collection of collections) {
|
||||
for (const [groupKey, options] of Object.entries(collection.groups)) {
|
||||
if (
|
||||
(options.members && value in options.members) ||
|
||||
options.domains?.includes(domain)
|
||||
) {
|
||||
return groupKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `${DYNAMIC_PREFIX}${domain}`;
|
||||
};
|
||||
+187
-5
@@ -15,8 +15,10 @@ import {
|
||||
} from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { live } from "lit/directives/live";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../../common/decorators/consume";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
|
||||
@@ -24,6 +26,7 @@ import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { computeEntityNameParts } from "../../../../common/entity/compute_entity_name_display";
|
||||
import { getDeviceAreaId } from "../../../../common/entity/context/get_device_context";
|
||||
import { stringCompare } from "../../../../common/string/compare";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-floor-icon";
|
||||
import "../../../../components/ha-icon";
|
||||
import "../../../../components/ha-icon-next";
|
||||
@@ -64,11 +67,19 @@ import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
|
||||
import {
|
||||
TARGET_SEPARATOR,
|
||||
type SingleHassServiceTarget,
|
||||
type TargetType,
|
||||
} from "../../../../data/target";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
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 {
|
||||
MAX_RECENT_TARGETS,
|
||||
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
|
||||
@@ -121,6 +132,8 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
@property({ attribute: "automation-element-type" })
|
||||
public automationElementType!: AddAutomationElementDialogParams["type"];
|
||||
|
||||
@property({ attribute: false }) public recentTargets?: string[];
|
||||
|
||||
// #endregion properties
|
||||
|
||||
// #region context
|
||||
@@ -150,6 +163,14 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
|
||||
@state() private _entries: Record<string, Level1Entries> = {};
|
||||
|
||||
// Nothing is rendered until the tree is built, as recents and preselected
|
||||
// targets open it
|
||||
@state() private _treeLoaded = false;
|
||||
|
||||
private _initialDataLoadStarted = false;
|
||||
|
||||
private _configEntriesLoaded?: Promise<void>;
|
||||
|
||||
@state() private _showShowMoreButton?: boolean;
|
||||
|
||||
@state() private _fullHeight = false;
|
||||
@@ -164,6 +185,15 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
// Without config entries the tree only lacks integration icons
|
||||
this._configEntriesLoaded = this._loadConfigEntries().catch(
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
|
||||
// The tree sorts helpers by their manifest, so it waits for them
|
||||
if (!this._initialDataLoadStarted && this.manifests) {
|
||||
this._initialDataLoadStarted = true;
|
||||
this._initialDataLoad();
|
||||
}
|
||||
|
||||
@@ -185,8 +215,9 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
}
|
||||
|
||||
private async _initialDataLoad() {
|
||||
await this._loadConfigEntries();
|
||||
await this._configEntriesLoaded;
|
||||
this._getTreeData();
|
||||
this._treeLoaded = true;
|
||||
}
|
||||
|
||||
private async _setShowTargetShowMoreButton() {
|
||||
@@ -202,7 +233,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
|
||||
// #region render
|
||||
protected render() {
|
||||
if (!this.manifests || !this._configEntryLookup) {
|
||||
if (!this._treeLoaded) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -217,6 +248,16 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
.clipboardItem=${this.clipboardItem}
|
||||
></ha-automation-add-element-paste>
|
||||
</ha-list-base>
|
||||
${this._renderRecentTargets(
|
||||
this.narrow,
|
||||
this._getRecentTargets(
|
||||
this.recentTargets,
|
||||
this._registries,
|
||||
this.states,
|
||||
this._labelRegistry
|
||||
),
|
||||
this.value
|
||||
)}
|
||||
${this._renderFloors(this.narrow, this._entries, this.value)}
|
||||
${this._renderTimeLocation(
|
||||
this.narrow,
|
||||
@@ -336,6 +377,97 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _getRecentTargets = memoizeOne(
|
||||
(
|
||||
recentTargets: string[] | undefined,
|
||||
registries: HomeAssistantRegistries,
|
||||
states: HomeAssistant["states"],
|
||||
labels: LabelRegistryEntry[] | undefined
|
||||
): [TargetType, string][] =>
|
||||
Array.isArray(recentTargets)
|
||||
? recentTargets
|
||||
.map(parseRecentTarget)
|
||||
.filter(
|
||||
(target): target is [TargetType, string] =>
|
||||
!!target &&
|
||||
targetExists(registries, states, labels, target[0], target[1])
|
||||
)
|
||||
.slice(0, MAX_RECENT_TARGETS)
|
||||
: []
|
||||
);
|
||||
|
||||
private _renderRecentTargets(
|
||||
narrow: boolean,
|
||||
targets: [TargetType, string][],
|
||||
value?: SingleHassServiceTarget
|
||||
) {
|
||||
if (!targets.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const selected = this._getSelectedTargetId(value);
|
||||
|
||||
return html`<ha-section-title>
|
||||
${this._i18n.localize("ui.panel.config.automation.editor.recently_used")}
|
||||
<ha-button
|
||||
class="clear-recent"
|
||||
aria-label=${this._i18n.localize(
|
||||
"ui.panel.config.automation.editor.clear_recently_used"
|
||||
)}
|
||||
appearance="plain"
|
||||
variant="neutral"
|
||||
size="s"
|
||||
@click=${this._clearRecentTargets}
|
||||
>
|
||||
${this._i18n.localize("ui.common.clear")}
|
||||
</ha-button>
|
||||
</ha-section-title>
|
||||
<ha-list-base>
|
||||
${targets.map(([type, id]) => {
|
||||
const target = `${type}${TARGET_SEPARATOR}${id}`;
|
||||
return html`<ha-list-item-button
|
||||
.target=${target}
|
||||
@click=${this._selectRecentTarget}
|
||||
class=${selected === target ? "recent selected" : "recent"}
|
||||
>
|
||||
<div slot="start" class="target-icon">
|
||||
${getTargetIcon(
|
||||
this._registries,
|
||||
this.states,
|
||||
type,
|
||||
id,
|
||||
this._configEntryLookup,
|
||||
this._getLabel
|
||||
)}
|
||||
</div>
|
||||
<div slot="headline">
|
||||
${getTargetText(
|
||||
this._registries,
|
||||
this.states,
|
||||
this._i18n.localize,
|
||||
type,
|
||||
id,
|
||||
this._getLabel
|
||||
)}
|
||||
</div>
|
||||
<span slot="end" class="target-type"
|
||||
>${this._i18n.localize(
|
||||
`ui.components.target-picker.type.${type}`
|
||||
)}</span
|
||||
>
|
||||
${narrow ? html`<ha-icon-next slot="end"></ha-icon-next>` : nothing}
|
||||
</ha-list-item-button>`;
|
||||
})}
|
||||
</ha-list-base>`;
|
||||
}
|
||||
|
||||
private _clearRecentTargets() {
|
||||
fireEvent(this, "clear-recent-targets");
|
||||
}
|
||||
|
||||
private _getLabel = (id: string) =>
|
||||
this._labelRegistry?.find(({ label_id }) => label_id === id);
|
||||
|
||||
private _renderFloors = memoizeOne(
|
||||
(
|
||||
narrow: boolean,
|
||||
@@ -943,7 +1075,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
.lazy=${lazy}
|
||||
@wa-lazy-load=${this._expandItem}
|
||||
@wa-collapse=${this._collapseItem}
|
||||
.expanded=${open}
|
||||
.expanded=${live(open)}
|
||||
.title=${label}
|
||||
>
|
||||
${icon?.()} ${label} ${children || nothing}
|
||||
@@ -1431,6 +1563,12 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _selectRecentTarget(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElement & { target: string }>
|
||||
) {
|
||||
this._valueChanged(ev.currentTarget.target, !this.narrow);
|
||||
}
|
||||
|
||||
private _selectTimeLocationGroup(ev: CustomEvent) {
|
||||
const value = (ev.currentTarget as any).value;
|
||||
if (value) {
|
||||
@@ -1448,9 +1586,19 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
if (expand && id) {
|
||||
this._expandTreeToItem(type, id);
|
||||
await this.updateComplete;
|
||||
// Nested tree items created already expanded collapse themselves again,
|
||||
// one more render lets live() open them before scrolling
|
||||
this.requestUpdate();
|
||||
await this.updateComplete;
|
||||
// Opening rows push the selection down, scroll once they are open
|
||||
await Promise.all(
|
||||
[...this.shadowRoot!.querySelectorAll("wa-tree-item[expanded]")]
|
||||
.flatMap((item) => item.shadowRoot?.getAnimations() ?? [])
|
||||
.map((animation) => animation.finished.catch(() => undefined))
|
||||
);
|
||||
if (type === "label") {
|
||||
this.shadowRoot!.querySelector(
|
||||
"ha-list-item-button.selected"
|
||||
"ha-list-item-button.selected:not(.recent)"
|
||||
)?.scrollIntoView({
|
||||
block: "center",
|
||||
});
|
||||
@@ -1647,6 +1795,39 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Icons vary in kind, a fixed box keeps the labels aligned */
|
||||
.target-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: var(--ha-space-8);
|
||||
height: var(--ha-space-8);
|
||||
}
|
||||
.target-icon > * {
|
||||
padding: 0;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.target-type {
|
||||
font-size: var(--ha-font-size-s);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
ha-list-item-button.selected::part(end) {
|
||||
color: var(--ha-color-on-primary-normal);
|
||||
}
|
||||
|
||||
ha-button.clear-recent {
|
||||
/* :host tints the quiet fill for the tree items, keep it neutral */
|
||||
--wa-color-neutral-fill-quiet: var(--ha-color-fill-neutral-quiet-hover);
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: calc(-1 * var(--ha-space-2));
|
||||
--ha-button-height: var(--ha-space-6);
|
||||
--wa-form-control-padding-inline: var(--ha-space-2);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
|
||||
wa-tree-item::part(item) {
|
||||
height: var(--ha-space-10);
|
||||
padding: var(--ha-space-1) var(--ha-space-3);
|
||||
@@ -1758,5 +1939,6 @@ declare global {
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"time-location-group-selected": { value: string };
|
||||
"clear-recent-targets": undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { addRecent } from "../../../../common/array/add-recent";
|
||||
import {
|
||||
TARGET_SEPARATOR,
|
||||
type SingleHassServiceTarget,
|
||||
type TargetType,
|
||||
} from "../../../../data/target";
|
||||
|
||||
export const RECENT_TARGETS_STORAGE_KEY = "automation-recent-targets";
|
||||
|
||||
export const MAX_RECENT_TARGETS = 4;
|
||||
|
||||
const RECENT_TARGET_TYPES: readonly TargetType[] = [
|
||||
"floor",
|
||||
"area",
|
||||
"device",
|
||||
"entity",
|
||||
"label",
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse a stored "<type>________<id>" recent target, rejecting malformed
|
||||
* entries and structural buckets like unassigned devices.
|
||||
*/
|
||||
export const parseRecentTarget = (
|
||||
value: unknown
|
||||
): [TargetType, string] | undefined => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const [type, id] = value.split(TARGET_SEPARATOR, 2);
|
||||
if (!id || !RECENT_TARGET_TYPES.includes(type as TargetType)) {
|
||||
return undefined;
|
||||
}
|
||||
return [type as TargetType, id];
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a target picked in the add automation element dialog to the recent
|
||||
* targets.
|
||||
*/
|
||||
export const addRecentTarget = (
|
||||
recents: string[] | undefined,
|
||||
target: SingleHassServiceTarget
|
||||
): string[] | undefined =>
|
||||
RECENT_TARGET_TYPES.flatMap((type) => {
|
||||
const id = target[`${type}_id`];
|
||||
return id ? [`${type}${TARGET_SEPARATOR}${id}`] : [];
|
||||
}).reduce<string[] | undefined>(
|
||||
(list, id) =>
|
||||
addRecent(list, id, MAX_RECENT_TARGETS, (item) =>
|
||||
Boolean(parseRecentTarget(item))
|
||||
),
|
||||
recents
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
|
||||
import type { TargetType } from "../../../../data/target";
|
||||
import type { HomeAssistant, HomeAssistantRegistries } from "../../../../types";
|
||||
|
||||
const hasKey = (obj: object, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(obj, key);
|
||||
|
||||
/**
|
||||
* Check whether a target exists and is part of the target tree. Entities need
|
||||
* a registry entry and disabled devices are left out, like in the tree.
|
||||
*/
|
||||
export const targetExists = (
|
||||
registries: HomeAssistantRegistries,
|
||||
states: HomeAssistant["states"],
|
||||
labels: LabelRegistryEntry[] | undefined,
|
||||
targetType: TargetType,
|
||||
targetId: string
|
||||
): boolean => {
|
||||
switch (targetType) {
|
||||
case "entity":
|
||||
return hasKey(states, targetId) && hasKey(registries.entities, targetId);
|
||||
case "device":
|
||||
return (
|
||||
hasKey(registries.devices, targetId) &&
|
||||
!registries.devices[targetId].disabled_by
|
||||
);
|
||||
case "area":
|
||||
return hasKey(registries.areas, targetId);
|
||||
case "floor":
|
||||
return hasKey(registries.floors, targetId);
|
||||
case "label":
|
||||
return !!labels?.some((label) => label.label_id === targetId);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -238,7 +238,7 @@ class DialogImportBlueprint extends DirtyStateProviderMixin<BlueprintImportState
|
||||
@click=${this._import}
|
||||
.disabled=${this._importing}
|
||||
.loading=${this._importing}
|
||||
.ariaLabel=${this.hass.localize(
|
||||
.accessibleLabel=${this.hass.localize(
|
||||
`ui.panel.config.blueprint.add.${this._importing ? "importing" : "import_btn"}`
|
||||
)}
|
||||
>
|
||||
@@ -253,7 +253,7 @@ class DialogImportBlueprint extends DirtyStateProviderMixin<BlueprintImportState
|
||||
@click=${this._save}
|
||||
.disabled=${this._saving || !!this._result.validation_errors}
|
||||
.loading=${this._saving}
|
||||
.ariaLabel=${this.hass.localize(
|
||||
.accessibleLabel=${this.hass.localize(
|
||||
`ui.panel.config.blueprint.add.${this._saving ? "saving" : this._result.exists ? "save_btn_override" : "save_btn"}`
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1219,19 +1219,21 @@ ${rejected
|
||||
};
|
||||
|
||||
private async _handleBulkLabel(ev: HaDropdownSelectEvent) {
|
||||
ev.preventDefault(); // Prevent the dropdown from closing
|
||||
|
||||
const label = ev.detail.item.value;
|
||||
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (label === "label_create") {
|
||||
this._bulkCreateLabel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = (ev.detail.item as any).action;
|
||||
this._bulkLabel(label.substring(6), action);
|
||||
await this._bulkLabel(label.substring(6), action);
|
||||
}
|
||||
|
||||
private async _bulkLabel(label: string, action: "add" | "remove") {
|
||||
|
||||
@@ -197,7 +197,7 @@ export class DialogEnergyCustomise
|
||||
<ha-switch
|
||||
.checked=${applicable && !this._hidden!.has(card.key)}
|
||||
.disabled=${!applicable}
|
||||
.ariaLabel=${label}
|
||||
.accessibleLabel=${label}
|
||||
data-card-key=${card.key}
|
||||
@change=${this._toggleCard}
|
||||
></ha-switch>
|
||||
|
||||
@@ -16,20 +16,27 @@ import {
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { mainWindow } from "../../../common/dom/get_main_window";
|
||||
import { computeAreaName } from "../../../common/entity/compute_area_name";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { getHistoryState, navigate } from "../../../common/navigate";
|
||||
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { currentPath } from "../../../common/url/current-path";
|
||||
import type {
|
||||
LocalizeFunc,
|
||||
LocalizeKeys,
|
||||
} from "../../../common/translations/localize";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import {
|
||||
helpersFilterQueryParamConfig,
|
||||
helpersFilterQueryParams,
|
||||
helpersLinkFromUrl,
|
||||
} from "../../../common/url/helpers-query-params";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import {
|
||||
hasRejectedItems,
|
||||
@@ -107,6 +114,7 @@ import {
|
||||
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
@@ -237,8 +245,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _diagnosticHandlers?: Record<string, boolean>;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state()
|
||||
private _filters: DataTableFiltersValues = {};
|
||||
|
||||
@@ -267,10 +273,25 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _filteredHelperEntityIds?: string[] | null;
|
||||
|
||||
// Not cached, the table only renders once the helpers have loaded
|
||||
@query("hass-tabs-subpage-data-table")
|
||||
private _dataTable?: HaTabsSubpageDataTable;
|
||||
|
||||
private _sizeController = new ResizeController(this, {
|
||||
callback: (entries) => entries[0]?.contentRect.width,
|
||||
});
|
||||
|
||||
// The filters are kept in the URL by the page, the search, sorting and
|
||||
// grouping by the table
|
||||
private _filterParams = new QueryParamsController(
|
||||
this,
|
||||
helpersFilterQueryParamConfig,
|
||||
{
|
||||
enabled: () => true,
|
||||
navigated: () => this._applyLink(),
|
||||
}
|
||||
);
|
||||
|
||||
private get _availableAssistants() {
|
||||
return getAvailableAssistants(this.cloudStatus, this.hass);
|
||||
}
|
||||
@@ -367,6 +388,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
urlName: "type",
|
||||
},
|
||||
editable: getEditableTableColumn(
|
||||
localize,
|
||||
@@ -682,6 +704,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
.activeFilters=${this._activeFilters}
|
||||
@clear-filter=${this._clearFilter}
|
||||
@row-click=${this._openEditDialog}
|
||||
sync-url
|
||||
.filter=${this._filter}
|
||||
@search-changed=${this._handleSearchChange}
|
||||
has-fab
|
||||
@@ -854,9 +877,8 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
|
||||
this._filters = { ...this._filters, [type]: ev.detail.value };
|
||||
this._filteredItems = { ...this._filteredItems, [type]: ev.detail.items };
|
||||
if (!this._fromUrl) {
|
||||
this._storageFilters = this._filters;
|
||||
}
|
||||
this._storageFilters = this._filters;
|
||||
this._writeFilters();
|
||||
this._applyFilters();
|
||||
}
|
||||
|
||||
@@ -916,16 +938,16 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
) {
|
||||
const categoryItems = new Set<string>();
|
||||
this._helperEntities
|
||||
.filter(
|
||||
(stateItem) =>
|
||||
filter[0] ===
|
||||
entityRegistryByEntityId(this._entityReg || [])[
|
||||
stateItem.entity_id
|
||||
]?.categories.helpers
|
||||
)
|
||||
.filter((stateItem) => {
|
||||
const category = entityRegistryByEntityId(this._entityReg || [])[
|
||||
stateItem.entity_id
|
||||
]?.categories.helpers;
|
||||
|
||||
return category !== undefined && filter.includes(category);
|
||||
})
|
||||
.forEach((stateItem) => categoryItems.add(stateItem.entity_id));
|
||||
(this._disabledEntityEntries || [])
|
||||
.filter((entry) => filter[0] === entry.categories.helpers)
|
||||
.filter((entry) => filter.includes(entry.categories.helpers))
|
||||
.forEach((entry) => categoryItems.add(entry.entity_id));
|
||||
if (!items) {
|
||||
items = categoryItems;
|
||||
@@ -972,63 +994,56 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
this._filteredHelperEntityIds = items ? [...items] : undefined;
|
||||
}
|
||||
|
||||
private _fromUrl = false;
|
||||
// The domain an add link asked for, started once the page has hass
|
||||
private _addDomain?: string;
|
||||
|
||||
public connectedCallback() {
|
||||
// Leave the add path before the URL is read, so the table params are kept
|
||||
// on the helpers page itself. The route isn't set yet on the first
|
||||
// connect, so this reads the URL. navigate() would only replace it after
|
||||
// the table params are written, and drop them.
|
||||
if (currentPath() === "/config/helpers/add") {
|
||||
const params = new URLSearchParams(mainWindow.location.search);
|
||||
this._addDomain = params.get("domain") ?? undefined;
|
||||
params.delete("domain");
|
||||
const queryString = params.toString() ? `?${params}` : "";
|
||||
replaceCurrentUrl(
|
||||
__DEMO__
|
||||
? `${mainWindow.location.pathname}${queryString}#/config/helpers`
|
||||
: `/config/helpers${queryString}`
|
||||
);
|
||||
fireEvent(mainWindow, "location-changed", { replace: true });
|
||||
// Opened once the table is there, see updated()
|
||||
this.requestUpdate();
|
||||
}
|
||||
super.connectedCallback();
|
||||
window.addEventListener("location-changed", this._locationChanged);
|
||||
window.addEventListener("popstate", this._popState);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("location-changed", this._locationChanged);
|
||||
window.removeEventListener("popstate", this._popState);
|
||||
// Links, like the ones on a device page, say in the URL what to show. The
|
||||
// router keeps the page open, so a link can land on it.
|
||||
private _applyLink() {
|
||||
const searchParams = this._filterParams.searchParams();
|
||||
const link = searchParams ? helpersLinkFromUrl(searchParams) : undefined;
|
||||
|
||||
if (link) {
|
||||
this._filter = link.search;
|
||||
this._filters = link.filters;
|
||||
this._storageFilters = link.filters;
|
||||
this._applyFilters();
|
||||
}
|
||||
|
||||
this._writeFilters();
|
||||
}
|
||||
|
||||
private _locationChanged = () => {
|
||||
if (window.location.search.substring(1) !== this._searchParms.toString()) {
|
||||
this._searchParms = new URLSearchParams(window.location.search);
|
||||
this._setFiltersFromUrl();
|
||||
}
|
||||
};
|
||||
|
||||
private _popState = () => {
|
||||
if (window.location.search.substring(1) !== this._searchParms.toString()) {
|
||||
this._searchParms = new URLSearchParams(window.location.search);
|
||||
this._setFiltersFromUrl();
|
||||
}
|
||||
};
|
||||
|
||||
private _setFiltersFromUrl() {
|
||||
const area = this._searchParms.get("area");
|
||||
const device = this._searchParms.get("device");
|
||||
const label = this._searchParms.get("label");
|
||||
const category = this._searchParms.get("category");
|
||||
const voiceAssistant = this._searchParms.get("voice_assistant");
|
||||
|
||||
if (!area && !category && !label && !device && !voiceAssistant) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._fromUrl = true;
|
||||
this._filter = getHistoryState()?.filter || "";
|
||||
|
||||
this._filters = {
|
||||
"ha-filter-floor-areas": area ? { areas: [area] } : undefined,
|
||||
"ha-filter-devices": device ? [device] : [],
|
||||
"ha-filter-labels": label ? [label] : [],
|
||||
"ha-filter-categories": category ? [category] : [],
|
||||
"ha-filter-voice-assistants": voiceAssistant ? [voiceAssistant] : [],
|
||||
};
|
||||
private _writeFilters() {
|
||||
this._filterParams.write(helpersFilterQueryParams(this._filters));
|
||||
}
|
||||
|
||||
private _clearFilter() {
|
||||
this._filters = {};
|
||||
this._filteredItems = {};
|
||||
if (!this._fromUrl) {
|
||||
this._storageFilters = {};
|
||||
}
|
||||
this._storageFilters = {};
|
||||
this._writeFilters();
|
||||
this._applyFilters();
|
||||
}
|
||||
|
||||
@@ -1141,7 +1156,6 @@ ${rejected
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._setFiltersFromUrl();
|
||||
this._fetchEntitySources();
|
||||
|
||||
if (isComponentLoaded(this.hass.config, "diagnostics")) {
|
||||
@@ -1153,9 +1167,13 @@ ${rejected
|
||||
this._diagnosticHandlers = handlers;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.route.path === "/add") {
|
||||
this._handleAdd();
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (this._addDomain && this._dataTable) {
|
||||
this._handleAdd(this._dataTable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1194,12 +1212,18 @@ ${rejected
|
||||
this._entitySource = entityDomains;
|
||||
}
|
||||
|
||||
private async _handleAdd() {
|
||||
const domain = extractSearchParam("domain");
|
||||
navigate("/config/helpers", { replace: true });
|
||||
private async _handleAdd(dataTable: HaTabsSubpageDataTable) {
|
||||
const domain = this._addDomain;
|
||||
this._addDomain = undefined;
|
||||
if (!domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The dialog adds a history entry. Let the table write its search,
|
||||
// sorting and grouping first, or they would only be on that entry and
|
||||
// be lost when it closes.
|
||||
await dataTable.updateComplete;
|
||||
|
||||
if (isHelperDomain(domain)) {
|
||||
showHelperDetailDialog(this, {
|
||||
domain,
|
||||
@@ -1254,7 +1278,7 @@ ${rejected
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._filters = this._storageFilters;
|
||||
this._setFiltersFromUrl();
|
||||
this._applyLink();
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -3,11 +3,14 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/input/ha-input-search";
|
||||
import { internationalizationContext } from "../../../data/context";
|
||||
import { apiContext, internationalizationContext } from "../../../data/context";
|
||||
import { fetchHassioLogsLegacy } from "../../../data/hassio/supervisor";
|
||||
import { DialogMixin } from "../../../dialogs/dialog-mixin";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistantApi } from "../../../types";
|
||||
import "./error-log-card";
|
||||
import type { LogsDialogParams } from "./show-dialog-logs";
|
||||
|
||||
@@ -17,12 +20,24 @@ class LogsDialog extends DialogMixin<LogsDialogParams>(LitElement) {
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: HomeAssistantApi;
|
||||
|
||||
@state() private _filter = "";
|
||||
|
||||
// Set when the filter the dialog opened with matched none of the loaded lines.
|
||||
@state() private _unmatchedFilter?: string;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._filter = this.params?.filter ?? "";
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
// The log card's strings live in the config fragment, which isn't loaded on dashboards.
|
||||
this._i18n.loadFragmentTranslation("config");
|
||||
this._checkInitialFilter();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -38,10 +53,21 @@ class LogsDialog extends DialogMixin<LogsDialogParams>(LitElement) {
|
||||
.placeholder=${this._i18n.localize("ui.panel.config.logs.search")}
|
||||
@input=${this._filterChanged}
|
||||
></ha-input-search>
|
||||
${
|
||||
this._unmatchedFilter
|
||||
? html`<ha-alert alert-type="info">
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.logs.no_matches_showing_all",
|
||||
{ term: this._unmatchedFilter }
|
||||
)}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<error-log-card
|
||||
.header=${this.params.title}
|
||||
.provider=${this.params.provider}
|
||||
.filter=${this._filter}
|
||||
class=${this._unmatchedFilter ? "with-alert" : ""}
|
||||
></error-log-card>
|
||||
</ha-dialog>
|
||||
`;
|
||||
@@ -49,18 +75,51 @@ class LogsDialog extends DialogMixin<LogsDialogParams>(LitElement) {
|
||||
|
||||
private _filterChanged(ev: InputEvent) {
|
||||
this._filter = (ev.target as HTMLInputElement).value;
|
||||
this._unmatchedFilter = undefined;
|
||||
}
|
||||
|
||||
// Lines for a failed update don't always name the app, so fall back to all
|
||||
// lines. The card's stream never completes, so check a snapshot instead: the
|
||||
// Supervisor's default of 100 lines, the same as the card loads.
|
||||
private async _checkInitialFilter() {
|
||||
const filter = this.params?.filter;
|
||||
|
||||
if (!filter || !this.params) {
|
||||
return;
|
||||
}
|
||||
|
||||
let logs: string;
|
||||
|
||||
try {
|
||||
logs = await fetchHassioLogsLegacy(this._api, this.params.provider);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this._filter === filter &&
|
||||
!logs.toLowerCase().includes(filter.toLowerCase())
|
||||
) {
|
||||
this._unmatchedFilter = filter;
|
||||
this._filter = "";
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-input-search {
|
||||
ha-input-search,
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
error-log-card {
|
||||
--error-log-card-height: calc(100vh - 340px);
|
||||
--error-log-card-margin: 0;
|
||||
--error-log-card-height: max(80px, calc(100vh - 320px));
|
||||
}
|
||||
error-log-card.with-alert {
|
||||
--error-log-card-height: max(80px, calc(100vh - 368px));
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -68,11 +68,16 @@ import {
|
||||
getHassioLogDownloadUrl,
|
||||
} from "../../../data/hassio/supervisor";
|
||||
import type { HomeAssistantApi, HomeAssistantConfig } from "../../../types";
|
||||
import { haStyleScrollbar } from "../../../resources/styles";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import { showDownloadLogsDialog } from "./show-dialog-download-logs";
|
||||
|
||||
const NUMBER_OF_LINES = 100;
|
||||
|
||||
// Lines shown around each search match, as a failure's cause is often logged
|
||||
// on the lines next to its summary.
|
||||
const SEARCH_CONTEXT_LINES = 3;
|
||||
|
||||
@customElement("error-log-card")
|
||||
class ErrorLogCard extends LitElement {
|
||||
@property({ attribute: false }) public localizeFunc?: LocalizeFunc<any>;
|
||||
@@ -295,7 +300,7 @@ class ErrorLogCard extends LitElement {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content error-log">
|
||||
<div class="card-content error-log ha-scrollbar">
|
||||
<div id="scroll-top-marker"></div>
|
||||
${
|
||||
this._loadingPrevState === "loading"
|
||||
@@ -334,6 +339,8 @@ class ErrorLogCard extends LitElement {
|
||||
}
|
||||
<ha-ansi-to-html
|
||||
?wrap-disabled=${!this._wrapLines}
|
||||
.contextLines=${SEARCH_CONTEXT_LINES}
|
||||
.expandLabel=${this._expandLabel}
|
||||
></ha-ansi-to-html>
|
||||
<div id="scroll-bottom-marker"></div>
|
||||
</div>
|
||||
@@ -410,8 +417,19 @@ class ErrorLogCard extends LitElement {
|
||||
this._newLogsIndicator = false;
|
||||
}
|
||||
|
||||
if (changedProps.has("filter")) {
|
||||
if (
|
||||
changedProps.has("filter") ||
|
||||
(changedProps.has("_loadingState") &&
|
||||
this._loadingState === "loaded" &&
|
||||
this.filter)
|
||||
) {
|
||||
this._debounceSearch();
|
||||
} else if (
|
||||
(changedProps.has("_localize") || changedProps.has("localizeFunc")) &&
|
||||
this.filter
|
||||
) {
|
||||
// The hidden lines buttons are built outside Lit, so relabel them.
|
||||
this._ansiToHtmlElement?.filterLines(this.filter);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -641,6 +659,12 @@ class ErrorLogCard extends LitElement {
|
||||
}
|
||||
}, 150);
|
||||
|
||||
private _expandLabel = (count: number) =>
|
||||
(this.localizeFunc || this._localize)(
|
||||
"ui.panel.config.logs.show_hidden_lines",
|
||||
{ count }
|
||||
);
|
||||
|
||||
private _debounceScrollToBottom = debounce(() => {
|
||||
this._logElement!.scrollTop = this._logElement!.scrollHeight;
|
||||
}, 300);
|
||||
@@ -793,12 +817,14 @@ class ErrorLogCard extends LitElement {
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
${haStyleScrollbar}
|
||||
|
||||
:host {
|
||||
direction: var(--direction);
|
||||
}
|
||||
.error-log-intro {
|
||||
text-align: center;
|
||||
margin: 16px;
|
||||
margin: var(--error-log-card-margin, 16px);
|
||||
}
|
||||
|
||||
ha-card {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fireEvent } from "../../../common/dom/fire_event";
|
||||
export interface LogsDialogParams {
|
||||
provider: string;
|
||||
title: string;
|
||||
filter?: string;
|
||||
}
|
||||
|
||||
export const showLogsDialog = (
|
||||
|
||||
@@ -101,6 +101,7 @@ export class HaConfigUsers extends LitElement {
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
urlName: "group",
|
||||
},
|
||||
is_active: {
|
||||
title: this.hass.localize(
|
||||
@@ -202,6 +203,7 @@ export class HaConfigUsers extends LitElement {
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
.filter=${this._filter}
|
||||
@search-changed=${this._handleSearchChange}
|
||||
sync-url
|
||||
@row-click=${this._editUser}
|
||||
has-fab
|
||||
clickable
|
||||
|
||||
@@ -60,6 +60,18 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
|
||||
return view;
|
||||
}
|
||||
|
||||
view.sections!.push({
|
||||
type: "grid",
|
||||
column_span: 3,
|
||||
cards: [
|
||||
{
|
||||
type: "energy-compare",
|
||||
collection_key: collectionKey,
|
||||
grid_options: { columns: "full" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (isEnergyCardVisible("overview", "energy-distribution", prefs, hidden)) {
|
||||
view.sections!.push({
|
||||
type: "grid",
|
||||
|
||||
@@ -26,11 +26,20 @@ import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { formattersContext } from "../../../data/context";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { hasTileCardAction } from "../cards/tile/tile-card-helpers";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { hasAction } from "../common/has-action";
|
||||
import type { LovelaceBadge, LovelaceBadgeEditor } from "../types";
|
||||
import type { AlertBadgeConfig } from "./types";
|
||||
|
||||
export const ALERT_BADGE_DEFAULT_CONFIG: Partial<AlertBadgeConfig> = {
|
||||
type: "alert",
|
||||
show_name: true,
|
||||
show_state: true,
|
||||
show_icon: true,
|
||||
};
|
||||
|
||||
@customElement("hui-alert-badge")
|
||||
export class HuiAlertBadge extends LitElement implements LovelaceBadge {
|
||||
public static async getConfigElement(): Promise<LovelaceBadgeEditor> {
|
||||
@@ -55,6 +64,10 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
|
||||
return { type: "alert", entity: entity || "" };
|
||||
}
|
||||
|
||||
public static getDefaultConfig(): Partial<AlertBadgeConfig> {
|
||||
return ALERT_BADGE_DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
@state() private _config?: AlertBadgeConfig;
|
||||
|
||||
@state()
|
||||
@@ -87,7 +100,7 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
|
||||
);
|
||||
}
|
||||
|
||||
this._config = config;
|
||||
this._config = { ...ALERT_BADGE_DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -115,10 +128,21 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
|
||||
const pulse = active && this._config.pulse === true;
|
||||
const hasColor = color !== undefined && color !== "none";
|
||||
|
||||
const name = this._formatters.formatEntityName(stateObj, this._config.name);
|
||||
|
||||
const { show_name: showName, show_state: showState } = this._config;
|
||||
|
||||
const content = showState
|
||||
? this._formatters.formatEntityState(stateObj)
|
||||
: showName
|
||||
? name
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-badge
|
||||
type="button"
|
||||
.label=${this._formatters.formatEntityName(stateObj)}
|
||||
.type=${hasTileCardAction(this._config) ? "button" : "badge"}
|
||||
.label=${showState && showName ? name : undefined}
|
||||
.iconOnly=${!content}
|
||||
class=${classMap({ alert: true, pulse, "no-color": !hasColor })}
|
||||
style=${styleMap({
|
||||
"--ha-alert-color":
|
||||
@@ -126,20 +150,29 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
|
||||
})}
|
||||
@action=${this._handleAction}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: false,
|
||||
hasDoubleClick: false,
|
||||
hasHold: hasAction(this._config.hold_action),
|
||||
hasDoubleClick: hasAction(this._config.double_tap_action),
|
||||
})}
|
||||
>
|
||||
<ha-state-icon slot="icon" .stateObj=${stateObj}></ha-state-icon>
|
||||
${this._formatters.formatEntityState(stateObj)}
|
||||
${
|
||||
this._config.show_icon
|
||||
? html`<ha-state-icon
|
||||
slot="icon"
|
||||
.stateObj=${stateObj}
|
||||
.icon=${this._config.icon}
|
||||
></ha-state-icon>`
|
||||
: nothing
|
||||
}
|
||||
${content}
|
||||
</ha-badge>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleAction(ev: ActionHandlerEvent): void {
|
||||
if (ev.detail.action === "tap" && this._config) {
|
||||
fireEvent(this, "hass-more-info", { entityId: this._config.entity });
|
||||
}
|
||||
fireEvent(this, "hass-action", {
|
||||
config: this._config!,
|
||||
action: ev.detail.action!,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -8,15 +8,17 @@ import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { stateActive } from "../../../common/entity/state_active";
|
||||
import { stateColorCss } from "../../../common/entity/state_color";
|
||||
import "../../../components/ha-badge";
|
||||
import "../../../components/ha-state-icon";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { cameraUrlWithWidthHeight } from "../../../data/camera";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
getTileImageUrl,
|
||||
hasTileCardAction,
|
||||
} from "../cards/tile/tile-card-helpers";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
@@ -99,12 +101,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
|
||||
}
|
||||
|
||||
get hasAction() {
|
||||
return (
|
||||
!this._config?.tap_action ||
|
||||
hasAction(this._config?.tap_action) ||
|
||||
hasAction(this._config?.hold_action) ||
|
||||
hasAction(this._config?.double_tap_action)
|
||||
);
|
||||
return hasTileCardAction(this._config);
|
||||
}
|
||||
|
||||
private _computeStateColor = memoizeOne(
|
||||
@@ -138,21 +135,6 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
|
||||
}
|
||||
);
|
||||
|
||||
private _getImageUrl(stateObj: HassEntity): string | undefined {
|
||||
const entityPicture =
|
||||
stateObj.attributes.entity_picture_local ||
|
||||
stateObj.attributes.entity_picture;
|
||||
|
||||
if (!entityPicture) return undefined;
|
||||
|
||||
let imageUrl = this.hass!.hassUrl(entityPicture);
|
||||
if (computeStateDomain(stateObj) === "camera") {
|
||||
imageUrl = cameraUrlWithWidthHeight(imageUrl, 32, 32);
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this.hass) {
|
||||
return nothing;
|
||||
@@ -195,7 +177,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
|
||||
const showEntityPicture = this._config.show_entity_picture;
|
||||
|
||||
const imageUrl = showEntityPicture
|
||||
? this._getImageUrl(stateObj)
|
||||
? getTileImageUrl(this.hass, stateObj, 32)
|
||||
: undefined;
|
||||
|
||||
const label = showState && showName ? name : undefined;
|
||||
|
||||
@@ -34,9 +34,17 @@ export interface StateLabelBadgeConfig extends LovelaceBadgeConfig {
|
||||
export interface AlertBadgeConfig extends LovelaceBadgeConfig {
|
||||
type: "alert";
|
||||
entity: string;
|
||||
name?: string | EntityNameItem | EntityNameItem[];
|
||||
icon?: string;
|
||||
alert_color?: string;
|
||||
clear_color?: string;
|
||||
pulse?: boolean;
|
||||
show_name?: boolean;
|
||||
show_state?: boolean;
|
||||
show_icon?: boolean;
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface ShortcutBadgeConfig extends LovelaceBadgeConfig {
|
||||
|
||||
@@ -25,11 +25,13 @@ import { formattersContext } from "../../../data/context";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { hasAction } from "../common/has-action";
|
||||
import type {
|
||||
LovelaceCard,
|
||||
LovelaceCardEditor,
|
||||
LovelaceGridOptions,
|
||||
} from "../types";
|
||||
import { hasTileCardAction } from "./tile/tile-card-helpers";
|
||||
import { tileCardStyle } from "./tile/tile-card-style";
|
||||
import type { AlertCardConfig } from "./types";
|
||||
|
||||
@@ -84,19 +86,25 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
`Specify an entity from within the ${ALERT_DOMAINS.join(", ")} domains`
|
||||
);
|
||||
}
|
||||
this._config = config;
|
||||
|
||||
this._config = {
|
||||
tap_action: { action: "more-info" },
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
return 1;
|
||||
return this._config?.vertical ? 2 : 1;
|
||||
}
|
||||
|
||||
public getGridOptions(): LovelaceGridOptions {
|
||||
const vertical = Boolean(this._config?.vertical);
|
||||
|
||||
return {
|
||||
columns: 6,
|
||||
rows: 1,
|
||||
min_columns: 6,
|
||||
min_rows: 1,
|
||||
rows: vertical ? 2 : 1,
|
||||
min_columns: vertical ? 3 : 6,
|
||||
min_rows: vertical ? 2 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,11 +118,6 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const stateDisplay = this._formatters.formatEntityState(stateObj);
|
||||
const areaName = this._formatters.formatEntityName(stateObj, {
|
||||
type: "area",
|
||||
});
|
||||
|
||||
const active = isAlertActive(stateObj);
|
||||
|
||||
const color = active
|
||||
@@ -124,6 +127,10 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
const pulse = active && this._config.pulse === true;
|
||||
const hasColor = color !== undefined && color !== "none";
|
||||
|
||||
const areaName = this._formatters.formatEntityName(stateObj, {
|
||||
type: "area",
|
||||
});
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
class=${classMap({ pulse, "no-color": !hasColor })}
|
||||
@@ -136,25 +143,40 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
})}
|
||||
>
|
||||
<ha-tile-container
|
||||
.interactive=${true}
|
||||
.actionHandlerOptions=${{ hasHold: false, hasDoubleClick: false }}
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.interactive=${hasTileCardAction(this._config)}
|
||||
.actionHandlerOptions=${{
|
||||
hasHold: hasAction(this._config.hold_action),
|
||||
hasDoubleClick: hasAction(this._config.double_tap_action),
|
||||
}}
|
||||
@action=${this._handleAction}
|
||||
>
|
||||
<ha-tile-icon slot="icon">
|
||||
<ha-state-icon slot="icon" .stateObj=${stateObj}></ha-state-icon>
|
||||
<ha-state-icon
|
||||
slot="icon"
|
||||
.icon=${this._config.icon}
|
||||
.stateObj=${stateObj}
|
||||
></ha-state-icon>
|
||||
</ha-tile-icon>
|
||||
<ha-tile-info slot="info">
|
||||
<span slot="primary"
|
||||
>${this._formatters.formatEntityName(stateObj)}</span
|
||||
>${this._formatters.formatEntityName(
|
||||
stateObj,
|
||||
this._config.name
|
||||
)}</span
|
||||
>
|
||||
<span slot="secondary">
|
||||
${stateDisplay} ·
|
||||
<ha-relative-time
|
||||
.datetime=${stateObj.last_changed}
|
||||
.format=${"short"}
|
||||
></ha-relative-time>
|
||||
${areaName ? html` · ${areaName}` : nothing}
|
||||
</span>
|
||||
${
|
||||
this._config.hide_state
|
||||
? nothing
|
||||
: html`<span slot="secondary">
|
||||
${this._formatters.formatEntityState(stateObj)} ·
|
||||
<ha-relative-time
|
||||
.datetime=${stateObj.last_changed}
|
||||
.format=${"short"}
|
||||
></ha-relative-time>
|
||||
${areaName ? html` · ${areaName}` : nothing}
|
||||
</span>`
|
||||
}
|
||||
</ha-tile-info>
|
||||
</ha-tile-container>
|
||||
</ha-card>
|
||||
@@ -162,9 +184,10 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
private _handleAction(ev: ActionHandlerEvent): void {
|
||||
if (ev.detail.action === "tap" && this._config) {
|
||||
fireEvent(this, "hass-more-info", { entityId: this._config.entity });
|
||||
}
|
||||
fireEvent(this, "hass-action", {
|
||||
config: this._config!,
|
||||
action: ev.detail.action!,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = [
|
||||
|
||||
@@ -8,7 +8,6 @@ import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color";
|
||||
import { DOMAINS_TOGGLE } from "../../../common/const";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { stateActive } from "../../../common/entity/state_active";
|
||||
import { stateColorCss } from "../../../common/entity/state_color";
|
||||
@@ -18,7 +17,6 @@ import "../../../components/tile/ha-tile-badge";
|
||||
import "../../../components/tile/ha-tile-container";
|
||||
import "../../../components/tile/ha-tile-icon";
|
||||
import "../../../components/tile/ha-tile-info";
|
||||
import { cameraUrlWithWidthHeight } from "../../../data/camera";
|
||||
import { timerJustFinished } from "../../../data/timer";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import "../../../state-display/state-display";
|
||||
@@ -39,18 +37,16 @@ import type {
|
||||
LovelaceGridOptions,
|
||||
} from "../types";
|
||||
import { renderTileBadge } from "./tile/badges/tile-badge";
|
||||
import {
|
||||
getTileActionDefaults,
|
||||
getTileIconActionConfig,
|
||||
getTileImageUrl,
|
||||
hasTileCardAction,
|
||||
hasTileIconAction,
|
||||
} from "./tile/tile-card-helpers";
|
||||
import { tileCardStyle } from "./tile/tile-card-style";
|
||||
import type { TileCardConfig } from "./types";
|
||||
|
||||
export const getEntityDefaultTileIconAction = (entityId: string) => {
|
||||
const domain = computeDomain(entityId);
|
||||
const supportsIconAction =
|
||||
DOMAINS_TOGGLE.has(domain) ||
|
||||
["button", "input_button", "scene"].includes(domain);
|
||||
|
||||
return supportsIconAction ? "toggle" : "none";
|
||||
};
|
||||
|
||||
@customElement("hui-tile-card")
|
||||
export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||
@@ -124,12 +120,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
this._config = {
|
||||
tap_action: {
|
||||
action: "more-info",
|
||||
},
|
||||
icon_tap_action: {
|
||||
action: getEntityDefaultTileIconAction(config.entity),
|
||||
},
|
||||
...getTileActionDefaults(config.entity),
|
||||
...config,
|
||||
};
|
||||
this._featureContext = {
|
||||
@@ -184,28 +175,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
entity: this._config!.entity,
|
||||
tap_action: this._config!.icon_tap_action,
|
||||
hold_action: this._config!.icon_hold_action,
|
||||
double_tap_action: this._config!.icon_double_tap_action,
|
||||
};
|
||||
handleAction(this, this.hass!, config, ev.detail.action!);
|
||||
}
|
||||
|
||||
private _getImageUrl(entity: HassEntity): string | undefined {
|
||||
const entityPicture =
|
||||
entity.attributes.entity_picture_local ||
|
||||
entity.attributes.entity_picture;
|
||||
|
||||
if (!entityPicture) return undefined;
|
||||
|
||||
let imageUrl = this.hass!.hassUrl(entityPicture);
|
||||
if (computeDomain(entity.entity_id) === "camera") {
|
||||
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
handleAction(
|
||||
this,
|
||||
this.hass!,
|
||||
getTileIconActionConfig(this._config!.entity, this._config!),
|
||||
ev.detail.action!
|
||||
);
|
||||
}
|
||||
|
||||
private _computeStateColor = memoizeOne(
|
||||
@@ -247,24 +222,6 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
);
|
||||
|
||||
private get _hasCardAction() {
|
||||
return (
|
||||
!this._config?.tap_action ||
|
||||
hasAction(this._config?.tap_action) ||
|
||||
hasAction(this._config?.hold_action) ||
|
||||
hasAction(this._config?.double_tap_action)
|
||||
);
|
||||
}
|
||||
|
||||
private get _hasIconAction() {
|
||||
return (
|
||||
!this._config?.icon_tap_action ||
|
||||
hasAction(this._config?.icon_tap_action) ||
|
||||
hasAction(this._config?.icon_hold_action) ||
|
||||
hasAction(this._config?.icon_double_tap_action)
|
||||
);
|
||||
}
|
||||
|
||||
private _featurePosition = memoizeOne((config: TileCardConfig) => {
|
||||
if (config.vertical) {
|
||||
return "bottom";
|
||||
@@ -317,7 +274,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
};
|
||||
|
||||
const imageUrl = this._config.show_entity_picture
|
||||
? this._getImageUrl(stateObj)
|
||||
? getTileImageUrl(this.hass, stateObj)
|
||||
: undefined;
|
||||
|
||||
const featurePosition = this._featurePosition(this._config);
|
||||
@@ -334,7 +291,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
.featurePosition=${featurePosition}
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${this._hasCardAction}
|
||||
.interactive=${hasTileCardAction(this._config)}
|
||||
.actionHandlerOptions=${{
|
||||
hasHold: hasAction(this._config!.hold_action),
|
||||
hasDoubleClick: hasAction(this._config!.double_tap_action),
|
||||
@@ -348,7 +305,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
hasHold: hasAction(this._config!.icon_hold_action),
|
||||
hasDoubleClick: hasAction(this._config!.icon_double_tap_action),
|
||||
}}
|
||||
.interactive=${this._hasIconAction}
|
||||
.interactive=${hasTileIconAction(this._config)}
|
||||
.accessibleLabel=${name}
|
||||
.imageUrl=${imageUrl}
|
||||
data-domain=${ifDefined(domain)}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { DOMAINS_TOGGLE } from "../../../../common/const";
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { cameraUrlWithWidthHeight } from "../../../../data/camera";
|
||||
import type { HomeAssistantConnection } from "../../../../types";
|
||||
import type { ActionConfigParams } from "../../common/handle-action";
|
||||
import { hasAction } from "../../common/has-action";
|
||||
import type { TileActionsConfig } from "../types";
|
||||
|
||||
export const getEntityDefaultTileIconAction = (entityId: string) => {
|
||||
const domain = computeDomain(entityId);
|
||||
|
||||
const supportsIconAction =
|
||||
DOMAINS_TOGGLE.has(domain) ||
|
||||
["button", "input_button", "scene"].includes(domain);
|
||||
|
||||
return supportsIconAction ? "toggle" : "none";
|
||||
};
|
||||
|
||||
export const getTileActionDefaults = (
|
||||
entityId: string
|
||||
): Pick<TileActionsConfig, "tap_action" | "icon_tap_action"> => ({
|
||||
tap_action: { action: "more-info" },
|
||||
icon_tap_action: { action: getEntityDefaultTileIconAction(entityId) },
|
||||
});
|
||||
|
||||
export const hasTileCardAction = (config?: TileActionsConfig) =>
|
||||
!config?.tap_action ||
|
||||
hasAction(config.tap_action) ||
|
||||
hasAction(config.hold_action) ||
|
||||
hasAction(config.double_tap_action);
|
||||
|
||||
export const hasTileIconAction = (config?: TileActionsConfig) =>
|
||||
!config?.icon_tap_action ||
|
||||
hasAction(config.icon_tap_action) ||
|
||||
hasAction(config.icon_hold_action) ||
|
||||
hasAction(config.icon_double_tap_action);
|
||||
|
||||
export const getTileIconActionConfig = (
|
||||
entity: string,
|
||||
config: TileActionsConfig
|
||||
): ActionConfigParams => ({
|
||||
entity,
|
||||
tap_action: config.icon_tap_action,
|
||||
hold_action: config.icon_hold_action,
|
||||
double_tap_action: config.icon_double_tap_action,
|
||||
});
|
||||
|
||||
export const getTileImageUrl = (
|
||||
connection: Pick<HomeAssistantConnection, "hassUrl">,
|
||||
stateObj: HassEntity,
|
||||
size = 80
|
||||
): string | undefined => {
|
||||
const entityPicture =
|
||||
stateObj.attributes.entity_picture_local ||
|
||||
stateObj.attributes.entity_picture;
|
||||
|
||||
if (!entityPicture) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const imageUrl = connection.hassUrl(entityPicture);
|
||||
|
||||
return computeDomain(stateObj.entity_id) === "camera"
|
||||
? cameraUrlWithWidthHeight(imageUrl, size, size)
|
||||
: imageUrl;
|
||||
};
|
||||
@@ -687,7 +687,16 @@ export interface WeatherForecastCardConfig extends LovelaceCardConfig {
|
||||
double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface TileCardConfig extends LovelaceCardConfig {
|
||||
export interface TileActionsConfig {
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
double_tap_action?: ActionConfig;
|
||||
icon_tap_action?: ActionConfig;
|
||||
icon_hold_action?: ActionConfig;
|
||||
icon_double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface TileCardConfig extends LovelaceCardConfig, TileActionsConfig {
|
||||
entity: string;
|
||||
name?: string | EntityNameItem | EntityNameItem[];
|
||||
hide_state?: boolean;
|
||||
@@ -696,12 +705,6 @@ export interface TileCardConfig extends LovelaceCardConfig {
|
||||
color?: string;
|
||||
show_entity_picture?: boolean;
|
||||
vertical?: boolean;
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
double_tap_action?: ActionConfig;
|
||||
icon_tap_action?: ActionConfig;
|
||||
icon_hold_action?: ActionConfig;
|
||||
icon_double_tap_action?: ActionConfig;
|
||||
features?: LovelaceCardFeatureConfig[];
|
||||
features_position?: LovelaceCardFeaturePosition;
|
||||
time_format?: TimestampRenderingFormat;
|
||||
@@ -740,9 +743,16 @@ export interface ShortcutCardConfig extends LovelaceCardConfig {
|
||||
export interface AlertCardConfig extends LovelaceCardConfig {
|
||||
type: "alert";
|
||||
entity: string;
|
||||
name?: string | EntityNameItem | EntityNameItem[];
|
||||
icon?: string;
|
||||
alert_color?: string;
|
||||
clear_color?: string;
|
||||
pulse?: boolean;
|
||||
hide_state?: boolean;
|
||||
vertical?: boolean;
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface ToggleGroupCardConfig extends LovelaceCardConfig {
|
||||
|
||||
@@ -34,7 +34,7 @@ class HuiEntitiesToggle extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-switch
|
||||
aria-label=${this.hass!.localize(
|
||||
.accessibleLabel=${this.hass!.localize(
|
||||
"ui.panel.lovelace.card.entities.toggle"
|
||||
)}
|
||||
.checked=${this._toggleEntities!.some((entityId) => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mdiAlertOutline, mdiTextShort } from "@mdi/js";
|
||||
import { DEFAULT_ALERT_COLOR } from "../../../../common/entity/alert_active";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { HaFormSchema } from "../../../../components/ha-form/types";
|
||||
import { contentLayoutSchema, displayedElementsSchema } from "./content-schema";
|
||||
|
||||
export const alertSchema = () =>
|
||||
({
|
||||
name: "alert",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiAlertOutline,
|
||||
schema: [
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "clear_color",
|
||||
selector: {
|
||||
ui_color: { include_none: true, default_color: "none" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alert_color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
include_none: true,
|
||||
default_color: DEFAULT_ALERT_COLOR,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: "pulse", selector: { boolean: {} } },
|
||||
],
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
const nameSchema = {
|
||||
name: "name",
|
||||
selector: { entity_name: {} },
|
||||
context: { entity: "entity" },
|
||||
} as const satisfies HaFormSchema;
|
||||
|
||||
const iconSchema = {
|
||||
name: "icon",
|
||||
selector: { icon: {} },
|
||||
context: { icon_entity: "entity" },
|
||||
} as const satisfies HaFormSchema;
|
||||
|
||||
export const alertCardContentSchema = (localize: LocalizeFunc) =>
|
||||
({
|
||||
name: "content",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiTextShort,
|
||||
schema: [
|
||||
nameSchema,
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [iconSchema, { name: "hide_state", selector: { boolean: {} } }],
|
||||
},
|
||||
contentLayoutSchema(localize),
|
||||
],
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
export const alertBadgeContentSchema = (localize: LocalizeFunc) =>
|
||||
({
|
||||
name: "content",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiTextShort,
|
||||
schema: [nameSchema, iconSchema, displayedElementsSchema(localize)],
|
||||
}) as const satisfies HaFormSchema;
|
||||
@@ -0,0 +1,246 @@
|
||||
import { mdiTextShort } from "@mdi/js";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { HaFormSchema } from "../../../../components/ha-form/types";
|
||||
|
||||
export interface TileContentFormConfig {
|
||||
hide_state?: boolean;
|
||||
state_content?: string | string[];
|
||||
vertical?: boolean;
|
||||
content_layout?: string;
|
||||
}
|
||||
|
||||
export interface BadgeContentFormConfig {
|
||||
show_name?: boolean;
|
||||
show_state?: boolean;
|
||||
show_icon?: boolean;
|
||||
state_content?: string | string[];
|
||||
displayed_elements?: string[];
|
||||
}
|
||||
|
||||
const colorSchema = {
|
||||
name: "color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
default_color: "state",
|
||||
include_state: true,
|
||||
},
|
||||
},
|
||||
} as const satisfies HaFormSchema;
|
||||
|
||||
export const contentLayoutSchema = (localize: LocalizeFunc) =>
|
||||
({
|
||||
name: "content_layout",
|
||||
required: true,
|
||||
selector: {
|
||||
select: {
|
||||
mode: "box",
|
||||
options: ["horizontal", "vertical"].map((value) => ({
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.tile.content_layout_options.${value}`
|
||||
),
|
||||
value,
|
||||
image: {
|
||||
src: `/static/images/form/tile_content_layout_${value}.svg`,
|
||||
src_dark: `/static/images/form/tile_content_layout_${value}_dark.svg`,
|
||||
flip_rtl: true,
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
export const displayedElementsSchema = (localize: LocalizeFunc) =>
|
||||
({
|
||||
name: "displayed_elements",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "list",
|
||||
multiple: true,
|
||||
options: (["name", "state", "icon"] as const).map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.${value}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
export const tileContentSchema = (
|
||||
localize: LocalizeFunc,
|
||||
showTimeFormat: boolean
|
||||
) =>
|
||||
({
|
||||
name: "content",
|
||||
flatten: true,
|
||||
type: "expandable",
|
||||
iconPath: mdiTextShort,
|
||||
schema: [
|
||||
{
|
||||
name: "name",
|
||||
selector: {
|
||||
entity_name: {},
|
||||
},
|
||||
context: { entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "icon",
|
||||
selector: {
|
||||
icon: {},
|
||||
},
|
||||
context: { icon_entity: "entity" },
|
||||
},
|
||||
colorSchema,
|
||||
{
|
||||
name: "show_entity_picture",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hide_state",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "state_content",
|
||||
visible: { field: "hide_state", operator: "not_eq", value: true },
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_context: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time_format",
|
||||
visible: showTimeFormat,
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
contentLayoutSchema(localize),
|
||||
],
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
export const badgeContentSchema = (
|
||||
localize: LocalizeFunc,
|
||||
showTimeFormat: boolean
|
||||
) =>
|
||||
({
|
||||
name: "content",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiTextShort,
|
||||
schema: [
|
||||
{
|
||||
name: "name",
|
||||
selector: {
|
||||
entity_name: {},
|
||||
},
|
||||
context: { entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
colorSchema,
|
||||
{
|
||||
name: "icon",
|
||||
selector: {
|
||||
icon: {},
|
||||
},
|
||||
context: { icon_entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "show_entity_picture",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displayedElementsSchema(localize),
|
||||
{
|
||||
name: "state_content",
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_name: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time_format",
|
||||
visible: showTimeFormat,
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
export const tileContentFormData = <T extends TileContentFormConfig>(
|
||||
config: T
|
||||
): T => ({
|
||||
...config,
|
||||
content_layout: config.vertical ? "vertical" : "horizontal",
|
||||
});
|
||||
|
||||
export const tileContentConfigFromForm = <T extends TileContentFormConfig>(
|
||||
value: T
|
||||
): T => {
|
||||
const config = { ...value };
|
||||
|
||||
if (config.hide_state || !config.state_content) {
|
||||
delete config.state_content;
|
||||
}
|
||||
|
||||
if (config.content_layout) {
|
||||
config.vertical = config.content_layout === "vertical";
|
||||
delete config.content_layout;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
export const badgeContentFormData = <T extends BadgeContentFormConfig>(
|
||||
config: T
|
||||
): T => ({
|
||||
...config,
|
||||
displayed_elements: [
|
||||
...(config.show_name ? ["name"] : []),
|
||||
...(config.show_state ? ["state"] : []),
|
||||
...(config.show_icon ? ["icon"] : []),
|
||||
],
|
||||
});
|
||||
|
||||
export const badgeContentConfigFromForm = <T extends BadgeContentFormConfig>(
|
||||
value: T
|
||||
): T => {
|
||||
const config = { ...value };
|
||||
|
||||
if (!config.state_content) {
|
||||
delete config.state_content;
|
||||
}
|
||||
|
||||
if (config.displayed_elements) {
|
||||
config.show_name = config.displayed_elements.includes("name");
|
||||
config.show_state = config.displayed_elements.includes("state");
|
||||
config.show_icon = config.displayed_elements.includes("icon");
|
||||
delete config.displayed_elements;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -1,53 +1,47 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { assert, assign, boolean, object, optional, string } from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import {
|
||||
ALERT_DOMAINS,
|
||||
DEFAULT_ALERT_COLOR,
|
||||
} from "../../../../common/entity/alert_active";
|
||||
import { ALERT_DOMAINS } from "../../../../common/entity/alert_active";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { AlertBadgeConfig } from "../../badges/types";
|
||||
import { ALERT_BADGE_DEFAULT_CONFIG } from "../../badges/hui-alert-badge";
|
||||
import type { LovelaceBadgeEditor } from "../../types";
|
||||
import { actionConfigStruct } from "../structs/action-struct";
|
||||
import { baseLovelaceBadgeConfig } from "../structs/base-badge-struct";
|
||||
import { entityNameStruct } from "../structs/entity-name-struct";
|
||||
import { alertBadgeContentSchema, alertSchema } from "./alert-schema";
|
||||
import {
|
||||
badgeContentConfigFromForm,
|
||||
badgeContentFormData,
|
||||
} from "./content-schema";
|
||||
import { entityInteractionsSchema } from "./interactions-schema";
|
||||
|
||||
const badgeConfigStruct = assign(
|
||||
baseLovelaceBadgeConfig,
|
||||
object({
|
||||
entity: optional(string()),
|
||||
name: optional(entityNameStruct),
|
||||
icon: optional(string()),
|
||||
alert_color: optional(string()),
|
||||
clear_color: optional(string()),
|
||||
pulse: optional(boolean()),
|
||||
show_name: optional(boolean()),
|
||||
show_state: optional(boolean()),
|
||||
show_icon: optional(boolean()),
|
||||
tap_action: optional(actionConfigStruct),
|
||||
hold_action: optional(actionConfigStruct),
|
||||
double_tap_action: optional(actionConfigStruct),
|
||||
})
|
||||
);
|
||||
|
||||
const SCHEMA = [
|
||||
{
|
||||
name: "entity",
|
||||
required: true,
|
||||
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "clear_color",
|
||||
selector: { ui_color: { include_none: true, default_color: "none" } },
|
||||
},
|
||||
{
|
||||
name: "alert_color",
|
||||
selector: {
|
||||
ui_color: { include_none: true, default_color: DEFAULT_ALERT_COLOR },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: "pulse", selector: { boolean: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("hui-alert-badge-editor")
|
||||
export class HuiAlertBadgeEditor
|
||||
extends LitElement
|
||||
@@ -59,9 +53,25 @@ export class HuiAlertBadgeEditor
|
||||
|
||||
public setConfig(config: AlertBadgeConfig): void {
|
||||
assert(config, badgeConfigStruct);
|
||||
this._config = config;
|
||||
this._config = { ...ALERT_BADGE_DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{
|
||||
name: "entity",
|
||||
required: true,
|
||||
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
|
||||
},
|
||||
alertSchema(),
|
||||
alertBadgeContentSchema(localize),
|
||||
entityInteractionsSchema(),
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
private _formData = memoizeOne(badgeContentFormData<AlertBadgeConfig>);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
@@ -70,8 +80,8 @@ export class HuiAlertBadgeEditor
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${SCHEMA}
|
||||
.data=${this._formData(this._config)}
|
||||
.schema=${this._schema(this.hass.localize)}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
@@ -81,22 +91,40 @@ export class HuiAlertBadgeEditor
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<AlertBadgeConfig>): void {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
|
||||
const config = badgeContentConfigFromForm(ev.detail.value);
|
||||
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
if (schema.name === "entity") {
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.entity"
|
||||
);
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "pulse":
|
||||
case "alert_color":
|
||||
case "clear_color":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}`
|
||||
);
|
||||
case "alert":
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.alert.alert_section"
|
||||
);
|
||||
case "displayed_elements":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.badge.entity.${schema.name}`
|
||||
);
|
||||
default:
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.${schema.name}`
|
||||
);
|
||||
}
|
||||
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}`
|
||||
);
|
||||
};
|
||||
|
||||
private _computeHelperCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
private _computeHelperCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
if (
|
||||
schema.name === "pulse" ||
|
||||
schema.name === "alert_color" ||
|
||||
|
||||
@@ -1,53 +1,45 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { assert, assign, boolean, object, optional, string } from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import {
|
||||
ALERT_DOMAINS,
|
||||
DEFAULT_ALERT_COLOR,
|
||||
} from "../../../../common/entity/alert_active";
|
||||
import { ALERT_DOMAINS } from "../../../../common/entity/alert_active";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { AlertCardConfig } from "../../cards/types";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { actionConfigStruct } from "../structs/action-struct";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
import { entityNameStruct } from "../structs/entity-name-struct";
|
||||
import { alertCardContentSchema, alertSchema } from "./alert-schema";
|
||||
import {
|
||||
tileContentConfigFromForm,
|
||||
tileContentFormData,
|
||||
} from "./content-schema";
|
||||
import { entityInteractionsSchema } from "./interactions-schema";
|
||||
|
||||
const cardConfigStruct = assign(
|
||||
baseLovelaceCardConfig,
|
||||
object({
|
||||
entity: optional(string()),
|
||||
name: optional(entityNameStruct),
|
||||
icon: optional(string()),
|
||||
alert_color: optional(string()),
|
||||
clear_color: optional(string()),
|
||||
pulse: optional(boolean()),
|
||||
hide_state: optional(boolean()),
|
||||
vertical: optional(boolean()),
|
||||
tap_action: optional(actionConfigStruct),
|
||||
hold_action: optional(actionConfigStruct),
|
||||
double_tap_action: optional(actionConfigStruct),
|
||||
})
|
||||
);
|
||||
|
||||
const SCHEMA = [
|
||||
{
|
||||
name: "entity",
|
||||
required: true,
|
||||
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "clear_color",
|
||||
selector: { ui_color: { include_none: true, default_color: "none" } },
|
||||
},
|
||||
{
|
||||
name: "alert_color",
|
||||
selector: {
|
||||
ui_color: { include_none: true, default_color: DEFAULT_ALERT_COLOR },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: "pulse", selector: { boolean: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("hui-alert-card-editor")
|
||||
export class HuiAlertCardEditor
|
||||
extends LitElement
|
||||
@@ -62,6 +54,20 @@ export class HuiAlertCardEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{
|
||||
name: "entity",
|
||||
required: true,
|
||||
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
|
||||
},
|
||||
alertSchema(),
|
||||
alertCardContentSchema(localize),
|
||||
entityInteractionsSchema(),
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
@@ -70,8 +76,8 @@ export class HuiAlertCardEditor
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${SCHEMA}
|
||||
.data=${tileContentFormData(this._config)}
|
||||
.schema=${this._schema(this.hass.localize)}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
@@ -81,22 +87,41 @@ export class HuiAlertCardEditor
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<AlertCardConfig>): void {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
|
||||
const config = tileContentConfigFromForm(ev.detail.value);
|
||||
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
if (schema.name === "entity") {
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.entity"
|
||||
);
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "pulse":
|
||||
case "alert_color":
|
||||
case "clear_color":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}`
|
||||
);
|
||||
case "alert":
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.alert.alert_section"
|
||||
);
|
||||
case "hide_state":
|
||||
case "content_layout":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.tile.${schema.name}`
|
||||
);
|
||||
default:
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.${schema.name}`
|
||||
);
|
||||
}
|
||||
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}`
|
||||
);
|
||||
};
|
||||
|
||||
private _computeHelperCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
private _computeHelperCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
if (
|
||||
schema.name === "pulse" ||
|
||||
schema.name === "alert_color" ||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mdiGestureTap, mdiTextShort } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -20,7 +19,7 @@ import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
DISPLAY_TYPES,
|
||||
@@ -28,11 +27,16 @@ import {
|
||||
} from "../../badges/hui-entity-badge";
|
||||
import type { EntityBadgeConfig } from "../../badges/types";
|
||||
import type { LovelaceBadgeEditor } from "../../types";
|
||||
import { ACTION_RELATED_CONTEXT } from "../../components/hui-action-editor";
|
||||
import { actionConfigStruct } from "../structs/action-struct";
|
||||
import { baseLovelaceBadgeConfig } from "../structs/base-badge-struct";
|
||||
import { entityNameStruct } from "../structs/entity-name-struct";
|
||||
import { configElementStyle } from "./config-elements-style";
|
||||
import {
|
||||
badgeContentConfigFromForm,
|
||||
badgeContentFormData,
|
||||
badgeContentSchema,
|
||||
} from "./content-schema";
|
||||
import { entityInteractionsSchema } from "./interactions-schema";
|
||||
import { stateContentHasTimestamp } from "../../../../state-display/state-display";
|
||||
import { timeFormatConfigStruct } from "../../components/types";
|
||||
|
||||
@@ -78,148 +82,12 @@ export class HuiEntityBadgeEditor
|
||||
(localize: LocalizeFunc, showTimeFormat: boolean) =>
|
||||
[
|
||||
{ name: "entity", selector: { entity: {} } },
|
||||
{
|
||||
name: "content",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiTextShort,
|
||||
schema: [
|
||||
{
|
||||
name: "name",
|
||||
selector: {
|
||||
entity_name: {},
|
||||
},
|
||||
context: { entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
default_color: "state",
|
||||
include_state: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "icon",
|
||||
selector: {
|
||||
icon: {},
|
||||
},
|
||||
context: { icon_entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "show_entity_picture",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "displayed_elements",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "list",
|
||||
multiple: true,
|
||||
options: [
|
||||
{
|
||||
value: "name",
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.name`
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "state",
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.state`
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "icon",
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.icon`
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "state_content",
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_name: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
...(showTimeFormat
|
||||
? ([
|
||||
{
|
||||
name: "time_format",
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "interactions",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiGestureTap,
|
||||
schema: [
|
||||
{
|
||||
name: "tap_action",
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "more-info",
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "optional_actions",
|
||||
flatten: true,
|
||||
schema: (["hold_action", "double_tap_action"] as const).map(
|
||||
(action) => ({
|
||||
name: action,
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "none" as const,
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
})
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
badgeContentSchema(localize, showTimeFormat),
|
||||
entityInteractionsSchema(),
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
_displayedElements = memoizeOne((config: EntityBadgeConfig) => {
|
||||
const elements: string[] = [];
|
||||
if (config.show_name) {
|
||||
elements.push("name");
|
||||
}
|
||||
if (config.show_state) {
|
||||
elements.push("state");
|
||||
}
|
||||
if (config.show_icon) {
|
||||
elements.push("icon");
|
||||
}
|
||||
return elements;
|
||||
});
|
||||
private _formData = memoizeOne(badgeContentFormData<EntityBadgeConfig>);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
@@ -238,10 +106,7 @@ export class HuiEntityBadgeEditor
|
||||
|
||||
const schema = this._schema(this.hass!.localize, showTimeFormat);
|
||||
|
||||
const data = {
|
||||
...this._config,
|
||||
displayed_elements: this._displayedElements(this._config),
|
||||
};
|
||||
const data = this._formData(this._config);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
@@ -255,24 +120,13 @@ export class HuiEntityBadgeEditor
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
private _valueChanged(ev: ValueChangedEvent<EntityBadgeConfig>): void {
|
||||
ev.stopPropagation();
|
||||
if (!this._config || !this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = { ...ev.detail.value } as EntityBadgeConfig;
|
||||
|
||||
if (!config.state_content) {
|
||||
delete config.state_content;
|
||||
}
|
||||
|
||||
if (config.displayed_elements) {
|
||||
config.show_name = config.displayed_elements.includes("name");
|
||||
config.show_state = config.displayed_elements.includes("state");
|
||||
config.show_icon = config.displayed_elements.includes("icon");
|
||||
delete config.displayed_elements;
|
||||
}
|
||||
const config = badgeContentConfigFromForm(ev.detail.value);
|
||||
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiGestureTap, mdiListBox, mdiTextShort } from "@mdi/js";
|
||||
import { mdiListBox } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -30,8 +30,6 @@ import type {
|
||||
LovelaceCardFeatureConfig,
|
||||
LovelaceCardFeatureContext,
|
||||
} from "../../card-features/types";
|
||||
import { ACTION_RELATED_CONTEXT } from "../../components/hui-action-editor";
|
||||
import { getEntityDefaultTileIconAction } from "../../cards/hui-tile-card";
|
||||
import type { TileCardConfig } from "../../cards/types";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { actionConfigStruct } from "../structs/action-struct";
|
||||
@@ -39,7 +37,13 @@ import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
import { entityNameStruct } from "../structs/entity-name-struct";
|
||||
import type { EditDetailElementEvent, EditSubElementEvent } from "../types";
|
||||
import { configElementStyle } from "./config-elements-style";
|
||||
import {
|
||||
tileContentConfigFromForm,
|
||||
tileContentFormData,
|
||||
tileContentSchema,
|
||||
} from "./content-schema";
|
||||
import { getSupportedFeaturesType } from "./hui-card-features-editor";
|
||||
import { tileInteractionsSchema } from "./interactions-schema";
|
||||
import { stateContentHasTimestamp } from "../../../../state-display/state-display";
|
||||
import { timeFormatConfigStruct } from "../../components/types";
|
||||
|
||||
@@ -96,147 +100,8 @@ export class HuiTileCardEditor
|
||||
) =>
|
||||
[
|
||||
{ name: "entity", selector: { entity: {} } },
|
||||
{
|
||||
name: "content",
|
||||
flatten: true,
|
||||
type: "expandable",
|
||||
iconPath: mdiTextShort,
|
||||
schema: [
|
||||
{
|
||||
name: "name",
|
||||
selector: {
|
||||
entity_name: {},
|
||||
},
|
||||
context: { entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "icon",
|
||||
selector: {
|
||||
icon: {},
|
||||
},
|
||||
context: { icon_entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
default_color: "state",
|
||||
include_state: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "show_entity_picture",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hide_state",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "state_content",
|
||||
visible: { field: "hide_state", operator: "not_eq", value: true },
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_context: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time_format",
|
||||
visible: showTimeFormat,
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content_layout",
|
||||
required: true,
|
||||
selector: {
|
||||
select: {
|
||||
mode: "box",
|
||||
options: ["horizontal", "vertical"].map((value) => ({
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.tile.content_layout_options.${value}`
|
||||
),
|
||||
value,
|
||||
image: {
|
||||
src: `/static/images/form/tile_content_layout_${value}.svg`,
|
||||
src_dark: `/static/images/form/tile_content_layout_${value}_dark.svg`,
|
||||
flip_rtl: true,
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "interactions",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiGestureTap,
|
||||
schema: [
|
||||
{
|
||||
name: "tap_action",
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "more-info",
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
name: "icon_tap_action",
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: entityId
|
||||
? getEntityDefaultTileIconAction(entityId)
|
||||
: "more-info",
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "optional_actions",
|
||||
flatten: true,
|
||||
schema: (
|
||||
[
|
||||
"hold_action",
|
||||
"icon_hold_action",
|
||||
"double_tap_action",
|
||||
"icon_double_tap_action",
|
||||
] as const
|
||||
).map((action) => ({
|
||||
name: action,
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "none" as const,
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
tileContentSchema(localize, showTimeFormat),
|
||||
tileInteractionsSchema(entityId),
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
@@ -296,10 +161,7 @@ export class HuiTileCardEditor
|
||||
|
||||
const featuresSchema = this._featuresSchema(this.hass.localize, vertical);
|
||||
|
||||
const data = {
|
||||
...this._config,
|
||||
content_layout: vertical ? "vertical" : "horizontal",
|
||||
};
|
||||
const data = tileContentFormData(this._config);
|
||||
|
||||
// Default features position to bottom and force it to bottom in vertical mode
|
||||
if (!data.features_position || vertical) {
|
||||
@@ -362,24 +224,10 @@ export class HuiTileCardEditor
|
||||
|
||||
const newConfig = ev.detail.value as TileCardConfig;
|
||||
|
||||
let config: TileCardConfig = {
|
||||
let config: TileCardConfig = tileContentConfigFromForm({
|
||||
features: this._config.features,
|
||||
...newConfig,
|
||||
};
|
||||
|
||||
if (config.hide_state) {
|
||||
delete config.state_content;
|
||||
}
|
||||
|
||||
if (!config.state_content) {
|
||||
delete config.state_content;
|
||||
}
|
||||
|
||||
// Convert content_layout to vertical
|
||||
if (config.content_layout) {
|
||||
config.vertical = config.content_layout === "vertical";
|
||||
delete config.content_layout;
|
||||
}
|
||||
});
|
||||
|
||||
config = orderProperties(config, fieldOrder);
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { mdiGestureTap } from "@mdi/js";
|
||||
import type { HaFormSchema } from "../../../../components/ha-form/types";
|
||||
import { getEntityDefaultTileIconAction } from "../../cards/tile/tile-card-helpers";
|
||||
import { ACTION_RELATED_CONTEXT } from "../../components/hui-action-editor";
|
||||
|
||||
export const tileInteractionsSchema = (entityId: string | undefined) =>
|
||||
({
|
||||
name: "interactions",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiGestureTap,
|
||||
schema: [
|
||||
{
|
||||
name: "tap_action",
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "more-info",
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
name: "icon_tap_action",
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: entityId
|
||||
? getEntityDefaultTileIconAction(entityId)
|
||||
: "more-info",
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "optional_actions",
|
||||
flatten: true,
|
||||
schema: (
|
||||
[
|
||||
"hold_action",
|
||||
"icon_hold_action",
|
||||
"double_tap_action",
|
||||
"icon_double_tap_action",
|
||||
] as const
|
||||
).map((action) => ({
|
||||
name: action,
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "none" as const,
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
export const entityInteractionsSchema = () =>
|
||||
({
|
||||
name: "interactions",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiGestureTap,
|
||||
schema: [
|
||||
{
|
||||
name: "tap_action",
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "more-info",
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "optional_actions",
|
||||
flatten: true,
|
||||
schema: (["hold_action", "double_tap_action"] as const).map(
|
||||
(action) => ({
|
||||
name: action,
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "none" as const,
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
})
|
||||
),
|
||||
},
|
||||
],
|
||||
}) as const satisfies HaFormSchema;
|
||||
@@ -44,11 +44,14 @@ class HuiDateEntityRow extends LitElement implements LovelaceRow {
|
||||
|
||||
const unavailable = stateObj.state === UNAVAILABLE;
|
||||
|
||||
const name = this.hass.formatEntityName(stateObj, this._config.name);
|
||||
|
||||
return html`
|
||||
<hui-generic-entity-row .hass=${this.hass} .config=${this._config}>
|
||||
<ha-date-input
|
||||
.locale=${this.hass.locale}
|
||||
.disabled=${unavailable}
|
||||
.accessibleLabel=${name}
|
||||
.value=${
|
||||
stateObj.state === UNAVAILABLE || stateObj.state === UNKNOWN
|
||||
? undefined
|
||||
|
||||
@@ -72,6 +72,7 @@ class HuiInputDatetimeEntityRow extends LitElement implements LovelaceRow {
|
||||
</ha-date-input>
|
||||
<ha-time-input
|
||||
.value=${time}
|
||||
.accessibleLabel=${name}
|
||||
.disabled=${unavailable}
|
||||
.locale=${this.hass.locale}
|
||||
@value-changed=${this._timeChanged}
|
||||
|
||||
@@ -68,6 +68,7 @@ class HuiInputDatetimeEntityRow extends LitElement implements LovelaceRow {
|
||||
? html`
|
||||
<ha-date-input
|
||||
.label=${stateObj.attributes.has_time ? name : undefined}
|
||||
.accessibleLabel=${stateObj.attributes.has_time ? undefined : name}
|
||||
.locale=${this.hass.locale}
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
.value=${stateToIsoDateString(stateObj)}
|
||||
@@ -88,6 +89,7 @@ class HuiInputDatetimeEntityRow extends LitElement implements LovelaceRow {
|
||||
? stateObj.state.split(" ")[1]
|
||||
: stateObj.state
|
||||
}
|
||||
.accessibleLabel=${name}
|
||||
.locale=${this.hass.locale}
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
@value-changed=${this._timeChanged}
|
||||
|
||||
@@ -71,6 +71,8 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
`;
|
||||
}
|
||||
|
||||
const name = this.hass.formatEntityName(stateObj, this._config.name);
|
||||
|
||||
return html`
|
||||
<hui-generic-entity-row .hass=${this.hass} .config=${this._config}>
|
||||
${
|
||||
@@ -79,6 +81,7 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
<div class="flex">
|
||||
<ha-slider
|
||||
labeled
|
||||
.accessibleLabel=${name}
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
.step=${Number(stateObj.attributes.step)}
|
||||
.min=${Number(stateObj.attributes.min)}
|
||||
@@ -94,6 +97,7 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
: html`
|
||||
<div class="flex box">
|
||||
<ha-input
|
||||
.accessibleLabel=${name}
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
pattern="[0-9]+([\\.][0-9]+)?"
|
||||
.step=${Number(stateObj.attributes.step)}
|
||||
|
||||
@@ -73,6 +73,8 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
`;
|
||||
}
|
||||
|
||||
const name = this.hass.formatEntityName(stateObj, this._config.name);
|
||||
|
||||
return html`
|
||||
<hui-generic-entity-row .hass=${this.hass} .config=${this._config}>
|
||||
${
|
||||
@@ -81,6 +83,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
<div class="flex">
|
||||
<ha-slider
|
||||
labeled
|
||||
.accessibleLabel=${name}
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
.step=${Number(stateObj.attributes.step)}
|
||||
.min=${Number(stateObj.attributes.min)}
|
||||
@@ -101,6 +104,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
: html`
|
||||
<div class="flex box">
|
||||
<ha-input
|
||||
.accessibleLabel=${name}
|
||||
auto-validate
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
pattern="[0-9]+([\\.][0-9]+)?"
|
||||
|
||||
@@ -44,6 +44,8 @@ class HuiTimeEntityRow extends LitElement implements LovelaceRow {
|
||||
|
||||
const unavailable = stateObj.state === UNAVAILABLE;
|
||||
|
||||
const name = this.hass.formatEntityName(stateObj, this._config.name);
|
||||
|
||||
return html`
|
||||
<hui-generic-entity-row .hass=${this.hass} .config=${this._config}>
|
||||
<ha-time-input
|
||||
@@ -52,6 +54,7 @@ class HuiTimeEntityRow extends LitElement implements LovelaceRow {
|
||||
? undefined
|
||||
: stateObj.state
|
||||
}
|
||||
.accessibleLabel=${name}
|
||||
.locale=${this.hass.locale}
|
||||
.disabled=${unavailable}
|
||||
@value-changed=${this._timeChanged}
|
||||
|
||||
@@ -277,7 +277,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
|
||||
step="1"
|
||||
.withTooltip=${false}
|
||||
size="s"
|
||||
aria-label=${this.hass.localize(
|
||||
.accessibleLabel=${this.hass.localize(
|
||||
"ui.card.media_player.track_position"
|
||||
)}
|
||||
?disabled=${!supportsFeature(
|
||||
@@ -300,7 +300,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
|
||||
step="1"
|
||||
.withTooltip=${false}
|
||||
size="s"
|
||||
aria-label=${this.hass.localize(
|
||||
.accessibleLabel=${this.hass.localize(
|
||||
"ui.card.media_player.track_position"
|
||||
)}
|
||||
?disabled=${!supportsFeature(
|
||||
|
||||
@@ -21,6 +21,14 @@ import { documentationUrl } from "../../util/documentation-url";
|
||||
// That's why redirects should be sorted with more specific ones first
|
||||
// Or else pressing "M" will link to the higher level page.
|
||||
|
||||
// The users table reads these from the link
|
||||
const usersTableParams = {
|
||||
search: "string?",
|
||||
sort: "string?",
|
||||
direction: "string?",
|
||||
group: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
// The Marketplace tabs with a table read these from the link
|
||||
const marketplaceTableParams = {
|
||||
search: "string?",
|
||||
@@ -29,11 +37,34 @@ const marketplaceTableParams = {
|
||||
source: "string?",
|
||||
sort: "string?",
|
||||
direction: "string?",
|
||||
group: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
// The helpers table reads these from the link
|
||||
const helpersTableParams = {
|
||||
search: "string?",
|
||||
sort: "string?",
|
||||
direction: "string?",
|
||||
group: "string?",
|
||||
floor: "string?",
|
||||
area: "string?",
|
||||
device: "string?",
|
||||
label: "string?",
|
||||
category: "string?",
|
||||
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",
|
||||
@@ -259,6 +290,7 @@ export const getMyRedirects = (): Redirects => ({
|
||||
},
|
||||
helpers: {
|
||||
redirect: "/config/helpers",
|
||||
params: helpersTableParams,
|
||||
},
|
||||
labs: {
|
||||
redirect: "/config/labs",
|
||||
@@ -301,6 +333,7 @@ export const getMyRedirects = (): Redirects => ({
|
||||
},
|
||||
users: {
|
||||
redirect: "/config/users",
|
||||
params: usersTableParams,
|
||||
},
|
||||
general: {
|
||||
redirect: "/config/general",
|
||||
|
||||
@@ -500,6 +500,7 @@
|
||||
"submit": "Submit",
|
||||
"rename": "Rename",
|
||||
"reset": "Reset",
|
||||
"retry": "Retry",
|
||||
"search": "[%key:ui::components::data-table::search%]",
|
||||
"ok": "OK",
|
||||
"yes": "Yes",
|
||||
@@ -856,6 +857,11 @@
|
||||
"entity-state-content-picker": {
|
||||
"add": "Add",
|
||||
"custom_attribute": "Custom attribute"
|
||||
},
|
||||
"entity-toggle": {
|
||||
"toggle": "Toggle {name}",
|
||||
"turn_on": "Turn {name} on",
|
||||
"turn_off": "Turn {name} off"
|
||||
}
|
||||
},
|
||||
"target-picker": {
|
||||
@@ -1314,6 +1320,10 @@
|
||||
"search_placeholder": "Search {name}",
|
||||
"no_results": "No results found"
|
||||
},
|
||||
"filter": {
|
||||
"filter_placeholder": "Filter {name}",
|
||||
"no_matches": "No items match your filter"
|
||||
},
|
||||
"filter_media_type": "Filter by media type",
|
||||
"no_items": "No items",
|
||||
"not_shown": "{count} {count, plural,\n one {item}\n other {items}\n} incompatible with the currently selected player {count, plural,\n one {is}\n other {are}\n} hidden",
|
||||
@@ -4895,6 +4905,8 @@
|
||||
"search": "Search logs",
|
||||
"failed_get_logs": "Failed to get {provider} logs, {error}",
|
||||
"no_issues_search": "No issues found for search term ''{term}''",
|
||||
"no_matches_showing_all": "Nothing in the recent logs mentions ''{term}'', so all lines are shown.",
|
||||
"show_hidden_lines": "Show {count} hidden {count, plural,\n one {line}\n other {lines}\n}",
|
||||
"load_logs": "Load logs",
|
||||
"nr_of_lines": "Number of lines",
|
||||
"loading_log": "Loading log…",
|
||||
@@ -5573,6 +5585,8 @@
|
||||
"home": "Home",
|
||||
"unassigned": "Unassigned",
|
||||
"other": "Other",
|
||||
"recently_used": "Recently used",
|
||||
"clear_recently_used": "Clear recently used",
|
||||
"blocks": "Blocks",
|
||||
"tabs": {
|
||||
"target": "By target",
|
||||
@@ -10504,7 +10518,8 @@
|
||||
"clear_color": "Clear color",
|
||||
"clear_color_helper": "Used when the entity isn't in an alert state. The background doesn't pulse.",
|
||||
"pulse": "Pulse",
|
||||
"pulse_helper": "Animates the colored background while the entity is in an alert state. When reduced motion is on, the background stays tinted instead."
|
||||
"pulse_helper": "Animates the colored background while the entity is in an alert state. When reduced motion is on, the background stays tinted instead.",
|
||||
"alert_section": "Alert"
|
||||
},
|
||||
"area": {
|
||||
"name": "Area",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addRecent } from "../../../src/common/array/add-recent";
|
||||
|
||||
describe("addRecent", () => {
|
||||
it("works", () => {
|
||||
expect(addRecent(undefined, "a", 3)).toEqual(["a"]);
|
||||
expect(addRecent(["a", "b", "c"], "b", 3)).toEqual(["b", "a", "c"]);
|
||||
expect(addRecent(["a", "b", "c", "d"], "b", 3)).toEqual(["b", "a", "c"]);
|
||||
expect(addRecent(["a", "b", "c"], "d", 3)).toEqual(["d", "a", "b"]);
|
||||
expect(addRecent(["a", "x", "b"], "d", 3, (item) => item !== "x")).toEqual([
|
||||
"d",
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
expect(addRecent("broken" as unknown as string[], "a", 3)).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
helpersFilterQueryParamConfig,
|
||||
helpersFilterQueryParams,
|
||||
helpersLinkFromUrl,
|
||||
} from "../../../src/common/url/helpers-query-params";
|
||||
import {
|
||||
createQueryString,
|
||||
updateQueryString,
|
||||
} from "../../../src/common/url/query-params";
|
||||
|
||||
describe("helpers query params", () => {
|
||||
it("reads nothing without table params", () => {
|
||||
expect(helpersLinkFromUrl("?domain=input_boolean")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("replaces every filter and the search with what a link asks for", () => {
|
||||
expect(helpersLinkFromUrl("?category=abc&label=a,b")).toEqual({
|
||||
search: "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas": undefined,
|
||||
"ha-filter-devices": [],
|
||||
"ha-filter-labels": ["a", "b"],
|
||||
"ha-filter-categories": ["abc"],
|
||||
"ha-filter-voice-assistants": [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("still reads voice_assistant, with assistants winning", () => {
|
||||
expect(
|
||||
helpersLinkFromUrl("?voice_assistant=conversation")?.filters[
|
||||
"ha-filter-voice-assistants"
|
||||
]
|
||||
).toEqual(["conversation"]);
|
||||
expect(
|
||||
helpersLinkFromUrl("?voice_assistant=conversation&assistants=cloud.alexa")
|
||||
?.filters["ha-filter-voice-assistants"]
|
||||
).toEqual(["cloud.alexa"]);
|
||||
});
|
||||
|
||||
it("replaces voice_assistant with assistants on write", () => {
|
||||
const url = "?historyBack=1&voice_assistant=conversation";
|
||||
const link = helpersLinkFromUrl(url);
|
||||
|
||||
expect(
|
||||
updateQueryString(
|
||||
url,
|
||||
helpersFilterQueryParams(link?.filters ?? {}),
|
||||
helpersFilterQueryParamConfig
|
||||
)
|
||||
).toBe("historyBack=1&assistants=conversation");
|
||||
});
|
||||
|
||||
it("round trips the filters", () => {
|
||||
const filters = {
|
||||
"ha-filter-floor-areas": { floors: ["ground"], areas: ["kitchen"] },
|
||||
"ha-filter-devices": ["abc"],
|
||||
"ha-filter-labels": ["a"],
|
||||
"ha-filter-categories": ["cat"],
|
||||
"ha-filter-voice-assistants": ["conversation"],
|
||||
};
|
||||
|
||||
expect(
|
||||
helpersLinkFromUrl(
|
||||
createQueryString(
|
||||
helpersFilterQueryParams(filters),
|
||||
helpersFilterQueryParamConfig
|
||||
)
|
||||
)
|
||||
).toEqual({ search: "", filters });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DYNAMIC_PREFIX } from "../../../../src/data/automation";
|
||||
import { CONDITION_COLLECTIONS } from "../../../../src/data/condition";
|
||||
import { TRIGGER_COLLECTIONS } from "../../../../src/data/trigger";
|
||||
import { findElementGroupKey } from "../../../../src/panels/config/automation/add-automation-element/element-group";
|
||||
|
||||
describe("findElementGroupKey", () => {
|
||||
const forTrigger = (key: string) =>
|
||||
findElementGroupKey("trigger", TRIGGER_COLLECTIONS, key);
|
||||
|
||||
it("puts a member of a curated group in that group", () => {
|
||||
expect(forTrigger("state")).toBe("entity");
|
||||
expect(forTrigger("numeric_state")).toBe("entity");
|
||||
expect(forTrigger("time_pattern")).toBe("time");
|
||||
});
|
||||
|
||||
it("strips the dynamic prefix a backend element carries", () => {
|
||||
expect(forTrigger(`${DYNAMIC_PREFIX}sun.sunrise`)).toBe("sun");
|
||||
expect(forTrigger(`${DYNAMIC_PREFIX}calendar.event`)).toBe("time");
|
||||
});
|
||||
|
||||
it("falls back to the generated group for a domain no group curates", () => {
|
||||
expect(forTrigger(`${DYNAMIC_PREFIX}light.turned_on`)).toBe(
|
||||
`${DYNAMIC_PREFIX}light`
|
||||
);
|
||||
});
|
||||
|
||||
it("gives an element that is a row of its own no other category", () => {
|
||||
// Resolves to a generated group that is never rendered
|
||||
expect(forTrigger("template")).toBe(`${DYNAMIC_PREFIX}template`);
|
||||
expect(forTrigger("webhook")).toBe(`${DYNAMIC_PREFIX}webhook`);
|
||||
});
|
||||
|
||||
it("reads a condition's domain with the condition helper", () => {
|
||||
expect(
|
||||
findElementGroupKey("condition", CONDITION_COLLECTIONS, "numeric_state")
|
||||
).toBe("entity");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addRecentTarget,
|
||||
parseRecentTarget,
|
||||
} from "../../../../src/panels/config/automation/target/recent_targets";
|
||||
|
||||
describe("parseRecentTarget", () => {
|
||||
it("works", () => {
|
||||
expect(parseRecentTarget("area________kitchen")).toEqual([
|
||||
"area",
|
||||
"kitchen",
|
||||
]);
|
||||
expect(parseRecentTarget(null)).toBeUndefined();
|
||||
expect(parseRecentTarget("device________")).toBeUndefined();
|
||||
expect(parseRecentTarget("entity_light________")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addRecentTarget", () => {
|
||||
it("works", () => {
|
||||
expect(
|
||||
addRecentTarget(["area________kitchen"], { entity_id: "light.a" })
|
||||
).toEqual(["entity________light.a", "area________kitchen"]);
|
||||
expect(addRecentTarget(undefined, { floor_id: "ground" })).toEqual([
|
||||
"floor________ground",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { targetExists } from "../../../../src/panels/config/automation/target/target_exists";
|
||||
import type { HomeAssistantRegistries } from "../../../../src/types";
|
||||
|
||||
describe("targetExists", () => {
|
||||
it("works", () => {
|
||||
const registries = {
|
||||
entities: { "light.kitchen": {} },
|
||||
devices: { enabled: {}, disabled: { disabled_by: "user" } },
|
||||
areas: { kitchen: {} },
|
||||
floors: {},
|
||||
} as unknown as HomeAssistantRegistries;
|
||||
const states = {
|
||||
"light.kitchen": {},
|
||||
"light.yaml": {},
|
||||
} as unknown as Parameters<typeof targetExists>[1];
|
||||
|
||||
expect(
|
||||
targetExists(registries, states, [], "entity", "light.kitchen")
|
||||
).toBe(true);
|
||||
expect(targetExists(registries, states, [], "area", "kitchen")).toBe(true);
|
||||
expect(targetExists(registries, states, [], "device", "enabled")).toBe(
|
||||
true
|
||||
);
|
||||
expect(targetExists(registries, states, [], "device", "disabled")).toBe(
|
||||
false
|
||||
);
|
||||
// Entities without a registry entry are not part of the target tree
|
||||
expect(targetExists(registries, states, [], "entity", "light.yaml")).toBe(
|
||||
false
|
||||
);
|
||||
// Inherited object properties don't count as targets
|
||||
expect(targetExists(registries, states, [], "entity", "toString")).toBe(
|
||||
false
|
||||
);
|
||||
expect(targetExists(registries, states, [], "area", "constructor")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user