mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	ui limits for flags (#7541)
This PR disables the "create feature flag" button when you've reached the limits. This one is a little more complex than the other UI limits, because we also have to take into account the project feature limit. I've tried to touch as little as possible, but I _have_ extracted the calculation of both limits into a single hook.
This commit is contained in:
		
							parent
							
								
									225d8a91f1
								
							
						
					
					
						commit
						ef80d7f81e
					
				| @ -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( | ||||||
|  |         <Routes> | ||||||
|  |             <Route | ||||||
|  |                 path='/projects/:projectId/create-toggle' | ||||||
|  |                 element={<CreateFeature />} | ||||||
|  |             /> | ||||||
|  |         </Routes>, | ||||||
|  |         { | ||||||
|  |             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( | ||||||
|  |         <Routes> | ||||||
|  |             <Route | ||||||
|  |                 path='/projects/:projectId/create-toggle' | ||||||
|  |                 element={<CreateFeature />} | ||||||
|  |             /> | ||||||
|  |         </Routes>, | ||||||
|  |         { | ||||||
|  |             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(); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
| @ -17,12 +17,14 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit | |||||||
| import useProjectOverview, { | import useProjectOverview, { | ||||||
|     featuresCount, |     featuresCount, | ||||||
| } from 'hooks/api/getters/useProjectOverview/useProjectOverview'; | } from 'hooks/api/getters/useProjectOverview/useProjectOverview'; | ||||||
|  | import { useUiFlag } from 'hooks/useUiFlag'; | ||||||
|  | import { useGlobalFeatureSearch } from '../FeatureToggleList/useGlobalFeatureSearch'; | ||||||
| 
 | 
 | ||||||
| const StyledAlert = styled(Alert)(({ theme }) => ({ | const StyledAlert = styled(Alert)(({ theme }) => ({ | ||||||
|     marginBottom: theme.spacing(2), |     marginBottom: theme.spacing(2), | ||||||
| })); | })); | ||||||
| 
 | 
 | ||||||
| export const isFeatureLimitReached = ( | export const isProjectFeatureLimitReached = ( | ||||||
|     featureLimit: number | null | undefined, |     featureLimit: number | null | undefined, | ||||||
|     currentFeatureCount: number, |     currentFeatureCount: number, | ||||||
| ): boolean => { | ): 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 CreateFeature = () => { | ||||||
|     const { setToastData, setToastApiError } = useToast(); |     const { setToastData, setToastApiError } = useToast(); | ||||||
|     const { setShowFeedback } = useContext(UIContext); |     const { setShowFeedback } = useContext(UIContext); | ||||||
| @ -60,6 +103,21 @@ const CreateFeature = () => { | |||||||
| 
 | 
 | ||||||
|     const { createFeatureToggle, loading } = useFeatureApi(); |     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) => { |     const handleSubmit = async (e: Event) => { | ||||||
|         e.preventDefault(); |         e.preventDefault(); | ||||||
|         clearErrors(); |         clearErrors(); | ||||||
| @ -98,10 +156,6 @@ const CreateFeature = () => { | |||||||
|         navigate(GO_BACK); |         navigate(GO_BACK); | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     const featureLimitReached = isFeatureLimitReached( |  | ||||||
|         projectInfo.featureLimit, |  | ||||||
|         featuresCount(projectInfo), |  | ||||||
|     ); |  | ||||||
|     return ( |     return ( | ||||||
|         <FormTemplate |         <FormTemplate | ||||||
|             loading={loading} |             loading={loading} | ||||||
| @ -113,7 +167,7 @@ const CreateFeature = () => { | |||||||
|             formatApiCode={formatApiCode} |             formatApiCode={formatApiCode} | ||||||
|         > |         > | ||||||
|             <ConditionallyRender |             <ConditionallyRender | ||||||
|                 condition={featureLimitReached} |                 condition={projectFlagLimitReached} | ||||||
|                 show={ |                 show={ | ||||||
|                     <StyledAlert severity='error'> |                     <StyledAlert severity='error'> | ||||||
|                         <strong>Feature flag project limit reached. </strong> To |                         <strong>Feature flag project limit reached. </strong> To | ||||||
| @ -145,10 +199,18 @@ const CreateFeature = () => { | |||||||
|             > |             > | ||||||
|                 <CreateButton |                 <CreateButton | ||||||
|                     name='feature flag' |                     name='feature flag' | ||||||
|                     disabled={featureLimitReached} |                     disabled={ | ||||||
|  |                         loadingTotalFlagCount || | ||||||
|  |                         globalFlagLimitReached || | ||||||
|  |                         projectFlagLimitReached | ||||||
|  |                     } | ||||||
|                     permission={CREATE_FEATURE} |                     permission={CREATE_FEATURE} | ||||||
|                     projectId={project} |                     projectId={project} | ||||||
|                     data-testid={CF_CREATE_BTN_ID} |                     data-testid={CF_CREATE_BTN_ID} | ||||||
|  |                     tooltipProps={{ | ||||||
|  |                         title: limitMessage, | ||||||
|  |                         arrow: true, | ||||||
|  |                     }} | ||||||
|                 /> |                 /> | ||||||
|             </FeatureForm> |             </FeatureForm> | ||||||
|         </FormTemplate> |         </FormTemplate> | ||||||
|  | |||||||
| @ -1,21 +1,21 @@ | |||||||
| import { isFeatureLimitReached } from './CreateFeature'; | import { isProjectFeatureLimitReached } from './CreateFeature'; | ||||||
| 
 | 
 | ||||||
| test('isFeatureLimitReached  should return false when featureLimit is null', async () => { | 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 () => { | 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 () => { | 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 () => { | 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 () => { | 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); | ||||||
| }); | }); | ||||||
| @ -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, | ||||||
|  |     }); | ||||||
|  | }); | ||||||
| @ -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 ( |  | ||||||
|         <ConditionallyRender |  | ||||||
|             condition={smallScreen} |  | ||||||
|             show={ |  | ||||||
|                 <PermissionIconButton |  | ||||||
|                     permission={CREATE_FEATURE} |  | ||||||
|                     projectId={createFeature.projectId} |  | ||||||
|                     component={Link} |  | ||||||
|                     to={createFeature.path} |  | ||||||
|                     size='large' |  | ||||||
|                     tooltipProps={{ |  | ||||||
|                         title: 'Create feature flag', |  | ||||||
|                     }} |  | ||||||
|                 > |  | ||||||
|                     <Add /> |  | ||||||
|                 </PermissionIconButton> |  | ||||||
|             } |  | ||||||
|             elseShow={ |  | ||||||
|                 <PermissionButton |  | ||||||
|                     onClick={() => { |  | ||||||
|                         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 |  | ||||||
|                 </PermissionButton> |  | ||||||
|             } |  | ||||||
|         /> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @ -1,4 +1,4 @@ | |||||||
| import { type ReactNode, type VFC, useState } from 'react'; | import { type ReactNode, type FC, useState } from 'react'; | ||||||
| import { | import { | ||||||
|     Box, |     Box, | ||||||
|     Button, |     Button, | ||||||
| @ -40,7 +40,7 @@ const StyledResponsiveButton = styled(ResponsiveButton)(() => ({ | |||||||
|     whiteSpace: 'nowrap', |     whiteSpace: 'nowrap', | ||||||
| })); | })); | ||||||
| 
 | 
 | ||||||
| export const ProjectFeatureTogglesHeader: VFC< | export const ProjectFeatureTogglesHeader: FC< | ||||||
|     IProjectFeatureTogglesHeaderProps |     IProjectFeatureTogglesHeaderProps | ||||||
| > = ({ | > = ({ | ||||||
|     isLoading, |     isLoading, | ||||||
|  | |||||||
| @ -43,5 +43,6 @@ export const defaultValue: IUiConfig = { | |||||||
|         projects: 500, |         projects: 500, | ||||||
|         segments: 300, |         segments: 300, | ||||||
|         apiTokens: 2000, |         apiTokens: 2000, | ||||||
|  |         featureFlags: 5000, | ||||||
|     }, |     }, | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -41,4 +41,7 @@ export interface ResourceLimitsSchema { | |||||||
|      * total number of tokens across all projects in your |      * total number of tokens across all projects in your | ||||||
|      * organization. */ |      * organization. */ | ||||||
|     apiTokens: number; |     apiTokens: number; | ||||||
|  |     /** The maximum number of feature flags you can have at the same | ||||||
|  |      * time. Archived flags do not count towards this limit. */ | ||||||
|  |     featureFlags: number; | ||||||
| } | } | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user