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} +

+ )} +
)