diff --git a/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.test.tsx b/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.test.tsx index 7aaaa663..5d504c6d 100644 --- a/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.test.tsx +++ b/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.test.tsx @@ -15,7 +15,7 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import userEvent from "@testing-library/user-event"; import { JavaInstrumentationListPage } from "./java-instrumentation-list-page"; import type { InstrumentationData } from "@/types/javaagent"; @@ -31,6 +31,11 @@ vi.mock("@/components/ui/back-button", () => ({ import { useVersions, useInstrumentations } from "@/hooks/use-javaagent-data"; +function LocationDisplay() { + const location = useLocation(); + return
{location.pathname + location.search}
; +} + function renderPage(initialPath = "/java-agent/instrumentation/2.0.0") { return render( @@ -41,94 +46,185 @@ function renderPage(initialPath = "/java-agent/instrumentation/2.0.0") { element={} /> + ); } -describe("JavaInstrumentationListPage - Filtering", () => { - const mockInstrumentations: InstrumentationData[] = [ - { - name: "http-client", - display_name: "HTTP Client", - description: "Instrumentation for HTTP clients", - scope: { name: "http" }, - has_javaagent: true, - javaagent_target_versions: ["1.0.0"], - telemetry: [{ when: "always", spans: [{ span_kind: "CLIENT" }] }], - semantic_conventions: ["http"], - features: ["stable"], - }, - { - name: "jdbc", - display_name: "JDBC", - description: "Database instrumentation for JDBC", - scope: { name: "jdbc" }, - has_standalone_library: true, - telemetry: [ - { - when: "always", - metrics: [ - { - name: "db.connections", - description: "DB connections", - instrument: "counter", - data_type: "LONG_SUM", - unit: "1", - }, - ], - }, - ], - semantic_conventions: ["db"], - }, - { - name: "kafka-client", - display_name: "Kafka Client", - description: "Messaging instrumentation for Kafka", - scope: { name: "kafka" }, - has_javaagent: true, - javaagent_target_versions: ["1.0.0"], - has_standalone_library: true, - telemetry: [ - { - when: "always", - spans: [{ span_kind: "PRODUCER" }], - metrics: [ - { - name: "kafka.messages", - description: "Messages sent", - data_type: "COUNTER", - instrument: "counter", - unit: "1", - }, - ], - }, - ], - semantic_conventions: ["messaging"], - features: ["stable"], - }, - { - name: "spring-web", - display_name: "Spring Web", - description: "Instrumentation for Spring Web applications", - scope: { name: "spring" }, - has_javaagent: true, - javaagent_target_versions: ["1.0.0"], - features: ["experimental"], - }, - ]; - - beforeEach(() => { - vi.mocked(useVersions).mockReturnValue({ - data: { - versions: [ - { version: "2.0.0", is_latest: true }, - { version: "1.9.0", is_latest: false }, +const mockVersions = { + data: { + versions: [ + { version: "2.0.0", is_latest: true }, + { version: "1.9.0", is_latest: false }, + ], + }, + loading: false, + error: null, +}; + +const mockInstrumentations: InstrumentationData[] = [ + { + name: "http-client", + display_name: "HTTP Client", + description: "Instrumentation for HTTP clients", + scope: { name: "http" }, + has_javaagent: true, + javaagent_target_versions: ["1.0.0"], + telemetry: [{ when: "always", spans: [{ span_kind: "CLIENT" }] }], + semantic_conventions: ["http"], + features: ["stable"], + }, + { + name: "jdbc", + display_name: "JDBC", + description: "Database instrumentation for JDBC", + scope: { name: "jdbc" }, + has_standalone_library: true, + telemetry: [ + { + when: "always", + metrics: [ + { + name: "db.connections", + description: "DB connections", + instrument: "counter", + data_type: "LONG_SUM", + unit: "1", + }, + ], + }, + ], + semantic_conventions: ["db"], + }, + { + name: "kafka-client", + display_name: "Kafka Client", + description: "Messaging instrumentation for Kafka", + scope: { name: "kafka" }, + has_javaagent: true, + javaagent_target_versions: ["1.0.0"], + has_standalone_library: true, + telemetry: [ + { + when: "always", + spans: [{ span_kind: "PRODUCER" }], + metrics: [ + { + name: "kafka.messages", + description: "Messages sent", + data_type: "COUNTER", + instrument: "counter", + unit: "1", + }, ], }, + ], + semantic_conventions: ["messaging"], + features: ["stable"], + }, + { + name: "spring-web", + display_name: "Spring Web", + description: "Instrumentation for Spring Web applications", + scope: { name: "spring" }, + has_javaagent: true, + javaagent_target_versions: ["1.0.0"], + features: ["experimental"], + }, +]; + +describe("JavaInstrumentationListPage - URL Persistence", () => { + beforeEach(() => { + vi.mocked(useVersions).mockReturnValue(mockVersions); + vi.mocked(useInstrumentations).mockReturnValue({ + data: mockInstrumentations, loading: false, error: null, }); + }); + + it("reads search query from URL on mount", async () => { + renderPage("/java-agent/instrumentation/2.0.0?search=kafka"); + + await waitFor(() => { + expect(screen.getByText("Kafka Client")).toBeInTheDocument(); + expect(screen.queryByText("HTTP Client")).not.toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText("Search instrumentations..."); + expect(searchInput).toHaveValue("kafka"); + }); + + it("writes search query to URL when user types", async () => { + const user = userEvent.setup(); + renderPage(); + + const searchInput = await screen.findByPlaceholderText("Search instrumentations..."); + await user.type(searchInput, "http"); + + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toContain("search=http"); + }); + }); + + it("reads telemetry filter from URL on mount", async () => { + renderPage("/java-agent/instrumentation/2.0.0?telemetry=spans"); + + await waitFor(() => { + expect(screen.getByText("HTTP Client")).toBeInTheDocument(); + expect(screen.getByText("Kafka Client")).toBeInTheDocument(); + expect(screen.queryByText("JDBC")).not.toBeInTheDocument(); + }); + }); + + it("writes telemetry filter to URL when toggled", async () => { + const user = userEvent.setup(); + renderPage(); + + const spansButton = await screen.findByRole("button", { name: "Spans" }); + await user.click(spansButton); + + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toContain("telemetry=spans"); + }); + }); + + it("reads type filter from URL on mount", async () => { + renderPage("/java-agent/instrumentation/2.0.0?type=javaagent"); + + await waitFor(() => { + expect(screen.getByText("HTTP Client")).toBeInTheDocument(); + expect(screen.queryByText("JDBC")).not.toBeInTheDocument(); + }); + }); + it("writes type filter to URL when toggled", async () => { + const user = userEvent.setup(); + renderPage(); + + const javaAgentButton = await screen.findByRole("button", { name: "Java Agent" }); + await user.click(javaAgentButton); + + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toContain("type=javaagent"); + }); + }); + + it("preserves existing search params when redirecting no-version to latest", async () => { + renderPage("/java-agent/instrumentation/latest?search=kafka&telemetry=spans"); + + await waitFor(() => { + const loc = screen.getByTestId("location").textContent ?? ""; + expect(loc).toContain("2.0.0"); + expect(loc).toContain("search=kafka"); + expect(loc).toContain("telemetry=spans"); + }); + }); +}); + +describe("JavaInstrumentationListPage - Filtering", () => { + beforeEach(() => { + vi.mocked(useVersions).mockReturnValue(mockVersions); vi.mocked(useInstrumentations).mockReturnValue({ data: mockInstrumentations, loading: false, diff --git a/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.tsx b/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.tsx index 9e4e72e2..57337e43 100644 --- a/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.tsx +++ b/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AlertCircle, Loader2 } from "lucide-react"; +import { AlertCircle, Loader2, X } from "lucide-react"; import { BackButton } from "@/components/ui/back-button"; import { useVersions, useInstrumentations } from "@/hooks/use-javaagent-data"; import { @@ -21,8 +21,7 @@ import { InstrumentationFilterBar, } from "@/features/java-agent/components/instrumentation-filter-bar.tsx"; import { useMemo, useState, useEffect } from "react"; -import { useParams, useNavigate, useSearchParams } from "react-router-dom"; -import { X } from "lucide-react"; +import { useParams, useNavigate, useSearchParams, useLocation } from "react-router-dom"; import { InstrumentationGroupCard } from "@/features/java-agent/components/instrumentation-group-card.tsx"; import { VersionSelector } from "@/features/java-agent/components/version-selector"; import { getInstrumentationDisplayName } from "./utils/format"; @@ -32,12 +31,13 @@ import { PageContainer } from "@/components/layout/page-container"; export function JavaInstrumentationListPage() { const { version: versionParam } = useParams<{ version?: string }>(); const navigate = useNavigate(); + const location = useLocation(); const { data: versionsData, loading: versionsLoading, error: versionsError } = useVersions(); const latestVersion = versionsData?.versions.find((v) => v.is_latest)?.version ?? ""; - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const invalidVersion = searchParams.get("redirectedFrom"); const [bannerDismissed, setBannerDismissed] = useState(false); @@ -46,19 +46,21 @@ export function JavaInstrumentationListPage() { versionParam === "latest" || (!!versionsData && versionsData.versions.some((v) => v.version === versionParam)); - // Redirect /java-agent/instrumentation (no version) or /latest to the actual latest version - // Also redirect invalid versions to latest and show a dismissible inline alert + // Redirect: no-version/latest → latest (preserving query params so shared filter URLs survive). + // Invalid version → latest with redirectedFrom banner. useEffect(() => { if (versionsData && latestVersion) { if (!versionParam || versionParam === "latest") { - navigate(`/java-agent/instrumentation/${latestVersion}`, { replace: true }); + navigate(`/java-agent/instrumentation/${latestVersion}${location.search}`, { + replace: true, + }); } else if (!isVersionValid) { navigate(`/java-agent/instrumentation/${latestVersion}?redirectedFrom=${versionParam}`, { replace: true, }); } } - }, [versionParam, versionsData, latestVersion, navigate, isVersionValid]); + }, [versionParam, versionsData, latestVersion, navigate, isVersionValid, location.search]); const resolvedVersion = versionParam && versionParam !== "latest" ? versionParam : ""; @@ -68,13 +70,95 @@ export function JavaInstrumentationListPage() { error, } = useInstrumentations(resolvedVersion); - const [filters, setFilters] = useState({ - search: "", - telemetry: new Set(), - target: new Set(), - semantic: [], - features: [], - }); + const urlSearch = searchParams.get("search") ?? ""; + + // Local state for the search input — updates immediately on every keystroke, + // avoiding the URL round-trip lag that causes flicker on CTRL+A + type. + const [localSearch, setLocalSearch] = useState(urlSearch); + + // Detect external URL changes (back/forward) and sync the input using + // React's documented derived state during render pattern. + const [syncedUrlSearch, setSyncedUrlSearch] = useState(urlSearch); + if (syncedUrlSearch !== urlSearch) { + setSyncedUrlSearch(urlSearch); + setLocalSearch(urlSearch); + } + + const filters: FilterState = useMemo(() => { + const telemetryParam = searchParams.get("telemetry"); + const typeParam = searchParams.get("type"); + const semanticParam = searchParams.get("semantic"); + const featuresParam = searchParams.get("features"); + + const telemetry = new Set<"spans" | "metrics">(); + if (telemetryParam) { + telemetryParam.split(",").forEach((v) => { + if (v === "spans" || v === "metrics") telemetry.add(v); + }); + } + + const target = new Set<"javaagent" | "library">(); + if (typeParam) { + typeParam.split(",").forEach((v) => { + if (v === "javaagent" || v === "library") target.add(v); + }); + } + + const semantic = semanticParam ? semanticParam.split(",").filter(Boolean) : []; + const features = featuresParam ? featuresParam.split(",").filter(Boolean) : []; + + return { search: localSearch, telemetry, target, semantic, features }; + }, [searchParams, localSearch]); + + const handleFiltersChange = (newFilters: FilterState) => { + // Update local search state immediately so the input never lags behind keystrokes. + setLocalSearch(newFilters.search); + + // Use replace for high-frequency search typing to avoid polluting browser history. + // Use push (replace: false) for discrete toggle changes so back button works. + const setsUnchanged = + newFilters.telemetry.size === filters.telemetry.size && + newFilters.target.size === filters.target.size && + newFilters.semantic.length === filters.semantic.length && + newFilters.features.length === filters.features.length && + [...newFilters.telemetry].every((v) => filters.telemetry.has(v)) && + [...newFilters.target].every((v) => filters.target.has(v)) && + newFilters.semantic.every((v, i) => filters.semantic[i] === v) && + newFilters.features.every((v, i) => filters.features[i] === v); + + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (newFilters.search) { + next.set("search", newFilters.search); + } else { + next.delete("search"); + } + if (newFilters.telemetry.size > 0) { + next.set("telemetry", [...newFilters.telemetry].join(",")); + } else { + next.delete("telemetry"); + } + if (newFilters.target.size > 0) { + next.set("type", [...newFilters.target].join(",")); + } else { + next.delete("type"); + } + if (newFilters.semantic.length > 0) { + next.set("semantic", newFilters.semantic.join(",")); + } else { + next.delete("semantic"); + } + if (newFilters.features.length > 0) { + next.set("features", newFilters.features.join(",")); + } else { + next.delete("features"); + } + return next; + }, + { replace: setsUnchanged } + ); + }; const filteredInstrumentations = useMemo(() => { if (!instrumentations) return []; @@ -125,9 +209,7 @@ export function JavaInstrumentationListPage() { } if (filters.features.length > 0) { - const hasMatch = filters.features.some((f) => { - return instr.features?.includes(f); - }); + const hasMatch = filters.features.some((f) => instr.features?.includes(f)); if (!hasMatch) return false; } @@ -153,7 +235,10 @@ export function JavaInstrumentationListPage() { ); const handleVersionChange = (newVersion: string) => { - navigate(`/java-agent/instrumentation/${newVersion}`); + const params = new URLSearchParams(location.search); + params.delete("redirectedFrom"); + const query = params.toString(); + navigate(`/java-agent/instrumentation/${newVersion}${query ? `?${query}` : ""}`); }; return ( @@ -201,7 +286,7 @@ export function JavaInstrumentationListPage() {