Skip to content

Commit 861c868

Browse files
V48 Gate 3 (implementation-only): one reused rich search-dropdown component (SearchableSelect) for repo/branch/commit selects
The deposit branch and commit pickers were plain native <select> elements while the repository picker used a rich Command/Popover search combobox (VCSRepositorySelector), so the same "pick one of a list" interaction looked and behaved inconsistently on one page. Extracted the combobox machinery into a new generic uapi/components/base/bitcode/forms/SearchableSelect.tsx (search input, badge/ description/meta rendering, loading/empty/disabled states, optional open-state observation for lazy-fetch consumers) and made VCSRepositorySelector compose it instead of duplicating the Command/Popover wiring — the repo select is now the SAME reused component, not a bespoke one. Wired DepositSourceSelection's Branch and Commit fields onto it too (branch shows a "default" badge; commit shows the short SHA as the label and the commit message as description, richer than the old flat option text). Added a SearchableSelect unit suite (8 tests: placeholder, selected label, open+list, search filter, empty state, select+close, loading, disabled) and registered it in jest.config.cjs's explicit testMatch allowlist (new test files here must be added there to run in CI). uapi tsc 0; deposit/terminal/vcs suites green (32/32 across the touched surfaces). Note: an app-wide sweep of every native <select> (19 files, several of them small fixed-option pickers where a search popover adds no value) is a separate, larger scoping decision — this chunk covers the concrete repo/ branch/commit dropdowns shown in QA and establishes the one component to converge on next.
1 parent c3ee464 commit 861c868

5 files changed

Lines changed: 385 additions & 179 deletions

File tree

uapi/app/deposit/DepositSourceSelection.tsx

Lines changed: 62 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Anchor, GitBranch, Lock, RefreshCw } from "lucide-react";
1616
import type { VCSBranch, VCSCommit, VCSRepository } from "@bitcode/vcs-core";
1717

