Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions next-env.d.ts

This file was deleted.

4 changes: 4 additions & 0 deletions src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const DiffIntro = forwardRef<ElementRef<typeof Stack>, DiffIntroProps>(
left={
<SpecBox
pkg={aWithName}
otherPkg={bWithName}
side="a"
pkgClassName="rounded-r-none"
/>
}
Expand All @@ -46,6 +48,8 @@ const DiffIntro = forwardRef<ElementRef<typeof Stack>, DiffIntroProps>(
right={
<SpecBox
pkg={bWithName}
otherPkg={aWithName}
side="b"
pkgClassName="rounded-l-none"
/>
}
Expand Down
18 changes: 16 additions & 2 deletions src/app/[...parts]/_page/DiffIntro/SpecBox.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,12 +10,25 @@ import ServiceLinks from "./ServiceLinks";
interface SpecBoxProps extends HTMLAttributes<HTMLElement> {
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<HTMLElement, SpecBoxProps>(
({ pkg, pkgClassName, ...props }, ref) => (
({ pkg, pkgClassName, otherPkg, side, ...props }, ref) => (
<section {...props} ref={ref}>
<Pkg pkg={pkg} className={cx("px-1", pkgClassName)} />
{otherPkg && side ? (
<PkgWithVersionSelector
pkg={pkg}
otherPkg={otherPkg}
side={side}
className={cx("px-1", pkgClassName)}
/>
) : (
<Pkg pkg={pkg} className={cx("px-1", pkgClassName)} />
)}
<PublishDate
suspenseKey={"publishdate-" + simplePackageSpecToString(pkg)}
pkg={pkg}
Expand Down
168 changes: 168 additions & 0 deletions src/app/[...parts]/_page/DiffIntro/VersionSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// @jest-environment jsdom

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useRouter } from "next/navigation";
import VersionSelector from "./VersionSelector";

// Mock next/navigation
jest.mock("next/navigation", () => ({
useRouter: jest.fn(),
}));

// Mock fetch
global.fetch = jest.fn();

describe("VersionSelector", () => {
const mockPush = jest.fn();
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;

beforeEach(() => {
jest.clearAllMocks();
(useRouter as jest.Mock).mockReturnValue({
push: mockPush,
});
});

it("renders loading state initially", () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => [],
} as Response);

render(
<VersionSelector
currentSpec={{ name: "react", version: "18.0.0" }}
otherSpec={{ name: "react", version: "17.0.0" }}
side="a"
/>,
);

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(
<VersionSelector
currentSpec={{ name: "react", version: "18.0.0" }}
otherSpec={{ name: "react", version: "17.0.0" }}
side="a"
/>,
);

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(
<VersionSelector
currentSpec={{ name: "react", version: "18.0.0" }}
otherSpec={{ name: "react", version: "17.0.0" }}
side="a"
/>,
);

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(
<VersionSelector
currentSpec={{ name: "react", version: "17.0.0" }}
otherSpec={{ name: "react", version: "18.0.0" }}
side="b"
/>,
);

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(
<VersionSelector
currentSpec={{ name: "react", version: "18.0.0" }}
otherSpec={{ name: "react", version: "17.0.0" }}
side="a"
/>,
);

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();
});
});
112 changes: 112 additions & 0 deletions src/app/[...parts]/_page/DiffIntro/VersionSelector.tsx
Original file line number Diff line number Diff line change
@@ -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<Version[]>([]);
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 (
<span className={className} aria-busy="true" aria-live="polite">
{currentSpec.version}
</span>
);
}

if (hasError || versions.length === 0) {
// Fallback to plain text if we can't fetch versions
return <span className={className}>{currentSpec.version}</span>;
}

// 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 (
<select
value={currentSpec.version}
onChange={(e) => handleVersionChange(e.target.value)}
className={className}
aria-label={`Select version for ${currentSpec.name}`}
>
{sortedVersions.map((version) => (
<option key={version.version} value={version.version}>
{version.version}
{version.tags && version.tags.length > 0
? ` (${version.tags.join(", ")})`
: ""}
</option>
))}
</select>
);
}
43 changes: 43 additions & 0 deletions src/components/ui/PkgWithVersionSelector.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Code>,
PkgWithVersionSelectorProps
>(
(
{
pkg,
otherPkg,
side,
className,
...props
}: PkgWithVersionSelectorProps,
ref,
) => {
return (
<Code {...props} className={className} ref={ref}>
{pkg.name}@
<VersionSelector
currentSpec={pkg}
otherSpec={otherPkg}
side={side}
className="cursor-pointer border-none bg-transparent underline decoration-dotted outline-none hover:decoration-solid focus:decoration-solid"
/>
</Code>
);
},
);
PkgWithVersionSelector.displayName = "PkgWithVersionSelector";

export default PkgWithVersionSelector;