1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-07-25 15:34:17 +01:00

Use typed query param handling in todo and refactor handler typing (#52505)

* Use typed query param handlers for todo

* Refactor to query param config obj

* Remove type casts

* Use main window

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Fix import

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
This commit is contained in:
Aidan Timson
2026-06-09 12:40:45 +01:00
committed by GitHub
parent 9ba34bdf9a
commit 8a85d1cf31
5 changed files with 214 additions and 74 deletions
+6 -21
View File
@@ -16,32 +16,17 @@ export type HistoryLogbookTargetParamKey =
| "area_id"
| "device_id";
export type HistoryLogbookDateParamKey = "start_date" | "end_date";
export type HistoryLogbookBooleanParamKey = "back";
export type HistoryLogbookQueryParams = QueryParamValues<
HistoryLogbookTargetParamKey,
HistoryLogbookDateParamKey,
HistoryLogbookBooleanParamKey
>;
export const historyLogbookTargetParamKeys: HistoryLogbookTargetParamKey[] = [
"entity_id",
"label_id",
"floor_id",
"area_id",
"device_id",
];
export const historyLogbookTargetParamKeys: readonly HistoryLogbookTargetParamKey[] =
["entity_id", "label_id", "floor_id", "area_id", "device_id"];
export const historyLogbookQueryParamConfig = {
list: historyLogbookTargetParamKeys,
date: ["start_date", "end_date"],
boolean: [{ key: "back", trueValue: "1" }],
} satisfies QueryParamConfig<
HistoryLogbookTargetParamKey,
HistoryLogbookDateParamKey,
HistoryLogbookBooleanParamKey
} as const satisfies QueryParamConfig;
export type HistoryLogbookQueryParams = QueryParamValues<
typeof historyLogbookQueryParamConfig
>;
export const decodeHistoryLogbookQueryParams = (
+73 -41
View File
@@ -6,29 +6,49 @@ export type SearchParamsSource =
| Record<string, string>
| string;
export interface QueryParamConfig<
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
> {
list?: readonly ListKey[];
date?: readonly DateKey[];
export interface QueryParamConfig {
list?: readonly string[];
date?: readonly string[];
boolean?: readonly {
key: BooleanKey;
key: string;
trueValue: string;
}[];
string?: readonly string[];
}
export type QueryParamValues<
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
> = Partial<
Record<ListKey, string[]> &
Record<DateKey, Date> &
Record<BooleanKey, boolean>
type ListKeyOf<C extends QueryParamConfig> = C extends {
list: readonly (infer K extends string)[];
}
? K
: never;
type DateKeyOf<C extends QueryParamConfig> = C extends {
date: readonly (infer K extends string)[];
}
? K
: never;
type BooleanKeyOf<C extends QueryParamConfig> = C extends {
boolean: readonly { key: infer K extends string }[];
}
? K
: never;
type StringKeyOf<C extends QueryParamConfig> = C extends {
string: readonly (infer K extends string)[];
}
? K
: never;
export type QueryParamValues<C extends QueryParamConfig> = Partial<
Record<ListKeyOf<C>, string[]> &
Record<DateKeyOf<C>, Date> &
Record<BooleanKeyOf<C>, boolean> &
Record<StringKeyOf<C>, string>
>;
type QueryParamValue = string[] | Date | boolean | string;
export type ServiceTargetQueryParams<
Key extends keyof HassServiceTarget & string,
> = Partial<Record<Key, string[]>>;
@@ -46,53 +66,59 @@ const getSearchParam = (
return searchParams[key] ?? null;
};
export const decodeQueryParams = <
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
>(
export function decodeQueryParams<C extends QueryParamConfig>(
searchParams: SearchParamsSource,
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
): QueryParamValues<ListKey, DateKey, BooleanKey> => {
const params: QueryParamValues<ListKey, DateKey, BooleanKey> = {};
config: C
): QueryParamValues<C>;
export function decodeQueryParams(
searchParams: SearchParamsSource,
config: QueryParamConfig
): Record<string, QueryParamValue | undefined> {
const params: Record<string, QueryParamValue> = {};
for (const key of config.list ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = value.split(",") as (typeof params)[typeof key];
params[key] = value.split(",");
}
}
for (const key of config.date ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = new Date(value) as (typeof params)[typeof key];
params[key] = new Date(value);
}
}
for (const { key, trueValue } of config.boolean ?? []) {
if (getSearchParam(searchParams, key) === trueValue) {
params[key] = true as (typeof params)[typeof key];
params[key] = true;
}
}
for (const key of config.string ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = value;
}
}
return params;
};
}
export const createQueryString = <
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
>(
values: QueryParamValues<ListKey, DateKey, BooleanKey>,
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
): string => {
export function createQueryString<C extends QueryParamConfig>(
values: QueryParamValues<NoInfer<C>>,
config: C
): string;
export function createQueryString(
values: Record<string, QueryParamValue | undefined>,
config: QueryParamConfig
): string {
const searchParams = new URLSearchParams();
for (const key of config.list ?? []) {
const value = values[key] as string[] | undefined;
if (value?.length) {
const value = values[key];
if (Array.isArray(value) && value.length) {
searchParams.append(key, value.join(","));
}
}
for (const key of config.date ?? []) {
const value = values[key] as Date | undefined;
if (value) {
const value = values[key];
if (value instanceof Date) {
searchParams.append(key, value.toISOString());
}
}
@@ -101,8 +127,14 @@ export const createQueryString = <
searchParams.append(key, trueValue);
}
}
for (const key of config.string ?? []) {
const value = values[key];
if (typeof value === "string" && value) {
searchParams.append(key, value);
}
}
return searchParams.toString();
};
}
export const serviceTargetFromQueryParams = <
Key extends keyof HassServiceTarget & string,
+21
View File
@@ -0,0 +1,21 @@
import {
createQueryString,
decodeQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
export const todoQueryParamConfig = {
string: ["entity_id"],
boolean: [{ key: "add_item", trueValue: "true" }],
} as const satisfies QueryParamConfig;
export type TodoQueryParams = QueryParamValues<typeof todoQueryParamConfig>;
export const decodeTodoQueryParams = (
searchParams: SearchParamsSource
): TodoQueryParams => decodeQueryParams(searchParams, todoQueryParamConfig);
export const createTodoQueryString = (values: TodoQueryParams): string =>
createQueryString(values, todoQueryParamConfig);
+18 -12
View File
@@ -19,11 +19,11 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { supportsFeature } from "../../common/entity/supports-feature";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { extractSearchParamsObject } from "../../common/url/search-params";
import {
createSearchParam,
extractSearchParam,
removeSearchParam,
} from "../../common/url/search-params";
createTodoQueryString,
decodeTodoQueryParams,
} from "../../common/url/todo-query-params";
import "../../components/ha-button";
import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
@@ -104,10 +104,11 @@ class PanelTodo extends LitElement {
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
const urlEntityId = extractSearchParam("entity_id");
this._openAddItemFromUrl = extractSearchParam("add_item") === "true";
if (urlEntityId) {
this._entityId = urlEntityId;
const params = decodeTodoQueryParams(extractSearchParamsObject());
this._openAddItemFromUrl = params.add_item ?? false;
if (params.entity_id) {
this._entityId = params.entity_id;
} else {
if (this._entityId && !(this._entityId in this.hass.states)) {
this._entityId = undefined;
@@ -127,9 +128,12 @@ class PanelTodo extends LitElement {
}
this._openAddItemFromUrl = false;
navigate(constructUrlCurrentPath(removeSearchParam("add_item")), {
replace: true,
});
navigate(
constructUrlCurrentPath(
createTodoQueryString({ entity_id: this._entityId })
),
{ replace: true }
);
if (
supportsFeature(
this.hass.states[this._entityId],
@@ -146,7 +150,9 @@ class PanelTodo extends LitElement {
return;
}
navigate(
constructUrlCurrentPath(createSearchParam({ entity_id: this._entityId })),
constructUrlCurrentPath(
createTodoQueryString({ entity_id: this._entityId })
),
{ replace: true }
);
}
+96
View File
@@ -12,7 +12,12 @@ import {
decodeQueryParams,
queryParamsFromServiceTarget,
serviceTargetFromQueryParams,
type QueryParamConfig,
} from "../../../src/common/url/query-params";
import {
createTodoQueryString,
decodeTodoQueryParams,
} from "../../../src/common/url/todo-query-params";
const panelQueryParams = [
{
@@ -144,3 +149,94 @@ describe("history logbook query params", () => {
).toBeUndefined();
});
});
describe("string params", () => {
const stringConfig = {
string: ["name", "color"],
} as const satisfies QueryParamConfig;
it("decodes scalar string params", () => {
expect(decodeQueryParams("?name=hello&color=blue", stringConfig)).toEqual({
name: "hello",
color: "blue",
});
});
it("ignores empty string values", () => {
expect(decodeQueryParams("?name=&color=blue", stringConfig)).toEqual({
color: "blue",
});
});
it("ignores missing string params", () => {
expect(decodeQueryParams("?color=green", stringConfig)).toEqual({
color: "green",
});
});
it("encodes scalar string params", () => {
expect(
createQueryString({ name: "hello", color: "blue" }, stringConfig)
).toBe("name=hello&color=blue");
});
it("omits undefined string values from encoding", () => {
expect(createQueryString({ name: "hello" }, stringConfig)).toBe(
"name=hello"
);
});
});
describe("todo query params", () => {
it("decodes entity_id", () => {
expect(decodeTodoQueryParams("?entity_id=todo.shopping")).toEqual({
entity_id: "todo.shopping",
});
});
it("decodes entity_id with add_item", () => {
expect(
decodeTodoQueryParams("?entity_id=todo.shopping&add_item=true")
).toEqual({
entity_id: "todo.shopping",
add_item: true,
});
});
it("ignores add_item with non-true value", () => {
expect(
decodeTodoQueryParams("?entity_id=todo.shopping&add_item=false")
).toEqual({
entity_id: "todo.shopping",
});
});
it("returns empty for no params", () => {
expect(decodeTodoQueryParams("")).toEqual({});
});
it("creates query string with entity_id only", () => {
expect(createTodoQueryString({ entity_id: "todo.shopping" })).toBe(
"entity_id=todo.shopping"
);
});
it("creates query string with entity_id and add_item", () => {
expect(
createTodoQueryString({ entity_id: "todo.shopping", add_item: true })
).toBe("add_item=true&entity_id=todo.shopping");
});
it("omits add_item when false or undefined", () => {
expect(
createTodoQueryString({ entity_id: "todo.shopping", add_item: false })
).toBe("entity_id=todo.shopping");
});
it("round-trips decode and encode", () => {
const original = "?entity_id=todo.tasks&add_item=true";
const decoded = decodeTodoQueryParams(original);
const encoded = createTodoQueryString(decoded);
expect(encoded).toBe("add_item=true&entity_id=todo.tasks");
});
});