Compare commits

..
52 changed files with 430 additions and 801 deletions

No files matched your search

+3 -22
View File
@@ -2,14 +2,10 @@
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);
@@ -87,24 +83,9 @@ async function copyMapPanel(staticDir) {
function copyZXingWasm(staticDir) {
const staticPath = genStaticPath(staticDir);
// 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
copyFileDir(
npmPath("zxing-wasm/dist/reader/zxing_reader.wasm"),
staticPath("js")
);
}
+1 -3
View File
@@ -153,9 +153,7 @@ gulp.task("rspack-watch-app", () => {
: createAppConfig({ isProdBuild: false, latestBuild: true })
).watch({ poll: isWsl }, doneHandler());
gulp.watch(
// 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"),
path.join(paths.translations_src, "en.json"),
gulp.series("build-translations", "copy-translations-app")
);
});
+3 -16
View File
@@ -40,21 +40,6 @@ 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
@@ -78,7 +63,9 @@ 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}@${toolchainVersion(pkg)}`),
...TOOLCHAIN_PACKAGES.map(
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
),
...CONFIG_FILES.map(
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
),
+2 -3
View File
@@ -133,15 +133,14 @@
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1",
"xss": "1.0.15",
"zrender": "6.1.0"
"zrender": "6.1.0",
"zxing-wasm": "3.1.5"
},
"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",
+11 -6
View File
@@ -462,6 +462,9 @@ 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
@@ -475,12 +478,6 @@ 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])
@@ -7839,6 +7836,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-3lC9BJk4fR5ZJxcGjb0hVnDFOW7KpLXHIebiBmVd4FDRQZVeObztoTKxRUPxlWwSgxrMRONK0u50hBD1aTYEKg==}
[email protected]:
resolution: {integrity: sha512-jmxXvTCR/qZxMz2XwR6V+nL2BlDTD1aux7ZWSH9BV77TG9u4k0VyHba/NCCHBHJ60Wg6VV0QL/SMlJczKhJI4w==}
snapshots:
'@ampproject/[email protected]':
@@ -15838,3 +15838,8 @@ 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
-5
View File
@@ -98,11 +98,6 @@
"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",
@@ -3,7 +3,7 @@ import type {
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import { mainWindow } from "../dom/get_main_window";
import { replaceCurrentUrl } from "../navigate";
import { replaceCurrentUrl, type NavigateOptions } from "../navigate";
import { constructUrlCurrentPath } from "../url/construct-url";
import { currentPath } from "../url/current-path";
import {
@@ -30,7 +30,7 @@ export interface QueryParamsControllerOptions {
* shared, bookmarked and reloaded. Writes replace the current history entry,
* so Back leaves the page instead of undoing each change, and only touch the
* config's params. The controller only acts while the URL is still the
* page the host was connected on.
* page the host was connected on, or the default page its router opened.
*/
export class QueryParamsController<
C extends QueryParamConfig,
@@ -120,8 +120,16 @@ export class QueryParamsController<
return this._options.enabled() && currentPath() === this._path;
}
private _navigated = () => {
private _navigated = (ev?: Event & { detail?: NavigateOptions }) => {
this._cancelPending();
// A router opening its default page, like /config/devices going to
// /config/devices/dashboard, replaces the URL after the host connected
const path = currentPath();
if (ev?.detail?.replace && path.startsWith(`${this._path}/`)) {
this._path = path;
}
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated();
+1
View File
@@ -14,6 +14,7 @@ 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}`
+99
View File
@@ -0,0 +1,99 @@
import type {
DataTableFilter,
DataTableFilters,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
decodeQueryParams,
hasQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
// The filters of the devices table. Search, sorting and grouping are kept by
// the data table, see data-table-query-params.
export const devicesFilterQueryParamConfig = {
list: [
"status",
"floor",
"area",
"domain",
"label",
"config_entry",
"sub_entry",
],
} as const satisfies QueryParamConfig;
export type DevicesFilterQueryParams = QueryParamValues<
typeof devicesFilterQueryParamConfig
>;
const list = (filter: DataTableFilter | undefined): string[] | undefined =>
Array.isArray(filter?.value) ? filter.value : undefined;
const floorAreas = (
filter: DataTableFilter | undefined
): Record<string, string[]> | undefined =>
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
export const devicesFilterQueryParams = (
filters: DataTableFilters
): DevicesFilterQueryParams => ({
status: list(filters["ha-filter-states"]),
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
domain: list(filters["ha-filter-integrations"]),
label: list(filters["ha-filter-labels"]),
config_entry: list(filters.config_entry),
sub_entry: list(filters.sub_entry),
});
// What a link says to show, undefined when it has no table params so what was
// picked before stays. A link replaces the search and the filters, ones left
// behind would hide part of what it shows. The table applies its sorting and
// grouping. The filters work out their items from the values.
export interface DevicesLink {
search: string;
filters: DataTableFilters;
}
export const devicesLinkFromUrl = (
searchParams: SearchParamsSource
): DevicesLink | undefined => {
if (
!hasQueryParams(searchParams, devicesFilterQueryParamConfig) &&
!hasQueryParams(searchParams, dataTableQueryParamConfig)
) {
return undefined;
}
const params = decodeQueryParams(searchParams, devicesFilterQueryParamConfig);
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof DevicesFilterQueryParams): string[] =>
params[key]?.filter(Boolean) ?? [];
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
value,
items: undefined,
});
const floors = values("floor");
const areas = values("area");
return {
search: search ?? "",
filters: {
"ha-filter-states": filter(values("status")),
"ha-filter-floor-areas": filter(
floors.length || areas.length ? { floors, areas } : undefined
),
"ha-filter-integrations": filter(values("domain")),
"ha-filter-labels": filter(values("label")),
config_entry: filter(values("config_entry")),
sub_entry: filter(values("sub_entry")),
},
};
};
+3 -20
View File
@@ -5,7 +5,6 @@ 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";
@@ -14,7 +13,6 @@ 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 &&
@@ -39,10 +37,6 @@ 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;
@@ -55,14 +49,6 @@ 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 ||
@@ -70,7 +56,7 @@ export class HaEntityToggle extends LitElement {
) {
return html`
<ha-icon-button
.label=${turnOffLabel}
.label=${`Turn ${name} off`}
.path=${mdiFlashOff}
.disabled=${this.stateObj.state === UNAVAILABLE}
@click=${this._turnOff}
@@ -79,7 +65,7 @@ export class HaEntityToggle extends LitElement {
}
></ha-icon-button>
<ha-icon-button
.label=${turnOnLabel}
.label=${`Turn ${name} on`}
.path=${mdiFlash}
.disabled=${this.stateObj.state === UNAVAILABLE}
@click=${this._turnOn}
@@ -89,10 +75,7 @@ export class HaEntityToggle extends LitElement {
}
return html`<ha-switch
.accessibleLabel=${this._localize(
"ui.components.entity.entity-toggle.toggle",
{ name }
)}
aria-label=${`Toggle ${name} ${this._isOn ? "off" : "on"}`}
.checked=${this._isOn}
.disabled=${this.stateObj.state === UNAVAILABLE}
@change=${this._toggleChanged}
+8 -176
View File
@@ -1,4 +1,3 @@
import { mdiUnfoldMoreHorizontal } from "@mdi/js";
import {
css,
html,
@@ -14,10 +13,6 @@ 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;
@@ -35,27 +30,12 @@ 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>`;
}
@@ -77,15 +57,6 @@ 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);
}
@@ -148,7 +119,8 @@ export class HaAnsiToHtml extends LitElement {
}
::highlight(search-results) {
background-color: var(--ha-color-fill-primary-normal-resting);
background-color: var(--primary-color);
color: var(--text-primary-color);
}
`;
@@ -350,16 +322,12 @@ 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) {
@@ -367,59 +335,9 @@ export class HaAnsiToHtml extends LitElement {
}
} else {
const highlightRanges: Range[] = [];
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]) {
lines.forEach((line) => {
if (!line.textContent?.toLowerCase().includes(filter.toLowerCase())) {
line.style.display = "none";
} else if (!matches[lineIndex]) {
line.style.display = "";
} else {
line.style.display = "";
numberOfFoundLines++;
@@ -449,104 +367,18 @@ 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 lines = this._divs;
if (lines.length <= maxLines) {
const pre = this._pre;
if (!pre) {
return;
}
Array.from(lines)
.slice(0, lines.length - maxLines)
.forEach((line) => line.remove());
if (this._filter) {
this.filterLines(this._filter);
while (pre.childElementCount > maxLines && pre.firstElementChild) {
pre.firstElementChild.remove();
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ export class HaIconButton extends LitElement {
<ha-button
appearance="plain"
variant="neutral"
.accessibleLabel=${this.label ?? ""}
.ariaLabel=${this.label ?? null}
.title=${this.hideTitle ? "" : (this.label ?? "")}
aria-haspopup=${ifDefined(this.ariaHasPopup)}
.disabled=${this.disabled}
+2 -3
View File
@@ -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, ZXING_WASM_VERSION } from "barcode-detector";
import { prepareZXingModule } from "barcode-detector";
import type QrScanner from "qr-scanner";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
@@ -29,8 +29,7 @@ prepareZXingModule({
overrides: {
locateFile: (path: string, prefix: string) => {
if (path.endsWith(".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 "/static/js/zxing_reader.wasm";
}
return prefix + path;
},
-5
View File
@@ -126,10 +126,6 @@ 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;
@@ -280,7 +276,6 @@ 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,7 +23,6 @@ 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 {
@@ -95,13 +94,6 @@ 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,
@@ -152,8 +144,6 @@ export class HaMediaPlayerBrowse extends LitElement {
@state() private _searchQuery = "";
@state() private _filterQuery = "";
@state() private _searchResults?: MediaPlayerItem[];
@state() private _searching = false;
@@ -243,7 +233,6 @@ 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;
@@ -458,24 +447,13 @@ export class HaMediaPlayerBrowse extends LitElement {
});
}
// Search and filter are available on non-root pages, aside from the
// manual-entry and TTS pseudo-sources.
const allowsSearchOrFilter =
// Search is available on non-root pages that opt in via can_search, aside
// from the manual-entry and TTS pseudo-sources.
const showSearch =
this.navigateIds.length > 1 &&
!isManualMediaSourceContentId(currentItem.media_content_id) &&
!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)
);
}
!isTTSMediaSource(currentItem.media_content_id) &&
currentItem.can_search;
// 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 =
@@ -500,7 +478,7 @@ export class HaMediaPlayerBrowse extends LitElement {
return html`
${
canPickCurrent || showSearch || showFilter
canPickCurrent || showSearch
? html`
<div
class="header ${classMap({
@@ -516,9 +494,7 @@ export class HaMediaPlayerBrowse extends LitElement {
currentItem,
mediaClassFilterOptions
)
: showFilter
? this._renderFilterRow(currentItem)
: nothing
: nothing
}
${
canPickCurrent
@@ -641,7 +617,7 @@ export class HaMediaPlayerBrowse extends LitElement {
@tts-picked=${this._ttsPicked}
></ha-browse-media-tts>
`
: !children.length && (!notShown || isFiltering)
: !children.length && !notShown
? html`
<div class="container no-items">
${
@@ -649,29 +625,25 @@ export class HaMediaPlayerBrowse extends LitElement {
? this.hass.localize(
"ui.components.media-browser.search.no_results"
)
: 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"
)
: 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>
`
@@ -801,23 +773,6 @@ 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 {
@@ -891,12 +846,6 @@ 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();
@@ -116,7 +116,6 @@ export class HaTracePathDetails extends LitElement {
showIndices: false,
select: () => undefined,
fixDuplicateIds: async () => undefined,
updateTriggerId: () => false,
},
});
-1
View File
@@ -709,7 +709,6 @@ export interface BaseSidebarConfig {
export interface TriggerSidebarConfig extends BaseSidebarConfig {
save: (value: Trigger) => void;
rename: () => void;
editId: () => void;
disable: () => void;
duplicate: () => void;
cut: () => void;
+1 -11
View File
@@ -95,7 +95,7 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
>
<ha-dialog-header slot="header">
${
!confirmPrompt || this._params.showCloseButton
!confirmPrompt
? html`<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="close"
@@ -169,13 +169,6 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
`
: nothing
}
${
this._params.prompt && this._params.textAfterInput
? html`<div class="after-input">
${this._params.textAfterInput}
</div>`
: nothing
}
</div>
<ha-dialog-footer slot="footer">
${
@@ -314,9 +307,6 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
ha-input {
width: 100%;
}
.after-input {
margin-top: var(--ha-space-4);
}
.title {
font-weight: inherit;
font-size: inherit;
-3
View File
@@ -5,8 +5,6 @@ interface BaseDialogBoxParams {
/** Whether back closes the dialog. Off while a navigation is in flight. */
addHistory?: boolean;
confirmText?: string;
/** Show the close button for confirmations and prompts, which hide it by default. */
showCloseButton?: boolean;
text?: string | TemplateResult;
title?: string;
subtitle?: string;
@@ -28,7 +26,6 @@ export interface ConfirmationDialogParams extends BaseDialogBoxParams {
export interface PromptDialogParams extends BaseDialogBoxParams {
inputLabel?: string;
inputSuffix?: string;
textAfterInput?: string | TemplateResult;
dismissText?: string;
inputType?: string;
defaultValue?: string;
@@ -424,7 +424,7 @@ class MoreInfoMediaPlayer extends LitElement {
min="0"
max=${duration}
step="1"
.accessibleLabel=${this._localize(
aria-label=${this._localize(
"ui.card.media_player.track_position"
)}
@change=${this._handleMediaSeekChanged}
@@ -25,7 +25,6 @@ import {
apiContext,
configContext,
connectionContext,
devicesContext,
formattersContext,
internationalizationContext,
} from "../../../data/context";
@@ -54,8 +53,6 @@ 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;
@@ -78,9 +75,6 @@ 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>;
@@ -438,9 +432,7 @@ class MoreInfoUpdate extends LitElement {
// The dialog was closed, or is closing, while the update was installing.
const toastHost = document.querySelector("home-assistant");
if (toastHost instanceof HTMLElement) {
const filter = this._appSlug();
if (toastHost) {
showToast(toastHost, {
message: this._localize(
"ui.dialogs.more_info_control.update.install_failed",
@@ -452,7 +444,8 @@ class MoreInfoUpdate extends LitElement {
text: this._localize(
"ui.dialogs.more_info_control.update.supervisor_logs"
),
action: () => this._showSupervisorLogs(toastHost, filter),
action: () =>
this._showSupervisorLogs(toastHost as HTMLElement),
}
: undefined,
});
@@ -467,37 +460,17 @@ 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._appSlug());
this._showSupervisorLogs(this);
}
// The toast outlives this element, so it opens the dialog from its own host.
private _showSupervisorLogs(host: HTMLElement, filter?: string): void {
private _showSupervisorLogs(host: HTMLElement): void {
showLogsDialog(host, {
provider: "supervisor",
title: this._localize(
"ui.dialogs.more_info_control.update.supervisor_logs"
),
filter,
});
}
+2 -2
View File
@@ -232,7 +232,7 @@ export class HAFullCalendar extends LitElement {
? html`<ha-button
size="s"
class="fab-header"
.accessibleLabel=${this.hass.localize(
aria-label=${this.hass.localize(
"ui.components.calendar.event.add"
)}
@click=${this._createEvent}
@@ -289,7 +289,7 @@ export class HAFullCalendar extends LitElement {
? html`<ha-button
size="s"
class="fab-header"
.accessibleLabel=${this.hass.localize(
aria-label=${this.hass.localize(
"ui.components.calendar.event.add"
)}
@click=${this._createEvent}
@@ -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"),
confirmText: this.hass.localize("ui.common.reset_options"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
});
@@ -1574,7 +1574,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
showLogsDialog(this, {
provider: "supervisor",
title: this.i18n.localize("ui.panel.config.logs.supervisor_logs"),
filter: this.addon.slug,
});
}
@@ -89,12 +89,14 @@ type AreaQuickLinkKey =
const NAVIGATION_ACTIONS: {
value: string;
path: string;
params?: string;
icon: string;
countKey: AreaQuickLinkKey;
}[] = [
{
value: "navigate-devices",
path: "/config/devices/dashboard",
params: "&status=disabled",
icon: mdiDevices,
countKey: "devices",
},
@@ -852,7 +854,9 @@ class HaConfigAreaPage extends LitElement {
const navAction = NAVIGATION_ACTIONS.find((a) => a.value === action);
if (navAction) {
navigate(`${navAction.path}?historyBack=1&area=${this.areaId}`);
navigate(
`${navAction.path}?historyBack=1&area=${this.areaId}${navAction.params ?? ""}`
);
return;
}
@@ -5,7 +5,6 @@ import {
mdiContentCut,
mdiContentPaste,
mdiDelete,
mdiIdentifier,
mdiPlayCircleOutline,
mdiPlaylistEdit,
mdiPlusCircleMultipleOutline,
@@ -138,38 +137,23 @@ export default class HaAutomationSidebarTrigger extends LitElement {
${
type !== "list"
? html`<ha-dropdown-item
slot="menu-items"
value="edit_note"
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiCommentEditOutline}
></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.note.${(this.config.config as Exclude<Trigger, TriggerList>).note ? "edit" : "add"}`
)}
<span
class="shortcut-placeholder ${isMac ? "mac" : ""}"
></span>
</div>
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
value="edit_id"
.disabled=${this.disabled}
>
<ha-svg-icon slot="icon" .path=${mdiIdentifier}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<span
class="shortcut-placeholder ${isMac ? "mac" : ""}"
></span>
</div>
</ha-dropdown-item>`
slot="menu-items"
value="edit_note"
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiCommentEditOutline}
></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.note.${(this.config.config as Exclude<Trigger, TriggerList>).note ? "edit" : "add"}`
)}
<span
class="shortcut-placeholder ${isMac ? "mac" : ""}"
></span>
</div>
</ha-dropdown-item>`
: nothing
}
<wa-divider slot="menu-items"></wa-divider>
@@ -403,9 +387,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
case "edit_note":
this.config.editNote();
break;
case "edit_id":
this.config.editId();
break;
case "duplicate":
this.config.duplicate();
break;
@@ -4,7 +4,6 @@ import memoizeOne from "memoize-one";
import type {
AutomationConfig,
SidebarConfig,
Trigger,
TriggerCondition,
} from "../../../../data/automation";
import { ContextSubscriptionController } from "../../../../common/decorators/consume";
@@ -20,7 +19,6 @@ import {
getExplicitTriggerIds,
isGeneratedTriggerId,
makeDuplicateTriggerIdsUnique,
replaceLeafTrigger,
updateTriggerCondition,
} from "./automation-trigger-id";
import { preserveAutomationRowKeys } from "../ha-automation-sortable-list-mixin";
@@ -72,7 +70,6 @@ export class AutomationTriggerController implements ReactiveController {
showIndices: false,
select: this._selectTriggerIds,
fixDuplicateIds: this._fixDuplicateTriggerIds,
updateTriggerId: this._updateTriggerId,
},
});
_host.addController(this);
@@ -182,23 +179,6 @@ export class AutomationTriggerController implements ReactiveController {
}
};
/**
* Commit an explicit ID edit directly. Ordinary edits pass through cleanupRemovedIds,
* which would treat a changed generated ID as a deleted trigger and strip its references.
*/
private _updateTriggerId = (original: Trigger, updated: Trigger): boolean => {
const config = this._options.getConfig();
if (!config || !this._options.canEdit()) {
return false;
}
const triggers = replaceLeafTrigger(config.triggers, original, updated);
if (triggers === config.triggers) {
return false;
}
this._commit({ ...config, triggers });
return true;
};
private _commit(config: AutomationConfig) {
const current = this._options.getConfig();
if (config === current) {
@@ -47,11 +47,6 @@ export interface AutomationTriggerContext {
select: (condition: TriggerCondition, ids: string[]) => void;
/** Requests confirmation before migrating duplicate IDs throughout the automation. */
fixDuplicateIds: () => Promise<void>;
/**
* Replace a leaf trigger after an explicit ID edit, leaving references to the old ID
* dangling. Return false when the trigger is not one of the automation's triggers.
*/
updateTriggerId: (original: Trigger, updated: Trigger) => boolean;
}
export const automationTriggerContext =
@@ -168,16 +163,6 @@ const walkLeafTriggers = (
return callback(triggers);
};
/** Replace one leaf trigger by object identity; return the input unchanged if it is absent. */
export const replaceLeafTrigger = (
triggers: Trigger | Trigger[],
original: Trigger,
updated: Trigger
): Trigger | Trigger[] =>
walkLeafTriggers(triggers, (trigger) =>
trigger === original ? updated : trigger
);
/** Store only selected candidate IDs, matching options to their original trigger objects. */
export const assignGeneratedTriggerIds = (
triggers: Trigger | Trigger[],
@@ -9,7 +9,6 @@ import {
mdiContentPaste,
mdiDelete,
mdiDotsVertical,
mdiIdentifier,
mdiPlayCircleOutline,
mdiPlaylistEdit,
mdiPlusCircleMultipleOutline,
@@ -74,7 +73,6 @@ import {
showPromptDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url";
import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
@@ -408,24 +406,16 @@ export default class HaAutomationTriggerRow extends LitElement {
${
type !== "list"
? html`<ha-dropdown-item value="edit_note">
<ha-svg-icon
slot="icon"
.path=${mdiCommentEditOutline}
></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
`ui.panel.config.automation.editor.note.${(this.trigger as Exclude<Trigger, TriggerList>).note ? "edit" : "add"}`
)
)}
</ha-dropdown-item>
<ha-dropdown-item value="edit_id" .disabled=${this.disabled}>
<ha-svg-icon slot="icon" .path=${mdiIdentifier}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)
)}
</ha-dropdown-item>`
<ha-svg-icon
slot="icon"
.path=${mdiCommentEditOutline}
></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
`ui.panel.config.automation.editor.note.${(this.trigger as Exclude<Trigger, TriggerList>).note ? "edit" : "add"}`
)
)}
</ha-dropdown-item>`
: nothing
}
<wa-divider></wa-divider>
@@ -817,7 +807,6 @@ export default class HaAutomationTriggerRow extends LitElement {
this._renameTrigger();
},
editNote: this._editNoteTrigger,
editId: this._editIdTrigger,
toggleYamlMode: (yamlMode?: boolean) => {
if (yamlMode === undefined || yamlMode !== this._yamlMode) {
this._toggleYamlMode();
@@ -962,69 +951,6 @@ export default class HaAutomationTriggerRow extends LitElement {
}
};
private _editIdTrigger = async (): Promise<void> => {
if (isTriggerList(this.trigger)) return;
const trigger = this.trigger;
const result = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id_title"
),
textAfterInput: html`<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id_info",
{
link: html`<a
href=${documentationUrl(
this.hass,
"/docs/automation/trigger/#trigger-id"
)}
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id_learn_more"
)}</a
>`,
}
)}
</ha-alert>`,
inputLabel: this.hass.localize(
"ui.panel.config.automation.editor.triggers.id"
),
inputType: "string",
placeholder: this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id_placeholder"
),
defaultValue: trigger.id,
showCloseButton: true,
confirmText: this.hass.localize("ui.common.submit"),
});
if (result === null) {
return;
}
const id = result.trim() === "" ? undefined : result;
if (id === trigger.id) {
return;
}
const value = { ...trigger };
if (id === undefined) {
delete value.id;
} else {
value.id = id;
}
// Bypass the editor's generated ID cleanup so references to the old ID dangle
if (!this._triggers?.updateTriggerId(trigger, value)) {
fireEvent(this, "value-changed", {
value,
});
}
if (this._selected && this.optionsInSidebar) {
this.openSidebar(value); // refresh sidebar
} else if (this._yamlMode) {
this.triggerEditor?.yamlEditor?.setValue(value);
}
};
private _editNoteTrigger = async (): Promise<void> => {
if (isTriggerList(this.trigger)) return;
const trigger = this.trigger;
@@ -1179,9 +1105,6 @@ export default class HaAutomationTriggerRow extends LitElement {
case "edit_note":
this._editNoteTrigger();
break;
case "edit_id":
this._editIdTrigger();
break;
case "duplicate":
this._duplicateTrigger();
break;
@@ -238,7 +238,7 @@ class DialogImportBlueprint extends DirtyStateProviderMixin<BlueprintImportState
@click=${this._import}
.disabled=${this._importing}
.loading=${this._importing}
.accessibleLabel=${this.hass.localize(
.ariaLabel=${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}
.accessibleLabel=${this.hass.localize(
.ariaLabel=${this.hass.localize(
`ui.panel.config.blueprint.add.${this._saving ? "saving" : this._result.exists ? "save_btn_override" : "save_btn"}`
)}
>
@@ -12,6 +12,7 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
import { consume } from "../../../common/decorators/consume";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
@@ -24,12 +25,14 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { deepEqual } from "../../../common/util/deep-equal";
import {
devicesFilterQueryParamConfig,
devicesFilterQueryParams,
devicesLinkFromUrl,
} from "../../../common/url/devices-query-params";
import {
hasRejectedItems,
rejectedItems,
@@ -132,8 +135,6 @@ export class HaConfigDeviceDashboard extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _selected: string[] = [];
private _selectedCanDelete: string[] = [];
@@ -148,7 +149,7 @@ export class HaConfigDeviceDashboard extends LitElement {
state: true,
subscribe: false,
})
private _filter: string = getHistoryState()?.filter || "";
private _filter = "";
@state()
private _filters: DataTableFilters = {};
@@ -196,39 +197,16 @@ export class HaConfigDeviceDashboard extends LitElement {
callback: (entries) => entries[0]?.contentRect.width,
});
private _ignoreLocationChange = false;
private _fromUrl = false;
public connectedCallback() {
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
window.removeEventListener("popstate", this._popState);
}
private _locationChanged = () => {
if (this._ignoreLocationChange) {
this._ignoreLocationChange = false;
return;
// The filters are kept in the URL by the page, the search, sorting and
// grouping by the table
private _filterParams = new QueryParamsController(
this,
devicesFilterQueryParamConfig,
{
enabled: () => true,
navigated: () => this._applyLink(),
}
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _popState = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
);
private _states = memoizeOne((localize: LocalizeFunc) => [
{
@@ -241,7 +219,7 @@ export class HaConfigDeviceDashboard extends LitElement {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._filters = this._storageFilters;
this._setFiltersFromUrl();
this._applyLink();
}
if (changedProps.has("_selected") || changedProps.has("entries")) {
this._selectedCanDelete = this._selected.filter((d) => {
@@ -256,60 +234,38 @@ export class HaConfigDeviceDashboard extends LitElement {
}
}
private _setFiltersFromUrl() {
const area = this._searchParms.get("area");
const domain = this._searchParms.get("domain");
const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry");
const label = this._searchParms.has("label");
// Links, like the ones on an integration page, say in the URL what to show.
// The router keeps the page open, so a link can land on it.
private _applyLink() {
const searchParams = this._filterParams.searchParams();
const link = searchParams ? devicesLinkFromUrl(searchParams) : undefined;
if (!area && !domain && !configEntry && !label) {
return;
if (link) {
// The filters only work out their items again when their value changes,
// so keep the items of the ones the link leaves as they are
const filters = Object.fromEntries(
Object.entries(link.filters).map(([key, filter]) => {
const current = this._filters[key];
return [
key,
current && deepEqual(filter.value, current.value)
? current
: filter,
];
})
);
this._filter = link.search;
this._filters = filters;
this._storageFilters = filters;
}
this._fromUrl = true;
this._filter = getHistoryState()?.filter || "";
this._filters = {
"ha-filter-states": {
value: [
...((this._filters["ha-filter-states"]?.value as string[]) || []),
"disabled",
],
items: undefined,
},
"ha-filter-floor-areas": {
value: area ? { areas: [area] } : undefined,
items: undefined,
},
"ha-filter-integrations": {
value: domain ? [domain] : [],
items: undefined,
},
config_entry: {
value: configEntry ? [configEntry] : [],
items: undefined,
},
sub_entry: {
value: subEntry ? [subEntry] : [],
items: undefined,
},
};
this._filterLabel();
this._writeFilters();
}
private _filterLabel() {
const label = this._searchParms.get("label");
if (!label) {
return;
}
this._filters = {
...this._filters,
"ha-filter-labels": {
value: [label],
items: undefined,
},
};
private _writeFilters() {
this._filterParams.write(devicesFilterQueryParams(this._filters));
}
private _reloadConfigEntries() {
@@ -318,9 +274,8 @@ export class HaConfigDeviceDashboard extends LitElement {
private _clearFilter() {
this._filters = {};
if (!this._fromUrl) {
this._storageFilters = {};
}
this._storageFilters = {};
this._writeFilters();
}
private _devicesAndFilterDomains = memoizeOne(
@@ -399,24 +354,29 @@ export class HaConfigDeviceDashboard extends LitElement {
Array.isArray(filter.value) &&
filter.value.length
) {
if (!(
Array.isArray(this._filters.config_entry?.value) &&
this._filters.config_entry.value.length === 1
)) {
const configEntryIds = this._filters.config_entry?.value;
const subEntryIds = filter.value;
if (
!Array.isArray(configEntryIds) ||
!configEntryIds.length ||
!Array.isArray(subEntryIds)
) {
return;
}
const configEntryId = this._filters.config_entry.value[0];
outputDevices = outputDevices.filter(
(device) =>
device.config_entries_subentries[configEntryId] &&
(filter.value as string[]).some((subEntryId) =>
device.config_entries_subentries[configEntryId].includes(
subEntryId
)
outputDevices = outputDevices.filter((device) =>
configEntryIds.some((configEntryId) =>
device.config_entries_subentries[configEntryId]?.some(
(subEntryId) =>
subEntryId !== null && subEntryIds.includes(subEntryId)
)
)
);
if (!this._subEntries) {
this._loadSubEntries(configEntryId);
// The titles shown in the filter chip need a single config entry
if (configEntryIds.length === 1 && !this._subEntries) {
this._loadSubEntries(configEntryIds[0]);
}
} else if (
key === "ha-filter-integrations" &&
@@ -654,6 +614,7 @@ export class HaConfigDeviceDashboard extends LitElement {
sortable: true,
filterable: true,
groupable: true,
urlName: "integration",
minWidth: "120px",
template: (device) =>
!this.entries
@@ -669,6 +630,7 @@ export class HaConfigDeviceDashboard extends LitElement {
sortable: true,
filterable: true,
groupable: true,
urlName: "parent",
defaultHidden: true,
minWidth: "120px",
template: (device) => device.parent_device_name || "",
@@ -678,6 +640,7 @@ export class HaConfigDeviceDashboard extends LitElement {
sortable: true,
filterable: true,
groupable: true,
urlName: "manufacturer",
minWidth: "120px",
},
model: {
@@ -900,6 +863,7 @@ export class HaConfigDeviceDashboard extends LitElement {
@collapsed-changed=${this._handleCollapseChanged}
@row-click=${this._handleRowClicked}
clickable
sync-url
has-fab
class=${this.narrow ? "narrow" : ""}
>
@@ -1094,9 +1058,8 @@ export class HaConfigDeviceDashboard extends LitElement {
private _filterChanged(ev) {
const type = ev.target.localName;
this._filters = { ...this._filters, [type]: ev.detail };
if (!this._fromUrl) {
this._storageFilters = this._filters;
}
this._storageFilters = this._filters;
this._writeFilters();
}
private _batteryEntity(
@@ -1123,13 +1086,11 @@ export class HaConfigDeviceDashboard extends LitElement {
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const deviceId = ev.detail.id;
this._ignoreLocationChange = true;
navigate(`/config/devices/device/${deviceId}`);
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
updateHistoryState({ filter: this._filter });
}
private _addDevice() {
@@ -1157,8 +1118,13 @@ export class HaConfigDeviceDashboard extends LitElement {
});
return;
}
const integrations = this._filters["ha-filter-integrations"]?.value;
showAddIntegrationDialog(this, {
domain: this._searchParms.get("domain") || undefined,
domain:
Array.isArray(integrations) && integrations.length === 1
? integrations[0]
: undefined,
navigateToResult: true,
});
}
@@ -197,7 +197,7 @@ export class DialogEnergyCustomise
<ha-switch
.checked=${applicable && !this._hidden!.has(card.key)}
.disabled=${!applicable}
.accessibleLabel=${label}
.ariaLabel=${label}
data-card-key=${card.key}
@change=${this._toggleCard}
></ha-switch>
@@ -253,7 +253,7 @@ export class HaConfigEntryRow extends LitElement {
href=${
allDevices.length === 1
? `/config/devices/device/${allDevices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}&status=disabled`
}
>
<ha-dropdown-item value="devices">
@@ -278,7 +278,7 @@ export class HaConfigEntryRow extends LitElement {
href=${
allServices.length === 1
? `/config/devices/device/${allServices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}&status=disabled`
}
>
<ha-dropdown-item value="services">
@@ -617,7 +617,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
href=${
devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}&status=disabled`
}
>
${this.hass.localize(
@@ -635,7 +635,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
href=${
services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}`
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}&status=disabled`
}
>
${this.hass.localize(
@@ -113,7 +113,7 @@ class HaConfigSubEntryRow extends LitElement {
href=${
devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}&status=disabled`
}
>
<ha-dropdown-item value="devices">
@@ -138,7 +138,7 @@ class HaConfigSubEntryRow extends LitElement {
href=${
services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}&status=disabled`
}
>
<ha-dropdown-item value="services">
@@ -178,7 +178,7 @@ export class MatterConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
@@ -221,7 +221,7 @@ export class MQTTConfigPanel extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
@@ -237,7 +237,7 @@ class ZHAConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
@@ -16,7 +16,7 @@ export class DialogZWaveJSAddNode extends HTMLElement {
private async _openDialog() {
await navigate(
`/config/devices/dashboard?config_entry=${this.configEntryId}`,
`/config/devices/dashboard?config_entry=${this.configEntryId}&status=disabled`,
{
replace: true,
}
@@ -326,7 +326,7 @@ class ZWaveJSConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}`}
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}&status=disabled`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
+5 -3
View File
@@ -478,7 +478,7 @@ export class HaConfigLabels extends LitElement {
this._navigateConfig("/config/entities");
break;
case "navigate-devices":
this._navigateConfig("/config/devices/dashboard");
this._navigateConfig("/config/devices/dashboard", "&status=disabled");
break;
case "navigate-automations":
this._navigateConfig("/config/automation/dashboard");
@@ -495,8 +495,10 @@ export class HaConfigLabels extends LitElement {
}
};
private _navigateConfig = (path: string) => {
navigate(`${path}?historyBack=1&label=${this._overflowLabel.label_id}`);
private _navigateConfig = (path: string, params = "") => {
navigate(
`${path}?historyBack=1&label=${this._overflowLabel.label_id}${params}`
);
};
private _handleSortingChanged(ev: CustomEvent) {
+3 -62
View File
@@ -3,14 +3,11 @@ 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 { apiContext, internationalizationContext } from "../../../data/context";
import { fetchHassioLogsLegacy } from "../../../data/hassio/supervisor";
import { internationalizationContext } from "../../../data/context";
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";
@@ -20,24 +17,12 @@ 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() {
@@ -53,21 +38,10 @@ 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>
`;
@@ -75,51 +49,18 @@ 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-alert {
ha-input-search {
display: block;
margin-bottom: var(--ha-space-2);
}
error-log-card {
--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));
--error-log-card-height: calc(100vh - 340px);
}
`,
];
+3 -29
View File
@@ -68,16 +68,11 @@ 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>;
@@ -300,7 +295,7 @@ class ErrorLogCard extends LitElement {
}
</div>
</div>
<div class="card-content error-log ha-scrollbar">
<div class="card-content error-log">
<div id="scroll-top-marker"></div>
${
this._loadingPrevState === "loading"
@@ -339,8 +334,6 @@ 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>
@@ -417,19 +410,8 @@ class ErrorLogCard extends LitElement {
this._newLogsIndicator = false;
}
if (
changedProps.has("filter") ||
(changedProps.has("_loadingState") &&
this._loadingState === "loaded" &&
this.filter)
) {
if (changedProps.has("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 (
@@ -659,12 +641,6 @@ 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);
@@ -817,14 +793,12 @@ class ErrorLogCard extends LitElement {
}
static styles: CSSResultGroup = css`
${haStyleScrollbar}
:host {
direction: var(--direction);
}
.error-log-intro {
text-align: center;
margin: var(--error-log-card-margin, 16px);
margin: 16px;
}
ha-card {
@@ -3,7 +3,6 @@ import { fireEvent } from "../../../common/dom/fire_event";
export interface LogsDialogParams {
provider: string;
title: string;
filter?: string;
}
export const showLogsDialog = (
@@ -34,7 +34,7 @@ class HuiEntitiesToggle extends LitElement {
return html`
<ha-switch
.accessibleLabel=${this.hass!.localize(
aria-label=${this.hass!.localize(
"ui.panel.lovelace.card.entities.toggle"
)}
.checked=${this._toggleEntities!.some((entityId) => {
@@ -71,8 +71,6 @@ 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}>
${
@@ -81,7 +79,6 @@ 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)}
@@ -97,7 +94,6 @@ 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,8 +73,6 @@ 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}>
${
@@ -83,7 +81,6 @@ 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)}
@@ -104,7 +101,6 @@ 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]+)?"
@@ -277,7 +277,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
step="1"
.withTooltip=${false}
size="s"
.accessibleLabel=${this.hass.localize(
aria-label=${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"
.accessibleLabel=${this.hass.localize(
aria-label=${this.hass.localize(
"ui.card.media_player.track_position"
)}
?disabled=${!supportsFeature(
+15
View File
@@ -29,7 +29,21 @@ const marketplaceTableParams = {
source: "string?",
sort: "string?",
direction: "string?",
} satisfies Redirect["params"];
// The devices table reads these from the link
const devicesTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
group: "string?",
status: "string?",
floor: "string?",
area: "string?",
domain: "string?",
label: "string?",
config_entry: "string?",
sub_entry: "string?",
} satisfies Redirect["params"];
export const getMyRedirects = (): Redirects => ({
@@ -221,6 +235,7 @@ export const getMyRedirects = (): Redirects => ({
},
devices: {
redirect: "/config/devices/dashboard",
params: devicesTableParams,
},
entities: {
redirect: "/config/entities",
-16
View File
@@ -500,7 +500,6 @@
"submit": "Submit",
"rename": "Rename",
"reset": "Reset",
"retry": "Retry",
"search": "[%key:ui::components::data-table::search%]",
"ok": "OK",
"yes": "Yes",
@@ -857,11 +856,6 @@
"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": {
@@ -1320,10 +1314,6 @@
"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",
@@ -4905,8 +4895,6 @@
"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…",
@@ -5644,10 +5632,6 @@
"id": "Trigger ID",
"optional": "Optional",
"edit_id": "Edit ID",
"edit_id_title": "Edit trigger ID",
"edit_id_info": "Trigger IDs are generated automatically, so you usually don't need to set one yourself. {link}",
"edit_id_learn_more": "Learn more about trigger IDs",
"edit_id_placeholder": "e.g. sun_has_set",
"duplicate": "[%key:ui::common::duplicate%]",
"re_order": "Re-order",
"rename": "Rename",
@@ -107,6 +107,31 @@ describe("QueryParamsController", () => {
expect(navigated).not.toHaveBeenCalled();
});
it("follows the router replacing the URL with its default page", () => {
setUrl("/page/dashboard?other=1");
window.dispatchEvent(
Object.assign(new Event("location-changed"), {
detail: { replace: true },
})
);
expect(navigated).toHaveBeenCalledOnce();
controller.write({ search: "abc" });
expect(currentUrl()).toBe("/page/dashboard?other=1&search=abc");
});
it("does nothing once a link pushes a page below it", () => {
setUrl("/page/item?other=1");
window.dispatchEvent(
Object.assign(new Event("location-changed"), {
detail: { replace: false },
})
);
controller.write({ search: "abc" });
expect(currentUrl()).toBe("/page/item?other=1");
expect(navigated).not.toHaveBeenCalled();
});
it("does nothing when not enabled", () => {
enabled = false;
controller.write({ search: "abc" });
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
devicesFilterQueryParamConfig,
devicesFilterQueryParams,
devicesLinkFromUrl,
} from "../../../src/common/url/devices-query-params";
import { createQueryString } from "../../../src/common/url/query-params";
describe("devices query params", () => {
it("reads nothing without table params", () => {
expect(devicesLinkFromUrl("?historyBack=1")).toBeUndefined();
});
it("replaces every filter and the search with what a link asks for", () => {
expect(devicesLinkFromUrl("?config_entry=abc&label=a,b")).toEqual({
search: "",
filters: {
"ha-filter-states": { value: [], items: undefined },
"ha-filter-floor-areas": { value: undefined, items: undefined },
"ha-filter-integrations": { value: [], items: undefined },
"ha-filter-labels": { value: ["a", "b"], items: undefined },
config_entry: { value: ["abc"], items: undefined },
sub_entry: { value: [], items: undefined },
},
});
});
it("round trips the filters", () => {
const filters = {
"ha-filter-states": { value: ["disabled"], items: undefined },
"ha-filter-floor-areas": {
value: { floors: ["ground"], areas: ["kitchen"] },
items: undefined,
},
"ha-filter-integrations": { value: ["hue"], items: undefined },
"ha-filter-labels": { value: ["a"], items: undefined },
config_entry: { value: ["abc"], items: undefined },
sub_entry: { value: ["def"], items: undefined },
};
expect(
devicesLinkFromUrl(
createQueryString(
devicesFilterQueryParams(filters),
devicesFilterQueryParamConfig
)
)
).toEqual({ search: "", filters });
});
it("writes the values, not the items the filters found", () => {
expect(
devicesFilterQueryParams({
"ha-filter-floor-areas": {
value: { areas: ["kitchen"] },
items: new Set(["device_1"]),
},
})
).toEqual({
status: undefined,
floor: undefined,
area: ["kitchen"],
domain: undefined,
label: undefined,
config_entry: undefined,
sub_entry: undefined,
});
});
});