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, { | ||||
|     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 ( | ||||
|         <FormTemplate | ||||
|             loading={loading} | ||||
| @ -113,7 +167,7 @@ const CreateFeature = () => { | ||||
|             formatApiCode={formatApiCode} | ||||
|         > | ||||
|             <ConditionallyRender | ||||
|                 condition={featureLimitReached} | ||||
|                 condition={projectFlagLimitReached} | ||||
|                 show={ | ||||
|                     <StyledAlert severity='error'> | ||||
|                         <strong>Feature flag project limit reached. </strong> To | ||||
| @ -145,10 +199,18 @@ const CreateFeature = () => { | ||||
|             > | ||||
|                 <CreateButton | ||||
|                     name='feature flag' | ||||
|                     disabled={featureLimitReached} | ||||
|                     disabled={ | ||||
|                         loadingTotalFlagCount || | ||||
|                         globalFlagLimitReached || | ||||
|                         projectFlagLimitReached | ||||
|                     } | ||||
|                     permission={CREATE_FEATURE} | ||||
|                     projectId={project} | ||||
|                     data-testid={CF_CREATE_BTN_ID} | ||||
|                     tooltipProps={{ | ||||
|                         title: limitMessage, | ||||
|                         arrow: true, | ||||
|                     }} | ||||
|                 /> | ||||
|             </FeatureForm> | ||||
|         </FormTemplate> | ||||
|  | ||||
| @ -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); | ||||
| }); | ||||
| @ -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 { | ||||
|     Box, | ||||
|     Button, | ||||
| @ -40,7 +40,7 @@ const StyledResponsiveButton = styled(ResponsiveButton)(() => ({ | ||||
|     whiteSpace: 'nowrap', | ||||
| })); | ||||
| 
 | ||||
| export const ProjectFeatureTogglesHeader: VFC< | ||||
| export const ProjectFeatureTogglesHeader: FC< | ||||
|     IProjectFeatureTogglesHeaderProps | ||||
| > = ({ | ||||
|     isLoading, | ||||
|  | ||||
| @ -43,5 +43,6 @@ export const defaultValue: IUiConfig = { | ||||
|         projects: 500, | ||||
|         segments: 300, | ||||
|         apiTokens: 2000, | ||||
|         featureFlags: 5000, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| @ -41,4 +41,7 @@ export interface ResourceLimitsSchema { | ||||
|      * total number of tokens across all projects in your | ||||
|      * organization. */ | ||||
|     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