From c0d74687c92e48a1024f1e0a20eac7354106fbb4 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Sat, 29 Jun 2024 16:50:13 +0100 Subject: [PATCH 01/16] feat: preliminary support for multi-value params --- packages/core/playwright.config.ts | 3 + .../core/src/lib/__test__/params.test-d.ts | 8 +- .../src/lib/__test__/search-params.test.ts | 252 ++++++++++++++++++ packages/core/src/lib/adapters/types.ts | 6 +- packages/core/src/lib/create-params.svelte.ts | 74 +++-- packages/core/src/lib/goto.ts | 10 + packages/core/src/lib/index.ts | 2 + packages/core/src/lib/search-params.ts | 93 +++++++ packages/core/src/lib/types.ts | 15 +- packages/core/src/lib/utils.ts | 39 +-- 10 files changed, 417 insertions(+), 85 deletions(-) create mode 100644 packages/core/src/lib/__test__/search-params.test.ts create mode 100644 packages/core/src/lib/goto.ts create mode 100644 packages/core/src/lib/search-params.ts diff --git a/packages/core/playwright.config.ts b/packages/core/playwright.config.ts index 64fd10a..a5d434b 100644 --- a/packages/core/playwright.config.ts +++ b/packages/core/playwright.config.ts @@ -8,6 +8,9 @@ function playwrightDir(partialPath: string) { const CI = !!process.env.CI; export default defineConfig({ + expect: { + timeout: CI ? 10000 : 2000, + }, testDir: playwrightDir("specs"), outputDir: playwrightDir("results"), webServer: { diff --git a/packages/core/src/lib/__test__/params.test-d.ts b/packages/core/src/lib/__test__/params.test-d.ts index 7ecee11..a21045f 100644 --- a/packages/core/src/lib/__test__/params.test-d.ts +++ b/packages/core/src/lib/__test__/params.test-d.ts @@ -22,15 +22,15 @@ describe("Type tests", () => { { name: "function validators", schema: { - id: (value: string | undefined) => Number(value), - q: (value: string | undefined) => value, + id: (value: string | string[] | undefined) => Number(value), + q: (value: string | string[] | undefined) => value, }, }, { - name: "mixes and matched", + name: "mix and match", schema: { id: z.number(), - q: (value: string | undefined) => value, + q: (value: string | string[] | undefined) => value, }, }, ]; diff --git a/packages/core/src/lib/__test__/search-params.test.ts b/packages/core/src/lib/__test__/search-params.test.ts new file mode 100644 index 0000000..c986932 --- /dev/null +++ b/packages/core/src/lib/__test__/search-params.test.ts @@ -0,0 +1,252 @@ +import { ReactiveSearchParams } from "$lib/search-params"; +import { describe, expect, test } from "vitest"; + +describe("ReactiveSearchParams", () => { + describe("raw", () => { + test("should return key-value pairs as an object", () => { + const params = new ReactiveSearchParams({ id: "1", name: "john" }); + params.set("sort", "asc"); + + expect(params.raw).toEqual({ id: "1", name: "john", sort: "asc" }); + }); + + test("should return multi-value params as an array", () => { + const params = new ReactiveSearchParams({ id: "1", name: "john" }); + params.set("filters", ["a", "b", "c", "d"]); + + expect(params.raw).toEqual({ + id: "1", + name: "john", + filters: ["a", "b", "c", "d"], + }); + }); + + test("should return updated object when params changed", () => { + const params = new ReactiveSearchParams({ id: "1", name: "john" }); + params.set("sort", "asc"); + expect(params.raw).toEqual({ id: "1", name: "john", sort: "asc" }); + + params.set("name", "jane"); + expect(params.raw).toEqual({ id: "1", name: "jane", sort: "asc" }); + }); + }); + + describe("uniqueKeys", () => { + test("should return keys", () => { + const params = new ReactiveSearchParams({ id: "1", sort: "asc" }); + + expect(params.uniqueKeys).toEqual(["id", "sort"]); + }); + + test("should return unique keys for multi-value params", () => { + const params = new ReactiveSearchParams({ id: "1", sort: "asc" }); + params.set("sort", ["asc", "desc"]); + + expect(params.uniqueKeys).toEqual(["id", "sort"]); + }); + + test("should return updated keys when params changed", () => { + const params = new ReactiveSearchParams({ id: "1", sort: "asc" }); + expect(params.uniqueKeys).toEqual(["id", "sort"]); + + params.set("foo", "bar"); + expect(params.uniqueKeys).toEqual(["id", "sort", "foo"]); + }); + }); + + describe("search", () => { + test("should stringify search params", () => { + const params = new ReactiveSearchParams({ id: "1", sort: "asc" }); + + expect(params.search).toEqual("?id=1&sort=asc"); + }); + + test("should stringify multi-value search params", () => { + const params = new ReactiveSearchParams({ id: "1" }); + params.set("sort", ["asc", "desc"]); + + expect(params.search).toEqual("?id=1&sort=asc&sort=desc"); + }); + + test("should return empty string when there are no params", () => { + const params = new ReactiveSearchParams(); + + expect(params.search).toEqual(""); + }); + + test("should return updated string when params changed", () => { + const params = new ReactiveSearchParams({ id: "1", sort: "asc" }); + expect(params.search).toEqual("?id=1&sort=asc"); + + params.set("sort", "desc"); + expect(params.search).toEqual("?id=1&sort=desc"); + }); + }); + + describe("clear", () => { + test("should clear keys", () => { + const params = new ReactiveSearchParams({ id: "1" }); + expect([...params.keys()]).toEqual(["id"]); + + params.clear(); + expect([...params.keys()]).toHaveLength(0); + }); + }); + + describe("set", () => { + test("should set string-value params", () => { + const params = new ReactiveSearchParams({ id: "1" }); + expect(params.search).toEqual("?id=1"); + expect(params.get("id")).toEqual("1"); + expect(params.getAll("id")).toEqual(["1"]); + + params.set("id", "2"); + + expect(params.search).toEqual("?id=2"); + expect(params.get("id")).toEqual("2"); + expect(params.getAll("id")).toEqual(["2"]); + }); + + test("should handle multi-value params", () => { + const params = new ReactiveSearchParams({ id: "1" }); + expect(params.search).toEqual("?id=1"); + expect(params.get("id")).toEqual("1"); + expect(params.getAll("id")).toEqual(["1"]); + + params.set("id", ["2", "3"]); + + expect(params.search).toEqual("?id=2&id=3"); + expect(params.get("id")).toEqual("2"); + expect(params.getAll("id")).toEqual(["2", "3"]); + }); + }); + + describe("setFromObject", () => { + test("should set query params from an object", () => { + const params = new ReactiveSearchParams({ id: "1" }); + + params.setFromObject({ id: "3", name: "john" }); + + expect(params.search).toEqual("?id=3&name=john"); + expect(params.get("id")).toEqual("3"); + expect(params.getAll("id")).toEqual(["3"]); + expect(params.get("name")).toEqual("john"); + expect(params.getAll("name")).toEqual(["john"]); + }); + }); + + describe("setFromSearch", () => { + test("should set query params from search string", () => { + const params = new ReactiveSearchParams({ id: "1" }); + + expect(params.search).toEqual("?id=1"); + expect(params.get("id")).toEqual("1"); + expect(params.getAll("id")).toEqual(["1"]); + }); + + test("should overwrite query params", () => { + const params = new ReactiveSearchParams({ id: "1" }); + expect(params.search).toEqual("?id=1"); + expect(params.get("id")).toEqual("1"); + expect(params.getAll("id")).toEqual(["1"]); + + params.setFromSearch("?id=2"); + + expect(params.search).toEqual("?id=2"); + expect(params.get("id")).toEqual("2"); + expect(params.getAll("id")).toEqual(["2"]); + }); + + test("should handle multi-value params", () => { + const params = new ReactiveSearchParams(); + params.setFromSearch("?id=2&id=3"); + + expect(params.search).toEqual("?id=2&id=3"); + expect(params.get("id")).toEqual("2"); + expect(params.getAll("id")).toEqual(["2", "3"]); + }); + }); + + describe("changed", () => { + test("should return false when string values are the same", () => { + const params = new ReactiveSearchParams({ id: "1" }); + + expect(params.changed("id", "1")).toBeFalsy(); + }); + + test("should return false when arrays have the same values", () => { + const params = new ReactiveSearchParams(); + params.set("names", ["john", "jane"]); + + expect(params.changed("names", ["john", "jane"])).toBeFalsy(); + }); + + test("should return false when arrays have the same values in any order", () => { + const params = new ReactiveSearchParams(); + params.set("names", ["john", "jane"]); + + expect(params.changed("names", ["jane", "john"])).toBeFalsy(); + }); + + test("should return true when string values are not the same", () => { + const params = new ReactiveSearchParams({ id: "1" }); + + expect(params.changed("id", "2")).toBeTruthy(); + }); + + test("should return true when arrays do not have the same values", () => { + const params = new ReactiveSearchParams(); + params.set("names", ["john", "jane", "james"]); + + expect(params.changed("names", ["jane", "john"])).toBeTruthy(); + }); + }); + + describe("equals", () => { + test("should return true when object contains same properties", () => { + const params = new ReactiveSearchParams({ id: "2" }); + expect(params.equals({ id: "2" })).toBeTruthy(); + }); + + test("should return true when no params", () => { + const params = new ReactiveSearchParams(); + expect(params.equals({})).toBeTruthy(); + }); + + test("should handle multi-value params", () => { + const params = new ReactiveSearchParams({ id: "2" }); + params.set("names", ["john", "jane"]); + + expect(params.equals({ id: "2", names: ["john", "jane"] })).toBeTruthy(); + }); + + test("should handle multi-value params in any order", () => { + const params = new ReactiveSearchParams({ id: "2" }); + params.set("names", ["john", "jane"]); + + expect(params.equals({ id: "2", names: ["jane", "john"] })).toBeTruthy(); + }); + + test("should return false when object has different keys", () => { + const params = new ReactiveSearchParams({ id: "2" }); + expect(params.equals({ foo: "bar" })).toBeFalsy(); + }); + + test("should return false when object has different value", () => { + const params = new ReactiveSearchParams({ id: "2" }); + expect(params.equals({ id: "3" })).toBeFalsy(); + }); + + test("should return false when object has different number of keys", () => { + const params = new ReactiveSearchParams({ id: "2" }); + expect(params.equals({ id: "2", foo: "bar" })).toBeFalsy(); + }); + + test("should return false multi-value params are different", () => { + const params = new ReactiveSearchParams(); + params.set("names", ["john", "jane"]); + + expect(params.equals({ names: ["jane", "john", "james"] })).toBeFalsy(); + }); + }); +}); diff --git a/packages/core/src/lib/adapters/types.ts b/packages/core/src/lib/adapters/types.ts index 46277c6..e605577 100644 --- a/packages/core/src/lib/adapters/types.ts +++ b/packages/core/src/lib/adapters/types.ts @@ -17,8 +17,8 @@ export interface BrowserAdapter { /** * A function to update the browser query params and hash. * - * Note: The first param includes the `?` prefix and the second param - * includes the `#` if there is a hash + * @param search The search string - includes the `?` prefix if there are query params, otherwise an empty string + * @param hash The fragment - includes the `#` prefix if there is a hash, otherwise an empty string */ save: (search: string, hash: string) => void; } @@ -27,7 +27,7 @@ export interface ServerAdapter { /** * A function to update the server query params. * - * Note: The first param includes the `? + * @param search The search string - includes the `?` prefix if there are query params, otherwise an empty string */ save: (search: string) => void; } diff --git a/packages/core/src/lib/create-params.svelte.ts b/packages/core/src/lib/create-params.svelte.ts index dcdc129..d1851ae 100644 --- a/packages/core/src/lib/create-params.svelte.ts +++ b/packages/core/src/lib/create-params.svelte.ts @@ -1,5 +1,6 @@ import { tick } from "svelte"; import { browser } from "./adapters/browser"; +import { ReactiveSearchParams } from "./search-params"; import type { QueryHelpers, QueryParamsOptions, @@ -8,14 +9,7 @@ import type { WindowLike, inferShape, } from "./types"; -import { - debounce, - diff, - mapValues, - objectToQueryString, - parseQueryParams, - parseSearchString, -} from "./utils"; +import { debounce, mapValues, parseQueryParams } from "./utils"; /** * This returns a function (a hook) rather than a reactive object as the @@ -34,12 +28,12 @@ export function createUseQueryParams( typeof value === "string" ? value : JSON.stringify(value), } = options; - let raw = $state>({}); - const query = $derived(parseQueryParams(raw, validators)); - const merged = $derived({ ...raw, ...query }); + // TODO: Do we need this or can we just store each field as an array of values? smaller bundle + const searchParams = new ReactiveSearchParams(); + const parsedQuery = $derived(parseQueryParams(searchParams.raw, validators)); function readFromBrowser() { - raw = parseSearchString(adapter.browser.read().search); + searchParams.setFromSearch(adapter.browser.read().search); } const persistToBrowser = debounce((search: string, hash: string) => { @@ -47,10 +41,13 @@ export function createUseQueryParams( }, delay); function persistParams() { - const search = objectToQueryString(raw); adapter.isBrowser() - ? persistToBrowser(search, adapter.browser.read().hash) - : adapter.server.save(search); + ? persistToBrowser(searchParams.search, adapter.browser.read().hash) + : adapter.server.save(searchParams.search); + } + + function serialiseValue(value: unknown) { + return Array.isArray(value) ? value.map(serialise) : serialise(value); } let unsubscribe: () => void; @@ -60,20 +57,20 @@ export function createUseQueryParams( } return function useQueryParams(url) { - raw = parseSearchString(url.search); + searchParams.setFromSearch(url.search); const params = {} as inferShape; - for (const key of Object.keys(query)) { + for (const key of Object.keys(parsedQuery)) { Object.defineProperty(params, key, { enumerable: true, configurable: true, get() { - return query[key]; + return parsedQuery[key]; }, set(newValue) { - const value = serialise(newValue); - if (value !== raw[key]) { - raw = { ...raw, [key]: value }; + const value = serialiseValue(newValue); + if (searchParams.changed(key, value)) { + searchParams.setFromObject({ [key]: value }); persistParams(); } }, @@ -84,52 +81,51 @@ export function createUseQueryParams( unsubscribe = addWindowListener(window, readFromBrowser); } + // TODO: This is needed to write default values to the url, do we want this? + // searchParams.setFromObject(parsedQuery); + return [ params, { get raw() { - return raw; + return searchParams.raw; }, get search() { - return objectToQueryString(raw); + return searchParams.search; }, get all() { - return merged; + return { ...searchParams.raw, ...parsedQuery }; }, keys() { - return Object.keys(query); + return Object.keys(parsedQuery); }, entries() { - return Object.entries(query).map(([key]) => [key, query[key]]); + return Object.entries(parsedQuery); }, set(params) { - const updated = mapValues(params, serialise); - if (diff(raw, updated)) { - raw = updated; + const updated = mapValues(params, serialiseValue); + if (!searchParams.equals(updated)) { + searchParams.clear(); + searchParams.setFromObject(updated); persistParams(); } }, update(params) { - for (const [key, newValue] of Object.entries(params)) { - const value = serialise(newValue); - if (value !== raw[key]) { - raw = { ...raw, ...mapValues(params, serialise) }; - persistParams(); - break; - } + const updated = mapValues(params, serialiseValue); + if (!searchParams.equals(updated)) { + searchParams.setFromObject(updated); + persistParams(); } }, remove(...params) { - raw = Object.fromEntries( - Object.entries(raw).filter(([key]) => !params.includes(key)) - ); + params.map((param) => searchParams.delete(param as string)); }, unsubscribe() { diff --git a/packages/core/src/lib/goto.ts b/packages/core/src/lib/goto.ts new file mode 100644 index 0000000..d64c41a --- /dev/null +++ b/packages/core/src/lib/goto.ts @@ -0,0 +1,10 @@ +import { browser } from "$app/environment"; +import { goto } from "$app/navigation"; +import { type NumericRange, redirect as svelteRedirect } from "@sveltejs/kit"; + +export function redirect(path: string, code?: NumericRange<300, 308>) { + if (browser) return goto(path); + + if (!code) throw Error("redirect: You need to pass a redirect code"); + return svelteRedirect(code, path); +} diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 3e32fc8..9ffba7a 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -1,6 +1,8 @@ export { createUseQueryParams } from "./create-params.svelte.ts"; export type { FunctionValidator, + Query, + QueryValue, QueryHelpers, QueryParamsOptions, QueryHook, diff --git a/packages/core/src/lib/search-params.ts b/packages/core/src/lib/search-params.ts new file mode 100644 index 0000000..15a5bb7 --- /dev/null +++ b/packages/core/src/lib/search-params.ts @@ -0,0 +1,93 @@ +import { URLSearchParams as reactive_URLSearchParams } from "svelte/reactivity"; +import type { Query } from "./types"; + +export class ReactiveSearchParams extends reactive_URLSearchParams { + get raw() { + const raw: Query = {}; + + for (const [key, value] of this.entries()) { + if (key in raw) { + const existing = raw[key]; + if (Array.isArray(existing)) { + existing.push(value); + } else { + raw[key] = [existing, value]; + } + } else { + raw[key] = value; + } + } + + return raw; + } + + get uniqueKeys() { + // https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/size#getting_the_amount_of_search_parameter_entries + return [...new Set(this.keys())]; + } + + get search() { + return this.size ? `?${this.toString()}` : ""; + } + + clear() { + for (const key of this.keys()) { + this.delete(key); + } + } + + /** + * Replaces all query params under the given key with a new array of values + */ + set(key: string, values: string | string[]) { + if (Array.isArray(values)) { + this.delete(key); + + for (const arrayValue of values) { + this.append(key, arrayValue); + } + } else { + super.set(key, values); + } + } + + setFromObject(query: Query) { + for (const [key, value] of Object.entries(query)) { + this.set(key, value); + } + } + + setFromSearch(query: string) { + this.clear(); + const params = new URLSearchParams(query); + + for (const [key, value] of params.entries()) { + this.append(key, value); + } + } + + changed(key: string, value: string | string[]): boolean { + const compare = Array.isArray(value) ? value : [value]; + const existing = this.getAll(key).sort(); + if (compare.length !== existing.length) return true; + + const sortedNew = [...compare].sort(); + + for (let i = 0; i < existing.length; i++) { + if (existing[i] !== sortedNew[i]) return true; + } + + return false; + } + + equals(params: Record): boolean { + const paramKeys = Object.keys(params); + if (this.uniqueKeys.length !== paramKeys.length) return false; + + for (const key of this.uniqueKeys) { + if (this.changed(key, params[key])) return false; + } + + return true; + } +} diff --git a/packages/core/src/lib/types.ts b/packages/core/src/lib/types.ts index 1278c6c..0b60895 100644 --- a/packages/core/src/lib/types.ts +++ b/packages/core/src/lib/types.ts @@ -3,9 +3,9 @@ import type { z } from "zod"; import type { Adapter } from "./adapters/types.ts"; export type FunctionValidator = ( - value?: unknown + value: Query ) => TOut; -export type FunctionValueValidator = (value?: string) => TOut; +export type FunctionValueValidator = (value?: QueryValue) => TOut; export type ValibotValidator = BaseSchema; export type ZodValidator = z.ZodType; export type ValueValidator = @@ -28,6 +28,8 @@ export type inferFromValidator = export type QuerySchema = Validator | Record; type Empty = Record; +export type QueryValue = string | string[]; +export type Query = Record; export type inferShape = TShape extends Validator ? inferFromValidator @@ -86,17 +88,20 @@ export type QueryHelpers> = { * Note: this may include query params not defined in your schema. Values will * not have been parsed even if you have specified so in your validators. */ - readonly raw: Record; + readonly raw: Query; /** * Similar to {@linkcode raw}, but any params specified in your validators * will have been parsed - all other values are passed through as-is. * * Note: this may include query params not defined in your schema. */ - readonly all: Record & TShape; + readonly all: Query & TShape; /** * The query string, generated from the {@linkcode query} which may contain - * query params not defined in your schema. This always starts with a `?`. + * query params not defined in your schema. + * + * If there are query parameters, this will always string with `?`; if there + * are no query params, this will be the empty string. */ readonly search: string; /** Replace _ALL_ query params, triggering a reactive and browser update */ diff --git a/packages/core/src/lib/utils.ts b/packages/core/src/lib/utils.ts index 62aa5ba..6c6e8ab 100644 --- a/packages/core/src/lib/utils.ts +++ b/packages/core/src/lib/utils.ts @@ -1,25 +1,17 @@ import { parse } from "valibot"; import type { + Query, QuerySchema, + QueryValue, ValibotValidator, - Validator, ValueValidator, ZodValidator, inferShape, } from "./types.ts"; -export function parseSearchString(search: string) { - const params = new URLSearchParams(search); - return Object.fromEntries(params.entries()); -} - -export function objectToQueryString(init: Record) { - return `?${new URLSearchParams(init)}`; -} - function parseObject( schemas: Record, - input: Record + input: Query ): any { return Object.fromEntries( Object.entries(schemas).map(([key, schema]) => [ @@ -29,11 +21,7 @@ function parseObject( ); } -function parseValue( - key: string, - schema: ValueValidator | Validator, - value?: string -) { +function parseValue(key: string, schema: ValueValidator, value?: QueryValue) { if (typeof schema === "function") return schema(value); if (isZodSchema(schema)) return schema.parse(value); if (isValibotSchema(schema)) return parse(schema, value); @@ -69,7 +57,7 @@ function isValibotSchema(obj: QuerySchema): obj is ValibotValidator { } export function parseQueryParams( - params: Record, + params: Query, schemas: TSchema ): inferShape { if (typeof schemas === "function") return schemas(params); @@ -104,20 +92,3 @@ export function debounce any>( }, delay); }; } - -export function diff(obj1: Record, obj2: Record) { - const keys1 = Object.keys(obj1); - const keys2 = Object.keys(obj2); - - if (keys1.length !== keys2.length) { - return true; - } - - for (const key of keys1) { - if (obj1[key] !== obj2[key]) { - return true; - } - } - - return false; -} From b30ca50d633f62117475b2f3233e224420714043 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Sat, 29 Jun 2024 17:08:03 +0100 Subject: [PATCH 02/16] chore(docs): remove references to svelte runes as we don't use any --- README.md | 6 +++--- packages/core/README.md | 6 +++--- packages/core/package.json | 8 ++------ packages/core/src/lib/types.ts | 4 ++-- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index fd7d96c..9b66ed6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Svelte Query Params -The easiest way to reactively manage query params in Svelte _and_ SvelteKit applications, both on the server and in the browser. Built on Svelte 5 [runes](https://svelte-5-preview.vercel.app/docs/runes) and integrates with existing validation libraries to parse, coerce and transform query params into the data your application needs. +The easiest way to reactively manage query params in Svelte _and_ SvelteKit applications, both on the server and in the browser. Built for Svelte 5 and integrates with existing validation libraries to parse, coerce and transform query params into the data your application needs. ## Installation -Since Svelte Query Params uses runes, [`svelte^5`](https://svelte-5-preview.vercel.app/docs/introduction) is required: +[`svelte^5`](https://svelte-5-preview.vercel.app/docs/introduction) is required: ```bash npm install svelte-query-params svelte@next @@ -26,7 +26,7 @@ By default, `svelte-query-params` uses [`URLSearchParams`](https://developer.moz ## Features -- **Reactivity**: The library leverages Svelte's new runes reactivity system, providing a reactive object that reflects the current state of query parameters. +- **Reactivity**: The library providies a reactive object that reflects the current state of query parameters. - **Browser and Server Support**: The utility is designed to work seamlessly in both browser and server environments. diff --git a/packages/core/README.md b/packages/core/README.md index fd7d96c..9b66ed6 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,10 +1,10 @@ # Svelte Query Params -The easiest way to reactively manage query params in Svelte _and_ SvelteKit applications, both on the server and in the browser. Built on Svelte 5 [runes](https://svelte-5-preview.vercel.app/docs/runes) and integrates with existing validation libraries to parse, coerce and transform query params into the data your application needs. +The easiest way to reactively manage query params in Svelte _and_ SvelteKit applications, both on the server and in the browser. Built for Svelte 5 and integrates with existing validation libraries to parse, coerce and transform query params into the data your application needs. ## Installation -Since Svelte Query Params uses runes, [`svelte^5`](https://svelte-5-preview.vercel.app/docs/introduction) is required: +[`svelte^5`](https://svelte-5-preview.vercel.app/docs/introduction) is required: ```bash npm install svelte-query-params svelte@next @@ -26,7 +26,7 @@ By default, `svelte-query-params` uses [`URLSearchParams`](https://developer.moz ## Features -- **Reactivity**: The library leverages Svelte's new runes reactivity system, providing a reactive object that reflects the current state of query parameters. +- **Reactivity**: The library providies a reactive object that reflects the current state of query parameters. - **Browser and Server Support**: The utility is designed to work seamlessly in both browser and server environments. diff --git a/packages/core/package.json b/packages/core/package.json index 182e111..820a75d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,8 +26,7 @@ "query params", "query-params", "svelte", - "sveltekit", - "runes" + "sveltekit" ], "sideEffects": false, "publishConfig": { @@ -83,10 +82,7 @@ "module": "dist/index.svelte.js", "svelte": "dist/index.svelte.js", "types": "dist/index.svelte.d.ts", - "files": [ - "dist", - "README.md" - ], + "files": ["dist", "README.md"], "engines": { "node": ">=v20.0.0" }, diff --git a/packages/core/src/lib/types.ts b/packages/core/src/lib/types.ts index 0b60895..fe4619c 100644 --- a/packages/core/src/lib/types.ts +++ b/packages/core/src/lib/types.ts @@ -62,8 +62,8 @@ export interface QueryParamsOptions { * Add a delay (in ms) before updating the browser URL. This is useful in * situations where URL updates happen frequently, e.g., on every keystroke. * - * Note this does not affect the query params rune - this will always be - * updated optimistically. + * Note this does not affect the reactive query params object - this will + * always be updated immediately. * * @default 0 */ From a3e888e89b795746969f0b87fdaccff407fc0736 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Tue, 2 Jul 2024 10:13:15 +0100 Subject: [PATCH 03/16] test: fix failing unit tests --- packages/core/src/lib/__test__/svelte.test.ts | 14 ++++++++------ packages/core/src/lib/create-params.svelte.ts | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/src/lib/__test__/svelte.test.ts b/packages/core/src/lib/__test__/svelte.test.ts index 978c776..0035c7b 100644 --- a/packages/core/src/lib/__test__/svelte.test.ts +++ b/packages/core/src/lib/__test__/svelte.test.ts @@ -14,6 +14,11 @@ describe("createUseQueryParams", () => { const user = userEvent.setup(); const replaceState = history.replaceState.bind(history); + beforeEach(() => { + // reset window location for each test + replaceState({}, "", "?"); + }); + describe("when a value is bound to an input", () => { const useQueryParams = createUseQueryParams({ count: z.coerce.number().optional().default(0), @@ -109,9 +114,6 @@ describe("createUseQueryParams", () => { count: z.coerce.number().optional().default(0), id: z.coerce.number().optional().default(0), }); - - const [_params, helpers] = useQueryParams(window.location); - helpers.set({ count: 0, id: 0 }); }); test("should apply full updates", async () => { @@ -148,7 +150,7 @@ describe("createUseQueryParams", () => { await user.click(button); - expect(window.location.search).toEqual("?count=1&id=0"); + expect(window.location.search).toEqual("?count=1"); expect(countInput).toHaveValue(1); expect(idInput).toHaveValue(0); @@ -160,8 +162,8 @@ describe("createUseQueryParams", () => { ]); expect(params).toEqual({ count: 1, id: 0 }); - expect(helpers.raw).toEqual({ count: "1", id: "0" }); - expect(helpers.search).toEqual("?count=1&id=0"); + expect(helpers.raw).toEqual({ count: "1" }); + expect(helpers.search).toEqual("?count=1"); }); }); diff --git a/packages/core/src/lib/create-params.svelte.ts b/packages/core/src/lib/create-params.svelte.ts index d1851ae..a851e08 100644 --- a/packages/core/src/lib/create-params.svelte.ts +++ b/packages/core/src/lib/create-params.svelte.ts @@ -24,6 +24,7 @@ export function createUseQueryParams( debounce: delay = 0, windowObj = typeof window === "undefined" ? undefined : window, adapter = browser({ windowObj }), + // TODO: Do we need a deserialiser ? serialise = (value) => typeof value === "string" ? value : JSON.stringify(value), } = options; From 99dbbcd80829fd924145d7cacbe2ccc2874a3ab1 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Tue, 2 Jul 2024 10:25:06 +0100 Subject: [PATCH 04/16] docs: update function documentation --- packages/core/src/lib/types.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/core/src/lib/types.ts b/packages/core/src/lib/types.ts index fe4619c..a64c0d1 100644 --- a/packages/core/src/lib/types.ts +++ b/packages/core/src/lib/types.ts @@ -78,6 +78,11 @@ export interface QueryParamsOptions { */ serialise?: Serializer; + /** + * Adapter to control URL peristence. Defaults to the `browser` adapter. You + * **must** pass the `sveltekit` adapter when using SvelteKit with SSR, + * otherwise your app may break! + */ adapter?: Adapter; } @@ -87,6 +92,11 @@ export type QueryHelpers> = { * * Note: this may include query params not defined in your schema. Values will * not have been parsed even if you have specified so in your validators. + * + * Also note that if you've defined an optional property in your validators + * with a default value, it will `undefined` here - without applying the + * default - if the value isn't set in the URL. It will, however, be + * available (with the default) in {@linkcode QueryHelpers.all} */ readonly raw: Query; /** @@ -97,10 +107,10 @@ export type QueryHelpers> = { */ readonly all: Query & TShape; /** - * The query string, generated from the {@linkcode query} which may contain - * query params not defined in your schema. + * The query string, generated from the {@linkcode QueryHelpers.raw} query + * which may contain query params not defined in your schema. * - * If there are query parameters, this will always string with `?`; if there + * If there are query params, this will always start with `?`; if there * are no query params, this will be the empty string. */ readonly search: string; @@ -112,8 +122,16 @@ export type QueryHelpers> = { remove(...params: (keyof TShape)[]): void; /** Manually unset unregister all event listeners */ unsubscribe(): void; - /** Return the query keys. Unlike {@linkcode Object.keys}, this is type-safe */ + /** + * Return the parsed query keys. This only includes the keys from your + * validators. Unlike {@linkcode Object.keys}, this is type-safe. + */ keys(): Array; + /** + * Type-safe version of {@linkcode Object.entries}. Like + * {@linkcode QueryHelpers.keys}, this only contains entries from from your + * validators. + */ entries(): Array<[keyof TShape, TShape[keyof TShape]]>; }; From 76393e3c158af76702719802b26529b418716375 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Tue, 2 Jul 2024 10:25:24 +0100 Subject: [PATCH 05/16] test: update array param test fixture page --- .../routes/(test)/multiselect/+page.svelte | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/packages/core/src/routes/(test)/multiselect/+page.svelte b/packages/core/src/routes/(test)/multiselect/+page.svelte index ed9691f..c03cec7 100644 --- a/packages/core/src/routes/(test)/multiselect/+page.svelte +++ b/packages/core/src/routes/(test)/multiselect/+page.svelte @@ -3,6 +3,7 @@ import { page } from "$app/stores"; import { useMultiSelectFilters } from "./_hooks/multi-select"; const [q, helpers] = useMultiSelectFilters($page.url); +const CATEGORIES = ["books", "electronics", "toys"]; function updateCategories(category: string) { const categories = q.categories.includes(category) @@ -13,26 +14,23 @@ function updateCategories(category: string) {
    -
  • - -
  • -
  • - -
  • + {#each CATEGORIES as category} +
  • + +
  • + {/each} +
+ +
    + {#each q.categories as category} +
  • {category}
  • + {/each}
From e9411d2e7d30ce49d36c615e46b596c594ec0343 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Tue, 2 Jul 2024 10:27:55 +0100 Subject: [PATCH 06/16] chore: add npm keywords --- packages/core/package.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 820a75d..68eb4dc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,17 +16,23 @@ }, "keywords": [ "reactive", + "search", "search-params", "search params", "search-parameters", "search parameters", "query", + "querystring", "query parameters", "query-parameters", "query params", "query-params", "svelte", - "sveltekit" + "sveltejs", + "sveltekit", + "ssr", + "browser", + "url" ], "sideEffects": false, "publishConfig": { From 662fd6cc424fd44a5edca581d945e9d73146be09 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Wed, 3 Jul 2024 09:26:46 +0100 Subject: [PATCH 07/16] chore(example): update multiselect example --- .../src/routes/multiselect/+page.svelte | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/examples/sveltekit/src/routes/multiselect/+page.svelte b/examples/sveltekit/src/routes/multiselect/+page.svelte index 697c8e8..9679f77 100644 --- a/examples/sveltekit/src/routes/multiselect/+page.svelte +++ b/examples/sveltekit/src/routes/multiselect/+page.svelte @@ -2,37 +2,35 @@ import { page } from "$app/stores"; import { useMultiSelectFilters } from "$lib/hooks/multi-select"; -const [params, helpers] = useMultiSelectFilters($page.url); +const [q, helpers] = useMultiSelectFilters($page.url); +const CATEGORIES = ["books", "electronics", "toys"]; function updateCategories(category: string) { - const categories = params.categories.includes(category) - ? params.categories.filter((c) => c !== category) - : [...params.categories, category]; + const categories = q.categories.includes(category) + ? q.categories.filter((c) => c !== category) + : [...q.categories, category]; helpers.update({ categories }); }
    -
  • - -
  • -
  • - -
  • + {#each CATEGORIES as category} +
  • + +
  • + {/each} +
+ +
    + {#each q.categories as category} +
  • {category}
  • + {/each}
From 3548a8afcad0d46491ba2def6c290b0850993e51 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Wed, 3 Jul 2024 09:55:12 +0100 Subject: [PATCH 08/16] fix: fix issue with updating query string from browser causing duplicates --- .../core/src/lib/__test__/search-params.test.ts | 16 ++++++++++++++++ packages/core/src/lib/search-params.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/core/src/lib/__test__/search-params.test.ts b/packages/core/src/lib/__test__/search-params.test.ts index c986932..fc0b79a 100644 --- a/packages/core/src/lib/__test__/search-params.test.ts +++ b/packages/core/src/lib/__test__/search-params.test.ts @@ -87,9 +87,25 @@ describe("ReactiveSearchParams", () => { test("should clear keys", () => { const params = new ReactiveSearchParams({ id: "1" }); expect([...params.keys()]).toEqual(["id"]); + expect([...params.uniqueKeys]).toEqual(["id"]); params.clear(); + + expect([...params.keys()]).toHaveLength(0); + expect([...params.uniqueKeys]).toHaveLength(0); + }); + + test("should clear keys with array values", () => { + const params = new ReactiveSearchParams({ id: "1" }); + params.set("names", ["john", "jane"]); + + expect([...params.keys()]).toEqual(["id", "names", "names"]); + expect([...params.uniqueKeys]).toEqual(["id", "names"]); + + params.clear(); + expect([...params.keys()]).toHaveLength(0); + expect([...params.uniqueKeys]).toHaveLength(0); }); }); diff --git a/packages/core/src/lib/search-params.ts b/packages/core/src/lib/search-params.ts index 15a5bb7..e79f790 100644 --- a/packages/core/src/lib/search-params.ts +++ b/packages/core/src/lib/search-params.ts @@ -31,7 +31,7 @@ export class ReactiveSearchParams extends reactive_URLSearchParams { } clear() { - for (const key of this.keys()) { + for (const key of this.uniqueKeys) { this.delete(key); } } From 284bf00045f635f4680ec020c7a7f67a553a1dd1 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Wed, 3 Jul 2024 10:11:13 +0100 Subject: [PATCH 09/16] test: add missing awaits --- packages/core/tests/e2e/specs/e2e.test.ts | 89 ++++++++++++----------- 1 file changed, 46 insertions(+), 43 deletions(-) diff --git a/packages/core/tests/e2e/specs/e2e.test.ts b/packages/core/tests/e2e/specs/e2e.test.ts index 1363426..577c696 100644 --- a/packages/core/tests/e2e/specs/e2e.test.ts +++ b/packages/core/tests/e2e/specs/e2e.test.ts @@ -14,11 +14,11 @@ test.describe const title = page.getByRole("heading", { level: 1 }); const [home, users] = await page.getByRole("tab").all(); - expect(title).toHaveText("Users Page"); - expect(home).not.toBeDisabled(); - expect(home).toHaveAttribute("aria-selected", "false"); - expect(users).toBeDisabled(); - expect(users).toHaveAttribute("aria-selected", "true"); + await expect(title).toHaveText("Users Page"); + await expect(home).not.toBeDisabled(); + await expect(home).toHaveAttribute("aria-selected", "false"); + await expect(users).toBeDisabled(); + await expect(users).toHaveAttribute("aria-selected", "true"); }); test("should prepopulate from url with a hash", async ({ page }) => { @@ -26,13 +26,14 @@ test.describe const title = page.getByRole("heading", { level: 1 }); const [home, users] = await page.getByRole("tab").all(); - const url = location(page); - expect(title).toHaveText("Users Page"); - expect(home).not.toBeDisabled(); - expect(home).toHaveAttribute("aria-selected", "false"); - expect(users).toBeDisabled(); - expect(users).toHaveAttribute("aria-selected", "true"); + await expect(title).toHaveText("Users Page"); + await expect(home).not.toBeDisabled(); + await expect(home).toHaveAttribute("aria-selected", "false"); + await expect(users).toBeDisabled(); + await expect(users).toHaveAttribute("aria-selected", "true"); + + const url = location(page); expect(url.hash).toEqual("#hash"); }); @@ -41,33 +42,33 @@ test.describe const title = page.getByRole("heading", { level: 1 }); const [home, users] = await page.getByRole("tab").all(); - expect(title).toHaveText("Home Page"); - expect(home).toBeDisabled(); - expect(home).toHaveAttribute("aria-selected", "true"); - expect(users).not.toBeDisabled(); - expect(users).toHaveAttribute("aria-selected", "false"); + await expect(title).toHaveText("Home Page"); + await expect(home).toBeDisabled(); + await expect(home).toHaveAttribute("aria-selected", "true"); + await expect(users).not.toBeDisabled(); + await expect(users).toHaveAttribute("aria-selected", "false"); await users.click(); - const url = location(page); + await expect(title).toHaveText("Users Page"); + await expect(home).not.toBeDisabled(); + await expect(home).toHaveAttribute("aria-selected", "false"); + await expect(users).toBeDisabled(); + await expect(users).toHaveAttribute("aria-selected", "true"); + + const url = location(page); expect(url.search).toEqual("?tab=users"); - expect(title).toHaveText("Users Page"); - expect(home).not.toBeDisabled(); - expect(home).toHaveAttribute("aria-selected", "false"); - expect(users).toBeDisabled(); - expect(users).toHaveAttribute("aria-selected", "true"); }); test("should maintain hash", async ({ page }) => { await page.goto("/tabs#some-hash"); - const [home, users] = await page.getByRole("tab").all(); + const [_home, users] = await page.getByRole("tab").all(); const url = location(page); expect(url.hash).toEqual("#some-hash"); await users.click(); - - expect(page).toHaveURL("/tabs?tab=users#some-hash"); + await expect(page).toHaveURL("/tabs?tab=users#some-hash"); }); }); @@ -80,10 +81,10 @@ test.describe const subtitle = page.getByRole("heading", { level: 2 }); const [prev, next] = await page.getByRole("button").all(); - expect(title).toHaveText("Current Page: 4"); - expect(subtitle).toHaveText("Page Size: 10"); - expect(prev).not.toBeDisabled(); - expect(next).not.toBeDisabled(); + await expect(title).toHaveText("Current Page: 4"); + await expect(subtitle).toHaveText("Page Size: 10"); + await expect(prev).not.toBeDisabled(); + await expect(next).not.toBeDisabled(); }); test("should prepopulate from url with a hash", async ({ page }) => { @@ -92,13 +93,14 @@ test.describe const title = page.getByRole("heading", { level: 1 }); const subtitle = page.getByRole("heading", { level: 2 }); const [prev, next] = await page.getByRole("button").all(); - const url = location(page); + await expect(title).toHaveText("Current Page: 4"); + await expect(subtitle).toHaveText("Page Size: 10"); + await expect(prev).not.toBeDisabled(); + await expect(next).not.toBeDisabled(); + + const url = location(page); expect(url.hash).toEqual("#hash"); - expect(title).toHaveText("Current Page: 4"); - expect(subtitle).toHaveText("Page Size: 10"); - expect(prev).not.toBeDisabled(); - expect(next).not.toBeDisabled(); }); test("should update query params", async ({ page }) => { @@ -111,12 +113,13 @@ test.describe expect(next).not.toBeDisabled(); await next.click(); - const url = location(page); + await expect(title).toHaveText("Current Page: 2"); + await expect(next).not.toBeDisabled(); + await expect(prev).not.toBeDisabled(); + + const url = location(page); expect(url.search).toEqual("?page=2"); - expect(title).toHaveText("Current Page: 2"); - expect(next).not.toBeDisabled(); - expect(prev).not.toBeDisabled(); }); test("should maintain hash", async ({ page }) => { @@ -128,7 +131,7 @@ test.describe await next.click(); - expect(page).toHaveURL("/pagination?page=2#some-hash"); + await expect(page).toHaveURL("/pagination?page=2#some-hash"); }); }); @@ -163,8 +166,8 @@ test.describe const url = location(page); expect(url.search).toEqual("?count=10"); - expect(title).toHaveText("Count: 10"); - expect(subtitle).toHaveText("Count: 10"); + await expect(title).toHaveText("Count: 10"); + await expect(subtitle).toHaveText("Count: 10"); }); test("should maintain hash", async ({ page }) => { @@ -175,7 +178,7 @@ test.describe expect(url.hash).toEqual("#some-hash"); expect(url.search).toEqual("?count=10"); - expect(title).toHaveText("Count: 10"); - expect(subtitle).toHaveText("Count: 10"); + await expect(title).toHaveText("Count: 10"); + await expect(subtitle).toHaveText("Count: 10"); }); }); From 56b3e06b1c4d656db93f41056d4567398ccc2da0 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Wed, 3 Jul 2024 10:31:36 +0100 Subject: [PATCH 10/16] fix: ensure browser updates are performed when there are no query params --- packages/core/src/lib/adapters/browser.ts | 3 ++- packages/core/src/lib/adapters/sveltekit.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/lib/adapters/browser.ts b/packages/core/src/lib/adapters/browser.ts index 303ce92..22faaee 100644 --- a/packages/core/src/lib/adapters/browser.ts +++ b/packages/core/src/lib/adapters/browser.ts @@ -28,7 +28,8 @@ export function browser(options: BrowserAdapterOptions = {}): Adapter { isBrowser: () => typeof window !== "undefined", browser: { read: () => windowObj.location, - save: (search, hash) => update(null, "", `${search}${hash}`), + save: (search, hash) => + update(null, "", `${search.length ? search : "?"}${hash}`), }, server: { save: () => {}, diff --git a/packages/core/src/lib/adapters/sveltekit.ts b/packages/core/src/lib/adapters/sveltekit.ts index 90dca51..9e779ff 100644 --- a/packages/core/src/lib/adapters/sveltekit.ts +++ b/packages/core/src/lib/adapters/sveltekit.ts @@ -23,7 +23,8 @@ export function sveltekit(options: SvelteKitAdapterOptions = {}): Adapter { return window.location; }, save(search, hash) { - goto(`${search}${hash}`, { + const searchString = search.length ? search : "?"; + goto(`${searchString}${hash}`, { keepFocus: true, noScroll: true, replaceState: replace, From b72ac6c55289034f08993aedec9c02323f5fcd92 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Wed, 3 Jul 2024 10:32:32 +0100 Subject: [PATCH 11/16] test: add e2e tests for multi-value params --- packages/core/tests/e2e/specs/e2e.test.ts | 146 ++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/packages/core/tests/e2e/specs/e2e.test.ts b/packages/core/tests/e2e/specs/e2e.test.ts index 577c696..28c249b 100644 --- a/packages/core/tests/e2e/specs/e2e.test.ts +++ b/packages/core/tests/e2e/specs/e2e.test.ts @@ -182,3 +182,149 @@ test.describe await expect(subtitle).toHaveText("Count: 10"); }); }); + +test.describe("array values", () => { + test("should prepopulate from url", async ({ page }) => { + await page.goto("/multiselect?categories=electronics&categories=books"); + + const [optionsList, resultsList] = await page.getByRole("list").all(); + const options = await optionsList.getByRole("checkbox").all(); + const selectedCategories = await resultsList.getByRole("listitem").all(); + + expect(options[0]).toBeChecked(); + expect(options[1]).toBeChecked(); + expect(options[2]).not.toBeChecked(); + + expect(selectedCategories).toHaveLength(2); + expect(selectedCategories[0]).toHaveText("electronics"); + expect(selectedCategories[1]).toHaveText("books"); + }); + + test("should prepopulate from url with a hash", async ({ page }) => { + await page.goto( + "/multiselect?categories=electronics&categories=books#hash" + ); + + const [_otionsList, resultsList] = await page.getByRole("list").all(); + const selectedCategories = await resultsList.getByRole("listitem").all(); + + expect(selectedCategories).toHaveLength(2); + await expect(selectedCategories[0]).toHaveText("electronics"); + await expect(selectedCategories[1]).toHaveText("books"); + + const url = location(page); + expect(url.hash).toEqual("#hash"); + }); + + test("should add to array param", async ({ page }) => { + await page.goto("/multiselect"); + + const [optionsList, resultsList] = await page.getByRole("list").all(); + const options = await optionsList.getByRole("checkbox").all(); + let selectedCategories = await resultsList.getByRole("listitem").all(); + + expect(selectedCategories).toHaveLength(0); + await expect(options[0]).not.toBeChecked(); + await expect(options[1]).not.toBeChecked(); + await expect(options[2]).not.toBeChecked(); + + await options[0].click(); + await options[2].click(); + + selectedCategories = await resultsList.getByRole("listitem").all(); + + expect(selectedCategories).toHaveLength(2); + await expect(selectedCategories[0]).toHaveText("books"); + await expect(selectedCategories[1]).toHaveText("toys"); + + const url = location(page); + expect(url.search).toEqual("?categories=books&categories=toys"); + }); + + test("should remove from array param", async ({ page }) => { + await page.goto("/multiselect?categories=electronics&categories=books"); + + const [optionsList, resultsList] = await page.getByRole("list").all(); + const options = await optionsList.getByRole("checkbox").all(); + let selectedCategories = await resultsList.getByRole("listitem").all(); + + expect(selectedCategories).toHaveLength(2); + await expect(options[0]).toBeChecked(); + await expect(options[1]).toBeChecked(); + await expect(options[2]).not.toBeChecked(); + await expect(selectedCategories[0]).toHaveText("electronics"); + await expect(selectedCategories[1]).toHaveText("books"); + + await options[0].click(); + + await expect(options[0]).not.toBeChecked(); + await expect(options[1]).toBeChecked(); + await expect(options[2]).not.toBeChecked(); + + selectedCategories = await resultsList.getByRole("listitem").all(); + + expect(selectedCategories).toHaveLength(1); + await expect(selectedCategories[0]).toHaveText("electronics"); + + let url = location(page); + expect(url.search).toEqual("?categories=electronics"); + + await options[1].click(); + + await expect(options[0]).not.toBeChecked(); + await expect(options[1]).not.toBeChecked(); + await expect(options[2]).not.toBeChecked(); + + url = location(page); + selectedCategories = await resultsList.getByRole("listitem").all(); + expect(selectedCategories).toHaveLength(0); + expect(url.search).toEqual(""); + + await options[0].click(); + + await expect(options[0]).toBeChecked(); + await expect(options[1]).not.toBeChecked(); + await expect(options[2]).not.toBeChecked(); + + selectedCategories = await resultsList.getByRole("listitem").all(); + + expect(selectedCategories).toHaveLength(1); + await expect(selectedCategories[0]).toHaveText("books"); + + url = location(page); + expect(url.search).toEqual("?categories=books"); + + await options[1].click(); + + await expect(options[0]).toBeChecked(); + await expect(options[1]).toBeChecked(); + await expect(options[2]).not.toBeChecked(); + + selectedCategories = await resultsList.getByRole("listitem").all(); + + expect(selectedCategories).toHaveLength(2); + await expect(selectedCategories[0]).toHaveText("books"); + await expect(selectedCategories[1]).toHaveText("electronics"); + + url = location(page); + expect(url.search).toEqual("?categories=books&categories=electronics"); + + await options[2].click(); + + await expect(options[0]).toBeChecked(); + await expect(options[1]).toBeChecked(); + await expect(options[2]).toBeChecked(); + + selectedCategories = await resultsList.getByRole("listitem").all(); + + expect(selectedCategories).toHaveLength(3); + await expect(selectedCategories[0]).toHaveText("books"); + await expect(selectedCategories[1]).toHaveText("electronics"); + await expect(selectedCategories[2]).toHaveText("toys"); + + url = location(page); + expect(url.search).toEqual( + "?categories=books&categories=electronics&categories=toys" + ); + }); +}); From 348b686095f6a7a67282740f79c1bab50ebb1a42 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Wed, 3 Jul 2024 10:37:50 +0100 Subject: [PATCH 12/16] test: simplify test setup --- packages/core/src/lib/__test__/svelte.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/core/src/lib/__test__/svelte.test.ts b/packages/core/src/lib/__test__/svelte.test.ts index 0035c7b..145e4a1 100644 --- a/packages/core/src/lib/__test__/svelte.test.ts +++ b/packages/core/src/lib/__test__/svelte.test.ts @@ -25,9 +25,6 @@ describe("createUseQueryParams", () => { }); beforeEach(() => { - const [params] = useQueryParams(window.location); - params.count = 0; - render(Input, { useQueryParams }); }); @@ -83,9 +80,6 @@ describe("createUseQueryParams", () => { count: z.coerce.number().optional().default(0), }); - const [params] = useQueryParams(window.location); - params.count = 0; - render(Button, { useQueryParams }); }); From 672b26b9e5d351dfeab7da98890fdf67547e3844 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Wed, 3 Jul 2024 10:48:40 +0100 Subject: [PATCH 13/16] docs: add array value examples --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++ packages/core/README.md | 45 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/README.md b/README.md index 9b66ed6..f86e12a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,10 @@ By default, `svelte-query-params` uses [`URLSearchParams`](https://developer.moz - **Event Handling**: Automatically handles `popstate` events for accurate synchronisation with browser history. +- **Serialisation**: Control how query params are serialised into strings to the browser + +- **Multi-value params**: Supports multi-value query parameters with ease + ## Usage In some lib file e.g., `src/lib/params.ts`: @@ -141,6 +145,47 @@ const useQueryParams = createUseQueryParams({ }); ``` +### Array Values + +With a function validator, you may receive the param as either a string, an array of strings, or undefined. As a result, you must handle all three cases to support multi-value params: + +```javascript +const validators = { + categories: (value) => { + if (!value) return [] + return Array.isArray(value) ? value : [value] + } +} +``` + +With Zod, you need to handle the case where there's either 0 or 1 query param value as this library will not infer this as an array beforehand. You must define your array parameter like: + +```javascript +import { z } from "zod"; + +z.object({ + categories: z + .union([z.string().array(), z.string()]) + .default([]) + .transform((c) => (Array.isArray(c) ? c : [c])), +}) +``` + +The union between a string and array of strings handles 1 or more query params; a default is set to the empty array to allow the parameter to be omitted from the URL and it's transformed at the end to convert the single value param into an array. + +In the same manner, with Valibot: + +```javascript +import * as v from "valibot"; + +v.object({ + categories: v.pipe( + v.optional(v.union([v.array(v.string()), v.string()]), []), + v.transform((c) => (Array.isArray(c) ? c : [c])) + ), +}); +``` + ## Options `createUseQueryParams` takes an options object as the second argument, with the following properties: diff --git a/packages/core/README.md b/packages/core/README.md index 9b66ed6..f86e12a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -36,6 +36,10 @@ By default, `svelte-query-params` uses [`URLSearchParams`](https://developer.moz - **Event Handling**: Automatically handles `popstate` events for accurate synchronisation with browser history. +- **Serialisation**: Control how query params are serialised into strings to the browser + +- **Multi-value params**: Supports multi-value query parameters with ease + ## Usage In some lib file e.g., `src/lib/params.ts`: @@ -141,6 +145,47 @@ const useQueryParams = createUseQueryParams({ }); ``` +### Array Values + +With a function validator, you may receive the param as either a string, an array of strings, or undefined. As a result, you must handle all three cases to support multi-value params: + +```javascript +const validators = { + categories: (value) => { + if (!value) return [] + return Array.isArray(value) ? value : [value] + } +} +``` + +With Zod, you need to handle the case where there's either 0 or 1 query param value as this library will not infer this as an array beforehand. You must define your array parameter like: + +```javascript +import { z } from "zod"; + +z.object({ + categories: z + .union([z.string().array(), z.string()]) + .default([]) + .transform((c) => (Array.isArray(c) ? c : [c])), +}) +``` + +The union between a string and array of strings handles 1 or more query params; a default is set to the empty array to allow the parameter to be omitted from the URL and it's transformed at the end to convert the single value param into an array. + +In the same manner, with Valibot: + +```javascript +import * as v from "valibot"; + +v.object({ + categories: v.pipe( + v.optional(v.union([v.array(v.string()), v.string()]), []), + v.transform((c) => (Array.isArray(c) ? c : [c])) + ), +}); +``` + ## Options `createUseQueryParams` takes an options object as the second argument, with the following properties: From 7d01371d257f675d31cf2b978bc267d5c271ccdd Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Wed, 3 Jul 2024 13:05:58 +0100 Subject: [PATCH 14/16] chore: remove todos --- packages/core/src/lib/create-params.svelte.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/lib/create-params.svelte.ts b/packages/core/src/lib/create-params.svelte.ts index a851e08..c5a85e9 100644 --- a/packages/core/src/lib/create-params.svelte.ts +++ b/packages/core/src/lib/create-params.svelte.ts @@ -24,12 +24,10 @@ export function createUseQueryParams( debounce: delay = 0, windowObj = typeof window === "undefined" ? undefined : window, adapter = browser({ windowObj }), - // TODO: Do we need a deserialiser ? serialise = (value) => typeof value === "string" ? value : JSON.stringify(value), } = options; - // TODO: Do we need this or can we just store each field as an array of values? smaller bundle const searchParams = new ReactiveSearchParams(); const parsedQuery = $derived(parseQueryParams(searchParams.raw, validators)); From b85e94dd3d35a269cb62c561650b9ff3d88b6905 Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Wed, 3 Jul 2024 19:27:52 +0100 Subject: [PATCH 15/16] test: fix flaky tests --- packages/core/tests/e2e/specs/e2e.test.ts | 68 ++++++++++------------- 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/packages/core/tests/e2e/specs/e2e.test.ts b/packages/core/tests/e2e/specs/e2e.test.ts index 28c249b..1ab4cce 100644 --- a/packages/core/tests/e2e/specs/e2e.test.ts +++ b/packages/core/tests/e2e/specs/e2e.test.ts @@ -189,15 +189,15 @@ test.describe("array values", () => { const [optionsList, resultsList] = await page.getByRole("list").all(); const options = await optionsList.getByRole("checkbox").all(); - const selectedCategories = await resultsList.getByRole("listitem").all(); - expect(options[0]).toBeChecked(); - expect(options[1]).toBeChecked(); - expect(options[2]).not.toBeChecked(); + await expect(options[0]).toBeChecked(); + await expect(options[1]).toBeChecked(); + await expect(options[2]).not.toBeChecked(); + await expect(resultsList.getByRole("listitem")).toHaveCount(2); - expect(selectedCategories).toHaveLength(2); - expect(selectedCategories[0]).toHaveText("electronics"); - expect(selectedCategories[1]).toHaveText("books"); + const selectedCategories = await resultsList.getByRole("listitem").all(); + await expect(selectedCategories[0]).toHaveText("electronics"); + await expect(selectedCategories[1]).toHaveText("books"); }); test("should prepopulate from url with a hash", async ({ page }) => { @@ -205,15 +205,16 @@ test.describe("array values", () => { "/multiselect?categories=electronics&categories=books#hash" ); + await expect(page).toHaveURL( + "multiselect?categories=electronics&categories=books#hash" + ); + const [_otionsList, resultsList] = await page.getByRole("list").all(); - const selectedCategories = await resultsList.getByRole("listitem").all(); + await expect(resultsList.getByRole("listitem")).toHaveCount(2); - expect(selectedCategories).toHaveLength(2); + const selectedCategories = await resultsList.getByRole("listitem").all(); await expect(selectedCategories[0]).toHaveText("electronics"); await expect(selectedCategories[1]).toHaveText("books"); - - const url = location(page); - expect(url.hash).toEqual("#hash"); }); test("should add to array param", async ({ page }) => { @@ -221,9 +222,8 @@ test.describe("array values", () => { const [optionsList, resultsList] = await page.getByRole("list").all(); const options = await optionsList.getByRole("checkbox").all(); - let selectedCategories = await resultsList.getByRole("listitem").all(); + await expect(resultsList.getByRole("listitem")).toHaveCount(0); - expect(selectedCategories).toHaveLength(0); await expect(options[0]).not.toBeChecked(); await expect(options[1]).not.toBeChecked(); await expect(options[2]).not.toBeChecked(); @@ -231,9 +231,8 @@ test.describe("array values", () => { await options[0].click(); await options[2].click(); - selectedCategories = await resultsList.getByRole("listitem").all(); - - expect(selectedCategories).toHaveLength(2); + await expect(resultsList.getByRole("listitem")).toHaveCount(2); + const selectedCategories = await resultsList.getByRole("listitem").all(); await expect(selectedCategories[0]).toHaveText("books"); await expect(selectedCategories[1]).toHaveText("toys"); @@ -246,9 +245,9 @@ test.describe("array values", () => { const [optionsList, resultsList] = await page.getByRole("list").all(); const options = await optionsList.getByRole("checkbox").all(); - let selectedCategories = await resultsList.getByRole("listitem").all(); + await expect(resultsList.getByRole("listitem")).toHaveCount(2); - expect(selectedCategories).toHaveLength(2); + let selectedCategories = await resultsList.getByRole("listitem").all(); await expect(options[0]).toBeChecked(); await expect(options[1]).toBeChecked(); await expect(options[2]).not.toBeChecked(); @@ -261,13 +260,11 @@ test.describe("array values", () => { await expect(options[1]).toBeChecked(); await expect(options[2]).not.toBeChecked(); + await expect(resultsList.getByRole("listitem")).toHaveCount(1); selectedCategories = await resultsList.getByRole("listitem").all(); - - expect(selectedCategories).toHaveLength(1); await expect(selectedCategories[0]).toHaveText("electronics"); - let url = location(page); - expect(url.search).toEqual("?categories=electronics"); + expect(location(page).search).toEqual("?categories=electronics"); await options[1].click(); @@ -275,10 +272,9 @@ test.describe("array values", () => { await expect(options[1]).not.toBeChecked(); await expect(options[2]).not.toBeChecked(); - url = location(page); + await expect(resultsList.getByRole("listitem")).toHaveCount(0); selectedCategories = await resultsList.getByRole("listitem").all(); - expect(selectedCategories).toHaveLength(0); - expect(url.search).toEqual(""); + expect(location(page).search).toEqual(""); await options[0].click(); @@ -286,13 +282,11 @@ test.describe("array values", () => { await expect(options[1]).not.toBeChecked(); await expect(options[2]).not.toBeChecked(); + await expect(resultsList.getByRole("listitem")).toHaveCount(1); selectedCategories = await resultsList.getByRole("listitem").all(); - - expect(selectedCategories).toHaveLength(1); await expect(selectedCategories[0]).toHaveText("books"); - url = location(page); - expect(url.search).toEqual("?categories=books"); + expect(location(page).search).toEqual("?categories=books"); await options[1].click(); @@ -300,14 +294,14 @@ test.describe("array values", () => { await expect(options[1]).toBeChecked(); await expect(options[2]).not.toBeChecked(); + await expect(resultsList.getByRole("listitem")).toHaveCount(2); selectedCategories = await resultsList.getByRole("listitem").all(); - - expect(selectedCategories).toHaveLength(2); await expect(selectedCategories[0]).toHaveText("books"); await expect(selectedCategories[1]).toHaveText("electronics"); - url = location(page); - expect(url.search).toEqual("?categories=books&categories=electronics"); + expect(location(page).search).toEqual( + "?categories=books&categories=electronics" + ); await options[2].click(); @@ -315,15 +309,13 @@ test.describe("array values", () => { await expect(options[1]).toBeChecked(); await expect(options[2]).toBeChecked(); + await expect(resultsList.getByRole("listitem")).toHaveCount(3); selectedCategories = await resultsList.getByRole("listitem").all(); - - expect(selectedCategories).toHaveLength(3); await expect(selectedCategories[0]).toHaveText("books"); await expect(selectedCategories[1]).toHaveText("electronics"); await expect(selectedCategories[2]).toHaveText("toys"); - url = location(page); - expect(url.search).toEqual( + expect(location(page).search).toEqual( "?categories=books&categories=electronics&categories=toys" ); }); From b079db4c2dc4cfff2d66e529d72b9f1e9dd00bba Mon Sep 17 00:00:00 2001 From: Ernest Badu Date: Wed, 3 Jul 2024 19:32:34 +0100 Subject: [PATCH 16/16] chore: add changeset --- .changeset/spicy-carrots-sleep.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/spicy-carrots-sleep.md diff --git a/.changeset/spicy-carrots-sleep.md b/.changeset/spicy-carrots-sleep.md new file mode 100644 index 0000000..91ce378 --- /dev/null +++ b/.changeset/spicy-carrots-sleep.md @@ -0,0 +1,5 @@ +--- +"svelte-query-params": patch +--- + +Add support for multi-value params