diff --git a/next-env.d.ts b/next-env.d.ts deleted file mode 100644 index c4b7818f..00000000 --- a/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/dev/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx b/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx index 27a1776b..d8aa27e5 100644 --- a/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx +++ b/src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx @@ -35,6 +35,8 @@ const DiffIntro = forwardRef, DiffIntroProps>( left={ } @@ -46,6 +48,8 @@ const DiffIntro = forwardRef, DiffIntroProps>( right={ } diff --git a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx index 56aadf74..64db798c 100644 --- a/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx +++ b/src/app/[...parts]/_page/DiffIntro/SpecBox.tsx @@ -1,5 +1,6 @@ import { forwardRef, type HTMLAttributes } from "react"; import Pkg from "^/components/ui/Pkg"; +import PkgWithVersionSelector from "^/components/ui/PkgWithVersionSelector"; import { cx } from "^/lib/cva"; import type SimplePackageSpec from "^/lib/SimplePackageSpec"; import { simplePackageSpecToString } from "^/lib/SimplePackageSpec"; @@ -9,12 +10,25 @@ import ServiceLinks from "./ServiceLinks"; interface SpecBoxProps extends HTMLAttributes { pkg: SimplePackageSpec; pkgClassName?: string; + /** The other package spec (for interactive version selection) */ + otherPkg?: SimplePackageSpec; + /** Which side this is ('a' or 'b') - required for version selection */ + side?: "a" | "b"; } const SpecBox = forwardRef( - ({ pkg, pkgClassName, ...props }, ref) => ( + ({ pkg, pkgClassName, otherPkg, side, ...props }, ref) => (
- + {otherPkg && side ? ( + + ) : ( + + )} ({ + useRouter: jest.fn(), +})); + +// Mock fetch +global.fetch = jest.fn(); + +describe("VersionSelector", () => { + const mockPush = jest.fn(); + const mockFetch = global.fetch as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + (useRouter as jest.Mock).mockReturnValue({ + push: mockPush, + }); + }); + + it("renders loading state initially", () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [], + } as Response); + + render( + , + ); + + expect(screen.getByText("18.0.0")).toBeInTheDocument(); + }); + + it("fetches and displays versions", async () => { + const mockVersions = [ + { version: "18.2.0", time: "2023-01-01" }, + { version: "18.1.0", time: "2022-12-01" }, + { version: "18.0.0", time: "2022-11-01" }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockVersions, + } as Response); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + const select = screen.getByRole("combobox") as HTMLSelectElement; + expect(select.value).toBe("18.0.0"); + }); + + it("navigates to new diff when version is selected (side a)", async () => { + const mockVersions = [ + { version: "18.2.0", time: "2023-01-01" }, + { version: "18.0.0", time: "2022-11-01" }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockVersions, + } as Response); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + const select = screen.getByRole("combobox") as HTMLSelectElement; + fireEvent.change(select, { target: { value: "18.2.0" } }); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith( + "/react@18.2.0...react@17.0.0", + ); + }); + }); + + it("navigates to new diff when version is selected (side b)", async () => { + const mockVersions = [ + { version: "18.2.0", time: "2023-01-01" }, + { version: "17.0.0", time: "2022-11-01" }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockVersions, + } as Response); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + const select = screen.getByRole("combobox") as HTMLSelectElement; + fireEvent.change(select, { target: { value: "18.2.0" } }); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith( + "/react@18.0.0...react@18.2.0", + ); + }); + }); + + it("displays version tags when available", async () => { + const mockVersions = [ + { + version: "18.2.0", + time: "2023-01-01", + tags: ["latest", "next"], + }, + { version: "18.0.0", time: "2022-11-01" }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockVersions, + } as Response); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + // Check if option with tags exists + const option = screen.getByText("18.2.0 (latest, next)"); + expect(option).toBeInTheDocument(); + }); +}); diff --git a/src/app/[...parts]/_page/DiffIntro/VersionSelector.tsx b/src/app/[...parts]/_page/DiffIntro/VersionSelector.tsx new file mode 100644 index 00000000..16404734 --- /dev/null +++ b/src/app/[...parts]/_page/DiffIntro/VersionSelector.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { type Version } from "^/app/api/-/versions/types"; +import type SimplePackageSpec from "^/lib/SimplePackageSpec"; + +interface VersionSelectorProps { + /** The current package spec */ + currentSpec: SimplePackageSpec; + /** The other package spec (to keep in the diff) */ + otherSpec: SimplePackageSpec; + /** Whether this is the 'a' (left/from) or 'b' (right/to) side */ + side: "a" | "b"; + /** Class name for the select element */ + className?: string; +} + +export default function VersionSelector({ + currentSpec, + otherSpec, + side, + className, +}: VersionSelectorProps) { + const router = useRouter(); + const [versions, setVersions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + useEffect(() => { + const fetchVersions = async () => { + try { + setIsLoading(true); + setHasError(false); + const response = await fetch( + `/api/-/versions?package=${encodeURIComponent(currentSpec.name)}`, + ); + if (response.ok) { + const data: Version[] = await response.json(); + setVersions(data); + } else { + console.error( + `Failed to fetch versions: ${response.status} ${response.statusText}`, + ); + setHasError(true); + } + } catch (error) { + console.error("Failed to fetch versions:", error); + setHasError(true); + } finally { + setIsLoading(false); + } + }; + + fetchVersions(); + }, [currentSpec.name]); + + const handleVersionChange = (newVersion: string) => { + if (newVersion === currentSpec.version) { + return; // No change + } + + // Build the new URL based on which side we're updating + const newA = + side === "a" + ? `${currentSpec.name}@${newVersion}` + : `${otherSpec.name}@${otherSpec.version}`; + const newB = + side === "b" + ? `${currentSpec.name}@${newVersion}` + : `${otherSpec.name}@${otherSpec.version}`; + + // Navigate to the new diff URL + router.push(`/${newA}...${newB}`); + }; + + if (isLoading) { + return ( + + {currentSpec.version} + + ); + } + + if (hasError || versions.length === 0) { + // Fallback to plain text if we can't fetch versions + return {currentSpec.version}; + } + + // Sort versions in reverse chronological order (newest first) + const sortedVersions = [...versions].sort((a, b) => { + return new Date(b.time).getTime() - new Date(a.time).getTime(); + }); + + return ( + + ); +} diff --git a/src/components/ui/PkgWithVersionSelector.tsx b/src/components/ui/PkgWithVersionSelector.tsx new file mode 100644 index 00000000..a2a1861e --- /dev/null +++ b/src/components/ui/PkgWithVersionSelector.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { type ElementRef, forwardRef } from "react"; +import VersionSelector from "^/app/[...parts]/_page/DiffIntro/VersionSelector"; +import type SimplePackageSpec from "^/lib/SimplePackageSpec"; +import Code, { type CodeProps } from "./Code"; + +export interface PkgWithVersionSelectorProps extends CodeProps { + pkg: SimplePackageSpec; + otherPkg: SimplePackageSpec; + side: "a" | "b"; +} + +const PkgWithVersionSelector = forwardRef< + ElementRef, + PkgWithVersionSelectorProps +>( + ( + { + pkg, + otherPkg, + side, + className, + ...props + }: PkgWithVersionSelectorProps, + ref, + ) => { + return ( + + {pkg.name}@ + + + ); + }, +); +PkgWithVersionSelector.displayName = "PkgWithVersionSelector"; + +export default PkgWithVersionSelector;