Skip to content

Commit 6fe5d63

Browse files
CopilotoBusk
andcommitted
Add version selector component with tests
Co-authored-by: oBusk <[email protected]>
1 parent 68ddb40 commit 6fe5d63

File tree

5 files changed

+334
-2
lines changed

5 files changed

+334
-2
lines changed

src/app/[...parts]/_page/DiffIntro/DiffIntro.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ const DiffIntro = forwardRef<ElementRef<typeof Stack>, DiffIntroProps>(
3535
left={
3636
<SpecBox
3737
pkg={aWithName}
38+
otherPkg={bWithName}
39+
side="a"
3840
pkgClassName="rounded-r-none"
3941
/>
4042
}
@@ -46,6 +48,8 @@ const DiffIntro = forwardRef<ElementRef<typeof Stack>, DiffIntroProps>(
4648
right={
4749
<SpecBox
4850
pkg={bWithName}
51+
otherPkg={aWithName}
52+
side="b"
4953
pkgClassName="rounded-l-none"
5054
/>
5155
}

src/app/[...parts]/_page/DiffIntro/SpecBox.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
"use client";
2+
13
import { forwardRef, type HTMLAttributes } from "react";
24
import Pkg from "^/components/ui/Pkg";
5+
import PkgWithVersionSelector from "^/components/ui/PkgWithVersionSelector";
36
import { cx } from "^/lib/cva";
47
import type SimplePackageSpec from "^/lib/SimplePackageSpec";
58
import { simplePackageSpecToString } from "^/lib/SimplePackageSpec";
@@ -9,12 +12,25 @@ import ServiceLinks from "./ServiceLinks";
912
interface SpecBoxProps extends HTMLAttributes<HTMLElement> {
1013
pkg: SimplePackageSpec;
1114
pkgClassName?: string;
15+
/** The other package spec (for interactive version selection) */
16+
otherPkg?: SimplePackageSpec;
17+
/** Which side this is ('a' or 'b') - required for version selection */
18+
side?: "a" | "b";
1219
}
1320

1421
const SpecBox = forwardRef<HTMLElement, SpecBoxProps>(
15-
({ pkg, pkgClassName, ...props }, ref) => (
22+
({ pkg, pkgClassName, otherPkg, side, ...props }, ref) => (
1623
<section {...props} ref={ref}>
17-
<Pkg pkg={pkg} className={cx("px-1", pkgClassName)} />
24+
{otherPkg && side ? (
25+
<PkgWithVersionSelector
26+
pkg={pkg}
27+
otherPkg={otherPkg}
28+
side={side}
29+
className={cx("px-1", pkgClassName)}
30+
/>
31+
) : (
32+
<Pkg pkg={pkg} className={cx("px-1", pkgClassName)} />
33+
)}
1834
<PublishDate
1935
suspenseKey={"publishdate-" + simplePackageSpecToString(pkg)}
2036
pkg={pkg}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// @jest-environment jsdom
2+
3+
import { render, screen, waitFor } from "@testing-library/react";
4+
import { useRouter } from "next/navigation";
5+
import VersionSelector from "./VersionSelector";
6+
7+
// Mock next/navigation
8+
jest.mock("next/navigation", () => ({
9+
useRouter: jest.fn(),
10+
}));
11+
12+
// Mock fetch
13+
global.fetch = jest.fn();
14+
15+
describe("VersionSelector", () => {
16+
const mockPush = jest.fn();
17+
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
18+
19+
beforeEach(() => {
20+
jest.clearAllMocks();
21+
(useRouter as jest.Mock).mockReturnValue({
22+
push: mockPush,
23+
});
24+
});
25+
26+
it("renders loading state initially", () => {
27+
mockFetch.mockResolvedValueOnce({
28+
ok: true,
29+
json: async () => [],
30+
} as Response);
31+
32+
render(
33+
<VersionSelector
34+
currentSpec={{ name: "react", version: "18.0.0" }}
35+
otherSpec={{ name: "react", version: "17.0.0" }}
36+
side="a"
37+
/>,
38+
);
39+
40+
expect(screen.getByText("18.0.0")).toBeInTheDocument();
41+
});
42+
43+
it("fetches and displays versions", async () => {
44+
const mockVersions = [
45+
{ version: "18.2.0", time: "2023-01-01" },
46+
{ version: "18.1.0", time: "2022-12-01" },
47+
{ version: "18.0.0", time: "2022-11-01" },
48+
];
49+
50+
mockFetch.mockResolvedValueOnce({
51+
ok: true,
52+
json: async () => mockVersions,
53+
} as Response);
54+
55+
render(
56+
<VersionSelector
57+
currentSpec={{ name: "react", version: "18.0.0" }}
58+
otherSpec={{ name: "react", version: "17.0.0" }}
59+
side="a"
60+
/>,
61+
);
62+
63+
await waitFor(() => {
64+
expect(screen.getByRole("combobox")).toBeInTheDocument();
65+
});
66+
67+
const select = screen.getByRole("combobox") as HTMLSelectElement;
68+
expect(select.value).toBe("18.0.0");
69+
});
70+
71+
it("navigates to new diff when version is selected (side a)", async () => {
72+
const mockVersions = [
73+
{ version: "18.2.0", time: "2023-01-01" },
74+
{ version: "18.0.0", time: "2022-11-01" },
75+
];
76+
77+
mockFetch.mockResolvedValueOnce({
78+
ok: true,
79+
json: async () => mockVersions,
80+
} as Response);
81+
82+
const { container } = render(
83+
<VersionSelector
84+
currentSpec={{ name: "react", version: "18.0.0" }}
85+
otherSpec={{ name: "react", version: "17.0.0" }}
86+
side="a"
87+
/>,
88+
);
89+
90+
await waitFor(() => {
91+
expect(screen.getByRole("combobox")).toBeInTheDocument();
92+
});
93+
94+
const select = container.querySelector("select");
95+
if (select) {
96+
select.value = "18.2.0";
97+
select.dispatchEvent(new Event("change", { bubbles: true }));
98+
}
99+
100+
await waitFor(() => {
101+
expect(mockPush).toHaveBeenCalledWith(
102+
"/[email protected]@17.0.0",
103+
);
104+
});
105+
});
106+
107+
it("navigates to new diff when version is selected (side b)", async () => {
108+
const mockVersions = [
109+
{ version: "18.2.0", time: "2023-01-01" },
110+
{ version: "17.0.0", time: "2022-11-01" },
111+
];
112+
113+
mockFetch.mockResolvedValueOnce({
114+
ok: true,
115+
json: async () => mockVersions,
116+
} as Response);
117+
118+
const { container } = render(
119+
<VersionSelector
120+
currentSpec={{ name: "react", version: "17.0.0" }}
121+
otherSpec={{ name: "react", version: "18.0.0" }}
122+
side="b"
123+
/>,
124+
);
125+
126+
await waitFor(() => {
127+
expect(screen.getByRole("combobox")).toBeInTheDocument();
128+
});
129+
130+
const select = container.querySelector("select");
131+
if (select) {
132+
select.value = "18.2.0";
133+
select.dispatchEvent(new Event("change", { bubbles: true }));
134+
}
135+
136+
await waitFor(() => {
137+
expect(mockPush).toHaveBeenCalledWith(
138+
"/[email protected]@18.2.0",
139+
);
140+
});
141+
});
142+
143+
it("displays version tags when available", async () => {
144+
const mockVersions = [
145+
{
146+
version: "18.2.0",
147+
time: "2023-01-01",
148+
tags: ["latest", "next"],
149+
},
150+
{ version: "18.0.0", time: "2022-11-01" },
151+
];
152+
153+
mockFetch.mockResolvedValueOnce({
154+
ok: true,
155+
json: async () => mockVersions,
156+
} as Response);
157+
158+
render(
159+
<VersionSelector
160+
currentSpec={{ name: "react", version: "18.0.0" }}
161+
otherSpec={{ name: "react", version: "17.0.0" }}
162+
side="a"
163+
/>,
164+
);
165+
166+
await waitFor(() => {
167+
expect(screen.getByRole("combobox")).toBeInTheDocument();
168+
});
169+
170+
// Check if option with tags exists
171+
const option = screen.getByText("18.2.0 (latest, next)");
172+
expect(option).toBeInTheDocument();
173+
});
174+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"use client";
2+
3+
import { useRouter } from "next/navigation";
4+
import { useEffect, useState } from "react";
5+
import { type Version } from "^/app/api/-/versions/types";
6+
import type SimplePackageSpec from "^/lib/SimplePackageSpec";
7+
8+
interface VersionSelectorProps {
9+
/** The current package spec */
10+
currentSpec: SimplePackageSpec;
11+
/** The other package spec (to keep in the diff) */
12+
otherSpec: SimplePackageSpec;
13+
/** Whether this is the 'a' (left/from) or 'b' (right/to) side */
14+
side: "a" | "b";
15+
/** Class name for the select element */
16+
className?: string;
17+
}
18+
19+
export default function VersionSelector({
20+
currentSpec,
21+
otherSpec,
22+
side,
23+
className,
24+
}: VersionSelectorProps) {
25+
const router = useRouter();
26+
const [versions, setVersions] = useState<Version[]>([]);
27+
const [isLoading, setIsLoading] = useState(true);
28+
29+
useEffect(() => {
30+
const fetchVersions = async () => {
31+
try {
32+
setIsLoading(true);
33+
const response = await fetch(
34+
`/api/-/versions?package=${encodeURIComponent(currentSpec.name)}`,
35+
);
36+
if (response.ok) {
37+
const data: Version[] = await response.json();
38+
setVersions(data);
39+
}
40+
} catch (error) {
41+
console.error("Failed to fetch versions:", error);
42+
} finally {
43+
setIsLoading(false);
44+
}
45+
};
46+
47+
fetchVersions();
48+
}, [currentSpec.name]);
49+
50+
const handleVersionChange = (newVersion: string) => {
51+
if (newVersion === currentSpec.version) {
52+
return; // No change
53+
}
54+
55+
// Build the new URL based on which side we're updating
56+
const newA =
57+
side === "a"
58+
? `${currentSpec.name}@${newVersion}`
59+
: `${otherSpec.name}@${otherSpec.version}`;
60+
const newB =
61+
side === "b"
62+
? `${currentSpec.name}@${newVersion}`
63+
: `${otherSpec.name}@${otherSpec.version}`;
64+
65+
// Navigate to the new diff URL
66+
router.push(`/${newA}...${newB}`);
67+
};
68+
69+
if (isLoading) {
70+
return <span className={className}>{currentSpec.version}</span>;
71+
}
72+
73+
// Sort versions in reverse chronological order (newest first)
74+
const sortedVersions = [...versions].sort((a, b) => {
75+
return new Date(b.time).getTime() - new Date(a.time).getTime();
76+
});
77+
78+
return (
79+
<select
80+
value={currentSpec.version}
81+
onChange={(e) => handleVersionChange(e.target.value)}
82+
className={className}
83+
aria-label={`Select version for ${currentSpec.name}`}
84+
>
85+
{sortedVersions.map((version) => (
86+
<option key={version.version} value={version.version}>
87+
{version.version}
88+
{version.tags && version.tags.length > 0
89+
? ` (${version.tags.join(", ")})`
90+
: ""}
91+
</option>
92+
))}
93+
</select>
94+
);
95+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"use client";
2+
3+
import { type ElementRef, forwardRef } from "react";
4+
import VersionSelector from "^/app/[...parts]/_page/DiffIntro/VersionSelector";
5+
import type SimplePackageSpec from "^/lib/SimplePackageSpec";
6+
import Code, { type CodeProps } from "./Code";
7+
8+
export interface PkgWithVersionSelectorProps extends CodeProps {
9+
pkg: SimplePackageSpec;
10+
otherPkg: SimplePackageSpec;
11+
side: "a" | "b";
12+
}
13+
14+
const PkgWithVersionSelector = forwardRef<
15+
ElementRef<typeof Code>,
16+
PkgWithVersionSelectorProps
17+
>(
18+
(
19+
{
20+
pkg,
21+
otherPkg,
22+
side,
23+
className,
24+
...props
25+
}: PkgWithVersionSelectorProps,
26+
ref,
27+
) => {
28+
return (
29+
<Code {...props} className={className} ref={ref}>
30+
{pkg.name}@
31+
<VersionSelector
32+
currentSpec={pkg}
33+
otherSpec={otherPkg}
34+
side={side}
35+
className="cursor-pointer border-none bg-transparent underline decoration-dotted outline-none hover:decoration-solid focus:decoration-solid"
36+
/>
37+
</Code>
38+
);
39+
},
40+
);
41+
PkgWithVersionSelector.displayName = "PkgWithVersionSelector";
42+
43+
export default PkgWithVersionSelector;

0 commit comments

Comments
 (0)