feat: cap project selection at ten in the authorize interstitial

The field header counts the selection against the cap once anything is
selected, and at ten the unselected options are disabled with helper text
explaining how to swap one out. Deselection is never blocked, so the list
re-enables as soon as the selection drops below the cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kemalandClaude Opus 5 authored and Gildas Garcia committed 2026-10-01 11:29:06 +02:00
1 parent f3021f0739
commit 97ecd28c11
3 files changed
+177 -16

No files matched your search

@@ -39,4 +39,29 @@ describe('NoProjectsNotice', () => {
expect(onSwitchOrg).toHaveBeenCalledTimes(1)
})
test('interpolates a different app name, so the copy is not hardcoded', () => {
customRender(
<NoProjectsNotice appName="Fabrikam" organizationSlug="contoso-labs" onSwitchOrg={vi.fn()} />
)
expect(
screen.getByText(
"Fabrikam needs access to at least one project, and this organization doesn't have any yet."
)
).toBeInTheDocument()
})
test('shows the same organization slug it was given', () => {
customRender(
<NoProjectsNotice
appName="Vercel"
organizationSlug="northwind-traders"
onSwitchOrg={vi.fn()}
/>
)
expect(screen.getByText('No projects in northwind-traders')).toBeInTheDocument()
expect(screen.queryByText('No projects in contoso-labs')).not.toBeInTheDocument()
})
})
@@ -10,6 +10,15 @@ const PROJECTS: OAuthAppsAuthorizeOrganizationProject[] = [
{ ref: 'project-2', name: 'staging' },
]
const MANY_PROJECTS: OAuthAppsAuthorizeOrganizationProject[] = Array.from(
{ length: 12 },
(_, index) => ({ ref: `project-${index + 1}`, name: `project ${index + 1}` })
)
const refsUpTo = (count: number) => MANY_PROJECTS.slice(0, count).map((project) => project.ref)
const CAP_HELPER_TEXT = 'Maximum reached. Deselect a project to choose a different one.'
describe('ProjectMultiSelect', () => {
test('shows a placeholder when nothing is selected', () => {
customRender(<ProjectMultiSelect projects={PROJECTS} selectedRefs={[]} onChange={vi.fn()} />)
@@ -94,4 +103,105 @@ describe('ProjectMultiSelect', () => {
expect(screen.queryByText(/select all/i)).not.toBeInTheDocument()
})
test('hides the counter until something is selected', () => {
customRender(<ProjectMultiSelect projects={PROJECTS} selectedRefs={[]} onChange={vi.fn()} />)
expect(screen.queryByText('0/10')).not.toBeInTheDocument()
})
test('shows the selection counter once a project is selected', () => {
customRender(
<ProjectMultiSelect
projects={PROJECTS}
selectedRefs={['project-1', 'project-2']}
onChange={vi.fn()}
/>
)
expect(screen.getByText('2/10')).toBeInTheDocument()
})
test('at nine selected the cap is not yet reached and a tenth can be added', () => {
const onChange = vi.fn()
customRender(
<ProjectMultiSelect projects={MANY_PROJECTS} selectedRefs={refsUpTo(9)} onChange={onChange} />
)
expect(screen.getByText('9/10')).toBeInTheDocument()
expect(screen.queryByText(CAP_HELPER_TEXT)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByText('project 10'))
expect(onChange).toHaveBeenCalledWith(refsUpTo(10))
})
test('at the cap it shows 10/10 and the helper text', () => {
customRender(
<ProjectMultiSelect projects={MANY_PROJECTS} selectedRefs={refsUpTo(10)} onChange={vi.fn()} />
)
expect(screen.getByText('10/10')).toBeInTheDocument()
expect(screen.getByText(CAP_HELPER_TEXT)).toBeInTheDocument()
})
test('at the cap unselected options are disabled and cannot be selected', () => {
const onChange = vi.fn()
customRender(
<ProjectMultiSelect
projects={MANY_PROJECTS}
selectedRefs={refsUpTo(10)}
onChange={onChange}
/>
)
fireEvent.click(screen.getByRole('combobox'))
const unselected = screen.getByText('project 11').closest('[role="option"]')
expect(unselected).toHaveAttribute('aria-disabled', 'true')
fireEvent.click(screen.getByText('project 11'))
expect(onChange).not.toHaveBeenCalled()
})
test('the cap never blocks deselection, which re-enables the other options', () => {
const onChange = vi.fn()
const { rerender } = customRender(
<ProjectMultiSelect
projects={MANY_PROJECTS}
selectedRefs={refsUpTo(10)}
onChange={onChange}
/>
)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByText('project 1'))
expect(onChange).toHaveBeenCalledWith(refsUpTo(10).slice(1))
rerender(
<ProjectMultiSelect projects={MANY_PROJECTS} selectedRefs={refsUpTo(9)} onChange={onChange} />
)
expect(screen.queryByText(CAP_HELPER_TEXT)).not.toBeInTheDocument()
expect(screen.getByText('project 11').closest('[role="option"]')).not.toHaveAttribute(
'aria-disabled',
'true'
)
})
test('the required-selection error still renders at the cap boundary', () => {
customRender(
<ProjectMultiSelect
projects={PROJECTS}
selectedRefs={[]}
onChange={vi.fn()}
error="Must select at least one project to authorize."
/>
)
expect(screen.getByText('Must select at least one project to authorize.')).toBeInTheDocument()
expect(screen.queryByText(CAP_HELPER_TEXT)).not.toBeInTheDocument()
})
})
@@ -15,6 +15,8 @@ import {
import type { OAuthAppsAuthorizeOrganizationProject } from '@/data/oauth-apps/oauth-apps-authorize-organization-projects-query'
const MAX_SELECTED_PROJECTS = 10
export interface ProjectMultiSelectProps {
projects: OAuthAppsAuthorizeOrganizationProject[]
selectedRefs: string[]
@@ -31,9 +33,14 @@ export const ProjectMultiSelect = ({
const [open, setOpen] = useState(false)
const listId = useId()
const atCap = selectedRefs.length >= MAX_SELECTED_PROJECTS
const toggleProject = (ref: string) => {
const isSelected = selectedRefs.includes(ref)
if (!isSelected && atCap) return
onChange(
selectedRefs.includes(ref)
isSelected
? selectedRefs.filter((selectedRef) => selectedRef !== ref)
: [...selectedRefs, ref]
)
@@ -46,7 +53,14 @@ export const ProjectMultiSelect = ({
return (
<div className="flex flex-col gap-2 w-full">
<p className="text-xs text-foreground">Projects</p>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-foreground">Projects</p>
{selectedRefs.length > 0 && (
<p className="text-xs text-foreground-lighter">
{selectedRefs.length}/{MAX_SELECTED_PROJECTS}
</p>
)}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<button
@@ -75,24 +89,36 @@ export const ProjectMultiSelect = ({
<CommandInput placeholder="Search projects" />
<CommandList id={listId}>
<CommandEmpty>No projects found</CommandEmpty>
{projects.map((project) => (
<CommandItem
key={project.ref}
value={project.name}
onSelect={() => toggleProject(project.ref)}
className="flex items-center gap-2"
>
<Checkbox
checked={selectedRefs.includes(project.ref)}
onCheckedChange={() => toggleProject(project.ref)}
/>
<span className="truncate">{project.name}</span>
</CommandItem>
))}
{projects.map((project) => {
const isSelected = selectedRefs.includes(project.ref)
const isDisabled = atCap && !isSelected
return (
<CommandItem
key={project.ref}
value={project.name}
disabled={isDisabled}
onSelect={() => toggleProject(project.ref)}
className="flex items-center gap-2"
>
<Checkbox
checked={isSelected}
disabled={isDisabled}
onCheckedChange={() => toggleProject(project.ref)}
/>
<span className="truncate">{project.name}</span>
</CommandItem>
)
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{atCap && (
<p className="text-xs text-foreground-lighter">
Maximum reached. Deselect a project to choose a different one.
</p>
)}
{error && <p className="text-xs text-foreground-light">{error}</p>}
</div>
)