mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-20 12:50:04 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af4959b57a | ||
|
|
00ef6bf8b3 | ||
|
|
b94a4f947c |
No files matched your search
@@ -3,7 +3,7 @@ import type {
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import { mainWindow } from "../dom/get_main_window";
|
||||
import { replaceCurrentUrl, type NavigateOptions } from "../navigate";
|
||||
import { replaceCurrentUrl } from "../navigate";
|
||||
import { constructUrlCurrentPath } from "../url/construct-url";
|
||||
import { currentPath } from "../url/current-path";
|
||||
import {
|
||||
@@ -30,7 +30,7 @@ export interface QueryParamsControllerOptions {
|
||||
* shared, bookmarked and reloaded. Writes replace the current history entry,
|
||||
* so Back leaves the page instead of undoing each change, and only touch the
|
||||
* config's params. The controller only acts while the URL is still the
|
||||
* page the host was connected on, or the default page its router opened.
|
||||
* page the host was connected on.
|
||||
*/
|
||||
export class QueryParamsController<
|
||||
C extends QueryParamConfig,
|
||||
@@ -120,16 +120,8 @@ export class QueryParamsController<
|
||||
return this._options.enabled() && currentPath() === this._path;
|
||||
}
|
||||
|
||||
private _navigated = (ev?: Event & { detail?: NavigateOptions }) => {
|
||||
private _navigated = () => {
|
||||
this._cancelPending();
|
||||
// A router opening its default page, like /config/devices going to
|
||||
// /config/devices/dashboard, replaces the URL after the host connected
|
||||
const path = currentPath();
|
||||
|
||||
if (ev?.detail?.replace && path.startsWith(`${this._path}/`)) {
|
||||
this._path = path;
|
||||
}
|
||||
|
||||
// Leaving the page gets here too, before the router swaps the page
|
||||
if (this._isActive()) {
|
||||
this._options.navigated();
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableFilters,
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
decodeQueryParams,
|
||||
hasQueryParams,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
type SearchParamsSource,
|
||||
} from "./query-params";
|
||||
|
||||
// The filters of the devices table. Search, sorting and grouping are kept by
|
||||
// the data table, see data-table-query-params.
|
||||
export const devicesFilterQueryParamConfig = {
|
||||
list: [
|
||||
"status",
|
||||
"floor",
|
||||
"area",
|
||||
"domain",
|
||||
"label",
|
||||
"config_entry",
|
||||
"sub_entry",
|
||||
],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type DevicesFilterQueryParams = QueryParamValues<
|
||||
typeof devicesFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
const list = (filter: DataTableFilter | undefined): string[] | undefined =>
|
||||
Array.isArray(filter?.value) ? filter.value : undefined;
|
||||
|
||||
const floorAreas = (
|
||||
filter: DataTableFilter | undefined
|
||||
): Record<string, string[]> | undefined =>
|
||||
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
|
||||
|
||||
export const devicesFilterQueryParams = (
|
||||
filters: DataTableFilters
|
||||
): DevicesFilterQueryParams => ({
|
||||
status: list(filters["ha-filter-states"]),
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
domain: list(filters["ha-filter-integrations"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
config_entry: list(filters.config_entry),
|
||||
sub_entry: list(filters.sub_entry),
|
||||
});
|
||||
|
||||
// What a link says to show, undefined when it has no table params so what was
|
||||
// picked before stays. A link replaces the search and the filters, ones left
|
||||
// behind would hide part of what it shows. The table applies its sorting and
|
||||
// grouping. The filters work out their items from the values.
|
||||
export interface DevicesLink {
|
||||
search: string;
|
||||
filters: DataTableFilters;
|
||||
}
|
||||
|
||||
export const devicesLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): DevicesLink | undefined => {
|
||||
if (
|
||||
!hasQueryParams(searchParams, devicesFilterQueryParamConfig) &&
|
||||
!hasQueryParams(searchParams, dataTableQueryParamConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = decodeQueryParams(searchParams, devicesFilterQueryParamConfig);
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof DevicesFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
|
||||
value,
|
||||
items: undefined,
|
||||
});
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-states": filter(values("status")),
|
||||
"ha-filter-floor-areas": filter(
|
||||
floors.length || areas.length ? { floors, areas } : undefined
|
||||
),
|
||||
"ha-filter-integrations": filter(values("domain")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
config_entry: filter(values("config_entry")),
|
||||
sub_entry: filter(values("sub_entry")),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -424,7 +424,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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -232,7 +232,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 +289,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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -89,14 +89,12 @@ type AreaQuickLinkKey =
|
||||
const NAVIGATION_ACTIONS: {
|
||||
value: string;
|
||||
path: string;
|
||||
params?: string;
|
||||
icon: string;
|
||||
countKey: AreaQuickLinkKey;
|
||||
}[] = [
|
||||
{
|
||||
value: "navigate-devices",
|
||||
path: "/config/devices/dashboard",
|
||||
params: "&status=disabled",
|
||||
icon: mdiDevices,
|
||||
countKey: "devices",
|
||||
},
|
||||
@@ -854,9 +852,7 @@ class HaConfigAreaPage extends LitElement {
|
||||
|
||||
const navAction = NAVIGATION_ACTIONS.find((a) => a.value === action);
|
||||
if (navAction) {
|
||||
navigate(
|
||||
`${navAction.path}?historyBack=1&area=${this.areaId}${navAction.params ?? ""}`
|
||||
);
|
||||
navigate(`${navAction.path}?historyBack=1&area=${this.areaId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}`
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
@@ -25,14 +24,12 @@ import {
|
||||
PROTOCOL_INTEGRATIONS,
|
||||
protocolIntegrationPicked,
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { deepEqual } from "../../../common/util/deep-equal";
|
||||
import {
|
||||
devicesFilterQueryParamConfig,
|
||||
devicesFilterQueryParams,
|
||||
devicesLinkFromUrl,
|
||||
} from "../../../common/url/devices-query-params";
|
||||
getHistoryState,
|
||||
navigate,
|
||||
updateHistoryState,
|
||||
} from "../../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import {
|
||||
hasRejectedItems,
|
||||
rejectedItems,
|
||||
@@ -135,6 +132,8 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _selected: string[] = [];
|
||||
|
||||
private _selectedCanDelete: string[] = [];
|
||||
@@ -149,7 +148,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter = "";
|
||||
private _filter: string = getHistoryState()?.filter || "";
|
||||
|
||||
@state()
|
||||
private _filters: DataTableFilters = {};
|
||||
@@ -197,16 +196,39 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
callback: (entries) => entries[0]?.contentRect.width,
|
||||
});
|
||||
|
||||
// The filters are kept in the URL by the page, the search, sorting and
|
||||
// grouping by the table
|
||||
private _filterParams = new QueryParamsController(
|
||||
this,
|
||||
devicesFilterQueryParamConfig,
|
||||
{
|
||||
enabled: () => true,
|
||||
navigated: () => this._applyLink(),
|
||||
private _ignoreLocationChange = false;
|
||||
|
||||
private _fromUrl = false;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("location-changed", this._locationChanged);
|
||||
window.addEventListener("popstate", this._popState);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("location-changed", this._locationChanged);
|
||||
window.removeEventListener("popstate", this._popState);
|
||||
}
|
||||
|
||||
private _locationChanged = () => {
|
||||
if (this._ignoreLocationChange) {
|
||||
this._ignoreLocationChange = false;
|
||||
return;
|
||||
}
|
||||
);
|
||||
if (window.location.search.substring(1) !== this._searchParms.toString()) {
|
||||
this._searchParms = new URLSearchParams(window.location.search);
|
||||
this._setFiltersFromUrl();
|
||||
}
|
||||
};
|
||||
|
||||
private _popState = () => {
|
||||
if (window.location.search.substring(1) !== this._searchParms.toString()) {
|
||||
this._searchParms = new URLSearchParams(window.location.search);
|
||||
this._setFiltersFromUrl();
|
||||
}
|
||||
};
|
||||
|
||||
private _states = memoizeOne((localize: LocalizeFunc) => [
|
||||
{
|
||||
@@ -219,7 +241,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
super.willUpdate(changedProps);
|
||||
if (!this.hasUpdated) {
|
||||
this._filters = this._storageFilters;
|
||||
this._applyLink();
|
||||
this._setFiltersFromUrl();
|
||||
}
|
||||
if (changedProps.has("_selected") || changedProps.has("entries")) {
|
||||
this._selectedCanDelete = this._selected.filter((d) => {
|
||||
@@ -234,38 +256,60 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Links, like the ones on an integration page, say in the URL what to show.
|
||||
// The router keeps the page open, so a link can land on it.
|
||||
private _applyLink() {
|
||||
const searchParams = this._filterParams.searchParams();
|
||||
const link = searchParams ? devicesLinkFromUrl(searchParams) : undefined;
|
||||
private _setFiltersFromUrl() {
|
||||
const area = this._searchParms.get("area");
|
||||
const domain = this._searchParms.get("domain");
|
||||
const configEntry = this._searchParms.get("config_entry");
|
||||
const subEntry = this._searchParms.get("sub_entry");
|
||||
const label = this._searchParms.has("label");
|
||||
|
||||
if (link) {
|
||||
// The filters only work out their items again when their value changes,
|
||||
// so keep the items of the ones the link leaves as they are
|
||||
const filters = Object.fromEntries(
|
||||
Object.entries(link.filters).map(([key, filter]) => {
|
||||
const current = this._filters[key];
|
||||
|
||||
return [
|
||||
key,
|
||||
current && deepEqual(filter.value, current.value)
|
||||
? current
|
||||
: filter,
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
this._filter = link.search;
|
||||
this._filters = filters;
|
||||
this._storageFilters = filters;
|
||||
if (!area && !domain && !configEntry && !label) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._writeFilters();
|
||||
this._fromUrl = true;
|
||||
this._filter = getHistoryState()?.filter || "";
|
||||
|
||||
this._filters = {
|
||||
"ha-filter-states": {
|
||||
value: [
|
||||
...((this._filters["ha-filter-states"]?.value as string[]) || []),
|
||||
"disabled",
|
||||
],
|
||||
items: undefined,
|
||||
},
|
||||
"ha-filter-floor-areas": {
|
||||
value: area ? { areas: [area] } : undefined,
|
||||
items: undefined,
|
||||
},
|
||||
"ha-filter-integrations": {
|
||||
value: domain ? [domain] : [],
|
||||
items: undefined,
|
||||
},
|
||||
config_entry: {
|
||||
value: configEntry ? [configEntry] : [],
|
||||
items: undefined,
|
||||
},
|
||||
sub_entry: {
|
||||
value: subEntry ? [subEntry] : [],
|
||||
items: undefined,
|
||||
},
|
||||
};
|
||||
this._filterLabel();
|
||||
}
|
||||
|
||||
private _writeFilters() {
|
||||
this._filterParams.write(devicesFilterQueryParams(this._filters));
|
||||
private _filterLabel() {
|
||||
const label = this._searchParms.get("label");
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
this._filters = {
|
||||
...this._filters,
|
||||
"ha-filter-labels": {
|
||||
value: [label],
|
||||
items: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _reloadConfigEntries() {
|
||||
@@ -274,8 +318,9 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
private _clearFilter() {
|
||||
this._filters = {};
|
||||
this._storageFilters = {};
|
||||
this._writeFilters();
|
||||
if (!this._fromUrl) {
|
||||
this._storageFilters = {};
|
||||
}
|
||||
}
|
||||
|
||||
private _devicesAndFilterDomains = memoizeOne(
|
||||
@@ -354,29 +399,24 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
Array.isArray(filter.value) &&
|
||||
filter.value.length
|
||||
) {
|
||||
const configEntryIds = this._filters.config_entry?.value;
|
||||
const subEntryIds = filter.value;
|
||||
|
||||
if (
|
||||
!Array.isArray(configEntryIds) ||
|
||||
!configEntryIds.length ||
|
||||
!Array.isArray(subEntryIds)
|
||||
) {
|
||||
if (!(
|
||||
Array.isArray(this._filters.config_entry?.value) &&
|
||||
this._filters.config_entry.value.length === 1
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
outputDevices = outputDevices.filter((device) =>
|
||||
configEntryIds.some((configEntryId) =>
|
||||
device.config_entries_subentries[configEntryId]?.some(
|
||||
(subEntryId) =>
|
||||
subEntryId !== null && subEntryIds.includes(subEntryId)
|
||||
const configEntryId = this._filters.config_entry.value[0];
|
||||
outputDevices = outputDevices.filter(
|
||||
(device) =>
|
||||
device.config_entries_subentries[configEntryId] &&
|
||||
(filter.value as string[]).some((subEntryId) =>
|
||||
device.config_entries_subentries[configEntryId].includes(
|
||||
subEntryId
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// The titles shown in the filter chip need a single config entry
|
||||
if (configEntryIds.length === 1 && !this._subEntries) {
|
||||
this._loadSubEntries(configEntryIds[0]);
|
||||
if (!this._subEntries) {
|
||||
this._loadSubEntries(configEntryId);
|
||||
}
|
||||
} else if (
|
||||
key === "ha-filter-integrations" &&
|
||||
@@ -614,7 +654,6 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
urlName: "integration",
|
||||
minWidth: "120px",
|
||||
template: (device) =>
|
||||
!this.entries
|
||||
@@ -630,7 +669,6 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
urlName: "parent",
|
||||
defaultHidden: true,
|
||||
minWidth: "120px",
|
||||
template: (device) => device.parent_device_name || "",
|
||||
@@ -640,7 +678,6 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
urlName: "manufacturer",
|
||||
minWidth: "120px",
|
||||
},
|
||||
model: {
|
||||
@@ -863,7 +900,6 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
@row-click=${this._handleRowClicked}
|
||||
clickable
|
||||
sync-url
|
||||
has-fab
|
||||
class=${this.narrow ? "narrow" : ""}
|
||||
>
|
||||
@@ -1058,8 +1094,9 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
private _filterChanged(ev) {
|
||||
const type = ev.target.localName;
|
||||
this._filters = { ...this._filters, [type]: ev.detail };
|
||||
this._storageFilters = this._filters;
|
||||
this._writeFilters();
|
||||
if (!this._fromUrl) {
|
||||
this._storageFilters = this._filters;
|
||||
}
|
||||
}
|
||||
|
||||
private _batteryEntity(
|
||||
@@ -1086,11 +1123,13 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
|
||||
const deviceId = ev.detail.id;
|
||||
this._ignoreLocationChange = true;
|
||||
navigate(`/config/devices/device/${deviceId}`);
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
updateHistoryState({ filter: this._filter });
|
||||
}
|
||||
|
||||
private _addDevice() {
|
||||
@@ -1118,13 +1157,8 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const integrations = this._filters["ha-filter-integrations"]?.value;
|
||||
showAddIntegrationDialog(this, {
|
||||
domain:
|
||||
Array.isArray(integrations) && integrations.length === 1
|
||||
? integrations[0]
|
||||
: undefined,
|
||||
domain: this._searchParms.get("domain") || undefined,
|
||||
navigateToResult: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -253,7 +253,7 @@ export class HaConfigEntryRow extends LitElement {
|
||||
href=${
|
||||
allDevices.length === 1
|
||||
? `/config/devices/device/${allDevices[0].id}`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}&status=disabled`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`
|
||||
}
|
||||
>
|
||||
<ha-dropdown-item value="devices">
|
||||
@@ -278,7 +278,7 @@ export class HaConfigEntryRow extends LitElement {
|
||||
href=${
|
||||
allServices.length === 1
|
||||
? `/config/devices/device/${allServices[0].id}`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}&status=disabled`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`
|
||||
}
|
||||
>
|
||||
<ha-dropdown-item value="services">
|
||||
|
||||
@@ -617,7 +617,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
href=${
|
||||
devices.length === 1
|
||||
? `/config/devices/device/${devices[0].id}`
|
||||
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}&status=disabled`
|
||||
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}`
|
||||
}
|
||||
>
|
||||
${this.hass.localize(
|
||||
@@ -635,7 +635,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
href=${
|
||||
services.length === 1
|
||||
? `/config/devices/device/${services[0].id}`
|
||||
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}&status=disabled`
|
||||
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}`
|
||||
}
|
||||
>
|
||||
${this.hass.localize(
|
||||
|
||||
@@ -113,7 +113,7 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
href=${
|
||||
devices.length === 1
|
||||
? `/config/devices/device/${devices[0].id}`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}&status=disabled`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`
|
||||
}
|
||||
>
|
||||
<ha-dropdown-item value="devices">
|
||||
@@ -138,7 +138,7 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
href=${
|
||||
services.length === 1
|
||||
? `/config/devices/device/${services[0].id}`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}&status=disabled`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`
|
||||
}
|
||||
>
|
||||
<ha-dropdown-item value="services">
|
||||
|
||||
@@ -178,7 +178,7 @@ export class MatterConfigDashboard extends LitElement {
|
||||
)}
|
||||
>
|
||||
<ha-list-item-button
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
|
||||
@@ -221,7 +221,7 @@ export class MQTTConfigPanel extends LitElement {
|
||||
)}
|
||||
>
|
||||
<ha-list-item-button
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
|
||||
@@ -237,7 +237,7 @@ class ZHAConfigDashboard extends LitElement {
|
||||
)}
|
||||
>
|
||||
<ha-list-item-button
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ export class DialogZWaveJSAddNode extends HTMLElement {
|
||||
|
||||
private async _openDialog() {
|
||||
await navigate(
|
||||
`/config/devices/dashboard?config_entry=${this.configEntryId}&status=disabled`,
|
||||
`/config/devices/dashboard?config_entry=${this.configEntryId}`,
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
|
||||
+1
-1
@@ -326,7 +326,7 @@ class ZWaveJSConfigDashboard extends LitElement {
|
||||
)}
|
||||
>
|
||||
<ha-list-item-button
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}&status=disabled`}
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
|
||||
@@ -478,7 +478,7 @@ export class HaConfigLabels extends LitElement {
|
||||
this._navigateConfig("/config/entities");
|
||||
break;
|
||||
case "navigate-devices":
|
||||
this._navigateConfig("/config/devices/dashboard", "&status=disabled");
|
||||
this._navigateConfig("/config/devices/dashboard");
|
||||
break;
|
||||
case "navigate-automations":
|
||||
this._navigateConfig("/config/automation/dashboard");
|
||||
@@ -495,10 +495,8 @@ export class HaConfigLabels extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
private _navigateConfig = (path: string, params = "") => {
|
||||
navigate(
|
||||
`${path}?historyBack=1&label=${this._overflowLabel.label_id}${params}`
|
||||
);
|
||||
private _navigateConfig = (path: string) => {
|
||||
navigate(`${path}?historyBack=1&label=${this._overflowLabel.label_id}`);
|
||||
};
|
||||
|
||||
private _handleSortingChanged(ev: CustomEvent) {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]+)?"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -31,21 +31,6 @@ const marketplaceTableParams = {
|
||||
direction: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
// The devices table reads these from the link
|
||||
const devicesTableParams = {
|
||||
search: "string?",
|
||||
sort: "string?",
|
||||
direction: "string?",
|
||||
group: "string?",
|
||||
status: "string?",
|
||||
floor: "string?",
|
||||
area: "string?",
|
||||
domain: "string?",
|
||||
label: "string?",
|
||||
config_entry: "string?",
|
||||
sub_entry: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
export const getMyRedirects = (): Redirects => ({
|
||||
application_credentials: {
|
||||
redirect: "/config/application_credentials",
|
||||
@@ -235,7 +220,6 @@ export const getMyRedirects = (): Redirects => ({
|
||||
},
|
||||
devices: {
|
||||
redirect: "/config/devices/dashboard",
|
||||
params: devicesTableParams,
|
||||
},
|
||||
entities: {
|
||||
redirect: "/config/entities",
|
||||
|
||||
@@ -856,6 +856,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 +1319,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 +4904,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…",
|
||||
|
||||
@@ -107,31 +107,6 @@ describe("QueryParamsController", () => {
|
||||
expect(navigated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("follows the router replacing the URL with its default page", () => {
|
||||
setUrl("/page/dashboard?other=1");
|
||||
window.dispatchEvent(
|
||||
Object.assign(new Event("location-changed"), {
|
||||
detail: { replace: true },
|
||||
})
|
||||
);
|
||||
expect(navigated).toHaveBeenCalledOnce();
|
||||
|
||||
controller.write({ search: "abc" });
|
||||
expect(currentUrl()).toBe("/page/dashboard?other=1&search=abc");
|
||||
});
|
||||
|
||||
it("does nothing once a link pushes a page below it", () => {
|
||||
setUrl("/page/item?other=1");
|
||||
window.dispatchEvent(
|
||||
Object.assign(new Event("location-changed"), {
|
||||
detail: { replace: false },
|
||||
})
|
||||
);
|
||||
controller.write({ search: "abc" });
|
||||
expect(currentUrl()).toBe("/page/item?other=1");
|
||||
expect(navigated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when not enabled", () => {
|
||||
enabled = false;
|
||||
controller.write({ search: "abc" });
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
devicesFilterQueryParamConfig,
|
||||
devicesFilterQueryParams,
|
||||
devicesLinkFromUrl,
|
||||
} from "../../../src/common/url/devices-query-params";
|
||||
import { createQueryString } from "../../../src/common/url/query-params";
|
||||
|
||||
describe("devices query params", () => {
|
||||
it("reads nothing without table params", () => {
|
||||
expect(devicesLinkFromUrl("?historyBack=1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("replaces every filter and the search with what a link asks for", () => {
|
||||
expect(devicesLinkFromUrl("?config_entry=abc&label=a,b")).toEqual({
|
||||
search: "",
|
||||
filters: {
|
||||
"ha-filter-states": { value: [], items: undefined },
|
||||
"ha-filter-floor-areas": { value: undefined, items: undefined },
|
||||
"ha-filter-integrations": { value: [], items: undefined },
|
||||
"ha-filter-labels": { value: ["a", "b"], items: undefined },
|
||||
config_entry: { value: ["abc"], items: undefined },
|
||||
sub_entry: { value: [], items: undefined },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("round trips the filters", () => {
|
||||
const filters = {
|
||||
"ha-filter-states": { value: ["disabled"], items: undefined },
|
||||
"ha-filter-floor-areas": {
|
||||
value: { floors: ["ground"], areas: ["kitchen"] },
|
||||
items: undefined,
|
||||
},
|
||||
"ha-filter-integrations": { value: ["hue"], items: undefined },
|
||||
"ha-filter-labels": { value: ["a"], items: undefined },
|
||||
config_entry: { value: ["abc"], items: undefined },
|
||||
sub_entry: { value: ["def"], items: undefined },
|
||||
};
|
||||
|
||||
expect(
|
||||
devicesLinkFromUrl(
|
||||
createQueryString(
|
||||
devicesFilterQueryParams(filters),
|
||||
devicesFilterQueryParamConfig
|
||||
)
|
||||
)
|
||||
).toEqual({ search: "", filters });
|
||||
});
|
||||
|
||||
it("writes the values, not the items the filters found", () => {
|
||||
expect(
|
||||
devicesFilterQueryParams({
|
||||
"ha-filter-floor-areas": {
|
||||
value: { areas: ["kitchen"] },
|
||||
items: new Set(["device_1"]),
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
status: undefined,
|
||||
floor: undefined,
|
||||
area: ["kitchen"],
|
||||
domain: undefined,
|
||||
label: undefined,
|
||||
config_entry: undefined,
|
||||
sub_entry: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user