From 97ecd28c11284fa3b89dd6fa82271d504ec9901f Mon Sep 17 00:00:00 2001 From: kemal Date: Mon, 7 Sep 2026 15:13:10 +0100 Subject: [PATCH] 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) --- .../Consent/NoProjectsNotice.test.tsx | 25 ++++ .../Consent/ProjectMultiSelect.test.tsx | 110 ++++++++++++++++++ .../OAuthApps/Consent/ProjectMultiSelect.tsx | 58 ++++++--- 3 files changed, 177 insertions(+), 16 deletions(-) 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} +

+ )} +
)