mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-28 00:06:53 +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