1818
import { VCSRepositorySelector } from "@/components/base/bitcode/vcs/VCSRepositorySelector";
19+
import { SearchableSelect } from "@/components/base/bitcode/forms/SearchableSelect";
1920
import {
2021
buildTerminalRepositoryAnchorDraft,
2122
type TerminalActivityRecordDraft,
@@ -54,12 +55,6 @@ function splitRepositoryFullName(fullName?: string | null) {
5455
return { owner, repo };
5556
}
5657

57-
function formatCommitOption(commit: VCSCommit) {
58-
const shortSha = commit.sha.slice(0, 7);
59-
const title = commit.message.split("\n")[0]?.trim() || "Commit";
60-
return `${shortSha} - ${title}`;
61-
}
62-
6358
export default function DepositSourceSelection({
6459
preferredRepository,
6560
onContextChange,
@@ -482,32 +477,38 @@ export default function DepositSourceSelection({
482477
<GitBranch className="h-3.5 w-3.5" />
483478
<span>Branch</span>
484479
</span>
485-
<select
486-
aria-label="Repository source branch"
487-
value={selectedBranch || ""}
488-
disabled={!selectedRepository || isLoadingBranches || branches.length === 0}
489-
onChange={(event) =>
490-
updateSourceParams((params) => {
491-
if (selectedRepository)
492-
params.set("repo", selectedRepository.fullName);
493-
params.set("sourceBranch", event.target.value);
494-
params.delete("sourceCommit");
495-
params.delete("branch");
496-
params.delete("commit");
497-
})
498-
}
499-
className="mt-3 w-full rounded-xl border border-white/10 bg-[rgba(10,15,30,0.88)] px-3 py-3 text-sm text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
500-
>
501-
{branches.length ? null : <option value="">No branches loaded</option>}
502-
{branches.map((branch) => (
503-
<option key={branch.name} value={branch.name}>
504-
{branch.name}
505-
{branch.name === (defaultBranch || selectedRepository?.defaultBranch)
506-
? " · default"
507-
: ""}
508-
</option>
509-
))}
510-
</select>
480+
<div className="mt-3">
481+
<SearchableSelect
482+
aria-label="Repository source branch"
483+
value={selectedBranch || null}
484+
disabled={!selectedRepository || isLoadingBranches || branches.length === 0}
485+
loading={isLoadingBranches}
486+
loadingMessage="Loading branches…"
487+
placeholder="Select branch..."
488+
searchPlaceholder="Search branches..."
489+
emptyMessage="No branches loaded."
490+
items={branches.map((branch) => ({
491+
key: branch.name,
492+
label: branch.name,
493+
badge:
494+
branch.name === (defaultBranch || selectedRepository?.defaultBranch)
495+
? "default"
496+
: null,
497+
}))}
498+
onSelect={(branchName) =>
499+
updateSourceParams((params) => {
500+
if (selectedRepository)
501+
params.set("repo", selectedRepository.fullName);
502+
if (branchName) params.set("sourceBranch", branchName);
503+
else params.delete("sourceBranch");
504+
params.delete("sourceCommit");
505+
params.delete("branch");
506+
params.delete("commit");
507+
})
508+
}
509+
className="rounded-xl border-white/10 bg-[rgba(10,15,30,0.88)] px-3 py-3 text-sm text-white hover:bg-[rgba(10,15,30,0.88)] focus:border-emerald-400/40"
510+
/>
511+
</div>
511512
<p className="mt-2 text-[0.68rem] uppercase tracking-[0.18em] text-neutral-500">
512513
{isLoadingBranches
513514
? "Loading branches…"
@@ -519,29 +520,35 @@ export default function DepositSourceSelection({
519520
<span className="flex items-center gap-2 text-[0.64rem] uppercase tracking-[0.2em] text-neutral-400">
520521
<span>Commit / ref</span>
521522
</span>
522-
<select
523-
aria-label="Repository source commit"
524-
value={selectedCommit || ""}
525-
disabled={!selectedBranch || isLoadingCommits || commits.length === 0}
526-
onChange={(event) =>
527-
updateSourceParams((params) => {
528-
if (selectedRepository)
529-
params.set("repo", selectedRepository.fullName);
530-
if (selectedBranch) params.set("sourceBranch", selectedBranch);
531-
params.set("sourceCommit", event.target.value);
532-
params.delete("branch");
533-
params.delete("commit");
534-
})
535-
}
536-
className="mt-3 w-full rounded-xl border border-white/10 bg-[rgba(10,15,30,0.88)] px-3 py-3 text-sm text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
537-
>
538-
{commits.length ? null : <option value="">No commits loaded</option>}
539-
{commits.map((commit) => (
540-
<option key={commit.sha} value={commit.sha}>
541-
{formatCommitOption(commit)}
542-
</option>
543-
))}
544-
</select>
523+
<div className="mt-3">
524+
<SearchableSelect
525+
aria-label="Repository source commit"
526+
value={selectedCommit || null}
527+
disabled={!selectedBranch || isLoadingCommits || commits.length === 0}
528+
loading={isLoadingCommits}
529+
loadingMessage="Loading commits…"
530+
placeholder="Select commit..."
531+
searchPlaceholder="Search commits..."
532+
emptyMessage="No commits loaded."
533+
items={commits.map((commit) => ({
534+
key: commit.sha,
535+
label: commit.sha.slice(0, 7),
536+
description: commit.message.split("\n")[0]?.trim() || "Commit",
537+
}))}
538+
onSelect={(commitSha) =>
539+
updateSourceParams((params) => {
540+
if (selectedRepository)
541+
params.set("repo", selectedRepository.fullName);
542+
if (selectedBranch) params.set("sourceBranch", selectedBranch);
543+
if (commitSha) params.set("sourceCommit", commitSha);
544+
else params.delete("sourceCommit");
545+
params.delete("branch");
546+
params.delete("commit");
547+
})
548+
}
549+
className="rounded-xl border-white/10 bg-[rgba(10,15,30,0.88)] px-3 py-3 text-sm text-white hover:bg-[rgba(10,15,30,0.88)] focus:border-emerald-400/40"
550+
/>
551+
</div>
545552
<p className="mt-2 text-[0.68rem] uppercase tracking-[0.18em] text-neutral-500">
546553
{isLoadingCommits
547554
? "Loading commits…"
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
'use client';
2+
3+
/**
4+
* SearchableSelect — the one rich, searchable dropdown component for the app.
5+
*
6+
* A generic Command/Popover combobox (search box + list, badges, description,
7+
* meta text, an icon slot) so every "pick one of a list" control looks and
8+
* behaves the same way, instead of every surface growing its own bespoke
9+
* native <select> or one-off popover. VCSRepositorySelector is the reference
10+
* implementation this was extracted from (repo picker with lock/fork icons,
11+
* language badge, "Updated" meta) — it now composes this component rather
12+
* than duplicating the combobox machinery.
13+
*/
14+
15+
import React, { useMemo, useState } from 'react';
16+
import { Check, ChevronsUpDown } from 'lucide-react';
17+
import { cn } from '@bitcode/styling';
18+
import { Button } from '@/components/base/shadcn/button';
19+
import {
20+
Command,
21+
CommandEmpty,
22+
CommandGroup,
23+
CommandInput,
24+
CommandItem,
25+
CommandList,
26+
} from '@/components/base/shadcn/command';
27+
import {
28+
Popover,
29+
PopoverContent,
30+
PopoverTrigger,
31+
} from '@/components/base/shadcn/popover';
32+
import { Badge } from '@/components/base/shadcn/badge';
33+
34+
export interface SearchableSelectItem {
35+
/** Unique, stable identifier — this is what onSelect receives. */
36+
key: string;
37+
label: string;
38+
description?: string | null;
39+
/** Short tag rendered as a badge (e.g. a language, a "default" marker). */
40+
badge?: string | null;
41+
/** Trailing meta text (e.g. "Updated 2/29/2024"). */
42+
meta?: string | null;
43+
/** Extra text matched against the search query but not otherwise shown. */
44+
searchText?: string | null;
45+
icon?: React.ReactNode;
46+
disabled?: boolean;
47+
}
48+
49+
export interface SearchableSelectProps {
50+
items: SearchableSelectItem[];
51+
/** The selected item's key, or null/undefined for no selection. */
52+
value?: string | null;
53+
onSelect: (key: string | null) => void;
54+
placeholder?: string;
55+
searchPlaceholder?: string;
56+
emptyMessage?: string;
57+
loading?: boolean;
58+
loadingMessage?: string;
59+
disabled?: boolean;
60+
/** Trigger button className (merged with the default full-width outline style). */
61+
className?: string;
62+
/** Popover content className override (defaults to matching the trigger's width). */
63+
contentClassName?: string;
64+
'aria-label'?: string;
65+
/** Observe/control the open state (e.g. to lazily fetch items on first open). */
66+
open?: boolean;
67+
onOpenChange?: (open: boolean) => void;
68+
}
69+
70+
export function SearchableSelect({
71+
items,
72+
value,
73+
onSelect,
74+
placeholder = 'Select...',
75+
searchPlaceholder = 'Search...',
76+
emptyMessage = 'No results found.',
77+
loading = false,
78+
loadingMessage = 'Loading...',
79+
disabled = false,
80+
className,
81+
contentClassName,
82+
'aria-label': ariaLabel,
83+
open: openProp,
84+
onOpenChange,
85+
}: SearchableSelectProps) {
86+
const [openState, setOpenState] = useState(false);
87+
const open = openProp ?? openState;
88+
const setOpen = (next: boolean) => {
89+
setOpenState(next);
90+
onOpenChange?.(next);
91+
};
92+
const [searchQuery, setSearchQuery] = useState('');
93+
94+
const selected = useMemo(
95+
() => items.find((item) => item.key === value) || null,
96+
[items, value],
97+
);
98+
99+
const filteredItems = useMemo(() => {
100+
const query = searchQuery.trim().toLowerCase();
101+
if (!query) return items;
102+
return items.filter((item) =>
103+
[item.label, item.description, item.searchText]
104+
.filter((text): text is string => Boolean(text))
105+
.some((text) => text.toLowerCase().includes(query)),
106+
);
107+
}, [items, searchQuery]);
108+
109+
const handleSelect = (item: SearchableSelectItem) => {
110+
if (item.disabled) return;
111+
onSelect(item.key);
112+
setOpen(false);
113+
setSearchQuery('');
114+
};
115+
116+
return (
117+
<Popover open={open} onOpenChange={setOpen}>
118+
<PopoverTrigger asChild>
119+
<Button
120+
type="button"
121+
variant="outline"
122+
role="combobox"
123+
aria-expanded={open}
124+
aria-label={ariaLabel}
125+
disabled={disabled}
126+
className={cn('w-full justify-between font-normal', className)}
127+
>
128+
{selected ? (
129+
<span className="flex min-w-0 items-center gap-2 truncate">
130+
{selected.icon}
131+
<span className="truncate">{selected.label}</span>
132+
</span>
133+
) : (
134+
<span className="truncate text-muted-foreground">{placeholder}</span>
135+
)}
136+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
137+
</Button>
138+
</PopoverTrigger>
139+
<PopoverContent
140+
className={cn('w-[--radix-popover-trigger-width] min-w-[280px] p-0', contentClassName)}
141+
align="start"
142+
>
143+
<Command shouldFilter={false}>
144+
<CommandInput
145+
placeholder={searchPlaceholder}
146+
value={searchQuery}
147+
onValueChange={setSearchQuery}
148+
/>
149+
<CommandList>
150+
{loading ? (
151+
<div className="py-6 text-center text-sm text-muted-foreground">{loadingMessage}</div>
152+
) : filteredItems.length === 0 ? (
153+
<CommandEmpty>{emptyMessage}</CommandEmpty>
154+
) : (
155+
<CommandGroup>
156+
{filteredItems.map((item) => (
157+
<CommandItem
158+
key={item.key}
159+
value={item.key}
160+
disabled={item.disabled}
161+
onSelect={() => handleSelect(item)}
162+
>
163+
<Check
164+
className={cn(
165+
'mr-2 h-4 w-4 shrink-0',
166+
selected?.key === item.key ? 'opacity-100' : 'opacity-0',
167+
)}
168+
/>
169+
<div className="min-w-0 flex-1 overflow-hidden">
170+
<div className="flex items-center gap-2">
171+
{item.icon}
172+
<span className="truncate font-medium">{item.label}</span>
173+
</div>
174+
{item.description ? (
175+
<p className="truncate text-xs text-muted-foreground">{item.description}</p>
176+
) : null}
177+
{item.badge || item.meta ? (
178+
<div className="mt-1 flex items-center gap-2">
179+
{item.badge ? (
180+
<Badge variant="secondary" className="text-xs">
181+
{item.badge}
182+
</Badge>
183+
) : null}
184+
{item.meta ? (
185+
<span className="text-xs text-muted-foreground">{item.meta}</span>
186+
) : null}
187+
</div>
188+
) : null}
189+
</div>
190+
</CommandItem>
191+
))}
192+
</CommandGroup>
193+
)}
194+
</CommandList>
195+
</Command>
196+
</PopoverContent>
197+
</Popover>
198+
);
199+
}

0 commit comments

Comments
 (0)