diff --git a/src/common/url/history-logbook-query-params.ts b/src/common/url/history-logbook-query-params.ts index d81b12afb6..61a6365961 100644 --- a/src/common/url/history-logbook-query-params.ts +++ b/src/common/url/history-logbook-query-params.ts @@ -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 = ( diff --git a/src/common/url/query-params.ts b/src/common/url/query-params.ts index 9292eb894b..0bfa15f135 100644 --- a/src/common/url/query-params.ts +++ b/src/common/url/query-params.ts @@ -6,29 +6,49 @@ export type SearchParamsSource = | Record | 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 & - Record & - Record +type ListKeyOf = C extends { + list: readonly (infer K extends string)[]; +} + ? K + : never; + +type DateKeyOf = C extends { + date: readonly (infer K extends string)[]; +} + ? K + : never; + +type BooleanKeyOf = C extends { + boolean: readonly { key: infer K extends string }[]; +} + ? K + : never; + +type StringKeyOf = C extends { + string: readonly (infer K extends string)[]; +} + ? K + : never; + +export type QueryParamValues = Partial< + Record, string[]> & + Record, Date> & + Record, boolean> & + Record, string> >; +type QueryParamValue = string[] | Date | boolean | string; + export type ServiceTargetQueryParams< Key extends keyof HassServiceTarget & string, > = Partial>; @@ -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( searchParams: SearchParamsSource, - config: QueryParamConfig -): QueryParamValues => { - const params: QueryParamValues = {}; + config: C +): QueryParamValues; +export function decodeQueryParams( + searchParams: SearchParamsSource, + config: QueryParamConfig +): Record { + const params: Record = {}; 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, - config: QueryParamConfig -): string => { +export function createQueryString( + values: QueryParamValues>, + config: C +): string; +export function createQueryString( + values: Record, + 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, diff --git a/src/common/url/todo-query-params.ts b/src/common/url/todo-query-params.ts new file mode 100644 index 0000000000..bef51deda6 --- /dev/null +++ b/src/common/url/todo-query-params.ts @@ -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; + +export const decodeTodoQueryParams = ( + searchParams: SearchParamsSource +): TodoQueryParams => decodeQueryParams(searchParams, todoQueryParamConfig); + +export const createTodoQueryString = (values: TodoQueryParams): string => + createQueryString(values, todoQueryParamConfig); diff --git a/src/panels/todo/ha-panel-todo.ts b/src/panels/todo/ha-panel-todo.ts index 58cc4778eb..24a953e993 100644 --- a/src/panels/todo/ha-panel-todo.ts +++ b/src/panels/todo/ha-panel-todo.ts @@ -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 } ); } diff --git a/test/common/url/query-params.test.ts b/test/common/url/query-params.test.ts index 4d10ac3030..8d49023db5 100644 --- a/test/common/url/query-params.test.ts +++ b/test/common/url/query-params.test.ts @@ -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"); + }); +});