From db7755f3362b21e43ecb13045f2711248b1d5669 Mon Sep 17 00:00:00 2001 From: Valerie Fanelle Date: Fri, 24 Apr 2026 16:48:01 -0700 Subject: [PATCH 1/5] feat(integrations): URL-anchored filters Sync the integrations page filter state to URL query params so filtered views can be linked, bookmarked, and shared. - Add use-url-filter-sync hook: two-way sync between Zustand store and URL via replaceState (no full navigation or scroll jump) - On mount: read params and apply filters before first paint - On popstate (back/forward): re-sync filters from URL - On filter change: update URL in same render cycle - Unknown/malformed params ignored gracefully - Clearing all filters strips the query string entirely - Add 21 Vitest tests for param parsing, serialization, and round-trips Supported params: category, type, pro, byoc, q Example: /integrations?pro=1&byoc=1 Made-with: Cursor --- .../components/toolkits-client.tsx | 3 + .../components/use-url-filter-sync.ts | 148 +++++++++++++++ tests/url-filter-sync.test.ts | 170 ++++++++++++++++++ 3 files changed, 321 insertions(+) create mode 100644 app/en/resources/integrations/components/use-url-filter-sync.ts create mode 100644 tests/url-filter-sync.test.ts diff --git a/app/en/resources/integrations/components/toolkits-client.tsx b/app/en/resources/integrations/components/toolkits-client.tsx index 8f478fb17..067a06838 100644 --- a/app/en/resources/integrations/components/toolkits-client.tsx +++ b/app/en/resources/integrations/components/toolkits-client.tsx @@ -17,6 +17,7 @@ import { FiltersBar } from "./filters-bar"; import { ToolCard } from "./tool-card"; import { TYPE_CONFIG, TYPE_DESCRIPTIONS } from "./type-config"; import { useFilterStore, useToolkitFilters } from "./use-toolkit-filters"; +import { useUrlFilterSync } from "./use-url-filter-sync"; type ToolkitsClientProps = { toolkits: ToolkitWithDocsLink[]; @@ -63,6 +64,8 @@ function getToolkitIconWithFallback( } export default function ToolkitsClient({ toolkits }: ToolkitsClientProps) { + useUrlFilterSync(); + const clearAllFilters = useFilterStore((state) => state.clearAllFilters); const { hasActiveFilters, filteredToolkits, resultsCount } = diff --git a/app/en/resources/integrations/components/use-url-filter-sync.ts b/app/en/resources/integrations/components/use-url-filter-sync.ts new file mode 100644 index 000000000..91ef58896 --- /dev/null +++ b/app/en/resources/integrations/components/use-url-filter-sync.ts @@ -0,0 +1,148 @@ +"use client"; + +import type { ToolkitType } from "@arcadeai/design-system"; +import { useEffect, useRef } from "react"; +import { useFilterStore } from "./use-toolkit-filters"; + +const VALID_TYPES = new Set([ + "arcade", + "arcade_starter", + "verified", + "community", + "auth", +]); + +const PARAM_CATEGORY = "category"; +const PARAM_TYPE = "type"; +const PARAM_PRO = "pro"; +const PARAM_BYOC = "byoc"; +const PARAM_SEARCH = "q"; + +function isTruthy(value: string | null): boolean { + return value !== null && value !== "" && value !== "0" && value !== "false"; +} + +export type ParsedFilters = Partial<{ + selectedCategory: string; + selectedType: ToolkitType | "all"; + filterByPro: boolean; + filterByByoc: boolean; + searchQuery: string; +}>; + +export function parseFiltersFromParams(search: string): ParsedFilters { + const params = new URLSearchParams(search); + const result: ParsedFilters = {}; + + const category = params.get(PARAM_CATEGORY); + if (category && category !== "all") { + result.selectedCategory = category; + } + + const type = params.get(PARAM_TYPE); + if (type && VALID_TYPES.has(type)) { + result.selectedType = type as ToolkitType; + } + + if (params.has(PARAM_PRO) && isTruthy(params.get(PARAM_PRO))) { + result.filterByPro = true; + } + + if (params.has(PARAM_BYOC) && isTruthy(params.get(PARAM_BYOC))) { + result.filterByByoc = true; + } + + const q = params.get(PARAM_SEARCH); + if (q) { + result.searchQuery = q; + } + + return result; +} + +export type SerializableFilterState = { + selectedCategory: string; + selectedType: string; + filterByPro: boolean; + filterByByoc: boolean; + searchQuery: string; +}; + +export function serializeFiltersToParams( + state: SerializableFilterState +): string { + const params = new URLSearchParams(); + + if (state.selectedCategory !== "all") { + params.set(PARAM_CATEGORY, state.selectedCategory); + } + if (state.selectedType !== "all") { + params.set(PARAM_TYPE, state.selectedType); + } + if (state.filterByPro) { + params.set(PARAM_PRO, "1"); + } + if (state.filterByByoc) { + params.set(PARAM_BYOC, "1"); + } + if (state.searchQuery) { + params.set(PARAM_SEARCH, state.searchQuery); + } + + return params.toString(); +} + +function writeFiltersToUrl(state: SerializableFilterState): void { + const qs = serializeFiltersToParams(state); + const newUrl = qs + ? `${window.location.pathname}?${qs}` + : window.location.pathname; + + if (newUrl !== `${window.location.pathname}${window.location.search}`) { + window.history.replaceState(null, "", newUrl); + } +} + +/** + * Two-way sync between the Zustand filter store and URL query params. + * + * - Mount: URL → store (so shared/bookmarked links work) + * - Filter change: store → URL (via replaceState, no navigation) + * - Back/forward: URL → store (via popstate listener) + */ +export function useUrlFilterSync(): void { + const hydrated = useRef(false); + + useEffect(() => { + if (!hydrated.current) { + hydrated.current = true; + const fromUrl = parseFiltersFromParams(window.location.search); + if (Object.keys(fromUrl).length > 0) { + useFilterStore.setState(fromUrl); + } + } + + const unsubscribe = useFilterStore.subscribe((state) => { + writeFiltersToUrl(state); + }); + + const handlePopState = () => { + const defaults = { + selectedCategory: "all", + selectedType: "all" as const, + filterByPro: false, + filterByByoc: false, + searchQuery: "", + }; + const fromUrl = parseFiltersFromParams(window.location.search); + useFilterStore.setState({ ...defaults, ...fromUrl }); + }; + + window.addEventListener("popstate", handlePopState); + + return () => { + unsubscribe(); + window.removeEventListener("popstate", handlePopState); + }; + }, []); +} diff --git a/tests/url-filter-sync.test.ts b/tests/url-filter-sync.test.ts new file mode 100644 index 000000000..8782d38d4 --- /dev/null +++ b/tests/url-filter-sync.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from "vitest"; +import { + parseFiltersFromParams, + type SerializableFilterState, + serializeFiltersToParams, +} from "../app/en/resources/integrations/components/use-url-filter-sync"; + +const DEFAULT_STATE: SerializableFilterState = { + selectedCategory: "all", + selectedType: "all", + filterByPro: false, + filterByByoc: false, + searchQuery: "", +}; + +describe("parseFiltersFromParams", () => { + test("returns empty object for no params", () => { + expect(parseFiltersFromParams("")).toEqual({}); + }); + + test("parses pro filter", () => { + expect(parseFiltersFromParams("?pro=1")).toEqual({ filterByPro: true }); + }); + + test("parses byoc filter", () => { + expect(parseFiltersFromParams("?byoc=1")).toEqual({ filterByByoc: true }); + }); + + test("parses pro + byoc together", () => { + expect(parseFiltersFromParams("?pro=1&byoc=1")).toEqual({ + filterByPro: true, + filterByByoc: true, + }); + }); + + test("parses category", () => { + expect(parseFiltersFromParams("?category=development")).toEqual({ + selectedCategory: "development", + }); + }); + + test("parses type", () => { + expect(parseFiltersFromParams("?type=arcade")).toEqual({ + selectedType: "arcade", + }); + }); + + test("parses search query", () => { + expect(parseFiltersFromParams("?q=gmail")).toEqual({ + searchQuery: "gmail", + }); + }); + + test("parses all params at once", () => { + expect( + parseFiltersFromParams( + "?category=productivity&type=verified&pro=1&byoc=1&q=slack" + ) + ).toEqual({ + selectedCategory: "productivity", + selectedType: "verified", + filterByPro: true, + filterByByoc: true, + searchQuery: "slack", + }); + }); + + test("ignores invalid type values", () => { + expect(parseFiltersFromParams("?type=bogus")).toEqual({}); + }); + + test("ignores falsy pro values", () => { + expect(parseFiltersFromParams("?pro=0")).toEqual({}); + expect(parseFiltersFromParams("?pro=false")).toEqual({}); + expect(parseFiltersFromParams("?pro=")).toEqual({}); + }); + + test("ignores category=all", () => { + expect(parseFiltersFromParams("?category=all")).toEqual({}); + }); + + test("ignores unknown params gracefully", () => { + expect(parseFiltersFromParams("?foo=bar&baz=1")).toEqual({}); + }); +}); + +describe("serializeFiltersToParams", () => { + test("returns empty string for default state", () => { + expect(serializeFiltersToParams(DEFAULT_STATE)).toBe(""); + }); + + test("serializes pro filter", () => { + expect( + serializeFiltersToParams({ ...DEFAULT_STATE, filterByPro: true }) + ).toBe("pro=1"); + }); + + test("serializes byoc filter", () => { + expect( + serializeFiltersToParams({ ...DEFAULT_STATE, filterByByoc: true }) + ).toBe("byoc=1"); + }); + + test("serializes category", () => { + expect( + serializeFiltersToParams({ + ...DEFAULT_STATE, + selectedCategory: "development", + }) + ).toBe("category=development"); + }); + + test("serializes type", () => { + expect( + serializeFiltersToParams({ + ...DEFAULT_STATE, + selectedType: "arcade", + }) + ).toBe("type=arcade"); + }); + + test("serializes search query", () => { + expect( + serializeFiltersToParams({ ...DEFAULT_STATE, searchQuery: "gmail" }) + ).toBe("q=gmail"); + }); + + test("serializes all filters at once", () => { + const qs = serializeFiltersToParams({ + selectedCategory: "productivity", + selectedType: "verified", + filterByPro: true, + filterByByoc: true, + searchQuery: "slack", + }); + const params = new URLSearchParams(qs); + expect(params.get("category")).toBe("productivity"); + expect(params.get("type")).toBe("verified"); + expect(params.get("pro")).toBe("1"); + expect(params.get("byoc")).toBe("1"); + expect(params.get("q")).toBe("slack"); + }); +}); + +describe("round-trip", () => { + test("serialize then parse produces equivalent filters", () => { + const state: SerializableFilterState = { + selectedCategory: "social", + selectedType: "community", + filterByPro: true, + filterByByoc: false, + searchQuery: "twitter", + }; + const qs = serializeFiltersToParams(state); + const parsed = parseFiltersFromParams(`?${qs}`); + expect(parsed).toEqual({ + selectedCategory: "social", + selectedType: "community", + filterByPro: true, + searchQuery: "twitter", + }); + }); + + test("default state round-trips to empty", () => { + const qs = serializeFiltersToParams(DEFAULT_STATE); + expect(qs).toBe(""); + const parsed = parseFiltersFromParams(qs); + expect(parsed).toEqual({}); + }); +}); From c42d8266fc18b4681738f92ec331dd60f6738849 Mon Sep 17 00:00:00 2001 From: Valerie Fanelle Date: Fri, 24 Apr 2026 16:50:00 -0700 Subject: [PATCH 2/5] Replace static Pro tools list with dynamic filtered link Swap the hardcoded "As of 04/24" Pro tools list for a link to the integrations page filtered by Pro (/integrations?pro=1). Made-with: Cursor --- app/en/resources/glossary/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/en/resources/glossary/page.mdx b/app/en/resources/glossary/page.mdx index f46eada38..7e2273705 100644 --- a/app/en/resources/glossary/page.mdx +++ b/app/en/resources/glossary/page.mdx @@ -184,7 +184,7 @@ Standard tools are the default tier and cover the majority of Arcade's catalog. Pro tools incur materially higher operational cost than Standard tools due to underlying infrastructure (e.g., compute-intensive sandboxes), provider-imposed fees (e.g., per-call API charges from data providers), or tool complexity. Each invocation of a Pro tool counts as one Pro Tool Execution against your plan's monthly Pro allowance, with any overage billed per execution at the Pro rate listed on the [pricing page](https://www.arcade.dev/pricing). Arcade may reclassify tools between Standard and Pro from time to time as the underlying cost structure of a tool changes; any such reclassification applies prospectively. -As of 04/24, Pro tools include: E2B, Firecrawl, Google Finance, Google Flights, Google Hotels, Google Jobs, Google Maps, Google News, Google Search, Google Shopping, Imgflip, Walmart, and YouTube. +See the current list of [Pro tools](/en/resources/integrations?pro=1). ### Bring Your Own Credentials (BYOC) From d916d96d962632ddb22a2ce279fd0dbae3f8381a Mon Sep 17 00:00:00 2001 From: Valerie Fanelle Date: Fri, 24 Apr 2026 17:01:07 -0700 Subject: [PATCH 3/5] Add BYOC filtered link to glossary and mark Granola + Bright Data as Pro - Add link to BYOC-eligible tools (/integrations?byoc=1) in the BYOC glossary definition - Set isPro: true for Granola and Bright Data toolkits Made-with: Cursor --- app/en/resources/glossary/page.mdx | 4 +++- toolkit-docs-generator/data/toolkits/brightdata.json | 2 +- toolkit-docs-generator/data/toolkits/granola.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/en/resources/glossary/page.mdx b/app/en/resources/glossary/page.mdx index 7e2273705..40eeba4c5 100644 --- a/app/en/resources/glossary/page.mdx +++ b/app/en/resources/glossary/page.mdx @@ -188,10 +188,12 @@ See the current list of [Pro tools](/en/resources/integrations?pro=1). ### Bring Your Own Credentials (BYOC) -Bring Your Own Credentials (BYOC) is a feature that allows you to use your own credentials to access Pro tools. This changes the cost of the tool execution, as you will be charged directly by the provider of the tool, rather than relying on Arcade to pay the bill for you. In exchange, the tool execution will be billed at the Standard rate. As of 04/26/2026, most credentials required for Pro tools are API Keys specific to the service being accessed. +Bring Your Own Credentials (BYOC) is a feature that allows you to use your own credentials to access Pro tools. This changes the cost of the tool execution, as you will be charged directly by the provider of the tool, rather than relying on Arcade to pay the bill for you. In exchange, the tool execution will be billed at the Standard rate. Most credentials required for Pro tools are API Keys specific to the service being accessed. To set your own credentials, set the requisite secret(s) within the Arcade Dashboard Secrets page, overwriting the default 'static' credentials. You can also set the secrets using the Arcade CLI. +See the current list of [BYOC-eligible tools](/en/resources/integrations?byoc=1). + ## Tool Execution and Tool Development ```mermaid diff --git a/toolkit-docs-generator/data/toolkits/brightdata.json b/toolkit-docs-generator/data/toolkits/brightdata.json index 479b0a8bb..9272db5ba 100644 --- a/toolkit-docs-generator/data/toolkits/brightdata.json +++ b/toolkit-docs-generator/data/toolkits/brightdata.json @@ -7,7 +7,7 @@ "category": "development", "iconUrl": "https://design-system.arcade.dev/icons/brightdata.svg", "isBYOC": true, - "isPro": false, + "isPro": true, "type": "community", "docsLink": "https://docs.arcade.dev/en/resources/integrations/development/brightdata", "isComingSoon": false, diff --git a/toolkit-docs-generator/data/toolkits/granola.json b/toolkit-docs-generator/data/toolkits/granola.json index d8c617a18..d6201e7f9 100644 --- a/toolkit-docs-generator/data/toolkits/granola.json +++ b/toolkit-docs-generator/data/toolkits/granola.json @@ -7,7 +7,7 @@ "category": "productivity", "iconUrl": "https://design-system.arcade.dev/icons/granola.svg", "isBYOC": true, - "isPro": false, + "isPro": true, "type": "arcade", "docsLink": "https://docs.arcade.dev/en/resources/integrations/productivity/granola", "isComingSoon": false, From 205ca98eba0d139e0f76fec0d6e7fb70a5254dbf Mon Sep 17 00:00:00 2001 From: Valerie Fanelle Date: Fri, 24 Apr 2026 17:08:59 -0700 Subject: [PATCH 4/5] Revert Granola and Bright Data Pro designation These toolkits are BYOC-only (user supplies their own API key), so they should not be marked Pro. Made-with: Cursor --- toolkit-docs-generator/data/toolkits/brightdata.json | 2 +- toolkit-docs-generator/data/toolkits/granola.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/toolkit-docs-generator/data/toolkits/brightdata.json b/toolkit-docs-generator/data/toolkits/brightdata.json index 9272db5ba..479b0a8bb 100644 --- a/toolkit-docs-generator/data/toolkits/brightdata.json +++ b/toolkit-docs-generator/data/toolkits/brightdata.json @@ -7,7 +7,7 @@ "category": "development", "iconUrl": "https://design-system.arcade.dev/icons/brightdata.svg", "isBYOC": true, - "isPro": true, + "isPro": false, "type": "community", "docsLink": "https://docs.arcade.dev/en/resources/integrations/development/brightdata", "isComingSoon": false, diff --git a/toolkit-docs-generator/data/toolkits/granola.json b/toolkit-docs-generator/data/toolkits/granola.json index d6201e7f9..d8c617a18 100644 --- a/toolkit-docs-generator/data/toolkits/granola.json +++ b/toolkit-docs-generator/data/toolkits/granola.json @@ -7,7 +7,7 @@ "category": "productivity", "iconUrl": "https://design-system.arcade.dev/icons/granola.svg", "isBYOC": true, - "isPro": true, + "isPro": false, "type": "arcade", "docsLink": "https://docs.arcade.dev/en/resources/integrations/productivity/granola", "isComingSoon": false, From b0487f73e4ee450aed34a7e8dcba9fb5d721d129 Mon Sep 17 00:00:00 2001 From: Valerie Fanelle Date: Fri, 1 May 2026 14:13:38 -0700 Subject: [PATCH 5/5] refactor(integrations): URL is single source of truth; use ?label= shape Address review feedback by making URL query params the single source of truth for the integrations page filters and simplifying the URL shape. - Remove the Zustand filter store and the use-url-filter-sync hook; replace them with a useFilters() hook that reads via useSearchParams() and writes via window.history.pushState (replaceState for the search input). Back/forward now steps through filter changes. - Switch the URL shape from `?pro=1&byoc=1` to a multi-value `?label=pro&label=byoc`. Add an isLabel type guard; unknown labels are silently ignored. - Move parse/serialize/type-guard helpers into a new pure filter-params module (no "use client"). Validate categories and toolkit types via real type guards instead of `as` casts. Tests now import from the pure module. - Anchor the filters bar + tool grid at `#list` and point the glossary's Pro Tools / BYOC links there so users land pre-filtered and scrolled to the list. - Wrap ToolkitsClient in Suspense, required by useSearchParams() on a statically prerendered route. - Apply Vale style fixes to the Pro / BYOC glossary copy: drop weasel "significantly", replace `e.g.,` with `for example,`, drop `(s)` from `secret(s)`. Made-with: Cursor --- app/en/resources/glossary/page.mdx | 10 +- .../integrations/components/filter-params.ts | 136 ++++++++ .../integrations/components/filters-bar.tsx | 4 +- .../components/toolkits-client.tsx | 10 +- .../integrations/components/toolkits.tsx | 10 +- .../integrations/components/use-filters.ts | 107 ++++++ .../components/use-toolkit-filters.ts | 39 +-- .../components/use-url-filter-sync.ts | 148 -------- tests/filter-params.test.ts | 315 ++++++++++++++++++ tests/url-filter-sync.test.ts | 170 ---------- 10 files changed, 580 insertions(+), 369 deletions(-) create mode 100644 app/en/resources/integrations/components/filter-params.ts create mode 100644 app/en/resources/integrations/components/use-filters.ts delete mode 100644 app/en/resources/integrations/components/use-url-filter-sync.ts create mode 100644 tests/filter-params.test.ts delete mode 100644 tests/url-filter-sync.test.ts diff --git a/app/en/resources/glossary/page.mdx b/app/en/resources/glossary/page.mdx index 40eeba4c5..a550a20ab 100644 --- a/app/en/resources/glossary/page.mdx +++ b/app/en/resources/glossary/page.mdx @@ -174,7 +174,7 @@ _Learn more about [tool executions](/guides/tool-calling)._ ### Standard and Pro Tool Executions -Arcade tools are divided into 2 categories: Standard and Pro. While all tools have some cost for Arcade to run, Pro tools are significantly more costly — either due to infrastructure costs, the complexity of the tool, or a cost imposed by the provider of the tool. Pro tools cost more to execute and have different limits. +Arcade tools are divided into 2 categories: Standard and Pro. While all tools have some cost for Arcade to run, Pro tools are more costly—either due to infrastructure costs, the complexity of the tool, or a cost imposed by the provider of the tool. Pro tools cost more to execute and have different limits. #### Standard Tool Executions @@ -182,17 +182,17 @@ Standard tools are the default tier and cover the majority of Arcade's catalog. #### Pro Tool Executions -Pro tools incur materially higher operational cost than Standard tools due to underlying infrastructure (e.g., compute-intensive sandboxes), provider-imposed fees (e.g., per-call API charges from data providers), or tool complexity. Each invocation of a Pro tool counts as one Pro Tool Execution against your plan's monthly Pro allowance, with any overage billed per execution at the Pro rate listed on the [pricing page](https://www.arcade.dev/pricing). Arcade may reclassify tools between Standard and Pro from time to time as the underlying cost structure of a tool changes; any such reclassification applies prospectively. +Pro tools incur materially higher operational cost than Standard tools due to underlying infrastructure (for example, compute-intensive sandboxes), provider-imposed fees (for example, per-call API charges from data providers), or tool complexity. Each invocation of a Pro tool counts as one Pro Tool Execution against your plan's monthly Pro allowance, with any overage billed per execution at the Pro rate listed on the [pricing page](https://www.arcade.dev/pricing). Arcade may reclassify tools between Standard and Pro from time to time as the underlying cost structure of a tool changes; any such reclassification applies prospectively. -See the current list of [Pro tools](/en/resources/integrations?pro=1). +See the current list of [Pro tools](/en/resources/integrations?label=pro#list). ### Bring Your Own Credentials (BYOC) Bring Your Own Credentials (BYOC) is a feature that allows you to use your own credentials to access Pro tools. This changes the cost of the tool execution, as you will be charged directly by the provider of the tool, rather than relying on Arcade to pay the bill for you. In exchange, the tool execution will be billed at the Standard rate. Most credentials required for Pro tools are API Keys specific to the service being accessed. -To set your own credentials, set the requisite secret(s) within the Arcade Dashboard Secrets page, overwriting the default 'static' credentials. You can also set the secrets using the Arcade CLI. +To set your own credentials, set the requisite secrets within the Arcade Dashboard Secrets page, overwriting the default 'static' credentials. You can also set the secrets using the Arcade CLI. -See the current list of [BYOC-eligible tools](/en/resources/integrations?byoc=1). +See the current list of [BYOC-eligible tools](/en/resources/integrations?label=byoc#list). ## Tool Execution and Tool Development diff --git a/app/en/resources/integrations/components/filter-params.ts b/app/en/resources/integrations/components/filter-params.ts new file mode 100644 index 000000000..432088986 --- /dev/null +++ b/app/en/resources/integrations/components/filter-params.ts @@ -0,0 +1,136 @@ +import type { ToolkitCategory, ToolkitType } from "@arcadeai/design-system"; + +const TOOLKIT_TYPES: readonly ToolkitType[] = [ + "arcade", + "arcade_starter", + "verified", + "community", + "auth", +]; + +const TOOLKIT_CATEGORIES: readonly ToolkitCategory[] = [ + "all", + "productivity", + "social", + "development", + "entertainment", + "search", + "payments", + "sales", + "databases", + "customer-support", +]; + +export const PARAM_CATEGORY = "category"; +export const PARAM_TYPE = "type"; +export const PARAM_LABEL = "label"; +export const PARAM_SEARCH = "q"; + +// Tool labels are multi-value: a tool can carry zero, one, or both labels. +// Encoded in the URL as repeated `label=...` keys, e.g. `?label=pro&label=byoc`. +export type Label = "pro" | "byoc"; +const LABELS: readonly Label[] = ["pro", "byoc"]; + +export function isLabel(value: string): value is Label { + return (LABELS as readonly string[]).includes(value); +} + +export type FilterState = { + selectedCategory: ToolkitCategory; + selectedType: ToolkitType | "all"; + filterByPro: boolean; + filterByByoc: boolean; + searchQuery: string; +}; + +export const DEFAULT_FILTER_STATE: FilterState = { + selectedCategory: "all", + selectedType: "all", + filterByPro: false, + filterByByoc: false, + searchQuery: "", +}; + +export function isToolkitType(value: string): value is ToolkitType { + return (TOOLKIT_TYPES as readonly string[]).includes(value); +} + +export function isToolkitCategory(value: string): value is ToolkitCategory { + return (TOOLKIT_CATEGORIES as readonly string[]).includes(value); +} + +type ParamsInput = string | URLSearchParams | null | undefined; + +function toSearchParams(input: ParamsInput): URLSearchParams { + if (input instanceof URLSearchParams) { + return input; + } + return new URLSearchParams(input ?? ""); +} + +export function parseFiltersFromParams(input: ParamsInput): FilterState { + const params = toSearchParams(input); + const state: FilterState = { ...DEFAULT_FILTER_STATE }; + + const category = params.get(PARAM_CATEGORY); + if (category && isToolkitCategory(category)) { + state.selectedCategory = category; + } + + const type = params.get(PARAM_TYPE); + if (type && isToolkitType(type)) { + state.selectedType = type; + } + + // Unknown labels (e.g. `?label=bogus`) are silently ignored. + for (const label of params.getAll(PARAM_LABEL)) { + if (!isLabel(label)) { + continue; + } + if (label === "pro") { + state.filterByPro = true; + } else if (label === "byoc") { + state.filterByByoc = true; + } + } + + const q = params.get(PARAM_SEARCH); + if (q) { + state.searchQuery = q; + } + + return state; +} + +export function serializeFiltersToParams(state: FilterState): string { + const params = new URLSearchParams(); + + if (state.selectedCategory !== "all") { + params.set(PARAM_CATEGORY, state.selectedCategory); + } + if (state.selectedType !== "all") { + params.set(PARAM_TYPE, state.selectedType); + } + // `append` (not `set`) so multiple labels round-trip as repeated keys. + if (state.filterByPro) { + params.append(PARAM_LABEL, "pro"); + } + if (state.filterByByoc) { + params.append(PARAM_LABEL, "byoc"); + } + if (state.searchQuery) { + params.set(PARAM_SEARCH, state.searchQuery); + } + + return params.toString(); +} + +export function hasActiveFilters(state: FilterState): boolean { + return ( + state.selectedCategory !== "all" || + state.selectedType !== "all" || + state.filterByPro || + state.filterByByoc || + state.searchQuery !== "" + ); +} diff --git a/app/en/resources/integrations/components/filters-bar.tsx b/app/en/resources/integrations/components/filters-bar.tsx index 9fc7024f2..044f4e47f 100644 --- a/app/en/resources/integrations/components/filters-bar.tsx +++ b/app/en/resources/integrations/components/filters-bar.tsx @@ -16,7 +16,7 @@ import { import { Check, Filter, FolderOpen, Layers, Search, X } from "lucide-react"; import type React from "react"; import { TYPE_CONFIG } from "./type-config"; -import { useFilterStore } from "./use-toolkit-filters"; +import { useFilters } from "./use-filters"; type FiltersBarProps = { resultsCount: number; @@ -35,7 +35,7 @@ export function FiltersBar({ resultsCount }: FiltersBarProps) { searchQuery, setSearchQuery, clearAllFilters, - } = useFilterStore(); + } = useFilters(); const hasActiveFilters = selectedCategory !== "all" || diff --git a/app/en/resources/integrations/components/toolkits-client.tsx b/app/en/resources/integrations/components/toolkits-client.tsx index 067a06838..d8f7e1a71 100644 --- a/app/en/resources/integrations/components/toolkits-client.tsx +++ b/app/en/resources/integrations/components/toolkits-client.tsx @@ -16,8 +16,8 @@ import { import { FiltersBar } from "./filters-bar"; import { ToolCard } from "./tool-card"; import { TYPE_CONFIG, TYPE_DESCRIPTIONS } from "./type-config"; -import { useFilterStore, useToolkitFilters } from "./use-toolkit-filters"; -import { useUrlFilterSync } from "./use-url-filter-sync"; +import { useFilters } from "./use-filters"; +import { useToolkitFilters } from "./use-toolkit-filters"; type ToolkitsClientProps = { toolkits: ToolkitWithDocsLink[]; @@ -64,9 +64,7 @@ function getToolkitIconWithFallback( } export default function ToolkitsClient({ toolkits }: ToolkitsClientProps) { - useUrlFilterSync(); - - const clearAllFilters = useFilterStore((state) => state.clearAllFilters); + const { clearAllFilters } = useFilters(); const { hasActiveFilters, filteredToolkits, resultsCount } = useToolkitFilters(toolkits); @@ -136,7 +134,7 @@ export default function ToolkitsClient({ toolkits }: ToolkitsClientProps) { -
+
diff --git a/app/en/resources/integrations/components/toolkits.tsx b/app/en/resources/integrations/components/toolkits.tsx index 03e46e613..b590f0ba3 100644 --- a/app/en/resources/integrations/components/toolkits.tsx +++ b/app/en/resources/integrations/components/toolkits.tsx @@ -1,4 +1,5 @@ import { TOOLKITS, type Toolkit } from "@arcadeai/design-system"; +import { Suspense } from "react"; import { readToolkitData } from "@/app/_lib/toolkit-data"; import { normalizeToolkitId } from "@/app/_lib/toolkit-slug"; import ToolkitsClient from "./toolkits-client"; @@ -44,5 +45,12 @@ const getToolkitsWithDocsLinks = async (): Promise => { export default async function Toolkits() { const toolkits = await getToolkitsWithDocsLinks(); - return ; + // Suspense boundary is required because ToolkitsClient calls + // useSearchParams(); without it, Next.js bails out of static prerendering + // for the whole page. + return ( + + + + ); } diff --git a/app/en/resources/integrations/components/use-filters.ts b/app/en/resources/integrations/components/use-filters.ts new file mode 100644 index 000000000..b312bc2d1 --- /dev/null +++ b/app/en/resources/integrations/components/use-filters.ts @@ -0,0 +1,107 @@ +"use client"; + +import type { ToolkitCategory, ToolkitType } from "@arcadeai/design-system"; +import { usePathname, useSearchParams } from "next/navigation"; +import { useCallback, useMemo } from "react"; +import { + DEFAULT_FILTER_STATE, + type FilterState, + parseFiltersFromParams, + serializeFiltersToParams, +} from "./filter-params"; + +type HistoryMethod = "push" | "replace"; + +/** + * Two-way binding between filter UI and URL query params, with the URL as the + * single source of truth. + * + * - Read: `useSearchParams()` re-renders this hook whenever the URL changes + * (including back/forward and `pushState`/`replaceState` calls), so the UI + * always reflects the current URL. + * - Write: setters compute the next state, serialize to a query string, and + * call `window.history.pushState` (or `replaceState` for the search input, + * to avoid one history entry per keystroke). Next.js picks up the change + * and re-renders subscribers automatically. + */ +export function useFilters() { + const searchParams = useSearchParams(); + const pathname = usePathname(); + + const state = useMemo( + () => parseFiltersFromParams(searchParams?.toString() ?? ""), + [searchParams] + ); + + const writeState = useCallback( + (next: FilterState, method: HistoryMethod = "push") => { + const qs = serializeFiltersToParams(next); + const url = qs ? `${pathname}?${qs}` : pathname; + const currentQs = searchParams?.toString() ?? ""; + const current = currentQs ? `${pathname}?${currentQs}` : pathname; + + if (url === current) { + return; + } + + if (method === "replace") { + window.history.replaceState(null, "", url); + } else { + window.history.pushState(null, "", url); + } + }, + [pathname, searchParams] + ); + + const setSelectedCategory = useCallback( + (category: ToolkitCategory) => { + writeState({ ...state, selectedCategory: category }); + }, + [state, writeState] + ); + + const setSelectedType = useCallback( + (type: ToolkitType | "all") => { + writeState({ ...state, selectedType: type }); + }, + [state, writeState] + ); + + const setFilterByPro = useCallback( + (value: boolean) => { + writeState({ ...state, filterByPro: value }); + }, + [state, writeState] + ); + + const setFilterByByoc = useCallback( + (value: boolean) => { + writeState({ ...state, filterByByoc: value }); + }, + [state, writeState] + ); + + // Search-query updates use replaceState to avoid one history entry per + // keystroke. Discrete filter changes above use pushState so back/forward + // steps through them. + const setSearchQuery = useCallback( + (query: string) => { + writeState({ ...state, searchQuery: query }, "replace"); + }, + [state, writeState] + ); + + const clearAllFilters = useCallback(() => { + writeState(DEFAULT_FILTER_STATE); + }, [writeState]); + + return { + ...state, + setSelectedCategory, + setSelectedType, + setFilterByPro, + setFilterByByoc, + setSearchQuery, + clearAllFilters, + }; +} diff --git a/app/en/resources/integrations/components/use-toolkit-filters.ts b/app/en/resources/integrations/components/use-toolkit-filters.ts index e26a5e71e..9f439735c 100644 --- a/app/en/resources/integrations/components/use-toolkit-filters.ts +++ b/app/en/resources/integrations/components/use-toolkit-filters.ts @@ -1,7 +1,7 @@ import type { Toolkit, ToolkitType } from "@arcadeai/design-system"; import { useDebounce } from "@uidotdev/usehooks"; import { useMemo } from "react"; -import { create } from "zustand"; +import { useFilters } from "./use-filters"; const DEFAULT_PRIORITY = 5; const DEBOUNCE_TIME = 300; @@ -45,41 +45,6 @@ const compareToolkits = (a: Toolkit, b: Toolkit): number => { return a.label.localeCompare(b.label); }; -type FilterState = { - selectedCategory: string; - selectedType: ToolkitType | "all"; - filterByPro: boolean; - filterByByoc: boolean; - searchQuery: string; - setSelectedCategory: (category: string) => void; - setSelectedType: (type: ToolkitType | "all") => void; - setFilterByPro: (value: boolean) => void; - setFilterByByoc: (value: boolean) => void; - setSearchQuery: (query: string) => void; - clearAllFilters: () => void; -}; - -export const useFilterStore = create((set) => ({ - selectedCategory: "all", - selectedType: "all", - filterByPro: false, - filterByByoc: false, - searchQuery: "", - setSelectedCategory: (category) => set({ selectedCategory: category }), - setSelectedType: (type) => set({ selectedType: type }), - setFilterByPro: (value) => set({ filterByPro: value }), - setFilterByByoc: (value) => set({ filterByByoc: value }), - setSearchQuery: (query) => set({ searchQuery: query }), - clearAllFilters: () => - set({ - selectedCategory: "all", - selectedType: "all", - filterByPro: false, - filterByByoc: false, - searchQuery: "", - }), -})); - export function useToolkitFilters(toolkits: Toolkit[]) { const { selectedCategory, @@ -87,7 +52,7 @@ export function useToolkitFilters(toolkits: Toolkit[]) { filterByPro, filterByByoc, searchQuery, - } = useFilterStore(); + } = useFilters(); const debouncedSearchQuery = useDebounce(searchQuery, DEBOUNCE_TIME); diff --git a/app/en/resources/integrations/components/use-url-filter-sync.ts b/app/en/resources/integrations/components/use-url-filter-sync.ts deleted file mode 100644 index 91ef58896..000000000 --- a/app/en/resources/integrations/components/use-url-filter-sync.ts +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import type { ToolkitType } from "@arcadeai/design-system"; -import { useEffect, useRef } from "react"; -import { useFilterStore } from "./use-toolkit-filters"; - -const VALID_TYPES = new Set([ - "arcade", - "arcade_starter", - "verified", - "community", - "auth", -]); - -const PARAM_CATEGORY = "category"; -const PARAM_TYPE = "type"; -const PARAM_PRO = "pro"; -const PARAM_BYOC = "byoc"; -const PARAM_SEARCH = "q"; - -function isTruthy(value: string | null): boolean { - return value !== null && value !== "" && value !== "0" && value !== "false"; -} - -export type ParsedFilters = Partial<{ - selectedCategory: string; - selectedType: ToolkitType | "all"; - filterByPro: boolean; - filterByByoc: boolean; - searchQuery: string; -}>; - -export function parseFiltersFromParams(search: string): ParsedFilters { - const params = new URLSearchParams(search); - const result: ParsedFilters = {}; - - const category = params.get(PARAM_CATEGORY); - if (category && category !== "all") { - result.selectedCategory = category; - } - - const type = params.get(PARAM_TYPE); - if (type && VALID_TYPES.has(type)) { - result.selectedType = type as ToolkitType; - } - - if (params.has(PARAM_PRO) && isTruthy(params.get(PARAM_PRO))) { - result.filterByPro = true; - } - - if (params.has(PARAM_BYOC) && isTruthy(params.get(PARAM_BYOC))) { - result.filterByByoc = true; - } - - const q = params.get(PARAM_SEARCH); - if (q) { - result.searchQuery = q; - } - - return result; -} - -export type SerializableFilterState = { - selectedCategory: string; - selectedType: string; - filterByPro: boolean; - filterByByoc: boolean; - searchQuery: string; -}; - -export function serializeFiltersToParams( - state: SerializableFilterState -): string { - const params = new URLSearchParams(); - - if (state.selectedCategory !== "all") { - params.set(PARAM_CATEGORY, state.selectedCategory); - } - if (state.selectedType !== "all") { - params.set(PARAM_TYPE, state.selectedType); - } - if (state.filterByPro) { - params.set(PARAM_PRO, "1"); - } - if (state.filterByByoc) { - params.set(PARAM_BYOC, "1"); - } - if (state.searchQuery) { - params.set(PARAM_SEARCH, state.searchQuery); - } - - return params.toString(); -} - -function writeFiltersToUrl(state: SerializableFilterState): void { - const qs = serializeFiltersToParams(state); - const newUrl = qs - ? `${window.location.pathname}?${qs}` - : window.location.pathname; - - if (newUrl !== `${window.location.pathname}${window.location.search}`) { - window.history.replaceState(null, "", newUrl); - } -} - -/** - * Two-way sync between the Zustand filter store and URL query params. - * - * - Mount: URL → store (so shared/bookmarked links work) - * - Filter change: store → URL (via replaceState, no navigation) - * - Back/forward: URL → store (via popstate listener) - */ -export function useUrlFilterSync(): void { - const hydrated = useRef(false); - - useEffect(() => { - if (!hydrated.current) { - hydrated.current = true; - const fromUrl = parseFiltersFromParams(window.location.search); - if (Object.keys(fromUrl).length > 0) { - useFilterStore.setState(fromUrl); - } - } - - const unsubscribe = useFilterStore.subscribe((state) => { - writeFiltersToUrl(state); - }); - - const handlePopState = () => { - const defaults = { - selectedCategory: "all", - selectedType: "all" as const, - filterByPro: false, - filterByByoc: false, - searchQuery: "", - }; - const fromUrl = parseFiltersFromParams(window.location.search); - useFilterStore.setState({ ...defaults, ...fromUrl }); - }; - - window.addEventListener("popstate", handlePopState); - - return () => { - unsubscribe(); - window.removeEventListener("popstate", handlePopState); - }; - }, []); -} diff --git a/tests/filter-params.test.ts b/tests/filter-params.test.ts new file mode 100644 index 000000000..3169bfe87 --- /dev/null +++ b/tests/filter-params.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, test } from "vitest"; +import { + DEFAULT_FILTER_STATE, + type FilterState, + hasActiveFilters, + isLabel, + isToolkitCategory, + isToolkitType, + parseFiltersFromParams, + serializeFiltersToParams, +} from "../app/en/resources/integrations/components/filter-params"; + +describe("isToolkitType", () => { + test("accepts known toolkit types", () => { + expect(isToolkitType("arcade")).toBe(true); + expect(isToolkitType("arcade_starter")).toBe(true); + expect(isToolkitType("verified")).toBe(true); + expect(isToolkitType("community")).toBe(true); + expect(isToolkitType("auth")).toBe(true); + }); + + test("rejects unknown values", () => { + expect(isToolkitType("bogus")).toBe(false); + expect(isToolkitType("ARCADE")).toBe(false); + expect(isToolkitType("")).toBe(false); + }); +}); + +describe("isToolkitCategory", () => { + test("accepts known categories", () => { + expect(isToolkitCategory("all")).toBe(true); + expect(isToolkitCategory("productivity")).toBe(true); + expect(isToolkitCategory("social")).toBe(true); + expect(isToolkitCategory("development")).toBe(true); + expect(isToolkitCategory("customer-support")).toBe(true); + }); + + test("rejects unknown values", () => { + expect(isToolkitCategory("bogus")).toBe(false); + expect(isToolkitCategory("Productivity")).toBe(false); + expect(isToolkitCategory("")).toBe(false); + }); +}); + +describe("isLabel", () => { + test("accepts known labels", () => { + expect(isLabel("pro")).toBe(true); + expect(isLabel("byoc")).toBe(true); + }); + + test("rejects unknown values", () => { + expect(isLabel("PRO")).toBe(false); + expect(isLabel("bogus")).toBe(false); + expect(isLabel("")).toBe(false); + expect(isLabel("1")).toBe(false); + }); +}); + +describe("parseFiltersFromParams", () => { + test("returns defaults for no params", () => { + expect(parseFiltersFromParams("")).toEqual(DEFAULT_FILTER_STATE); + expect(parseFiltersFromParams(null)).toEqual(DEFAULT_FILTER_STATE); + expect(parseFiltersFromParams(undefined)).toEqual(DEFAULT_FILTER_STATE); + }); + + test("parses pro label", () => { + expect(parseFiltersFromParams("?label=pro")).toEqual({ + ...DEFAULT_FILTER_STATE, + filterByPro: true, + }); + }); + + test("parses byoc label", () => { + expect(parseFiltersFromParams("?label=byoc")).toEqual({ + ...DEFAULT_FILTER_STATE, + filterByByoc: true, + }); + }); + + test("parses pro + byoc together (repeated key)", () => { + expect(parseFiltersFromParams("?label=pro&label=byoc")).toEqual({ + ...DEFAULT_FILTER_STATE, + filterByPro: true, + filterByByoc: true, + }); + }); + + test("label order does not matter", () => { + expect(parseFiltersFromParams("?label=byoc&label=pro")).toEqual({ + ...DEFAULT_FILTER_STATE, + filterByPro: true, + filterByByoc: true, + }); + }); + + test("parses category", () => { + expect(parseFiltersFromParams("?category=development")).toEqual({ + ...DEFAULT_FILTER_STATE, + selectedCategory: "development", + }); + }); + + test("parses type", () => { + expect(parseFiltersFromParams("?type=arcade")).toEqual({ + ...DEFAULT_FILTER_STATE, + selectedType: "arcade", + }); + }); + + test("parses search query", () => { + expect(parseFiltersFromParams("?q=gmail")).toEqual({ + ...DEFAULT_FILTER_STATE, + searchQuery: "gmail", + }); + }); + + test("parses all params at once", () => { + expect( + parseFiltersFromParams( + "?category=productivity&type=verified&label=pro&label=byoc&q=slack" + ) + ).toEqual({ + selectedCategory: "productivity", + selectedType: "verified", + filterByPro: true, + filterByByoc: true, + searchQuery: "slack", + }); + }); + + test("ignores invalid type values", () => { + expect(parseFiltersFromParams("?type=bogus")).toEqual(DEFAULT_FILTER_STATE); + }); + + test("ignores invalid category values", () => { + expect(parseFiltersFromParams("?category=bogus")).toEqual( + DEFAULT_FILTER_STATE + ); + expect(parseFiltersFromParams("?category=Productivity")).toEqual( + DEFAULT_FILTER_STATE + ); + }); + + test("ignores category=all (already the default)", () => { + expect(parseFiltersFromParams("?category=all")).toEqual( + DEFAULT_FILTER_STATE + ); + }); + + test("ignores unknown label values", () => { + expect(parseFiltersFromParams("?label=bogus")).toEqual( + DEFAULT_FILTER_STATE + ); + expect(parseFiltersFromParams("?label=PRO")).toEqual(DEFAULT_FILTER_STATE); + expect(parseFiltersFromParams("?label=")).toEqual(DEFAULT_FILTER_STATE); + }); + + test("applies known labels and ignores unknown ones in the same URL", () => { + expect(parseFiltersFromParams("?label=pro&label=bogus")).toEqual({ + ...DEFAULT_FILTER_STATE, + filterByPro: true, + }); + }); + + test("ignores duplicate label values", () => { + expect(parseFiltersFromParams("?label=pro&label=pro")).toEqual({ + ...DEFAULT_FILTER_STATE, + filterByPro: true, + }); + }); + + test("ignores unknown params gracefully", () => { + expect(parseFiltersFromParams("?foo=bar&baz=1")).toEqual( + DEFAULT_FILTER_STATE + ); + }); + + test("does not treat legacy ?pro=1 as truthy", () => { + // The old URL shape (`?pro=1&byoc=1`) has been retired; only `?label=...` + // is recognized. Legacy URLs degrade to defaults rather than throwing. + expect(parseFiltersFromParams("?pro=1&byoc=1")).toEqual( + DEFAULT_FILTER_STATE + ); + }); + + test("accepts URLSearchParams instance", () => { + const params = new URLSearchParams(); + params.append("label", "pro"); + params.set("q", "gmail"); + expect(parseFiltersFromParams(params)).toEqual({ + ...DEFAULT_FILTER_STATE, + filterByPro: true, + searchQuery: "gmail", + }); + }); +}); + +describe("serializeFiltersToParams", () => { + test("returns empty string for default state", () => { + expect(serializeFiltersToParams(DEFAULT_FILTER_STATE)).toBe(""); + }); + + test("serializes pro label", () => { + expect( + serializeFiltersToParams({ ...DEFAULT_FILTER_STATE, filterByPro: true }) + ).toBe("label=pro"); + }); + + test("serializes byoc label", () => { + expect( + serializeFiltersToParams({ ...DEFAULT_FILTER_STATE, filterByByoc: true }) + ).toBe("label=byoc"); + }); + + test("serializes both labels as repeated keys", () => { + expect( + serializeFiltersToParams({ + ...DEFAULT_FILTER_STATE, + filterByPro: true, + filterByByoc: true, + }) + ).toBe("label=pro&label=byoc"); + }); + + test("serializes category", () => { + expect( + serializeFiltersToParams({ + ...DEFAULT_FILTER_STATE, + selectedCategory: "development", + }) + ).toBe("category=development"); + }); + + test("serializes type", () => { + expect( + serializeFiltersToParams({ + ...DEFAULT_FILTER_STATE, + selectedType: "arcade", + }) + ).toBe("type=arcade"); + }); + + test("serializes search query", () => { + expect( + serializeFiltersToParams({ + ...DEFAULT_FILTER_STATE, + searchQuery: "gmail", + }) + ).toBe("q=gmail"); + }); + + test("serializes all filters at once", () => { + const qs = serializeFiltersToParams({ + selectedCategory: "productivity", + selectedType: "verified", + filterByPro: true, + filterByByoc: true, + searchQuery: "slack", + }); + const params = new URLSearchParams(qs); + expect(params.get("category")).toBe("productivity"); + expect(params.get("type")).toBe("verified"); + expect(params.getAll("label")).toEqual(["pro", "byoc"]); + expect(params.get("q")).toBe("slack"); + }); +}); + +describe("round-trip", () => { + test("serialize then parse produces equivalent filters", () => { + const state: FilterState = { + selectedCategory: "social", + selectedType: "community", + filterByPro: true, + filterByByoc: false, + searchQuery: "twitter", + }; + const qs = serializeFiltersToParams(state); + expect(parseFiltersFromParams(`?${qs}`)).toEqual(state); + }); + + test("both labels round-trip", () => { + const state: FilterState = { + ...DEFAULT_FILTER_STATE, + filterByPro: true, + filterByByoc: true, + }; + const qs = serializeFiltersToParams(state); + expect(qs).toBe("label=pro&label=byoc"); + expect(parseFiltersFromParams(`?${qs}`)).toEqual(state); + }); + + test("default state round-trips to empty query string", () => { + const qs = serializeFiltersToParams(DEFAULT_FILTER_STATE); + expect(qs).toBe(""); + expect(parseFiltersFromParams(qs)).toEqual(DEFAULT_FILTER_STATE); + }); +}); + +describe("hasActiveFilters", () => { + test("returns false for default state", () => { + expect(hasActiveFilters(DEFAULT_FILTER_STATE)).toBe(false); + }); + + test("returns true when any filter is active", () => { + expect( + hasActiveFilters({ ...DEFAULT_FILTER_STATE, filterByPro: true }) + ).toBe(true); + expect( + hasActiveFilters({ ...DEFAULT_FILTER_STATE, selectedCategory: "social" }) + ).toBe(true); + expect( + hasActiveFilters({ ...DEFAULT_FILTER_STATE, searchQuery: "x" }) + ).toBe(true); + }); +}); diff --git a/tests/url-filter-sync.test.ts b/tests/url-filter-sync.test.ts deleted file mode 100644 index 8782d38d4..000000000 --- a/tests/url-filter-sync.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - parseFiltersFromParams, - type SerializableFilterState, - serializeFiltersToParams, -} from "../app/en/resources/integrations/components/use-url-filter-sync"; - -const DEFAULT_STATE: SerializableFilterState = { - selectedCategory: "all", - selectedType: "all", - filterByPro: false, - filterByByoc: false, - searchQuery: "", -}; - -describe("parseFiltersFromParams", () => { - test("returns empty object for no params", () => { - expect(parseFiltersFromParams("")).toEqual({}); - }); - - test("parses pro filter", () => { - expect(parseFiltersFromParams("?pro=1")).toEqual({ filterByPro: true }); - }); - - test("parses byoc filter", () => { - expect(parseFiltersFromParams("?byoc=1")).toEqual({ filterByByoc: true }); - }); - - test("parses pro + byoc together", () => { - expect(parseFiltersFromParams("?pro=1&byoc=1")).toEqual({ - filterByPro: true, - filterByByoc: true, - }); - }); - - test("parses category", () => { - expect(parseFiltersFromParams("?category=development")).toEqual({ - selectedCategory: "development", - }); - }); - - test("parses type", () => { - expect(parseFiltersFromParams("?type=arcade")).toEqual({ - selectedType: "arcade", - }); - }); - - test("parses search query", () => { - expect(parseFiltersFromParams("?q=gmail")).toEqual({ - searchQuery: "gmail", - }); - }); - - test("parses all params at once", () => { - expect( - parseFiltersFromParams( - "?category=productivity&type=verified&pro=1&byoc=1&q=slack" - ) - ).toEqual({ - selectedCategory: "productivity", - selectedType: "verified", - filterByPro: true, - filterByByoc: true, - searchQuery: "slack", - }); - }); - - test("ignores invalid type values", () => { - expect(parseFiltersFromParams("?type=bogus")).toEqual({}); - }); - - test("ignores falsy pro values", () => { - expect(parseFiltersFromParams("?pro=0")).toEqual({}); - expect(parseFiltersFromParams("?pro=false")).toEqual({}); - expect(parseFiltersFromParams("?pro=")).toEqual({}); - }); - - test("ignores category=all", () => { - expect(parseFiltersFromParams("?category=all")).toEqual({}); - }); - - test("ignores unknown params gracefully", () => { - expect(parseFiltersFromParams("?foo=bar&baz=1")).toEqual({}); - }); -}); - -describe("serializeFiltersToParams", () => { - test("returns empty string for default state", () => { - expect(serializeFiltersToParams(DEFAULT_STATE)).toBe(""); - }); - - test("serializes pro filter", () => { - expect( - serializeFiltersToParams({ ...DEFAULT_STATE, filterByPro: true }) - ).toBe("pro=1"); - }); - - test("serializes byoc filter", () => { - expect( - serializeFiltersToParams({ ...DEFAULT_STATE, filterByByoc: true }) - ).toBe("byoc=1"); - }); - - test("serializes category", () => { - expect( - serializeFiltersToParams({ - ...DEFAULT_STATE, - selectedCategory: "development", - }) - ).toBe("category=development"); - }); - - test("serializes type", () => { - expect( - serializeFiltersToParams({ - ...DEFAULT_STATE, - selectedType: "arcade", - }) - ).toBe("type=arcade"); - }); - - test("serializes search query", () => { - expect( - serializeFiltersToParams({ ...DEFAULT_STATE, searchQuery: "gmail" }) - ).toBe("q=gmail"); - }); - - test("serializes all filters at once", () => { - const qs = serializeFiltersToParams({ - selectedCategory: "productivity", - selectedType: "verified", - filterByPro: true, - filterByByoc: true, - searchQuery: "slack", - }); - const params = new URLSearchParams(qs); - expect(params.get("category")).toBe("productivity"); - expect(params.get("type")).toBe("verified"); - expect(params.get("pro")).toBe("1"); - expect(params.get("byoc")).toBe("1"); - expect(params.get("q")).toBe("slack"); - }); -}); - -describe("round-trip", () => { - test("serialize then parse produces equivalent filters", () => { - const state: SerializableFilterState = { - selectedCategory: "social", - selectedType: "community", - filterByPro: true, - filterByByoc: false, - searchQuery: "twitter", - }; - const qs = serializeFiltersToParams(state); - const parsed = parseFiltersFromParams(`?${qs}`); - expect(parsed).toEqual({ - selectedCategory: "social", - selectedType: "community", - filterByPro: true, - searchQuery: "twitter", - }); - }); - - test("default state round-trips to empty", () => { - const qs = serializeFiltersToParams(DEFAULT_STATE); - expect(qs).toBe(""); - const parsed = parseFiltersFromParams(qs); - expect(parsed).toEqual({}); - }); -});