Improve test case selection ui

This commit is contained in:
Xin Wang
2026-08-08 20:52:38 +08:00
parent 433ff0b255
commit e988f9c133
2 changed files with 341 additions and 194 deletions
@@ -0,0 +1,333 @@
"use client";
/**
* 批量测试范围选择器。
* 负责搜索、折叠和多选交互;数据仍由批量测试页持有。
*/
import {
Check,
ChevronDown,
ChevronRight,
Folder,
Minus,
Search,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
ASSERTION_TYPE_LABEL,
type TestCase,
type TestSuite,
} from "@/data/test-suites";
import { cn } from "@/lib/utils";
type TestScopeSelectorProps = {
suites: TestSuite[];
casesBySuite: Record<string, TestCase[]>;
selectedCaseIds: Set<string>;
onSelectionChange: (next: Set<string>) => void;
};
export function TestScopeSelector({
suites,
casesBySuite,
selectedCaseIds,
onSelectionChange,
}: TestScopeSelectorProps) {
const [query, setQuery] = useState("");
const [expandedSuiteIds, setExpandedSuiteIds] = useState<Set<string>>(
() => new Set(suites[0] ? [suites[0].id] : []),
);
const allCases = useMemo(
() => suites.flatMap((suite) => casesBySuite[suite.id] ?? []),
[suites, casesBySuite],
);
const normalizedQuery = query.trim().toLocaleLowerCase();
const visibleSuites = useMemo(() => {
if (!normalizedQuery) {
return suites.map((suite) => ({
suite,
cases: casesBySuite[suite.id] ?? [],
}));
}
return suites.flatMap((suite) => {
const suiteCases = casesBySuite[suite.id] ?? [];
const suiteMatches = suite.name
.toLocaleLowerCase()
.includes(normalizedQuery);
const matchingCases = suiteMatches
? suiteCases
: suiteCases.filter((item) =>
item.name.toLocaleLowerCase().includes(normalizedQuery),
);
return matchingCases.length > 0 ? [{ suite, cases: matchingCases }] : [];
});
}, [suites, casesBySuite, normalizedQuery]);
const selectedCount = selectedCaseIds.size;
const totalCount = allCases.length;
const allSelected = totalCount > 0 && selectedCount === totalCount;
const selectionPercent =
totalCount === 0 ? 0 : Math.round((selectedCount / totalCount) * 100);
function toggleAll() {
onSelectionChange(
allSelected ? new Set() : new Set(allCases.map((item) => item.id)),
);
}
function toggleSuite(suiteId: string) {
const suiteCases = casesBySuite[suiteId] ?? [];
const suiteIsSelected =
suiteCases.length > 0 &&
suiteCases.every((item) => selectedCaseIds.has(item.id));
const next = new Set(selectedCaseIds);
for (const item of suiteCases) {
if (suiteIsSelected) next.delete(item.id);
else next.add(item.id);
}
onSelectionChange(next);
}
function toggleCase(caseId: string) {
const next = new Set(selectedCaseIds);
if (next.has(caseId)) next.delete(caseId);
else next.add(caseId);
onSelectionChange(next);
}
function toggleExpanded(suiteId: string) {
setExpandedSuiteIds((current) => {
const next = new Set(current);
if (next.has(suiteId)) next.delete(suiteId);
else next.add(suiteId);
return next;
});
}
return (
<div className="overflow-hidden rounded-2xl border border-hairline-strong bg-card">
<div className="border-b border-hairline bg-canvas-soft/60 p-3.5">
<div className="flex flex-wrap items-center gap-2.5">
<div className="relative min-w-[220px] flex-1">
<Search
size={15}
className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted-soft"
/>
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索测试集或用例"
aria-label="搜索测试集或用例"
className="h-9 rounded-xl border-hairline bg-background pr-3 pl-9"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onSelectionChange(new Set())}
disabled={selectedCount === 0}
className="text-muted-foreground"
>
清空
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={toggleAll}
disabled={totalCount === 0}
className="border-hairline-strong bg-card"
>
{allSelected ? "取消全选" : "全选全部"}
</Button>
</div>
<div className="mt-3 flex items-center gap-3">
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-strong">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width: `${selectionPercent}%` }}
/>
</div>
<span
className="shrink-0 text-xs tabular-nums text-muted-foreground"
aria-live="polite"
>
已选 <span className="font-medium text-foreground">{selectedCount}</span>
<span className="text-muted-soft"> / {totalCount}</span>
</span>
</div>
</div>
{suites.length === 0 ? (
<EmptyState>暂无测试集,请先在「测试用例」中创建。</EmptyState>
) : visibleSuites.length === 0 ? (
<EmptyState>没有匹配的测试集或用例。</EmptyState>
) : (
<div className="max-h-[390px] overflow-y-auto overscroll-contain">
<ul className="divide-y divide-hairline">
{visibleSuites.map(({ suite, cases: visibleCases }) => {
const suiteCases = casesBySuite[suite.id] ?? [];
const selectedInSuite = suiteCases.filter((item) =>
selectedCaseIds.has(item.id),
).length;
const suiteAllSelected =
suiteCases.length > 0 && selectedInSuite === suiteCases.length;
const suiteSomeSelected =
selectedInSuite > 0 && !suiteAllSelected;
const expanded = normalizedQuery
? true
: expandedSuiteIds.has(suite.id);
return (
<li key={suite.id}>
<div className="flex min-h-12 items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => toggleExpanded(suite.id)}
aria-label={
expanded ? `收起 ${suite.name}` : `展开 ${suite.name}`
}
aria-expanded={expanded}
disabled={Boolean(normalizedQuery)}
className="flex size-7 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-default"
>
{expanded ? (
<ChevronDown size={15} />
) : (
<ChevronRight size={15} />
)}
</button>
<TriStateCheckbox
checked={suiteAllSelected}
indeterminate={suiteSomeSelected}
label={`选择测试集 ${suite.name}`}
onChange={() => toggleSuite(suite.id)}
/>
<button
type="button"
onClick={() => toggleExpanded(suite.id)}
disabled={Boolean(normalizedQuery)}
className="flex min-w-0 flex-1 items-center gap-2 text-left disabled:cursor-default"
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-surface-strong text-muted-foreground">
<Folder size={14} />
</span>
<span className="truncate text-sm font-medium text-foreground">
{suite.name}
</span>
</button>
<Badge
variant={selectedInSuite > 0 ? "secondary" : "outline"}
className="h-6 min-w-14 tabular-nums"
>
{selectedInSuite} / {suiteCases.length}
</Badge>
</div>
{expanded && (
<ul className="border-t border-hairline bg-canvas-soft/45 py-1.5">
{visibleCases.length === 0 ? (
<li className="px-12 py-3 text-xs text-muted-soft">
该测试集暂无用例
</li>
) : (
visibleCases.map((item) => {
const selected = selectedCaseIds.has(item.id);
return (
<li key={item.id}>
<label
className={cn(
"flex min-h-10 cursor-pointer items-center gap-3 px-5 py-2 transition-colors hover:bg-surface-strong/60",
selected && "bg-surface-strong/45",
)}
>
<TriStateCheckbox
checked={selected}
label={`选择用例 ${item.name}`}
onChange={() => toggleCase(item.id)}
/>
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{item.name}
</span>
<span className="shrink-0 text-xs text-muted-soft">
{ASSERTION_TYPE_LABEL[item.assertionType]}
</span>
</label>
</li>
);
})
)}
</ul>
)}
</li>
);
})}
</ul>
</div>
)}
</div>
);
}
function TriStateCheckbox({
checked,
indeterminate = false,
label,
onChange,
}: {
checked: boolean;
indeterminate?: boolean;
label: string;
onChange: () => void;
}) {
return (
<span className="relative flex size-4 shrink-0">
<input
type="checkbox"
checked={checked}
ref={(element) => {
if (element) element.indeterminate = indeterminate;
}}
onChange={onChange}
aria-label={label}
className="peer absolute inset-0 z-10 cursor-pointer opacity-0"
/>
<span
className={cn(
"flex size-4 items-center justify-center rounded-[5px] border border-hairline-strong bg-background text-primary-foreground transition-colors peer-focus-visible:ring-3 peer-focus-visible:ring-ring/30",
(checked || indeterminate) && "border-primary bg-primary",
)}
aria-hidden
>
{indeterminate ? (
<Minus size={11} strokeWidth={2.5} />
) : checked ? (
<Check size={11} strokeWidth={2.5} />
) : null}
</span>
</span>
);
}
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<p className="px-4 py-10 text-center text-sm text-muted-soft">{children}</p>
);
}