mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01:00
feat: strategy limit to 30 (#7473)
This commit is contained in:
parent
fbda7cdc48
commit
5bd32f264d
@ -19,6 +19,7 @@ interface IFeatureStrategyMenuProps {
|
|||||||
variant?: IPermissionButtonProps['variant'];
|
variant?: IPermissionButtonProps['variant'];
|
||||||
matchWidth?: boolean;
|
matchWidth?: boolean;
|
||||||
size?: IPermissionButtonProps['size'];
|
size?: IPermissionButtonProps['size'];
|
||||||
|
disableReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledStrategyMenu = styled('div')({
|
const StyledStrategyMenu = styled('div')({
|
||||||
@ -43,6 +44,7 @@ export const FeatureStrategyMenu = ({
|
|||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
matchWidth,
|
matchWidth,
|
||||||
|
disableReason,
|
||||||
}: IFeatureStrategyMenuProps) => {
|
}: IFeatureStrategyMenuProps) => {
|
||||||
const [anchor, setAnchor] = useState<Element>();
|
const [anchor, setAnchor] = useState<Element>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -86,6 +88,10 @@ export const FeatureStrategyMenu = ({
|
|||||||
variant={variant}
|
variant={variant}
|
||||||
size={size}
|
size={size}
|
||||||
sx={{ minWidth: matchWidth ? '282px' : 'auto' }}
|
sx={{ minWidth: matchWidth ? '282px' : 'auto' }}
|
||||||
|
disabled={Boolean(disableReason)}
|
||||||
|
tooltipProps={{
|
||||||
|
title: disableReason ? disableReason : undefined,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
@ -99,8 +105,9 @@ export const FeatureStrategyMenu = ({
|
|||||||
variant='outlined'
|
variant='outlined'
|
||||||
size={size}
|
size={size}
|
||||||
hideLockIcon
|
hideLockIcon
|
||||||
|
disabled={Boolean(disableReason)}
|
||||||
tooltipProps={{
|
tooltipProps={{
|
||||||
title: 'More strategies',
|
title: disableReason ? disableReason : 'More strategies',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MoreVert
|
<MoreVert
|
||||||
|
@ -0,0 +1,97 @@
|
|||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import { render } from 'utils/testRenderer';
|
||||||
|
import FeatureOverviewEnvironment from './FeatureOverviewEnvironment';
|
||||||
|
import { testServerRoute, testServerSetup } from 'utils/testServer';
|
||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
|
||||||
|
import type { IFeatureStrategy } from 'interfaces/strategy';
|
||||||
|
|
||||||
|
const server = testServerSetup();
|
||||||
|
|
||||||
|
const setupApi = () => {
|
||||||
|
testServerRoute(server, '/api/admin/ui-config', {
|
||||||
|
flags: {
|
||||||
|
resourceLimits: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testServerRoute(
|
||||||
|
server,
|
||||||
|
'/api/admin/projects/default/features/featureWithoutStrategies',
|
||||||
|
{
|
||||||
|
environments: [environmentWithoutStrategies],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testServerRoute(
|
||||||
|
server,
|
||||||
|
'/api/admin/projects/default/features/featureWithManyStrategies',
|
||||||
|
{
|
||||||
|
environments: [environmentWithManyStrategies],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const strategy = {
|
||||||
|
name: 'default',
|
||||||
|
} as IFeatureStrategy;
|
||||||
|
const environmentWithoutStrategies = {
|
||||||
|
name: 'production',
|
||||||
|
enabled: true,
|
||||||
|
type: 'production',
|
||||||
|
strategies: [],
|
||||||
|
};
|
||||||
|
const environmentWithManyStrategies = {
|
||||||
|
name: 'production',
|
||||||
|
enabled: true,
|
||||||
|
type: 'production',
|
||||||
|
strategies: [...Array(30).keys()].map(() => strategy),
|
||||||
|
};
|
||||||
|
|
||||||
|
test('should allow to add strategy when no strategies', async () => {
|
||||||
|
setupApi();
|
||||||
|
render(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path='/projects/:projectId/features/:featureId/strategies/create'
|
||||||
|
element={
|
||||||
|
<FeatureOverviewEnvironment
|
||||||
|
env={environmentWithoutStrategies}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
{
|
||||||
|
route: '/projects/default/features/featureWithoutStrategies/strategies/create',
|
||||||
|
permissions: [{ permission: CREATE_FEATURE_STRATEGY }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = await screen.findByText('Add strategy');
|
||||||
|
expect(button).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not allow to add strategy when limit reached', async () => {
|
||||||
|
setupApi();
|
||||||
|
render(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path='/projects/:projectId/features/:featureId/strategies/create'
|
||||||
|
element={
|
||||||
|
<FeatureOverviewEnvironment
|
||||||
|
env={environmentWithManyStrategies}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
{
|
||||||
|
route: '/projects/default/features/featureWithManyStrategies/strategies/create',
|
||||||
|
permissions: [{ permission: CREATE_FEATURE_STRATEGY }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const button = await screen.findByText('Add strategy');
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
@ -22,6 +22,7 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
|||||||
import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons';
|
import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons';
|
||||||
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
|
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
|
||||||
import { Badge } from 'component/common/Badge/Badge';
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
|
||||||
interface IFeatureOverviewEnvironmentProps {
|
interface IFeatureOverviewEnvironmentProps {
|
||||||
env: IFeatureEnvironment;
|
env: IFeatureEnvironment;
|
||||||
@ -131,6 +132,11 @@ const FeatureOverviewEnvironment = ({
|
|||||||
const featureEnvironment = feature?.environments.find(
|
const featureEnvironment = feature?.environments.find(
|
||||||
(featureEnvironment) => featureEnvironment.name === env.name,
|
(featureEnvironment) => featureEnvironment.name === env.name,
|
||||||
);
|
);
|
||||||
|
const resourceLimitsEnabled = useUiFlag('resourceLimits');
|
||||||
|
const limitReached =
|
||||||
|
resourceLimitsEnabled &&
|
||||||
|
Array.isArray(featureEnvironment?.strategies) &&
|
||||||
|
featureEnvironment?.strategies.length >= 30;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
@ -179,6 +185,11 @@ const FeatureOverviewEnvironment = ({
|
|||||||
environmentId={env.name}
|
environmentId={env.name}
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
size='small'
|
size='small'
|
||||||
|
disableReason={
|
||||||
|
limitReached
|
||||||
|
? 'Limit reached'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<FeatureStrategyIcons
|
<FeatureStrategyIcons
|
||||||
strategies={
|
strategies={
|
||||||
@ -221,6 +232,11 @@ const FeatureOverviewEnvironment = ({
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
featureId={featureId}
|
featureId={featureId}
|
||||||
environmentId={env.name}
|
environmentId={env.name}
|
||||||
|
disableReason={
|
||||||
|
limitReached
|
||||||
|
? 'Limit reached'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<EnvironmentFooter
|
<EnvironmentFooter
|
||||||
|
@ -68,7 +68,7 @@ export const createFakeExportImportTogglesService = (
|
|||||||
const featureStrategiesStore = new FakeFeatureStrategiesStore();
|
const featureStrategiesStore = new FakeFeatureStrategiesStore();
|
||||||
const featureEnvironmentStore = new FakeFeatureEnvironmentStore();
|
const featureEnvironmentStore = new FakeFeatureEnvironmentStore();
|
||||||
const { accessService } = createFakeAccessService(config);
|
const { accessService } = createFakeAccessService(config);
|
||||||
const featureToggleService = createFakeFeatureToggleService(config);
|
const { featureToggleService } = createFakeFeatureToggleService(config);
|
||||||
const privateProjectChecker = createFakePrivateProjectChecker();
|
const privateProjectChecker = createFakePrivateProjectChecker();
|
||||||
|
|
||||||
const eventService = new EventService(
|
const eventService = new EventService(
|
||||||
|
@ -155,9 +155,7 @@ export const createFeatureToggleService = (
|
|||||||
return featureToggleService;
|
return featureToggleService;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createFakeFeatureToggleService = (
|
export const createFakeFeatureToggleService = (config: IUnleashConfig) => {
|
||||||
config: IUnleashConfig,
|
|
||||||
): FeatureToggleService => {
|
|
||||||
const { getLogger, flagResolver } = config;
|
const { getLogger, flagResolver } = config;
|
||||||
const eventStore = new FakeEventStore();
|
const eventStore = new FakeEventStore();
|
||||||
const strategyStore = new FakeStrategiesStore();
|
const strategyStore = new FakeStrategiesStore();
|
||||||
@ -216,5 +214,5 @@ export const createFakeFeatureToggleService = (
|
|||||||
dependentFeaturesService,
|
dependentFeaturesService,
|
||||||
featureLifecycleReadModel,
|
featureLifecycleReadModel,
|
||||||
);
|
);
|
||||||
return featureToggleService;
|
return { featureToggleService, featureToggleStore };
|
||||||
};
|
};
|
||||||
|
@ -358,6 +358,27 @@ class FeatureToggleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async validateStrategyLimit(
|
||||||
|
featureEnv: {
|
||||||
|
projectId: string;
|
||||||
|
environment: string;
|
||||||
|
featureName: string;
|
||||||
|
},
|
||||||
|
limit: number,
|
||||||
|
) {
|
||||||
|
if (!this.flagResolver.isEnabled('resourceLimits')) return;
|
||||||
|
const existingCount = (
|
||||||
|
await this.featureStrategiesStore.getStrategiesForFeatureEnv(
|
||||||
|
featureEnv.projectId,
|
||||||
|
featureEnv.featureName,
|
||||||
|
featureEnv.environment,
|
||||||
|
)
|
||||||
|
).length;
|
||||||
|
if (existingCount >= limit) {
|
||||||
|
throw new BadDataError(`Strategy limit of ${limit} exceeded}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async validateStrategyType(
|
async validateStrategyType(
|
||||||
strategyName: string | undefined,
|
strategyName: string | undefined,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@ -624,6 +645,11 @@ class FeatureToggleService {
|
|||||||
strategyConfig.variants = fixedVariants;
|
strategyConfig.variants = fixedVariants;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.validateStrategyLimit(
|
||||||
|
{ featureName, projectId, environment },
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newFeatureStrategy =
|
const newFeatureStrategy =
|
||||||
await this.featureStrategiesStore.createStrategyFeatureEnv({
|
await this.featureStrategiesStore.createStrategyFeatureEnv({
|
||||||
|
@ -0,0 +1,41 @@
|
|||||||
|
import { createFakeFeatureToggleService } from '../createFeatureToggleService';
|
||||||
|
import type {
|
||||||
|
IAuditUser,
|
||||||
|
IFlagResolver,
|
||||||
|
IStrategyConfig,
|
||||||
|
IUnleashConfig,
|
||||||
|
} from '../../../types';
|
||||||
|
import getLogger from '../../../../test/fixtures/no-logger';
|
||||||
|
|
||||||
|
const alwaysOnFlagResolver = {
|
||||||
|
isEnabled() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
} as unknown as IFlagResolver;
|
||||||
|
|
||||||
|
test('Should not allow to exceed strategy limit', async () => {
|
||||||
|
const { featureToggleService, featureToggleStore } =
|
||||||
|
createFakeFeatureToggleService({
|
||||||
|
getLogger,
|
||||||
|
flagResolver: alwaysOnFlagResolver,
|
||||||
|
} as unknown as IUnleashConfig);
|
||||||
|
|
||||||
|
const addStrategy = () =>
|
||||||
|
featureToggleService.unprotectedCreateStrategy(
|
||||||
|
{ name: 'default', featureName: 'feature' } as IStrategyConfig,
|
||||||
|
{ projectId: 'default', featureName: 'feature' } as any,
|
||||||
|
{} as IAuditUser,
|
||||||
|
);
|
||||||
|
await featureToggleStore.create('default', {
|
||||||
|
name: 'feature',
|
||||||
|
createdByUserId: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await addStrategy();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(addStrategy()).rejects.toThrow(
|
||||||
|
'Strategy limit of 30 exceeded',
|
||||||
|
);
|
||||||
|
});
|
@ -72,7 +72,8 @@ export const createFakeFrontendApiService = (
|
|||||||
eventService,
|
eventService,
|
||||||
);
|
);
|
||||||
// TODO: remove this dependency after we migrate frontend API
|
// TODO: remove this dependency after we migrate frontend API
|
||||||
const featureToggleServiceV2 = createFakeFeatureToggleService(config);
|
const featureToggleServiceV2 =
|
||||||
|
createFakeFeatureToggleService(config).featureToggleService;
|
||||||
const clientFeatureToggleReadModel = new FakeClientFeatureToggleReadModel();
|
const clientFeatureToggleReadModel = new FakeClientFeatureToggleReadModel();
|
||||||
const globalFrontendApiCache = new GlobalFrontendApiCache(
|
const globalFrontendApiCache = new GlobalFrontendApiCache(
|
||||||
config,
|
config,
|
||||||
|
@ -150,7 +150,7 @@ export const createFakeProjectService = (
|
|||||||
const featureTypeStore = new FakeFeatureTypeStore();
|
const featureTypeStore = new FakeFeatureTypeStore();
|
||||||
const projectStatsStore = new FakeProjectStatsStore();
|
const projectStatsStore = new FakeProjectStatsStore();
|
||||||
const { accessService } = createFakeAccessService(config);
|
const { accessService } = createFakeAccessService(config);
|
||||||
const featureToggleService = createFakeFeatureToggleService(config);
|
const { featureToggleService } = createFakeFeatureToggleService(config);
|
||||||
const favoriteFeaturesStore = new FakeFavoriteFeaturesStore();
|
const favoriteFeaturesStore = new FakeFavoriteFeaturesStore();
|
||||||
const favoriteProjectsStore = new FakeFavoriteProjectsStore();
|
const favoriteProjectsStore = new FakeFavoriteProjectsStore();
|
||||||
const eventService = new EventService(
|
const eventService = new EventService(
|
||||||
|
Loading…
Reference in New Issue
Block a user