Skip to content

Commit

Permalink
Merge branch 'main' into chore/delete-project-tokens-when-removing-la…
Browse files Browse the repository at this point in the history
…st-project
  • Loading branch information
daveleek committed Jul 8, 2024
2 parents 52bdea5 + 7ca2ace commit 6315022
Show file tree
Hide file tree
Showing 28 changed files with 714 additions and 245 deletions.
Original file line number Diff line number Diff line change
@@ -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();
});
});
76 changes: 69 additions & 7 deletions frontend/src/component/feature/CreateFeature/CreateFeature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -98,10 +156,6 @@ const CreateFeature = () => {
navigate(GO_BACK);
};

const featureLimitReached = isFeatureLimitReached(
projectInfo.featureLimit,
featuresCount(projectInfo),
);
return (
<FormTemplate
loading={loading}
Expand All @@ -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
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
71 changes: 71 additions & 0 deletions frontend/src/component/feature/CreateFeature/useFlagLimits.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ReactNode, type VFC, useState } from 'react';
import { type ReactNode, type FC, useState } from 'react';
import {
Box,
Button,
Expand Down Expand Up @@ -40,7 +40,7 @@ const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
whiteSpace: 'nowrap',
}));

export const ProjectFeatureTogglesHeader: VFC<
export const ProjectFeatureTogglesHeader: FC<
IProjectFeatureTogglesHeaderProps
> = ({
isLoading,
Expand Down
Loading

0 comments on commit 6315022

Please sign in to comment.