Skip to content

Commit

Permalink
feat: strategy limit to 30 (#7473)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew authored Jun 28, 2024
1 parent fbda7cd commit 5bd32f2
Show file tree
Hide file tree
Showing 9 changed files with 194 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface IFeatureStrategyMenuProps {
variant?: IPermissionButtonProps['variant'];
matchWidth?: boolean;
size?: IPermissionButtonProps['size'];
disableReason?: string;
}

const StyledStrategyMenu = styled('div')({
Expand All @@ -43,6 +44,7 @@ export const FeatureStrategyMenu = ({
variant,
size,
matchWidth,
disableReason,
}: IFeatureStrategyMenuProps) => {
const [anchor, setAnchor] = useState<Element>();
const navigate = useNavigate();
Expand Down Expand Up @@ -86,6 +88,10 @@ export const FeatureStrategyMenu = ({
variant={variant}
size={size}
sx={{ minWidth: matchWidth ? '282px' : 'auto' }}
disabled={Boolean(disableReason)}
tooltipProps={{
title: disableReason ? disableReason : undefined,
}}
>
{label}
</PermissionButton>
Expand All @@ -99,8 +105,9 @@ export const FeatureStrategyMenu = ({
variant='outlined'
size={size}
hideLockIcon
disabled={Boolean(disableReason)}
tooltipProps={{
title: 'More strategies',
title: disableReason ? disableReason : 'More strategies',
}}
>
<MoreVert
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons';
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
import { Badge } from 'component/common/Badge/Badge';
import { useUiFlag } from 'hooks/useUiFlag';

interface IFeatureOverviewEnvironmentProps {
env: IFeatureEnvironment;
Expand Down Expand Up @@ -131,6 +132,11 @@ const FeatureOverviewEnvironment = ({
const featureEnvironment = feature?.environments.find(
(featureEnvironment) => featureEnvironment.name === env.name,
);
const resourceLimitsEnabled = useUiFlag('resourceLimits');
const limitReached =
resourceLimitsEnabled &&
Array.isArray(featureEnvironment?.strategies) &&
featureEnvironment?.strategies.length >= 30;

return (
<ConditionallyRender
Expand Down Expand Up @@ -179,6 +185,11 @@ const FeatureOverviewEnvironment = ({
environmentId={env.name}
variant='outlined'
size='small'
disableReason={
limitReached
? 'Limit reached'
: undefined
}
/>
<FeatureStrategyIcons
strategies={
Expand Down Expand Up @@ -221,6 +232,11 @@ const FeatureOverviewEnvironment = ({
projectId={projectId}
featureId={featureId}
environmentId={env.name}
disableReason={
limitReached
? 'Limit reached'
: undefined
}
/>
</Box>
<EnvironmentFooter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const createFakeExportImportTogglesService = (
const featureStrategiesStore = new FakeFeatureStrategiesStore();
const featureEnvironmentStore = new FakeFeatureEnvironmentStore();
const { accessService } = createFakeAccessService(config);
const featureToggleService = createFakeFeatureToggleService(config);
const { featureToggleService } = createFakeFeatureToggleService(config);
const privateProjectChecker = createFakePrivateProjectChecker();

const eventService = new EventService(
Expand Down
6 changes: 2 additions & 4 deletions src/lib/features/feature-toggle/createFeatureToggleService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,7 @@ export const createFeatureToggleService = (
return featureToggleService;
};

export const createFakeFeatureToggleService = (
config: IUnleashConfig,
): FeatureToggleService => {
export const createFakeFeatureToggleService = (config: IUnleashConfig) => {
const { getLogger, flagResolver } = config;
const eventStore = new FakeEventStore();
const strategyStore = new FakeStrategiesStore();
Expand Down Expand Up @@ -216,5 +214,5 @@ export const createFakeFeatureToggleService = (
dependentFeaturesService,
featureLifecycleReadModel,
);
return featureToggleService;
return { featureToggleService, featureToggleStore };
};
26 changes: 26 additions & 0 deletions src/lib/features/feature-toggle/feature-toggle-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
strategyName: string | undefined,
): Promise<void> {
Expand Down Expand Up @@ -624,6 +645,11 @@ class FeatureToggleService {
strategyConfig.variants = fixedVariants;
}

await this.validateStrategyLimit(
{ featureName, projectId, environment },
30,
);

try {
const newFeatureStrategy =
await this.featureStrategiesStore.createStrategyFeatureEnv({
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
);
});
3 changes: 2 additions & 1 deletion src/lib/features/frontend-api/createFrontendApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ export const createFakeFrontendApiService = (
eventService,
);
// TODO: remove this dependency after we migrate frontend API
const featureToggleServiceV2 = createFakeFeatureToggleService(config);
const featureToggleServiceV2 =
createFakeFeatureToggleService(config).featureToggleService;
const clientFeatureToggleReadModel = new FakeClientFeatureToggleReadModel();
const globalFrontendApiCache = new GlobalFrontendApiCache(
config,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/features/project/createProjectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export const createFakeProjectService = (
const featureTypeStore = new FakeFeatureTypeStore();
const projectStatsStore = new FakeProjectStatsStore();
const { accessService } = createFakeAccessService(config);
const featureToggleService = createFakeFeatureToggleService(config);
const { featureToggleService } = createFakeFeatureToggleService(config);
const favoriteFeaturesStore = new FakeFavoriteFeaturesStore();
const favoriteProjectsStore = new FakeFavoriteProjectsStore();
const eventService = new EventService(
Expand Down

0 comments on commit 5bd32f2

Please sign in to comment.