Compare commits

...
4 changed files with 277 additions and 69 deletions

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
import type {
DataTableFiltersValue,
DataTableFiltersValues,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
decodeQueryParams,
hasQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
// The filters of the helpers table. Search, sorting and grouping are kept by
// the data table, see data-table-query-params.
export const helpersFilterQueryParamConfig = {
list: [
"floor",
"area",
"device",
"label",
"category",
"assistants",
// Read until 2027.5, replaced with `assistants` on the next write
"voice_assistant",
],
} as const satisfies QueryParamConfig;
export type HelpersFilterQueryParams = QueryParamValues<
typeof helpersFilterQueryParamConfig
>;
const list = (value: DataTableFiltersValue): string[] | undefined =>
Array.isArray(value) ? value : undefined;
const floorAreas = (
value: DataTableFiltersValue
): Record<string, string[]> | undefined =>
value && !Array.isArray(value) ? value : undefined;
export const helpersFilterQueryParams = (
filters: DataTableFiltersValues
): HelpersFilterQueryParams => ({
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
device: list(filters["ha-filter-devices"]),
label: list(filters["ha-filter-labels"]),
category: list(filters["ha-filter-categories"]),
assistants: list(filters["ha-filter-voice-assistants"]),
});
// What a link says to show, undefined when it has no table params so what was
// picked before stays. A link replaces the search and the filters, ones left
// behind would hide part of what it shows. The table applies its sorting and
// grouping.
export interface HelpersLink {
search: string;
filters: DataTableFiltersValues;
}
export const helpersLinkFromUrl = (
searchParams: SearchParamsSource
): HelpersLink | undefined => {
if (
!hasQueryParams(searchParams, helpersFilterQueryParamConfig) &&
!hasQueryParams(searchParams, dataTableQueryParamConfig)
) {
return undefined;
}
const params = decodeQueryParams(searchParams, helpersFilterQueryParamConfig);
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof HelpersFilterQueryParams): string[] =>
params[key]?.filter(Boolean) ?? [];
const floors = values("floor");
const areas = values("area");
const assistants = values("assistants");
return {
search: search ?? "",
filters: {
"ha-filter-floor-areas":
floors.length || areas.length ? { floors, areas } : undefined,
"ha-filter-devices": values("device"),
"ha-filter-labels": values("label"),
"ha-filter-categories": values("category"),
"ha-filter-voice-assistants": assistants.length
? assistants
: values("voice_assistant"),
},
};
};
+93 -69
View File
@@ -16,20 +16,27 @@ import {
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
import { consume } from "../../../common/decorators/consume";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import { mainWindow } from "../../../common/dom/get_main_window";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { getHistoryState, navigate } from "../../../common/navigate";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import { currentPath } from "../../../common/url/current-path";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../../common/translations/localize";
import { extractSearchParam } from "../../../common/url/search-params";
import {
helpersFilterQueryParamConfig,
helpersFilterQueryParams,
helpersLinkFromUrl,
} from "../../../common/url/helpers-query-params";
import { debounce } from "../../../common/util/debounce";
import {
hasRejectedItems,
@@ -107,6 +114,7 @@ import {
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
@@ -237,8 +245,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@state() private _diagnosticHandlers?: Record<string, boolean>;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state()
private _filters: DataTableFiltersValues = {};
@@ -267,10 +273,25 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@state() private _filteredHelperEntityIds?: string[] | null;
// Not cached, the table only renders once the helpers have loaded
@query("hass-tabs-subpage-data-table")
private _dataTable?: HaTabsSubpageDataTable;
private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width,
});
// The filters are kept in the URL by the page, the search, sorting and
// grouping by the table
private _filterParams = new QueryParamsController(
this,
helpersFilterQueryParamConfig,
{
enabled: () => true,
navigated: () => this._applyLink(),
}
);
private get _availableAssistants() {
return getAvailableAssistants(this.cloudStatus, this.hass);
}
@@ -367,6 +388,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
sortable: true,
filterable: true,
groupable: true,
urlName: "type",
},
editable: getEditableTableColumn(
localize,
@@ -682,6 +704,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
.activeFilters=${this._activeFilters}
@clear-filter=${this._clearFilter}
@row-click=${this._openEditDialog}
sync-url
.filter=${this._filter}
@search-changed=${this._handleSearchChange}
has-fab
@@ -854,9 +877,8 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
this._filters = { ...this._filters, [type]: ev.detail.value };
this._filteredItems = { ...this._filteredItems, [type]: ev.detail.items };
if (!this._fromUrl) {
this._storageFilters = this._filters;
}
this._storageFilters = this._filters;
this._writeFilters();
this._applyFilters();
}
@@ -916,16 +938,16 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
) {
const categoryItems = new Set<string>();
this._helperEntities
.filter(
(stateItem) =>
filter[0] ===
entityRegistryByEntityId(this._entityReg || [])[
stateItem.entity_id
]?.categories.helpers
)
.filter((stateItem) => {
const category = entityRegistryByEntityId(this._entityReg || [])[
stateItem.entity_id
]?.categories.helpers;
return category !== undefined && filter.includes(category);
})
.forEach((stateItem) => categoryItems.add(stateItem.entity_id));
(this._disabledEntityEntries || [])
.filter((entry) => filter[0] === entry.categories.helpers)
.filter((entry) => filter.includes(entry.categories.helpers))
.forEach((entry) => categoryItems.add(entry.entity_id));
if (!items) {
items = categoryItems;
@@ -972,63 +994,56 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
this._filteredHelperEntityIds = items ? [...items] : undefined;
}
private _fromUrl = false;
// The domain an add link asked for, started once the page has hass
private _addDomain?: string;
public connectedCallback() {
// Leave the add path before the URL is read, so the table params are kept
// on the helpers page itself. The route isn't set yet on the first
// connect, so this reads the URL. navigate() would only replace it after
// the table params are written, and drop them.
if (currentPath() === "/config/helpers/add") {
const params = new URLSearchParams(mainWindow.location.search);
this._addDomain = params.get("domain") ?? undefined;
params.delete("domain");
const queryString = params.toString() ? `?${params}` : "";
replaceCurrentUrl(
__DEMO__
? `${mainWindow.location.pathname}${queryString}#/config/helpers`
: `/config/helpers${queryString}`
);
fireEvent(mainWindow, "location-changed", { replace: true });
// Opened once the table is there, see updated()
this.requestUpdate();
}
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
window.removeEventListener("popstate", this._popState);
// Links, like the ones on a device page, say in the URL what to show. The
// router keeps the page open, so a link can land on it.
private _applyLink() {
const searchParams = this._filterParams.searchParams();
const link = searchParams ? helpersLinkFromUrl(searchParams) : undefined;
if (link) {
this._filter = link.search;
this._filters = link.filters;
this._storageFilters = link.filters;
this._applyFilters();
}
this._writeFilters();
}
private _locationChanged = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _popState = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _setFiltersFromUrl() {
const area = this._searchParms.get("area");
const device = this._searchParms.get("device");
const label = this._searchParms.get("label");
const category = this._searchParms.get("category");
const voiceAssistant = this._searchParms.get("voice_assistant");
if (!area && !category && !label && !device && !voiceAssistant) {
return;
}
this._fromUrl = true;
this._filter = getHistoryState()?.filter || "";
this._filters = {
"ha-filter-floor-areas": area ? { areas: [area] } : undefined,
"ha-filter-devices": device ? [device] : [],
"ha-filter-labels": label ? [label] : [],
"ha-filter-categories": category ? [category] : [],
"ha-filter-voice-assistants": voiceAssistant ? [voiceAssistant] : [],
};
private _writeFilters() {
this._filterParams.write(helpersFilterQueryParams(this._filters));
}
private _clearFilter() {
this._filters = {};
this._filteredItems = {};
if (!this._fromUrl) {
this._storageFilters = {};
}
this._storageFilters = {};
this._writeFilters();
this._applyFilters();
}
@@ -1141,7 +1156,6 @@ ${rejected
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._setFiltersFromUrl();
this._fetchEntitySources();
if (isComponentLoaded(this.hass.config, "diagnostics")) {
@@ -1153,9 +1167,13 @@ ${rejected
this._diagnosticHandlers = handlers;
});
}
}
if (this.route.path === "/add") {
this._handleAdd();
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (this._addDomain && this._dataTable) {
this._handleAdd(this._dataTable);
}
}
@@ -1194,12 +1212,18 @@ ${rejected
this._entitySource = entityDomains;
}
private async _handleAdd() {
const domain = extractSearchParam("domain");
navigate("/config/helpers", { replace: true });
private async _handleAdd(dataTable: HaTabsSubpageDataTable) {
const domain = this._addDomain;
this._addDomain = undefined;
if (!domain) {
return;
}
// The dialog adds a history entry. Let the table write its search,
// sorting and grouping first, or they would only be on that entry and
// be lost when it closes.
await dataTable.updateComplete;
if (isHelperDomain(domain)) {
showHelperDetailDialog(this, {
domain,
@@ -1254,7 +1278,7 @@ ${rejected
if (!this.hasUpdated) {
this._filters = this._storageFilters;
this._setFiltersFromUrl();
this._applyLink();
}
if (
+15
View File
@@ -31,6 +31,20 @@ const marketplaceTableParams = {
direction: "string?",
} satisfies Redirect["params"];
// The helpers table reads these from the link
const helpersTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
group: "string?",
floor: "string?",
area: "string?",
device: "string?",
label: "string?",
category: "string?",
assistants: "string?",
} satisfies Redirect["params"];
export const getMyRedirects = (): Redirects => ({
application_credentials: {
redirect: "/config/application_credentials",
@@ -259,6 +273,7 @@ export const getMyRedirects = (): Redirects => ({
},
helpers: {
redirect: "/config/helpers",
params: helpersTableParams,
},
labs: {
redirect: "/config/labs",
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
helpersFilterQueryParamConfig,
helpersFilterQueryParams,
helpersLinkFromUrl,
} from "../../../src/common/url/helpers-query-params";
import {
createQueryString,
updateQueryString,
} from "../../../src/common/url/query-params";
describe("helpers query params", () => {
it("reads nothing without table params", () => {
expect(helpersLinkFromUrl("?domain=input_boolean")).toBeUndefined();
});
it("replaces every filter and the search with what a link asks for", () => {
expect(helpersLinkFromUrl("?category=abc&label=a,b")).toEqual({
search: "",
filters: {
"ha-filter-floor-areas": undefined,
"ha-filter-devices": [],
"ha-filter-labels": ["a", "b"],
"ha-filter-categories": ["abc"],
"ha-filter-voice-assistants": [],
},
});
});
it("still reads voice_assistant, with assistants winning", () => {
expect(
helpersLinkFromUrl("?voice_assistant=conversation")?.filters[
"ha-filter-voice-assistants"
]
).toEqual(["conversation"]);
expect(
helpersLinkFromUrl("?voice_assistant=conversation&assistants=cloud.alexa")
?.filters["ha-filter-voice-assistants"]
).toEqual(["cloud.alexa"]);
});
it("replaces voice_assistant with assistants on write", () => {
const url = "?historyBack=1&voice_assistant=conversation";
const link = helpersLinkFromUrl(url);
expect(
updateQueryString(
url,
helpersFilterQueryParams(link?.filters ?? {}),
helpersFilterQueryParamConfig
)
).toBe("historyBack=1&assistants=conversation");
});
it("round trips the filters", () => {
const filters = {
"ha-filter-floor-areas": { floors: ["ground"], areas: ["kitchen"] },
"ha-filter-devices": ["abc"],
"ha-filter-labels": ["a"],
"ha-filter-categories": ["cat"],
"ha-filter-voice-assistants": ["conversation"],
};
expect(
helpersLinkFromUrl(
createQueryString(
helpersFilterQueryParams(filters),
helpersFilterQueryParamConfig
)
)
).toEqual({ search: "", filters });
});
});