Compare commits

...
Author SHA1 Message Date
Aidan Timson c2eca29c60 Keep one category from automations links 2026-10-22 15:54:20 +01:00
Aidan Timson df3c44bbcf Add query params to the automations table 2026-10-22 15:54:20 +01:00
4 changed files with 322 additions and 144 deletions

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
import type {
DataTableFilter,
DataTableFilters,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
createQueryString,
decodeQueryParams,
hasQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
// The filters of the automations table. Search, sorting and grouping are kept
// by the data table, see data-table-query-params.
export const automationsFilterQueryParamConfig = {
list: [
"floor",
"area",
"device",
"entity",
"label",
"category",
"assistants",
"blueprint",
],
} as const satisfies QueryParamConfig;
export type AutomationsFilterQueryParams = QueryParamValues<
typeof automationsFilterQueryParamConfig
>;
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 automationsFilterQueryParams = (
filters: DataTableFilters
): AutomationsFilterQueryParams => ({
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
device: list(filters["ha-filter-devices"]),
entity: list(filters["ha-filter-entities"]),
label: list(filters["ha-filter-labels"]),
category: list(filters["ha-filter-categories"]),
assistants: list(filters["ha-filter-voice-assistants"]),
blueprint: list(filters["ha-filter-blueprints"]),
});
// The filters as the URL holds them, so values that filter the same, such as
// no list and an empty one, or a list in another order, compare equal. A
// filter list sends back what it was given in its own order.
export const automationsFilterQueryString = (
filters: DataTableFilters
): string => {
const params = automationsFilterQueryParams(filters);
const sorted: AutomationsFilterQueryParams = {};
for (const key of automationsFilterQueryParamConfig.list) {
sorted[key] = params[key] && [...params[key]].sort();
}
return createQueryString(sorted, automationsFilterQueryParamConfig);
};
// 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 AutomationsLink {
search: string;
filters: DataTableFilters;
}
export const automationsLinkFromUrl = (
searchParams: SearchParamsSource
): AutomationsLink | undefined => {
if (
!hasQueryParams(searchParams, automationsFilterQueryParamConfig) &&
!hasQueryParams(searchParams, dataTableQueryParamConfig)
) {
return undefined;
}
const params = decodeQueryParams(
searchParams,
automationsFilterQueryParamConfig
);
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof AutomationsFilterQueryParams): 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-floor-areas": filter(
floors.length || areas.length ? { floors, areas } : undefined
),
"ha-filter-devices": filter(values("device")),
"ha-filter-entities": filter(values("entity")),
"ha-filter-labels": filter(values("label")),
// The category filter picks one category
"ha-filter-categories": filter(values("category").slice(0, 1)),
"ha-filter-voice-assistants": filter(values("assistants")),
"ha-filter-blueprints": filter(values("blueprint")),
},
};
};
@@ -26,6 +26,8 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
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";
@@ -34,6 +36,13 @@ import { fireEvent } from "../../../common/dom/fire_event";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
automationsFilterQueryParamConfig,
automationsFilterQueryParams,
automationsFilterQueryString,
automationsLinkFromUrl,
} from "../../../common/url/automations-query-params";
import { deepEqual } from "../../../common/util/deep-equal";
import {
hasRejectedItems,
rejectedItems,
@@ -43,7 +52,6 @@ import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/ha-button";
@@ -83,7 +91,10 @@ import {
} from "../../../data/category_registry";
import type { CloudStatus } from "../../../data/cloud";
import { fullEntitiesContext, labelsContext } from "../../../data/context";
import type { DataTableFilters } from "../../../data/data_table_filters";
import type {
DataTableFilter,
DataTableFilters,
} from "../../../data/data_table_filters";
import {
deserializeFilters,
isFilterUsed,
@@ -99,7 +110,6 @@ import { updateEntityRegistryEntry } from "../../../data/entity/entity_registry"
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
import { createLabelRegistryEntry } from "../../../data/label/label_registry";
import { findRelated } from "../../../data/search";
import {
showAlertDialog,
showConfirmationDialog,
@@ -177,18 +187,17 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public automations!: AutomationEntity[];
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _filteredEntityIds?: string[] | null;
@state()
@storage({
storage: "sessionStorage",
key: "automation-table-search",
state: true,
subscribe: false,
})
private _filter = "";
private _tableSettings = new DataTableSettingsController(
this,
{
search: "automation-table-search",
sorting: "automation-table-sort",
grouping: "automation-table-grouping",
},
{ defaultGrouping: "category" }
);
@state()
private _filters: DataTableFilters = {};
@@ -203,7 +212,16 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
})
private _storageFilters: DataTableFilters = {};
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,
automationsFilterQueryParamConfig,
{
enabled: () => true,
navigated: () => this._applyLink(),
}
);
@state() private _expandedFilter?: string;
@@ -222,12 +240,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
@state() private _overflowAutomation?: AutomationItem;
@storage({ key: "automation-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "automation-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({
key: "automation-table-collapsed",
state: false,
@@ -399,6 +411,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
maxWidth: "82px",
sortable: true,
groupable: true,
urlName: "state",
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) =>
@@ -522,14 +535,14 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
).length
}
.columns=${this._columns(this.narrow, this.hass.localize, automations)}
.initialGroupColumn=${this._activeGrouping ?? "category"}
.initialGroupColumn=${this._tableSettings.grouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
.initialSorting=${this._tableSettings.sorting}
.columnOrder=${this._activeColumnOrder}
.hiddenColumns=${this._activeHiddenColumns}
@columns-changed=${this._handleColumnsChanged}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@sorting-changed=${this._tableSettings.sortingChanged}
@grouping-changed=${this._tableSettings.groupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
.data=${automations}
.empty=${!this.automations.length}
@@ -538,8 +551,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
"ui.panel.config.automation.picker.no_automations"
)}
@clear-filter=${this._clearFilter}
.filter=${this._filter}
@search-changed=${this._handleSearchChange}
.filter=${this._tableSettings.search}
@search-changed=${this._tableSettings.searchChanged}
sync-url
has-fab
clickable
class=${this.narrow ? "narrow" : ""}
@@ -846,29 +860,57 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
const hasUrlFilter =
this._searchParms.has("area") ||
this._searchParms.has("blueprint") ||
this._searchParms.has("device") ||
this._searchParms.has("label");
if (!hasUrlFilter) {
this._filters = this._storageFilters;
}
if (this._searchParms.has("area")) {
this._filterArea();
}
if (this._searchParms.has("device")) {
this._filterDevice();
}
if (this._searchParms.has("blueprint")) {
this._filterBlueprint();
}
if (this._searchParms.has("label")) {
this._filterLabel();
}
this._filters = this._storageFilters;
this._applyLink();
}
}
// Links, like the ones on a device or area 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
? automationsLinkFromUrl(searchParams)
: undefined;
// Shown, but not stored as the user's choice
if (link) {
this._tableSettings.showSearch(link.search);
this._filters = this._keepItems(link.filters);
this._applyFilters();
} else if (searchParams?.toString() === "") {
// Opened again without params, like from the sidebar. Written once the
// table has seen the URL without params, so it restores what it stored
this._filters = this._keepItems(this._storageFilters);
this._applyFilters();
queueMicrotask(() => this._writeFilters());
return;
}
this._writeFilters();
}
// The filters only work out their items again when their value changes, so
// keep the items of the ones that stay as they are
private _keepItems(filters: DataTableFilters): DataTableFilters {
return Object.fromEntries(
Object.entries(filters).map(([key, filter]) => {
const current = this._filters[key];
return [
key,
current && deepEqual(filter.value, current.value) ? current : filter,
];
})
);
}
private _writeFilters() {
this._filterParams.write(automationsFilterQueryParams(this._filters));
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
// The filtered list holds entity IDs, so also refresh it when automations
@@ -888,30 +930,31 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
private _labelClicked = (ev: CustomEvent) => {
const label = ev.detail.label;
this._filters = {
...this._filters,
"ha-filter-labels": {
value: [label.label_id],
items: undefined,
},
};
if (!this._fromUrl) {
this._storageFilters = this._filters;
}
this._applyFilters();
this._setFilter("ha-filter-labels", {
value: [label.label_id],
items: undefined,
});
};
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
private _filterChanged(ev) {
this._setFilter(ev.target.localName, ev.detail);
}
private _filterChanged(ev) {
const type = ev.target.localName;
this._filters = { ...this._filters, [type]: ev.detail };
if (!this._fromUrl) {
this._storageFilters = this._filters;
private _setFilter(type: string, filter: DataTableFilter) {
const filters = { ...this._filters, [type]: filter };
// A filter also sends the value it was given, once it has found its items.
// Only a change to what the URL holds is the user's, so a link isn't stored.
if (
automationsFilterQueryString(filters) !==
automationsFilterQueryString(this._filters)
) {
this._storageFilters = { ...this._storageFilters, [type]: filter };
}
this._filters = filters;
this._applyFilters();
this._writeFilters();
}
private _applyFilters() {
@@ -961,81 +1004,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
this._filteredEntityIds = filteredEntityIds;
}
private _filterArea() {
const area = this._searchParms.get("area");
if (!area) {
return;
}
this._fromUrl = true;
this._filters = {
...this._filters,
"ha-filter-floor-areas": {
value: { areas: [area] },
items: undefined,
},
};
this._applyFilters();
}
private _filterDevice() {
const device = this._searchParms.get("device");
if (!device) {
return;
}
this._fromUrl = true;
this._filters = {
...this._filters,
"ha-filter-devices": {
value: [device],
items: undefined,
},
};
this._applyFilters();
}
private _filterLabel() {
const label = this._searchParms.get("label");
if (!label) {
return;
}
this._fromUrl = true;
this._filters = {
...this._filters,
"ha-filter-labels": {
value: [label],
items: undefined,
},
};
this._applyFilters();
}
private async _filterBlueprint() {
const blueprint = this._searchParms.get("blueprint");
if (!blueprint) {
return;
}
this._fromUrl = true;
const related = await findRelated(
this.hass,
"automation_blueprint",
blueprint
);
this._filters = {
...this._filters,
"ha-filter-blueprints": {
value: [blueprint],
items: new Set(related.automation || []),
},
};
this._applyFilters();
}
private _clearFilter() {
this._filters = {};
if (!this._fromUrl) {
this._storageFilters = {};
}
this._storageFilters = {};
this._applyFilters();
this._writeFilters();
}
private _handleOverflowAction = (ev: HaDropdownSelectEvent) => {
@@ -1589,14 +1562,6 @@ ${rejected
}
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value ?? "";
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
+17
View File
@@ -124,6 +124,22 @@ const tagsTableParams = {
direction: "string?",
} satisfies Redirect["params"];
// The automations table reads these from the link
const automationsTableParams = {
search: "string?",
sort: "string?",
direction: "string?",
group: "string?",
floor: "string?",
area: "string?",
device: "string?",
entity: "string?",
label: "string?",
category: "string?",
assistants: "string?",
blueprint: "string?",
} satisfies Redirect["params"];
export const getMyRedirects = (): Redirects => ({
application_credentials: {
redirect: "/config/application_credentials",
@@ -354,6 +370,7 @@ export const getMyRedirects = (): Redirects => ({
automations: {
component: "automation",
redirect: "/config/automation/dashboard",
params: automationsTableParams,
},
scenes: {
component: "scene",
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
automationsFilterQueryParamConfig,
automationsFilterQueryParams,
automationsFilterQueryString,
automationsLinkFromUrl,
} from "../../../src/common/url/automations-query-params";
import { createQueryString } from "../../../src/common/url/query-params";
const filter = (value: string[] | Record<string, string[]> | undefined) => ({
value,
items: undefined,
});
describe("automations query params", () => {
it("reads nothing without table params", () => {
expect(automationsLinkFromUrl("?historyBack=1")).toBeUndefined();
});
it("replaces every filter and the search with what a link asks for", () => {
expect(automationsLinkFromUrl("?device=abc")).toEqual({
search: "",
filters: {
"ha-filter-floor-areas": filter(undefined),
"ha-filter-devices": filter(["abc"]),
"ha-filter-entities": filter([]),
"ha-filter-labels": filter([]),
"ha-filter-categories": filter([]),
"ha-filter-voice-assistants": filter([]),
"ha-filter-blueprints": filter([]),
},
});
});
it("round trips the filters", () => {
const filters = {
"ha-filter-floor-areas": filter({ floors: ["ground"], areas: ["hall"] }),
"ha-filter-devices": filter(["a", "b"]),
"ha-filter-entities": filter(["light.hall"]),
"ha-filter-labels": filter(["test"]),
"ha-filter-categories": filter(["cat"]),
"ha-filter-voice-assistants": filter(["conversation"]),
"ha-filter-blueprints": filter(["motion_light.yaml"]),
};
expect(
automationsLinkFromUrl(
createQueryString(
automationsFilterQueryParams(filters),
automationsFilterQueryParamConfig
)
)
).toEqual({ search: "", filters });
});
it("compares the same filters in another order as equal", () => {
expect(
automationsFilterQueryString({ "ha-filter-labels": filter(["b", "a"]) })
).toBe(
automationsFilterQueryString({ "ha-filter-labels": filter(["a", "b"]) })
);
expect(
automationsFilterQueryString({ "ha-filter-labels": filter([]) })
).toBe(automationsFilterQueryString({}));
});
it("keeps one category, as the category filter picks one", () => {
expect(
automationsLinkFromUrl("?category=a,b")?.filters["ha-filter-categories"]
).toEqual(filter(["a"]));
});
});