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.",
- );
-});