diff --git a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.client.test.tsx index bb98803b8..4c7737a9c 100644 --- a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.client.test.tsx @@ -273,6 +273,7 @@ vi.mock('@/components/system', () => ({ Input: (props: InputHTMLAttributes) => , ArrowRight: (props: SVGProps) => , Loader2: (props: SVGProps) => , + PackagePlus: (props: SVGProps) => , Plus: (props: SVGProps) => , RefreshCcw: (props: SVGProps) => , RotateCw: (props: SVGProps) => , diff --git a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx index dc3f9fc72..ce74e3b60 100644 --- a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx @@ -532,6 +532,7 @@ export function StepRepoSelection({ selectedRepositoryIds={selectedRepositoryIds} onToggleRepository={toggleRepository} onCreateRepository={() => setCreateRepoDialogOpen(true)} + showSearch={false} inputPrefix="setup-repository" heightClassName="max-h-[calc(var(--effective-viewport-height)-40rem)] md:h-[18.75rem]" /> diff --git a/apps/web/src/components/settings/environments/CreateEnvironmentPage.client.test.tsx b/apps/web/src/components/settings/environments/CreateEnvironmentPage.client.test.tsx index a21e975bb..088e0d947 100644 --- a/apps/web/src/components/settings/environments/CreateEnvironmentPage.client.test.tsx +++ b/apps/web/src/components/settings/environments/CreateEnvironmentPage.client.test.tsx @@ -263,8 +263,11 @@ vi.mock('@/components/system', () => ({ ), HandMetal: (props: SVGProps) => , Info: (props: SVGProps) => , + Input: (props: InputHTMLAttributes) => , Loader2: (props: SVGProps) => , + PackagePlus: (props: SVGProps) => , Plus: (props: SVGProps) => , + Search: (props: SVGProps) => , ScrollArea: ({ children, ...props diff --git a/apps/web/src/components/settings/environments/EnvironmentRepositorySelector.client.test.tsx b/apps/web/src/components/settings/environments/EnvironmentRepositorySelector.client.test.tsx new file mode 100644 index 000000000..6bfb4fd98 --- /dev/null +++ b/apps/web/src/components/settings/environments/EnvironmentRepositorySelector.client.test.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { EnvironmentRepositorySelector } from './EnvironmentRepositorySelector'; + +const repositories = [ + { id: 'repo-web', fullName: 'Acme/web' }, + { id: 'repo-api', fullName: 'acme/api' }, + { id: 'repo-roomote', fullName: 'RoomoteInc/Roomote' }, +]; + +function SelectorHarness({ + initialSelection = [], + onCreateRepository, +}: { + initialSelection?: string[]; + onCreateRepository?: () => void; +}) { + const [selectedRepositoryIds, setSelectedRepositoryIds] = + useState(initialSelection); + + return ( + + setSelectedRepositoryIds((currentSelection) => + currentSelection.includes(repositoryId) + ? currentSelection.filter((id) => id !== repositoryId) + : [...currentSelection, repositoryId], + ) + } + onCreateRepository={onCreateRepository} + inputPrefix="test-repository" + /> + ); +} + +describe('EnvironmentRepositorySelector', () => { + it('filters repository full names case-insensitively and restores sorted results', async () => { + render(); + + expect( + screen.getAllByRole('checkbox').map((checkbox) => checkbox.id), + ).toEqual([ + 'test-repository-repo-api', + 'test-repository-repo-web', + 'test-repository-repo-roomote', + ]); + + const search = screen.getByRole('searchbox', { + name: 'Search repositories', + }); + fireEvent.change(search, { target: { value: 'ROOMOTE' } }); + + await waitFor(() => { + expect(screen.getByText('RoomoteInc/Roomote')).toBeInTheDocument(); + expect(screen.queryByText('acme/api')).not.toBeInTheDocument(); + }); + + fireEvent.change(search, { target: { value: '' } }); + + await waitFor(() => { + expect(screen.getAllByRole('checkbox')).toHaveLength(3); + }); + }); + + it('preserves selected repositories while they are filtered out', async () => { + render(); + + const search = screen.getByRole('searchbox', { + name: 'Search repositories', + }); + fireEvent.change(search, { target: { value: 'api' } }); + + await waitFor(() => { + expect(screen.queryByText('Acme/web')).not.toBeInTheDocument(); + }); + + fireEvent.change(search, { target: { value: '' } }); + + await waitFor(() => { + expect(screen.getByLabelText('Acme/web')).toBeChecked(); + }); + }); + + it('keeps repository creation available when no repositories match', async () => { + const onCreateRepository = vi.fn(); + render(); + + fireEvent.change( + screen.getByRole('searchbox', { name: 'Search repositories' }), + { target: { value: 'missing' } }, + ); + + await waitFor(() => { + expect(screen.getByText('No repositories found.')).toBeInTheDocument(); + }); + + fireEvent.click( + screen.getByRole('button', { name: 'Create a new repository' }), + ); + expect(onCreateRepository).toHaveBeenCalledOnce(); + }); + + it('omits search for an empty list while retaining repository creation', () => { + const onCreateRepository = vi.fn(); + + render( + , + ); + + expect( + screen.queryByRole('searchbox', { name: 'Search repositories' }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Create a new repository' }), + ).toBeInTheDocument(); + }); + + it('allows callers with an existing filter to suppress search', () => { + render( + , + ); + + expect( + screen.queryByRole('searchbox', { name: 'Search repositories' }), + ).not.toBeInTheDocument(); + expect(screen.getAllByRole('checkbox')).toHaveLength(3); + }); +}); diff --git a/apps/web/src/components/settings/environments/EnvironmentRepositorySelector.tsx b/apps/web/src/components/settings/environments/EnvironmentRepositorySelector.tsx index 6199d3528..0b7d2926e 100644 --- a/apps/web/src/components/settings/environments/EnvironmentRepositorySelector.tsx +++ b/apps/web/src/components/settings/environments/EnvironmentRepositorySelector.tsx @@ -1,9 +1,14 @@ 'use client'; -import { useMemo } from 'react'; +import { useDeferredValue, useMemo, useState } from 'react'; -import { Checkbox, ScrollArea } from '@/components/system'; -import { PackagePlus } from 'lucide-react'; +import { + Checkbox, + Input, + PackagePlus, + ScrollArea, + Search, +} from '@/components/system'; type RepositorySummary = { id: string; @@ -15,6 +20,7 @@ export function EnvironmentRepositorySelector({ selectedRepositoryIds, onToggleRepository, onCreateRepository, + showSearch = true, inputPrefix = 'environment-repository', heightClassName = 'max-h-[11.5rem] md:h-[18.75rem]', }: { @@ -22,9 +28,12 @@ export function EnvironmentRepositorySelector({ selectedRepositoryIds: string[]; onToggleRepository: (repositoryId: string) => void; onCreateRepository?: () => void; + showSearch?: boolean; inputPrefix?: string; heightClassName?: string; }) { + const [search, setSearch] = useState(''); + const normalizedSearch = useDeferredValue(search.trim().toLowerCase()); const sortedRepositories = useMemo( () => [...repositories].sort((left, right) => @@ -32,10 +41,34 @@ export function EnvironmentRepositorySelector({ ), [repositories], ); + const filteredRepositories = useMemo(() => { + if (!normalizedSearch) { + return sortedRepositories; + } + + return sortedRepositories.filter((repository) => + repository.fullName.toLowerCase().includes(normalizedSearch), + ); + }, [normalizedSearch, sortedRepositories]); return (
+ {showSearch && repositories.length > 0 ? ( +
+
+ + setSearch(event.currentTarget.value)} + className="pl-9" + /> +
+
+ ) : null} {onCreateRepository ? (

); diff --git a/apps/web/src/components/system/primitives/icons.ts b/apps/web/src/components/system/primitives/icons.ts index c4d10f058..2c1abc164 100644 --- a/apps/web/src/components/system/primitives/icons.ts +++ b/apps/web/src/components/system/primitives/icons.ts @@ -141,6 +141,7 @@ export { Music2Icon, PanelLeftClose, PanelLeftOpen, + PackagePlus, PaperclipIcon, Pencil, Pin,