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 = ({ } /> + + + + } + /> + +