Skip to content
Merged
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
195 changes: 195 additions & 0 deletions src/components/NPMVersionSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import NPMVersionSelect from './NPMVersionSelect';

describe('NPMVersionSelect', () => {
const mockSetVersionStr = vi.fn();

beforeEach(() => {
mockSetVersionStr.mockClear();
});

describe('rendering', () => {
it('should render null when versions array is empty', () => {
const { container } = render(
<NPMVersionSelect
versions={[]}
targetVersion="1.0.0"
tags={{}}
setVersionStr={mockSetVersionStr}
/>
);

expect(container.firstChild).toBeNull();
});

it('should render null when targetVersion is null', () => {
const { container } = render(
<NPMVersionSelect
versions={['1.0.0', '1.0.1']}
targetVersion={null}
tags={{}}
setVersionStr={mockSetVersionStr}
/>
);

expect(container.firstChild).toBeNull();
});

it('should render version select when versions and targetVersion are provided', () => {
render(
<NPMVersionSelect
versions={['1.0.0', '1.0.1', '2.0.0']}
targetVersion="1.0.1"
tags={{}}
setVersionStr={mockSetVersionStr}
/>
);

// Should render the version selector with the target version displayed
expect(screen.getByText('1.0.1')).toBeInTheDocument();
});

it('should render tag select with tag label when version matches', () => {
render(
<NPMVersionSelect
versions={['1.0.0']}
targetVersion="1.0.0"
tags={{ latest: '1.0.0' }}
setVersionStr={mockSetVersionStr}
/>
);

// When targetVersion matches a tag, the tag label is shown
expect(screen.getByTitle('latest')).toBeInTheDocument();
});
});

describe('version sorting', () => {
it('should sort versions in descending semver order', () => {
render(
<NPMVersionSelect
versions={['1.0.0', '2.0.0', '1.5.0', '10.0.0', '2.1.0']}
targetVersion="1.0.0"
tags={{}}
setVersionStr={mockSetVersionStr}
/>
);

// The component should render with sorted versions
// We can verify by checking that the selected version is displayed
expect(screen.getByText('1.0.0')).toBeInTheDocument();
});

it('should handle prerelease versions', () => {
render(
<NPMVersionSelect
versions={['1.0.0', '1.0.1-beta.1', '1.0.1-alpha.1', '1.0.1']}
targetVersion="1.0.0"
tags={{}}
setVersionStr={mockSetVersionStr}
/>
);

expect(screen.getByText('1.0.0')).toBeInTheDocument();
});

it('should handle invalid semver versions gracefully', () => {
render(
<NPMVersionSelect
versions={['1.0.0', 'invalid-version', '2.0.0']}
targetVersion="1.0.0"
tags={{}}
setVersionStr={mockSetVersionStr}
/>
);

expect(screen.getByText('1.0.0')).toBeInTheDocument();
});
});

describe('tags', () => {
it('should show tag value when targetVersion matches a tag', () => {
render(
<NPMVersionSelect
versions={['1.0.0', '2.0.0']}
targetVersion="2.0.0"
tags={{ latest: '2.0.0', next: '2.0.0' }}
setVersionStr={mockSetVersionStr}
/>
);

// When targetVersion matches a tag, the tag select should show the version
expect(screen.getByText('2.0.0')).toBeInTheDocument();
});

it('should show placeholder when targetVersion does not match any tag', () => {
render(
<NPMVersionSelect
versions={['1.0.0', '2.0.0']}
targetVersion="1.0.0"
tags={{ latest: '2.0.0' }}
setVersionStr={mockSetVersionStr}
/>
);

expect(screen.getByText('选择 Tag')).toBeInTheDocument();
});

it('should handle empty tags object', () => {
render(
<NPMVersionSelect
versions={['1.0.0']}
targetVersion="1.0.0"
tags={{}}
setVersionStr={mockSetVersionStr}
/>
);

expect(screen.getByText('1.0.0')).toBeInTheDocument();
});
});

describe('large version lists (performance)', () => {
it('should handle 1000+ versions without crashing', () => {
const manyVersions = Array.from({ length: 1000 }, (_, i) => {
const major = Math.floor(i / 100);
const minor = Math.floor((i % 100) / 10);
const patch = i % 10;
return `${major}.${minor}.${patch}`;
});

const { container } = render(
<NPMVersionSelect
versions={manyVersions}
targetVersion="0.0.0"
tags={{}}
setVersionStr={mockSetVersionStr}
/>
);

// Should render without crashing
expect(container.firstChild).not.toBeNull();
});

it('should handle versions with many tags', () => {
const versions = ['1.0.0', '2.0.0', '3.0.0'];
const manyTags: Record<string, string> = {};
for (let i = 0; i < 100; i++) {
manyTags[`tag-${i}`] = versions[i % versions.length];
}

render(
<NPMVersionSelect
versions={versions}
targetVersion="1.0.0"
tags={manyTags}
setVersionStr={mockSetVersionStr}
/>
);

expect(screen.getByText('1.0.0')).toBeInTheDocument();
});
});
});
150 changes: 38 additions & 112 deletions src/components/NPMVersionSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,82 +1,31 @@
'use client';
import { isEmpty } from 'lodash';
import { Cascader, Select, Space } from 'antd';
import { Select } from 'antd';
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import Space.

