mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 02:45:07 +03:00
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:
1 parent
f3021f0739
commit
97ecd28c11
3 files changed
+177
-16
No files matched your search
+25
@@ -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()
|
||||
})
|
||||
})
|
||||
+110
@@ -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()
|
||||
})
|
||||
})
|
||||
+42
-16
@@ -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>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user