From db7755f3362b21e43ecb13045f2711248b1d5669 Mon Sep 17 00:00:00 2001 From: Valerie Fanelle Date: Fri, 24 Apr 2026 16:48:01 -0700 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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,