diff --git a/frontend/src/component/project/ProjectList/ProjectList.test.tsx b/frontend/src/component/project/ProjectList/ProjectList.test.tsx new file mode 100644 index 0000000000..b01d8fab4b --- /dev/null +++ b/frontend/src/component/project/ProjectList/ProjectList.test.tsx @@ -0,0 +1,40 @@ +import { render } from 'utils/testRenderer'; +import { ProjectListNew } from './ProjectList'; +import { screen, waitFor } from '@testing-library/react'; +import { testServerRoute, testServerSetup } from 'utils/testServer'; +import { CREATE_PROJECT } from '../../providers/AccessProvider/permissions'; + +const server = testServerSetup(); + +const setupApi = () => { + testServerRoute(server, '/api/admin/ui-config', { + flags: { + resourceLimits: true, + }, + resourceLimits: { + projects: 1, + }, + versionInfo: { + current: { enterprise: 'version' }, + }, + }); + + testServerRoute(server, '/api/admin/projects', { + projects: [], + }); +}; + +test('Enabled new project button when limits, version and permission allow for it', async () => { + setupApi(); + render(, { + permissions: [{ permission: CREATE_PROJECT }], + }); + + const button = await screen.findByText('New project'); + expect(button).toBeDisabled(); + + await waitFor(async () => { + const button = await screen.findByText('New project'); + expect(button).not.toBeDisabled(); + }); +}); diff --git a/frontend/src/component/project/ProjectList/ProjectList.tsx b/frontend/src/component/project/ProjectList/ProjectList.tsx index 4010f25299..e626936c94 100644 --- a/frontend/src/component/project/ProjectList/ProjectList.tsx +++ b/frontend/src/component/project/ProjectList/ProjectList.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useMemo, useState } from 'react'; +import { type FC, useContext, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import useProjects from 'hooks/api/getters/useProjects/useProjects'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; @@ -24,6 +24,7 @@ import { useProfile } from 'hooks/api/getters/useProfile/useProfile'; import { groupProjects } from './group-projects'; import { ProjectGroup } from './ProjectGroup'; import { CreateProjectDialog } from '../Project/CreateProject/NewCreateProjectForm/CreateProjectDialog'; +import { useUiFlag } from 'hooks/useUiFlag'; const StyledApiError = styled(ApiError)(({ theme }) => ({ maxWidth: '500px', @@ -53,6 +54,7 @@ const NAVIGATE_TO_CREATE_PROJECT = 'NAVIGATE_TO_CREATE_PROJECT'; function resolveCreateButtonData( isOss: boolean, hasAccess: boolean, + limitReached: boolean, ): ICreateButtonData { if (isOss) { return { @@ -78,6 +80,13 @@ function resolveCreateButtonData( }, disabled: true, }; + } else if (limitReached) { + return { + tooltip: { + title: 'Limit of allowed projects reached', + }, + disabled: true, + }; } else { return { tooltip: { title: 'Click to create a new project' }, @@ -86,16 +95,31 @@ function resolveCreateButtonData( } } -const ProjectCreationButton = () => { +const useProjectLimit = (projectsLimit: number, projectCount: number) => { + const resourceLimitsEnabled = useUiFlag('resourceLimits'); + const limitReached = resourceLimitsEnabled && projectCount >= projectsLimit; + + return limitReached; +}; + +const ProjectCreationButton: FC<{ projectCount: number }> = ({ + projectCount, +}) => { const [searchParams] = useSearchParams(); const showCreateDialog = Boolean(searchParams.get('create')); const [openCreateDialog, setOpenCreateDialog] = useState(showCreateDialog); const { hasAccess } = useContext(AccessContext); - const { isOss } = useUiConfig(); + const { isOss, uiConfig, loading } = useUiConfig(); + + const limitReached = useProjectLimit( + uiConfig.resourceLimits.projects, + projectCount, + ); const createButtonData = resolveCreateButtonData( isOss(), hasAccess(CREATE_PROJECT), + limitReached, ); return ( @@ -106,7 +130,7 @@ const ProjectCreationButton = () => { onClick={() => setOpenCreateDialog(true)} maxWidth='700px' permission={CREATE_PROJECT} - disabled={createButtonData.disabled} + disabled={createButtonData.disabled || loading} tooltipProps={createButtonData.tooltip} data-testid={NAVIGATE_TO_CREATE_PROJECT} > @@ -201,7 +225,9 @@ export const ProjectListNew = () => { } /> - + } > diff --git a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.tsx b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.tsx index 2282727fbc..033faefd91 100644 --- a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.tsx +++ b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.tsx @@ -40,5 +40,6 @@ export const defaultValue: IUiConfig = { featureEnvironmentStrategies: 30, environments: 50, constraintValues: 250, + projects: 500, }, }; diff --git a/frontend/src/openapi/models/resourceLimitsSchema.ts b/frontend/src/openapi/models/resourceLimitsSchema.ts index fb56bb354c..f04845a529 100644 --- a/frontend/src/openapi/models/resourceLimitsSchema.ts +++ b/frontend/src/openapi/models/resourceLimitsSchema.ts @@ -30,4 +30,6 @@ export interface ResourceLimitsSchema { environments: number; /** The maximum number of values for a single constraint. */ constraintValues: number; + /** The maximum number of projects allowed. */ + projects: number; } diff --git a/src/lib/create-config.ts b/src/lib/create-config.ts index c329c8ea41..9a219f18bd 100644 --- a/src/lib/create-config.ts +++ b/src/lib/create-config.ts @@ -649,23 +649,32 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig { process.env.UNLEASH_SIGNAL_TOKENS_PER_ENDPOINT_LIMIT, 5, ), - featureEnvironmentStrategies: parseEnvVarNumber( - process.env.UNLEASH_FEATURE_ENVIRONMENT_STRATEGIES_LIMIT, - 30, + featureEnvironmentStrategies: Math.max( + 1, + parseEnvVarNumber( + process.env.UNLEASH_FEATURE_ENVIRONMENT_STRATEGIES_LIMIT, + 30, + ), ), - constraintValues: parseEnvVarNumber( - process.env.UNLEASH_CONSTRAINT_VALUES_LIMIT, - options?.resourceLimits?.constraintValues || 250, + constraintValues: Math.max( + 1, + parseEnvVarNumber( + process.env.UNLEASH_CONSTRAINT_VALUES_LIMIT, + options?.resourceLimits?.constraintValues || 250, + ), ), environments: parseEnvVarNumber( process.env.UNLEASH_ENVIRONMENTS_LIMIT, 50, ), + projects: Math.max( + 1, + parseEnvVarNumber(process.env.UNLEASH_PROJECTS_LIMIT, 500), + ), apiTokens: Math.max( 0, parseEnvVarNumber(process.env.UNLEASH_API_TOKENS_LIMIT, 2000), ), - projects: parseEnvVarNumber(process.env.UNLEASH_PROJECTS_LIMIT, 500), }; return { diff --git a/src/lib/features/project/project-service.limit.test.ts b/src/lib/features/project/project-service.limit.test.ts index 974626bc09..186af10bb8 100644 --- a/src/lib/features/project/project-service.limit.test.ts +++ b/src/lib/features/project/project-service.limit.test.ts @@ -28,24 +28,3 @@ test('Should not allow to exceed project limit', async () => { "Failed to create project. You can't create more than the established limit of 1.", ); }); - -test('Should enforce minimum project limit of 1', async () => { - const INVALID_LIMIT = 0; - const projectService = createFakeProjectService({ - getLogger, - flagResolver: alwaysOnFlagResolver, - resourceLimits: { - projects: INVALID_LIMIT, - }, - } as unknown as IUnleashConfig); - - const createProject = (name: string) => - projectService.createProject({ name }, {} as IUser, {} as IAuditUser); - - // allow to create one project - await createProject('projectA'); - - await expect(() => createProject('projectB')).rejects.toThrow( - "Failed to create project. You can't create more than the established limit of 1.", - ); -});