Compare commits

...
13 changed files with 312 additions and 85 deletions

No files matched your search

@@ -71,6 +71,9 @@ export class QueryParamsController<
mainWindow.removeEventListener("location-changed", this._navigated);
mainWindow.removeEventListener("popstate", this._navigated);
this._cancelPending();
// A detached host can still get late events, like a filter that finished
// a lookup, and must not write over the page that is open now
this._path = undefined;
}
/** The current search params, or undefined when not on the host's page. */
+110
View File
@@ -0,0 +1,110 @@
import type {
DataTableFiltersValue,
DataTableFiltersValues,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
decodeQueryParams,
hasQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
// The filters of the entities table. Search, sorting and grouping are kept
// by the data table, see data-table-query-params.
export const entitiesFilterQueryParamConfig = {
list: [
"status",
"floor",
"area",
"device",
"domain",
"entity_domain",
"label",
"assistants",
"config_entry",
"sub_entry",
// Read until 2027.5, replaced with `assistants` on the next write
"voice_assistant",
],
} as const satisfies QueryParamConfig;
export type EntitiesFilterQueryParams = QueryParamValues<
typeof entitiesFilterQueryParamConfig
>;
const list = (value: DataTableFiltersValue): string[] | undefined =>
Array.isArray(value) ? value : undefined;
const floorAreas = (
value: DataTableFiltersValue
): Record<string, string[]> | undefined =>
value && !Array.isArray(value) ? value : undefined;
export const entitiesFilterQueryParams = (
filters: DataTableFiltersValues
): EntitiesFilterQueryParams => ({
status: list(filters["ha-filter-states"]),
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
device: list(filters["ha-filter-devices"]),
domain: list(filters["ha-filter-integrations"]),
entity_domain: list(filters["ha-filter-domains"]),
label: list(filters["ha-filter-labels"]),
assistants: list(filters["ha-filter-voice-assistants"]),
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.
export interface EntitiesLink {
search: string;
filters: DataTableFiltersValues;
}
export const entitiesLinkFromUrl = (
searchParams: SearchParamsSource
): EntitiesLink | undefined => {
if (
!hasQueryParams(searchParams, entitiesFilterQueryParamConfig) &&
!hasQueryParams(searchParams, dataTableQueryParamConfig)
) {
return undefined;
}
const params = decodeQueryParams(
searchParams,
entitiesFilterQueryParamConfig
);
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof EntitiesFilterQueryParams): string[] =>
params[key]?.filter(Boolean) ?? [];
const floors = values("floor");
const areas = values("area");
const assistants = values("assistants");
return {
search: search ?? "",
filters: {
"ha-filter-states": values("status"),
"ha-filter-floor-areas":
floors.length || areas.length ? { floors, areas } : undefined,
"ha-filter-devices": values("device"),
"ha-filter-integrations": values("domain"),
"ha-filter-domains": values("entity_domain"),
"ha-filter-labels": values("label"),
"ha-filter-voice-assistants": assistants.length
? assistants
: values("voice_assistant"),
config_entry: values("config_entry"),
sub_entry: values("sub_entry"),
},
};
};
@@ -38,6 +38,7 @@ export function getDomainTableColumn<T>(
title: localize("ui.panel.config.generic.headers.domain"),
hidden: true,
groupable: true,
urlName: "entity_domain",
filterable: true,
sortable: false,
};
@@ -49,6 +50,7 @@ export function getAreaTableColumn<T>(
return {
title: localize("ui.panel.config.generic.headers.area"),
groupable: true,
urlName: "area",
filterable: true,
sortable: true,
minWidth: "120px",
@@ -62,6 +64,7 @@ export function getFloorTableColumn<T>(
title: localize("ui.panel.config.generic.headers.floor"),
defaultHidden: true,
groupable: true,
urlName: "floor",
filterable: true,
sortable: true,
minWidth: "120px",
@@ -75,6 +78,7 @@ export function getCategoryTableColumn<T>(
title: localize("ui.panel.config.generic.headers.category"),
defaultHidden: true,
groupable: true,
urlName: "category",
filterable: true,
sortable: true,
};
@@ -19,6 +19,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoize 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";
@@ -37,9 +38,13 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { getHistoryState, updateHistoryState } from "../../../common/navigate";
import { slugify } from "../../../common/string/slugify";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
entitiesFilterQueryParamConfig,
entitiesFilterQueryParams,
entitiesLinkFromUrl,
} from "../../../common/url/entities-query-params";
import {
hasRejectedItems,
rejectedItems,
@@ -154,6 +159,16 @@ export interface EntityRow extends StateEntity {
available: string;
}
// Local variant of data/expose/getExposedEntityVoiceAssistantIds, which works
// for entities both with and without a unique ID
const getVoiceAssistantIds = (
exposedEntities: Record<string, ExposeEntitySettings> | undefined,
entityId: string
) =>
Object.keys(voiceAssistants).filter(
(vaId) => exposedEntities?.[entityId]?.[vaId]
);
@customElement("ha-config-entities")
export class HaConfigEntities extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -190,9 +205,7 @@ export class HaConfigEntities extends LitElement {
state: true,
subscribe: false,
})
private _filter: string = getHistoryState()?.filter || "";
@state() private _searchParms = new URLSearchParams(window.location.search);
private _filter = "";
@state()
private _filters: DataTableFiltersValues = {};
@@ -251,12 +264,19 @@ export class HaConfigEntities extends LitElement {
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
private _fromUrl = false;
// The filters are kept in the URL by the page, the search, sorting and
// grouping by the table
private _filterParams = new QueryParamsController(
this,
entitiesFilterQueryParamConfig,
{
enabled: () => true,
navigated: () => this._applyLink(),
}
);
public connectedCallback() {
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
window.addEventListener(
"exposed-entities-changed",
this._fetchExposedEntities
@@ -265,28 +285,12 @@ export class HaConfigEntities extends LitElement {
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
window.removeEventListener("popstate", this._popState);
window.removeEventListener(
"exposed-entities-changed",
this._fetchExposedEntities
);
}
private _locationChanged = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _popState = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _states = memoize((localize: LocalizeFunc) => [
{
value: "available",
@@ -368,6 +372,7 @@ export class HaConfigEntities extends LitElement {
title: localize("ui.panel.config.entities.picker.headers.device"),
filterable: true,
groupable: true,
urlName: "device",
hidden: true,
},
area: getAreaTableColumn(localize),
@@ -376,6 +381,7 @@ export class HaConfigEntities extends LitElement {
title: localize("ui.panel.config.entities.picker.headers.integration"),
sortable: true,
groupable: true,
urlName: "integration",
filterable: true,
},
domain: getDomainTableColumn(localize),
@@ -462,18 +468,21 @@ export class HaConfigEntities extends LitElement {
title: localize("ui.panel.config.entities.picker.headers.availability"),
sortable: true,
groupable: true,
urlName: "available",
hidden: true,
},
visible: {
title: localize("ui.panel.config.entities.picker.headers.visibility"),
sortable: true,
groupable: true,
urlName: "visible",
hidden: true,
},
enabled: {
title: localize("ui.panel.config.entities.picker.headers.enabled"),
sortable: true,
groupable: true,
urlName: "enabled",
hidden: true,
},
labels: getLabelsTableColumn(),
@@ -495,6 +504,7 @@ export class HaConfigEntities extends LitElement {
entitiesWithoutUniqueId: StateEntity[],
filters: DataTableFiltersValues,
filteredItems: DataTableFiltersItems,
exposedEntities?: Record<string, ExposeEntitySettings>,
entries?: ConfigEntry[],
labelReg?: LabelRegistryEntry[]
) => {
@@ -554,19 +564,21 @@ export class HaConfigEntities extends LitElement {
Array.isArray(filter) &&
filter.length
) {
if (!(
Array.isArray(this._filters.config_entry) &&
this._filters.config_entry.length === 1
)) {
const configEntryIds = this._filters.config_entry;
if (!Array.isArray(configEntryIds) || !configEntryIds.length) {
return;
}
filteredEntities = filteredEntities.filter(
(entity) =>
entity.config_subentry_id &&
(filter as string[]).includes(entity.config_subentry_id)
);
if (!this._subEntries) {
this._loadSubEntries(this._filters.config_entry[0]);
// 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" &&
@@ -632,8 +644,8 @@ export class HaConfigEntities extends LitElement {
filter.length
) {
filteredEntities = filteredEntities.filter((entity) =>
this._getExposedEntityVoiceAssistantIds(entity.entity_id).some(
(va) => (filter as string[]).includes(va)
getVoiceAssistantIds(exposedEntities, entity.entity_id).some((va) =>
(filter as string[]).includes(va)
)
);
}
@@ -689,7 +701,9 @@ export class HaConfigEntities extends LitElement {
? `${deviceName} (${areaName})`
: deviceName
: undefined;
const assistants = this._getExposedEntityVoiceAssistantIds(
const assistants = getVoiceAssistantIds(
exposedEntities,
entry.entity_id
);
@@ -793,6 +807,7 @@ export class HaConfigEntities extends LitElement {
this._entitiesWithoutUniqueId,
this._filters,
this._filteredItems,
this._exposedEntities,
this._entries,
this._labels
);
@@ -840,6 +855,7 @@ export class HaConfigEntities extends LitElement {
@collapsed-changed=${this._handleCollapseChanged}
@selection-changed=${this._handleSelectionChanged}
clickable
sync-url
@clear-filter=${this._clearFilter}
.filter=${this._filter}
@search-changed=${this._handleSearchChange}
@@ -1072,59 +1088,41 @@ export class HaConfigEntities extends LitElement {
this._filters = { ...this._filters, [type]: ev.detail.value };
this._filteredItems = { ...this._filteredItems, [type]: ev.detail.items };
if (!this._fromUrl) {
this._storageFilters = this._filters;
}
this._storageFilters = this._filters;
this._writeFilters();
}
protected firstUpdated() {
this._setFiltersFromUrl();
this._fetchExposedEntities();
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
if (Object.keys(this._filters).length) {
return;
}
this._filters = {
"ha-filter-states": ["enabled"],
};
}
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 device = this._searchParms.get("device");
const label = this._searchParms.get("label");
const voiceAssistant = this._searchParms.get("voice_assistant");
// Links, like the ones on a device or 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 ? entitiesLinkFromUrl(searchParams) : undefined;
if (!area && !domain && !configEntry && !label && !device) {
return;
if (link) {
this._filter = link.search;
this._filters = link.filters;
this._storageFilters = link.filters;
}
this._fromUrl = true;
this._filter = getHistoryState()?.filter || "";
this._writeFilters();
}
this._filters = {
"ha-filter-states": [],
"ha-filter-floor-areas": area ? { areas: [area] } : undefined,
"ha-filter-integrations": domain ? [domain] : [],
"ha-filter-devices": device ? [device] : [],
"ha-filter-labels": label ? [label] : [],
"ha-filter-voice-assistants": voiceAssistant ? [voiceAssistant] : [],
config_entry: configEntry ? [configEntry] : [],
sub_entry: subEntry ? [subEntry] : [],
};
private _writeFilters() {
this._filterParams.write(entitiesFilterQueryParams(this._filters));
}
private _clearFilter() {
this._filters = {};
this._filteredItems = {};
if (!this._fromUrl) {
this._storageFilters = {};
}
this._storageFilters = {};
this._writeFilters();
}
private _fetchExposedEntities = async () => {
@@ -1139,14 +1137,6 @@ export class HaConfigEntities extends LitElement {
}
};
// local variant of data/expose/getExposedEntityVoiceAssistantIds
// which works for both entities with and without unique id
private _getExposedEntityVoiceAssistantIds(entityId: string) {
return Object.keys(voiceAssistants).filter(
(vaId) => this._exposedEntities?.[entityId]?.[vaId]
);
}
private _getExposedEntitySettingsAsOptions(
entityId: string
): EntityRegistryOptions {
@@ -1165,8 +1155,10 @@ export class HaConfigEntities extends LitElement {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._filters = this._storageFilters;
this._setFiltersFromUrl();
this._filters = Object.keys(this._storageFilters).length
? this._storageFilters
: { "ha-filter-states": ["enabled"] };
this._applyLink();
}
const oldHass = changedProps.get("hass");
@@ -1240,7 +1232,6 @@ export class HaConfigEntities extends LitElement {
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
updateHistoryState({ filter: this._filter });
}
private _handleSelectionChanged(
@@ -1541,6 +1532,7 @@ ${rejected
this._entitiesWithoutUniqueId,
this._filters,
this._filteredItems,
this._exposedEntities,
this._entries,
this._labels
);
@@ -1555,8 +1547,13 @@ ${rejected
});
return;
}
const integrations = this._filters["ha-filter-integrations"];
showAddIntegrationDialog(this, {
domain: this._searchParms.get("domain") || undefined,
domain:
Array.isArray(integrations) && integrations.length === 1
? integrations[0]
: undefined,
navigateToResult: true,
});
}
@@ -262,7 +262,7 @@ class HaConfigEntryDeviceRow extends LitElement {
};
private _handleNavigateToEntities = () => {
navigate(`/config/entities/?historyBack=1&device=${this.device.id}`);
navigate(`/config/entities?historyBack=1&device=${this.device.id}`);
};
private _doDisableDevice = async () => {
@@ -156,7 +156,7 @@ export class HaConfigEntryRow extends LitElement {
} else if (!allDevices.length && !allServices.length && entities.length) {
statusLine.push(
html`<a
href=${`/config/entities/?historyBack=1&config_entry=${item.entry_id}`}
href=${`/config/entities?historyBack=1&config_entry=${item.entry_id}`}
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
@@ -190,7 +190,7 @@ export class MatterConfigDashboard extends LitElement {
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
href=${`/config/entities?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
<div slot="headline">
@@ -232,7 +232,7 @@ export class MQTTConfigPanel extends LitElement {
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
href=${`/config/entities?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
<div slot="headline">
@@ -249,7 +249,7 @@ class ZHAConfigDashboard extends LitElement {
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
href=${`/config/entities?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
<div slot="headline">
@@ -338,7 +338,7 @@ class ZWaveJSConfigDashboard extends LitElement {
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this.configEntryId}`}
href=${`/config/entities?historyBack=1&config_entry=${this.configEntryId}`}
>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
<div slot="headline">
+19
View File
@@ -31,6 +31,24 @@ const marketplaceTableParams = {
direction: "string?",
} satisfies Redirect["params"];
// The entities table reads these from the link
const entitiesTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
group: "string?",
status: "string?",
floor: "string?",
area: "string?",
device: "string?",
domain: "string?",
entity_domain: "string?",
label: "string?",
assistants: "string?",
config_entry: "string?",
sub_entry: "string?",
} satisfies Redirect["params"];
export const getMyRedirects = (): Redirects => ({
application_credentials: {
redirect: "/config/application_credentials",
@@ -223,6 +241,7 @@ export const getMyRedirects = (): Redirects => ({
},
entities: {
redirect: "/config/entities",
params: entitiesTableParams,
},
labels: {
redirect: "/config/labels",
@@ -107,6 +107,17 @@ describe("QueryParamsController", () => {
expect(navigated).not.toHaveBeenCalled();
});
it("does nothing once disconnected, even on the same page", () => {
controller.hostDisconnected();
controller.write({ search: "abc" });
expect(currentUrl()).toBe("/page?other=1");
expect(controller.searchParams()).toBeUndefined();
controller.hostConnected();
controller.write({ search: "abc" });
expect(currentUrl()).toBe("/page?other=1&search=abc");
});
it("does nothing when not enabled", () => {
enabled = false;
controller.write({ search: "abc" });
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
entitiesFilterQueryParamConfig,
entitiesFilterQueryParams,
entitiesLinkFromUrl,
} from "../../../src/common/url/entities-query-params";
import {
createQueryString,
updateQueryString,
} from "../../../src/common/url/query-params";
describe("entities query params", () => {
it("reads nothing without table params", () => {
expect(entitiesLinkFromUrl("?historyBack=1")).toBeUndefined();
});
it("replaces every filter and the search with what a link asks for", () => {
expect(entitiesLinkFromUrl("?device=abc&label=a,b")).toEqual({
search: "",
filters: {
"ha-filter-states": [],
"ha-filter-floor-areas": undefined,
"ha-filter-devices": ["abc"],
"ha-filter-integrations": [],
"ha-filter-domains": [],
"ha-filter-labels": ["a", "b"],
"ha-filter-voice-assistants": [],
config_entry: [],
sub_entry: [],
},
});
});
it("still reads voice_assistant, with assistants winning", () => {
expect(
entitiesLinkFromUrl("?voice_assistant=conversation")?.filters[
"ha-filter-voice-assistants"
]
).toEqual(["conversation"]);
expect(
entitiesLinkFromUrl(
"?voice_assistant=conversation&assistants=cloud.alexa"
)?.filters["ha-filter-voice-assistants"]
).toEqual(["cloud.alexa"]);
});
it("replaces voice_assistant with assistants on write", () => {
const url = "?historyBack=1&voice_assistant=conversation";
const link = entitiesLinkFromUrl(url);
expect(
updateQueryString(
url,
entitiesFilterQueryParams(link?.filters ?? {}),
entitiesFilterQueryParamConfig
)
).toBe("historyBack=1&assistants=conversation");
});
it("round trips the filters", () => {
const filters = {
"ha-filter-states": ["enabled", "hidden"],
"ha-filter-floor-areas": { floors: ["ground"], areas: ["kitchen"] },
"ha-filter-devices": ["abc"],
"ha-filter-integrations": ["hue"],
"ha-filter-domains": ["light"],
"ha-filter-labels": ["a"],
"ha-filter-voice-assistants": ["conversation"],
config_entry: ["entry"],
sub_entry: ["sub"],
};
expect(
entitiesLinkFromUrl(
createQueryString(
entitiesFilterQueryParams(filters),
entitiesFilterQueryParamConfig
)
)
).toEqual({ search: "", filters });
});
});