diff --git a/frontend/src/component/feature/CreateFeature/CreateFeature.test.tsx b/frontend/src/component/feature/CreateFeature/CreateFeature.test.tsx
new file mode 100644
index 000000000000..2c3dae6ed1f0
--- /dev/null
+++ b/frontend/src/component/feature/CreateFeature/CreateFeature.test.tsx
@@ -0,0 +1,77 @@
+import { screen, waitFor } from '@testing-library/react';
+import { render } from 'utils/testRenderer';
+import { testServerRoute, testServerSetup } from 'utils/testServer';
+import CreateFeature from './CreateFeature';
+import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions';
+import { Route, Routes } from 'react-router-dom';
+
+const server = testServerSetup();
+
+const setupApi = ({
+ flagCount,
+ flagLimit,
+}: { flagCount: number; flagLimit: number }) => {
+ testServerRoute(server, '/api/admin/ui-config', {
+ flags: {
+ resourceLimits: true,
+ },
+ resourceLimits: {
+ featureFlags: flagLimit,
+ },
+ });
+
+ testServerRoute(server, '/api/admin/search/features', {
+ total: flagCount,
+ features: Array.from({ length: flagCount }).map((_, i) => ({
+ name: `flag-${i}`,
+ })),
+ });
+};
+
+test("should allow you to create feature flags when you're below the global limit", async () => {
+ setupApi({ flagLimit: 3, flagCount: 2 });
+
+ render(
+
+ }
+ />
+ ,
+ {
+ route: '/projects/default/create-toggle',
+ permissions: [{ permission: CREATE_FEATURE }],
+ },
+ );
+
+ await waitFor(async () => {
+ const button = await screen.findByRole('button', {
+ name: /create feature flag/i,
+ });
+ expect(button).not.toBeDisabled();
+ });
+});
+
+test("should not allow you to create API tokens when you're at the global limit", async () => {
+ setupApi({ flagLimit: 3, flagCount: 3 });
+
+ render(
+
+ }
+ />
+ ,
+ {
+ route: '/projects/default/create-toggle',
+ permissions: [{ permission: CREATE_FEATURE }],
+ },
+ );
+
+ await waitFor(async () => {
+ const button = await screen.findByRole('button', {
+ name: /create feature flag/i,
+ });
+ expect(button).toBeDisabled();
+ });
+});
diff --git a/frontend/src/component/feature/CreateFeature/CreateFeature.tsx b/frontend/src/component/feature/CreateFeature/CreateFeature.tsx
index 53328aaab2bd..c2b3930ff215 100644
--- a/frontend/src/component/feature/CreateFeature/CreateFeature.tsx
+++ b/frontend/src/component/feature/CreateFeature/CreateFeature.tsx
@@ -17,12 +17,14 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import useProjectOverview, {
featuresCount,
} from 'hooks/api/getters/useProjectOverview/useProjectOverview';
+import { useUiFlag } from 'hooks/useUiFlag';
+import { useGlobalFeatureSearch } from '../FeatureToggleList/useGlobalFeatureSearch';
const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(2),
}));
-export const isFeatureLimitReached = (
+export const isProjectFeatureLimitReached = (
featureLimit: number | null | undefined,
currentFeatureCount: number,
): boolean => {
@@ -33,6 +35,47 @@ export const isFeatureLimitReached = (
);
};
+const useGlobalFlagLimit = (flagLimit: number, flagCount: number) => {
+ const resourceLimitsEnabled = useUiFlag('resourceLimits');
+ const limitReached = resourceLimitsEnabled && flagCount >= flagLimit;
+
+ return {
+ limitReached,
+ limitMessage: limitReached
+ ? `You have reached the instance-wide limit of ${flagLimit} feature flags.`
+ : undefined,
+ };
+};
+
+type FlagLimitsProps = {
+ global: { limit: number; count: number };
+ project: { limit?: number; count: number };
+};
+
+export const useFlagLimits = ({ global, project }: FlagLimitsProps) => {
+ const {
+ limitReached: globalFlagLimitReached,
+ limitMessage: globalLimitMessage,
+ } = useGlobalFlagLimit(global.limit, global.count);
+
+ const projectFlagLimitReached = isProjectFeatureLimitReached(
+ project.limit,
+ project.count,
+ );
+
+ const limitMessage = globalFlagLimitReached
+ ? globalLimitMessage
+ : projectFlagLimitReached
+ ? `You have reached the project limit of ${project.limit} feature flags.`
+ : undefined;
+
+ return {
+ limitMessage,
+ globalFlagLimitReached,
+ projectFlagLimitReached,
+ };
+};
+
const CreateFeature = () => {
const { setToastData, setToastApiError } = useToast();
const { setShowFeedback } = useContext(UIContext);
@@ -60,6 +103,21 @@ const CreateFeature = () => {
const { createFeatureToggle, loading } = useFeatureApi();
+ const { total: totalFlags, loading: loadingTotalFlagCount } =
+ useGlobalFeatureSearch();
+
+ const { globalFlagLimitReached, projectFlagLimitReached, limitMessage } =
+ useFlagLimits({
+ global: {
+ limit: uiConfig.resourceLimits.featureFlags,
+ count: totalFlags ?? 0,
+ },
+ project: {
+ limit: projectInfo.featureLimit,
+ count: featuresCount(projectInfo),
+ },
+ });
+
const handleSubmit = async (e: Event) => {
e.preventDefault();
clearErrors();
@@ -98,10 +156,6 @@ const CreateFeature = () => {
navigate(GO_BACK);
};
- const featureLimitReached = isFeatureLimitReached(
- projectInfo.featureLimit,
- featuresCount(projectInfo),
- );
return (
{
formatApiCode={formatApiCode}
>
Feature flag project limit reached. To
@@ -145,10 +199,18 @@ const CreateFeature = () => {
>
diff --git a/frontend/src/component/feature/CreateFeature/isFeatureLimitReached.test.ts b/frontend/src/component/feature/CreateFeature/isProjectFeatureLimitReached.test.ts
similarity index 59%
rename from frontend/src/component/feature/CreateFeature/isFeatureLimitReached.test.ts
rename to frontend/src/component/feature/CreateFeature/isProjectFeatureLimitReached.test.ts
index 4ac55c90a733..77a520f9792e 100644
--- a/frontend/src/component/feature/CreateFeature/isFeatureLimitReached.test.ts
+++ b/frontend/src/component/feature/CreateFeature/isProjectFeatureLimitReached.test.ts
@@ -1,21 +1,21 @@
-import { isFeatureLimitReached } from './CreateFeature';
+import { isProjectFeatureLimitReached } from './CreateFeature';
test('isFeatureLimitReached should return false when featureLimit is null', async () => {
- expect(isFeatureLimitReached(null, 5)).toBe(false);
+ expect(isProjectFeatureLimitReached(null, 5)).toBe(false);
});
test('isFeatureLimitReached should return false when featureLimit is undefined', async () => {
- expect(isFeatureLimitReached(undefined, 5)).toBe(false);
+ expect(isProjectFeatureLimitReached(undefined, 5)).toBe(false);
});
test('isFeatureLimitReached should return false when featureLimit is smaller current feature count', async () => {
- expect(isFeatureLimitReached(6, 5)).toBe(false);
+ expect(isProjectFeatureLimitReached(6, 5)).toBe(false);
});
test('isFeatureLimitReached should return true when featureLimit is smaller current feature count', async () => {
- expect(isFeatureLimitReached(4, 5)).toBe(true);
+ expect(isProjectFeatureLimitReached(4, 5)).toBe(true);
});
test('isFeatureLimitReached should return true when featureLimit is equal to current feature count', async () => {
- expect(isFeatureLimitReached(5, 5)).toBe(true);
+ expect(isProjectFeatureLimitReached(5, 5)).toBe(true);
});
diff --git a/frontend/src/component/feature/CreateFeature/useFlagLimits.test.ts b/frontend/src/component/feature/CreateFeature/useFlagLimits.test.ts
new file mode 100644
index 000000000000..b8abb2cf312a
--- /dev/null
+++ b/frontend/src/component/feature/CreateFeature/useFlagLimits.test.ts
@@ -0,0 +1,71 @@
+import { renderHook } from '@testing-library/react';
+import { useFlagLimits } from './CreateFeature';
+import { vi } from 'vitest';
+
+vi.mock('hooks/useUiFlag', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...(actual as {}),
+ useUiFlag: (flag: string) => flag === 'resourceLimits',
+ };
+});
+
+test('if both global and project-level limits are reached, then the error message shows the message for instance-wide limits', () => {
+ const { result } = renderHook(() =>
+ useFlagLimits({
+ global: { limit: 1, count: 1 },
+ project: { limit: 1, count: 1 },
+ }),
+ );
+
+ expect(result.current).toMatchObject({
+ globalFlagLimitReached: true,
+ projectFlagLimitReached: true,
+ limitMessage: expect.stringContaining('instance-wide limit'),
+ });
+});
+
+test('if only global level is reached, the projectFlagLimitReached property is false', () => {
+ const { result } = renderHook(() =>
+ useFlagLimits({
+ global: { limit: 1, count: 1 },
+ project: { limit: 1, count: 0 },
+ }),
+ );
+
+ expect(result.current).toMatchObject({
+ globalFlagLimitReached: true,
+ projectFlagLimitReached: false,
+ limitMessage: expect.stringContaining('instance-wide limit'),
+ });
+});
+
+test('if only the project limit is reached, the limit message talks about the project limit', () => {
+ const { result } = renderHook(() =>
+ useFlagLimits({
+ global: { limit: 2, count: 1 },
+ project: { limit: 1, count: 1 },
+ }),
+ );
+
+ expect(result.current).toMatchObject({
+ globalFlagLimitReached: false,
+ projectFlagLimitReached: true,
+ limitMessage: expect.stringContaining('project limit'),
+ });
+});
+
+test('if neither limit is reached, the limit message is undefined', () => {
+ const { result } = renderHook(() =>
+ useFlagLimits({
+ global: { limit: 1, count: 0 },
+ project: { limit: 1, count: 0 },
+ }),
+ );
+
+ expect(result.current).toMatchObject({
+ globalFlagLimitReached: false,
+ projectFlagLimitReached: false,
+ limitMessage: undefined,
+ });
+});
diff --git a/frontend/src/component/feature/CreateFeatureButton/CreateFeatureButton.tsx b/frontend/src/component/feature/CreateFeatureButton/CreateFeatureButton.tsx
deleted file mode 100644
index 3e44b0c653ce..000000000000
--- a/frontend/src/component/feature/CreateFeatureButton/CreateFeatureButton.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import classnames from 'classnames';
-import { Link, useNavigate } from 'react-router-dom';
-import useMediaQuery from '@mui/material/useMediaQuery';
-import Add from '@mui/icons-material/Add';
-import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
-import { NAVIGATE_TO_CREATE_FEATURE } from 'utils/testIds';
-import { useCreateFeaturePath } from 'component/feature/CreateFeatureButton/useCreateFeaturePath';
-import PermissionButton from 'component/common/PermissionButton/PermissionButton';
-import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions';
-import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
-
-interface ICreateFeatureButtonProps {
- loading: boolean;
- filter: {
- query?: string;
- project: string;
- };
-}
-
-export const CreateFeatureButton = ({
- loading,
- filter,
-}: ICreateFeatureButtonProps) => {
- const smallScreen = useMediaQuery('(max-width:800px)');
- const createFeature = useCreateFeaturePath(filter);
- const navigate = useNavigate();
-
- if (!createFeature) {
- return null;
- }
-
- return (
-
-
-
- }
- elseShow={
- {
- navigate(createFeature.path);
- }}
- permission={CREATE_FEATURE}
- projectId={createFeature.projectId}
- color='primary'
- variant='contained'
- data-testid={NAVIGATE_TO_CREATE_FEATURE}
- className={classnames({ skeleton: loading })}
- >
- New feature flag
-
- }
- />
- );
-};
diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx
index f29ebced07e6..1d6985278ade 100644
--- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx
+++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader.tsx
@@ -1,4 +1,4 @@
-import { type ReactNode, type VFC, useState } from 'react';
+import { type ReactNode, type FC, useState } from 'react';
import {
Box,
Button,
@@ -40,7 +40,7 @@ const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
whiteSpace: 'nowrap',
}));
-export const ProjectFeatureTogglesHeader: VFC<
+export const ProjectFeatureTogglesHeader: FC<
IProjectFeatureTogglesHeaderProps
> = ({
isLoading,
diff --git a/frontend/src/component/segments/CreateSegmentButton/CreateSegmentButton.tsx b/frontend/src/component/segments/CreateSegmentButton/CreateSegmentButton.tsx
index 74711b6a7880..4ececc5016fd 100644
--- a/frontend/src/component/segments/CreateSegmentButton/CreateSegmentButton.tsx
+++ b/frontend/src/component/segments/CreateSegmentButton/CreateSegmentButton.tsx
@@ -8,10 +8,7 @@ import { useNavigate } from 'react-router-dom';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
import type { FC } from 'react';
-export const CreateSegmentButton: FC<{
- disabled: boolean;
- tooltip?: string;
-}> = ({ disabled, tooltip }) => {
+export const CreateSegmentButton: FC = () => {
const projectId = useOptionalPathParam('projectId');
const navigate = useNavigate();
@@ -26,10 +23,6 @@ export const CreateSegmentButton: FC<{
}}
permission={[CREATE_SEGMENT, UPDATE_PROJECT_SEGMENT]}
projectId={projectId}
- disabled={disabled}
- tooltipProps={{
- title: tooltip,
- }}
data-testid={NAVIGATE_TO_CREATE_SEGMENT}
>
New segment
diff --git a/frontend/src/component/segments/SegmentDocs.tsx b/frontend/src/component/segments/SegmentDocs.tsx
index 17b0aac14876..03a241c8eb9d 100644
--- a/frontend/src/component/segments/SegmentDocs.tsx
+++ b/frontend/src/component/segments/SegmentDocs.tsx
@@ -16,7 +16,8 @@ export const SegmentDocsValuesInfo = () => {
target='_blank'
rel='noreferrer'
>
- at most {segmentValuesLimit} across all of its contraints
+ at most {segmentValuesLimit} values across all of its
+ constraints
.
diff --git a/frontend/src/component/segments/SegmentFormStepOne.test.tsx b/frontend/src/component/segments/SegmentFormStepOne.test.tsx
new file mode 100644
index 000000000000..a455bef7613a
--- /dev/null
+++ b/frontend/src/component/segments/SegmentFormStepOne.test.tsx
@@ -0,0 +1,75 @@
+import { render } from 'utils/testRenderer';
+import { screen, waitFor } from '@testing-library/react';
+import { testServerRoute, testServerSetup } from 'utils/testServer';
+import { SegmentFormStepOne } from './SegmentFormStepOne';
+
+const server = testServerSetup();
+
+const setupRoutes = ({
+ limit,
+ segments,
+}: { limit: number; segments: number }) => {
+ testServerRoute(server, 'api/admin/segments', {
+ segments: [...Array(segments).keys()].map((i) => ({
+ name: `segment${i}`,
+ })),
+ });
+
+ testServerRoute(server, '/api/admin/ui-config', {
+ flags: {
+ SE: true,
+ resourceLimits: true,
+ },
+ resourceLimits: {
+ segments: limit,
+ },
+ });
+};
+
+const irrelevant = () => {};
+
+test('Do not allow next step when limit reached', async () => {
+ setupRoutes({ limit: 1, segments: 1 });
+
+ render(
+ ,
+ );
+
+ await screen.findByText('You have reached the limit for segments');
+ const nextStep = await screen.findByText('Next');
+ expect(nextStep).toBeDisabled();
+});
+
+test('Allows next step when approaching limit', async () => {
+ setupRoutes({ limit: 10, segments: 9 });
+
+ render(
+ ,
+ );
+
+ await screen.findByText('You are nearing the limit for segments');
+ await waitFor(async () => {
+ const nextStep = await screen.findByText('Next');
+ expect(nextStep).toBeEnabled();
+ });
+});
diff --git a/frontend/src/component/segments/SegmentFormStepOne.tsx b/frontend/src/component/segments/SegmentFormStepOne.tsx
index 46097e0fcd07..cb5a8b87ef5b 100644
--- a/frontend/src/component/segments/SegmentFormStepOne.tsx
+++ b/frontend/src/component/segments/SegmentFormStepOne.tsx
@@ -1,4 +1,4 @@
-import { Autocomplete, Button, styled, TextField } from '@mui/material';
+import { Autocomplete, Box, Button, styled, TextField } from '@mui/material';
import Input from 'component/common/Input/Input';
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
@@ -20,6 +20,10 @@ import {
import { SegmentProjectAlert } from './SegmentProjectAlert';
import { sortStrategiesByFeature } from './SegmentDelete/SegmentDeleteUsedSegment/sort-strategies';
import type { IFeatureStrategy } from 'interfaces/strategy';
+import { useUiFlag } from 'hooks/useUiFlag';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
+import { Limit } from '../common/Limit/Limit';
interface ISegmentFormPartOneProps {
name: string;
@@ -62,6 +66,32 @@ const StyledCancelButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3),
}));
+const LimitContainer = styled(Box)(({ theme }) => ({
+ flex: 1,
+ display: 'flex',
+ alignItems: 'flex-end',
+ marginTop: theme.spacing(3),
+ marginBottom: theme.spacing(3),
+}));
+
+const useSegmentLimit = () => {
+ const { segments, loading: loadingSegments } = useSegments();
+ const { uiConfig, loading: loadingConfig } = useUiConfig();
+ const segmentsLimit = uiConfig.resourceLimits.segments;
+ const segmentsCount = segments?.length || 0;
+ const resourceLimitsEnabled = useUiFlag('resourceLimits');
+ const limitReached =
+ resourceLimitsEnabled && segmentsCount >= segmentsLimit;
+
+ return {
+ limit: segmentsLimit,
+ limitReached,
+ currentCount: segmentsCount,
+ loading: loadingSegments || loadingConfig,
+ resourceLimitsEnabled,
+ };
+};
+
export const SegmentFormStepOne: React.FC = ({
name,
description,
@@ -76,6 +106,13 @@ export const SegmentFormStepOne: React.FC = ({
const projectId = useOptionalPathParam('projectId');
const navigate = useNavigate();
const { projects, loading: loadingProjects } = useProjects();
+ const {
+ limitReached,
+ limit,
+ currentCount,
+ loading: loadingSegmentLimit,
+ resourceLimitsEnabled,
+ } = useSegmentLimit();
const {
strategies,
@@ -106,7 +143,7 @@ export const SegmentFormStepOne: React.FC = ({
setSelectedProject(projects.find(({ id }) => id === project) ?? null);
}, [project, projects]);
- const loading = loadingProjects && loadingStrategies;
+ const loading = loadingProjects || loadingStrategies || loadingSegmentLimit;
return (
@@ -165,13 +202,32 @@ export const SegmentFormStepOne: React.FC = ({
}
/>
+
+
+
+ }
+ />
+
+