diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/NoProjectsNotice.test.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/NoProjectsNotice.test.tsx
index 044e5f6f3b7..63314d20b90 100644
--- a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/NoProjectsNotice.test.tsx
+++ b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/NoProjectsNotice.test.tsx
@@ -39,4 +39,29 @@ describe('NoProjectsNotice', () => {
expect(onSwitchOrg).toHaveBeenCalledTimes(1)
})
+
+ test('interpolates a different app name, so the copy is not hardcoded', () => {
+ customRender(
+
+ )
+
+ 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(
+
+ )
+
+ expect(screen.getByText('No projects in northwind-traders')).toBeInTheDocument()
+ expect(screen.queryByText('No projects in contoso-labs')).not.toBeInTheDocument()
+ })
})
diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/ProjectMultiSelect.test.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/ProjectMultiSelect.test.tsx
index fd5ab991c2f..3ea34ca8dde 100644
--- a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/ProjectMultiSelect.test.tsx
+++ b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/ProjectMultiSelect.test.tsx
@@ -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()
@@ -94,4 +103,105 @@ describe('ProjectMultiSelect', () => {
expect(screen.queryByText(/select all/i)).not.toBeInTheDocument()
})
+
+ test('hides the counter until something is selected', () => {
+ customRender()
+
+ expect(screen.queryByText('0/10')).not.toBeInTheDocument()
+ })
+
+ test('shows the selection counter once a project is selected', () => {
+ customRender(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ fireEvent.click(screen.getByRole('combobox'))
+ fireEvent.click(screen.getByText('project 1'))
+
+ expect(onChange).toHaveBeenCalledWith(refsUpTo(10).slice(1))
+
+ rerender(
+
+ )
+
+ 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(
+
+ )
+
+ expect(screen.getByText('Must select at least one project to authorize.')).toBeInTheDocument()
+ expect(screen.queryByText(CAP_HELPER_TEXT)).not.toBeInTheDocument()
+ })
})
diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/ProjectMultiSelect.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/ProjectMultiSelect.tsx
index 40e60af6c77..10b695fe7df 100644
--- a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/ProjectMultiSelect.tsx
+++ b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/ProjectMultiSelect.tsx
@@ -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 (
-
Projects
+
+
Projects
+ {selectedRefs.length > 0 && (
+
+ {selectedRefs.length}/{MAX_SELECTED_PROJECTS}
+
+ )}
+
No projects found
- {projects.map((project) => (
- toggleProject(project.ref)}
- className="flex items-center gap-2"
- >
- toggleProject(project.ref)}
- />
- {project.name}
-
- ))}
+ {projects.map((project) => {
+ const isSelected = selectedRefs.includes(project.ref)
+ const isDisabled = atCap && !isSelected
+
+ return (
+ toggleProject(project.ref)}
+ className="flex items-center gap-2"
+ >
+ toggleProject(project.ref)}
+ />
+ {project.name}
+
+ )
+ })}
+ {atCap && (
+
+ Maximum reached. Deselect a project to choose a different one.
+
+ )}
{error &&
{error}
}
)