Copilot uses AI. Check for mistakes.
import React from 'react';

import semver from 'semver';

interface VersionNode {
label: string;
value: string;
children: VersionNode[];
}

function sortNodes(nodes: VersionNode[]): void {
nodes.sort((a, b) => {
if ([a.value, b.value].every(v => semver.clean(v) === v)) {
return semver.rcompare(a.value, b.value);
// Simple version sorting - compare by semver descending
function sortVersions(versions: string[]): string[] {
return [...versions].sort((a, b) => {
const aClean = semver.clean(a);
const bClean = semver.clean(b);
if (aClean && bClean) {
return semver.rcompare(aClean, bClean);
}
const aVersion = semver.coerce(a.value);
const bVersion = semver.coerce(b.value);
if (!aVersion || !bVersion) {
return 0;
const aVersion = semver.coerce(a);
const bVersion = semver.coerce(b);
if (aVersion && bVersion) {
return semver.rcompare(aVersion, bVersion);
}
return semver.rcompare(aVersion, bVersion);
return 0;
});
for (const node of nodes) {
sortNodes(node.children);
}
}

function classifyVersions(versions: string[]): VersionNode[] {
const root: VersionNode = { label: '', value: '', children: [] };
const map: Map<string, VersionNode> = new Map();

for (const version of versions) {
const parsed = semver.parse(version);
if (!parsed) {
continue;
}

const major = `${parsed.major}.x`;
const minor = `${parsed.major}.${parsed.minor}.x`;
const patch = parsed.version;

let majorNode = map.get(major);
if (!majorNode) {
majorNode = { label: major, value: major, children: [] };
map.set(major, majorNode);
root.children.push(majorNode);
}

let minorNode = map.get(minor);
if (!minorNode) {
minorNode = { label: minor, value: minor, children: [] };
map.set(minor, minorNode);
majorNode.children.push(minorNode);
}

let patchNode = map.get(patch);
if (!patchNode) {
patchNode = { label: patch, value: version, children: [] };
map.set(patch, patchNode);
minorNode.children.push(patchNode);
}
}

sortNodes(root.children);

// 如果只有 0.0.x 的版本
// 就直接返回 children 不用额外返回 0.x 了
// 0.x
if (root.children.length === 1) {
// 0.0.x
const childrenNode = root.children[0].children;
if (childrenNode.length === 1) {
return childrenNode;
}
}
return root.children;
// Build flat version options for Select with virtual scrolling
function buildVersionOptions(versions: string[]): { label: string; value: string }[] {
const sorted = sortVersions(versions);
return sorted.map(v => ({ label: v, value: v }));
}

interface VersionSelectProps {
Expand All @@ -92,39 +41,15 @@ export default function NPMVersionSelect({
tags = {},
setVersionStr,
}: VersionSelectProps) {
// Update tag in select label
const selectVersionRender = React.useMemo(() => {
const version = targetVersion;
return <Space>{version}</Space>;
}, [targetVersion]);

const targetOptions = React.useMemo(() => {
return classifyVersions(versions);
// Memoize version options - flat list for virtual scrolling
const versionOptions = React.useMemo(() => {
return buildVersionOptions(versions);
}, [versions]);

const targetValue = React.useMemo(() => {
let value: string[] = [];

function findNode(nodes: VersionNode[] | undefined, prefixs: string[]) {
if (isEmpty(nodes) || !nodes) {
return;
}

nodes.some((item) => {
if (item.value === targetVersion) {
value = [...prefixs, targetVersion];
return true;
}
if (item.children) {
return findNode(item.children, [...prefixs, item.value]);
}
return false;
});
}

findNode(targetOptions, []);
return value;
}, [targetOptions, targetVersion]);
// Memoize tag options
const tagOptions = React.useMemo(() => {
return Object.keys(tags || {}).map((t) => ({ label: t, value: tags[t] }));
}, [tags]);

const hasTag = React.useMemo(() => {
if (!targetVersion) {
Expand All @@ -133,31 +58,32 @@ export default function NPMVersionSelect({
return Object.values(tags).includes(targetVersion);
}, [tags, targetVersion]);

if (isEmpty(targetOptions) || !targetVersion) {
if (isEmpty(versionOptions) || !targetVersion) {
return null;
}

// ========== render ==========
if (isEmpty(targetOptions)) return null

return (
<>
<Cascader
<Select
size="small"
options={targetOptions}
value={targetValue}
displayRender={() => selectVersionRender}
options={versionOptions}
value={targetVersion}
allowClear={false}
style={{ width: 'unset' }}
style={{ width: 'auto', minWidth: 100 }}
showSearch
placement={'bottomRight'}
virtual
listHeight={300}
popupMatchSelectWidth={false}
onChange={(val) => {
setVersionStr(val?.pop()?.toString() || '');
setVersionStr(val);
}}
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
/>
<Select
size="small"
options={Object.keys(tags || {}).map((t) => ({ label: t, value: tags[t] }))}
options={tagOptions}
placeholder="选择 Tag"
value={hasTag ? targetVersion : undefined}
popupMatchSelectWidth={180}
Expand All @@ -167,5 +93,5 @@ export default function NPMVersionSelect({
showSearch
/>
</>
)
);
}
Loading