1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-23 00:22:19 +01:00

feat: feature creation limit crud together with frontend (#4221)

This commit is contained in:
Jaanus Sellin 2023-07-13 14:02:35 +03:00 committed by GitHub
parent c387a19831
commit 3da1cbba47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 154 additions and 96 deletions

View File

@ -216,7 +216,6 @@ const getDeleteButtons = async () => {
deleteButtons.push(...removeButton);
})
);
console.log(deleteButtons);
return deleteButtons;
};

View File

@ -12,6 +12,24 @@ import UIContext from 'contexts/UIContext';
import { CF_CREATE_BTN_ID } from 'utils/testIds';
import { formatUnknownError } from 'utils/formatUnknownError';
import { GO_BACK } from 'constants/navigate';
import { Alert, styled } from '@mui/material';
import useProject from 'hooks/api/getters/useProject/useProject';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(2),
}));
export const isFeatureLimitReached = (
featureLimit: number | null | undefined,
currentFeatureCount: number
): boolean => {
return (
featureLimit !== null &&
featureLimit !== undefined &&
featureLimit <= currentFeatureCount
);
};
const CreateFeature = () => {
const { setToastData, setToastApiError } = useToast();
@ -36,6 +54,8 @@ const CreateFeature = () => {
errors,
} = useFeatureForm();
const { project: projectInfo } = useProject(project);
const { createFeatureToggle, loading } = useFeatureApi();
const handleSubmit = async (e: Event) => {
@ -74,6 +94,11 @@ const CreateFeature = () => {
navigate(GO_BACK);
};
const featureLimitReached =
isFeatureLimitReached(
projectInfo.featureLimit,
projectInfo.features.length
) && Boolean(uiConfig.flags.newProjectLayout);
return (
<FormTemplate
loading={loading}
@ -84,6 +109,18 @@ const CreateFeature = () => {
documentationLinkLabel="Feature toggle types documentation"
formatApiCode={formatApiCode}
>
<ConditionallyRender
condition={featureLimitReached}
show={
<StyledAlert severity="error">
<strong>Feature toggle project limit reached. </strong>{' '}
To be able to create more feature toggles in this
project please increase the feature toggle upper limit
in the project settings.
</StyledAlert>
}
/>
<FeatureForm
type={type}
name={name}
@ -104,6 +141,7 @@ const CreateFeature = () => {
>
<CreateButton
name="feature toggle"
disabled={featureLimitReached}
permission={CREATE_FEATURE}
projectId={project}
data-testid={CF_CREATE_BTN_ID}

View File

@ -0,0 +1,21 @@
import { isFeatureLimitReached } from './CreateFeature';
test('isFeatureLimitReached should return false when featureLimit is null', async () => {
expect(isFeatureLimitReached(null, 5)).toBe(false);
});
test('isFeatureLimitReached should return false when featureLimit is undefined', async () => {
expect(isFeatureLimitReached(undefined, 5)).toBe(false);
});
test('isFeatureLimitReached should return false when featureLimit is smaller current feature count', async () => {
expect(isFeatureLimitReached(6, 5)).toBe(false);
});
test('isFeatureLimitReached should return true when featureLimit is smaller current feature count', async () => {
expect(isFeatureLimitReached(4, 5)).toBe(true);
});
test('isFeatureLimitReached should return true when featureLimit is equal to current feature count', async () => {
expect(isFeatureLimitReached(5, 5)).toBe(true);
});

View File

@ -236,7 +236,6 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
</StyledRow>
</StyledFormControl>
</StyledContainer>
<StyledButtonContainer>
{children}
<StyledCancelButton onClick={handleCancel}>

View File

@ -230,8 +230,7 @@ const ProjectForm: React.FC<IProjectForm> = ({
<ConditionallyRender
condition={
featureCount !== undefined &&
featureLimit !== undefined &&
featureLimit.length > 0
Boolean(featureLimit)
}
show={
<Box>

View File

@ -54,7 +54,8 @@ const EditProject = () => {
project.name,
project.description,
defaultStickiness,
project.mode
project.mode,
project.featureLimit ? String(project.featureLimit) : ''
);
const formatApiCode = () => {

View File

@ -57,11 +57,18 @@ const useProjectForm = (
name: projectName,
description: projectDesc,
defaultStickiness: projectStickiness,
featureLimit: featureLimit,
featureLimit: getFeatureLimitAsNumber(),
mode: projectMode,
};
};
const getFeatureLimitAsNumber = () => {
if (featureLimit === '') {
return undefined;
}
return Number(featureLimit);
};
const validateProjectId = async () => {
if (projectId.length === 0) {
setErrors(prev => ({ ...prev, id: 'Id can not be empty.' }));

View File

@ -26,6 +26,7 @@ export interface IProject {
features: IFeatureToggleListItem[];
mode: 'open' | 'protected';
defaultStickiness: string;
featureLimit?: number;
}
export interface IProjectHealthReport extends IProject {

View File

@ -7,14 +7,12 @@ import {
IFlagResolver,
IProject,
IProjectWithCount,
ProjectMode,
} from '../types';
import {
IProjectHealthUpdate,
IProjectInsert,
IProjectQuery,
IProjectSettings,
IProjectSettingsRow,
IProjectStore,
ProjectEnvironment,
} from '../types/stores/project-store';
@ -35,7 +33,11 @@ const COLUMNS = [
'updated_at',
];
const TABLE = 'projects';
const SETTINGS_COLUMNS = ['project_mode', 'default_stickiness'];
const SETTINGS_COLUMNS = [
'project_mode',
'default_stickiness',
'feature_limit',
];
const SETTINGS_TABLE = 'project_settings';
const PROJECT_ENVIRONMENTS = 'project_environments';
@ -94,6 +96,20 @@ class ProjectStore implements IProjectStore {
return present;
}
async isFeatureLimitReached(id: string): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS(SELECT 1
FROM project_settings
LEFT JOIN features ON project_settings.project = features.project
WHERE project_settings.project = ?
GROUP BY project_settings.project
HAVING project_settings.feature_limit <= COUNT(features.project)) AS present`,
[id],
);
const { present } = result.rows[0];
return present;
}
async getProjectsWithCounts(
query?: IProjectQuery,
userId?: number,
@ -219,6 +235,7 @@ class ProjectStore implements IProjectStore {
project: project.id,
project_mode: project.mode,
default_stickiness: project.defaultStickiness,
feature_limit: project.featureLimit,
})
.returning('*');
return this.mapRow({ ...row[0], ...settingsRow[0] });
@ -245,12 +262,14 @@ class ProjectStore implements IProjectStore {
.update({
project_mode: data.mode,
default_stickiness: data.defaultStickiness,
feature_limit: data.featureLimit,
});
} else {
await this.db(SETTINGS_TABLE).insert({
project: data.id,
project_mode: data.mode,
default_stickiness: data.defaultStickiness,
feature_limit: data.featureLimit,
});
}
} catch (err) {
@ -486,24 +505,6 @@ class ProjectStore implements IProjectStore {
return Number(members.count);
}
async getProjectSettings(projectId: string): Promise<IProjectSettings> {
const row = await this.db(SETTINGS_TABLE).where({ project: projectId });
return this.mapSettingsRow(row[0]);
}
async setProjectSettings(
projectId: string,
defaultStickiness: string,
mode: ProjectMode,
): Promise<void> {
await this.db(SETTINGS_TABLE)
.update({
default_stickiness: defaultStickiness,
project_mode: mode,
})
.where({ project: projectId });
}
async getDefaultStrategy(
projectId: string,
environment: string,
@ -537,13 +538,6 @@ class ProjectStore implements IProjectStore {
.then((res) => Number(res[0].count));
}
mapSettingsRow(row?: IProjectSettingsRow): IProjectSettings {
return {
defaultStickiness: row?.default_stickiness || 'default',
mode: row?.project_mode || 'open',
};
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
mapLinkRow(row): IEnvironmentProjectLink {
return {
@ -567,6 +561,7 @@ class ProjectStore implements IProjectStore {
updatedAt: row.updated_at || new Date(),
mode: row.project_mode || 'open',
defaultStickiness: row.default_stickiness || 'default',
featureLimit: row.feature_limit,
};
}

View File

@ -12,7 +12,7 @@ import {
UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
UPDATE_TAG_TYPE,
} from '../../types';
import { InvalidOperationError } from '../../error';
import { PermissionError } from '../../error';
type Mode = 'regular' | 'change_request';
@ -149,9 +149,7 @@ export class ImportPermissionsService {
mode,
);
if (missingPermissions.length > 0) {
throw new InvalidOperationError(
'You are missing permissions to import',
);
throw new PermissionError(missingPermissions);
}
}
}

View File

@ -56,6 +56,13 @@ export const healthOverviewSchema = {
description:
"The project's [collaboration mode](https://docs.getunleash.io/reference/project-collaboration-mode). Determines whether non-project members can submit change requests or not.",
},
featureLimit: {
type: 'number',
nullable: true,
example: 100,
description:
'A limit on the number of features allowed in the project. Null if no limit.',
},
members: {
type: 'integer',
description: 'The number of users/members in the project.',

View File

@ -51,6 +51,13 @@ export const projectOverviewSchema = {
description:
"The project's [collaboration mode](https://docs.getunleash.io/reference/project-collaboration-mode). Determines whether non-project members can submit change requests or not.",
},
featureLimit: {
type: 'number',
nullable: true,
example: 100,
description:
'A limit on the number of features allowed in the project. Null if no limit.',
},
members: {
type: 'number',
example: 4,

View File

@ -921,6 +921,15 @@ class FeatureToggleService {
this.logger.info(`${createdBy} creates feature toggle ${value.name}`);
await this.validateName(value.name);
const exists = await this.projectStore.hasProject(projectId);
if (
this.flagResolver.isEnabled('newProjectLayout') &&
(await this.projectStore.isFeatureLimitReached(projectId))
) {
throw new InvalidOperationError(
'You have reached the maximum number of feature toggles for this project.',
);
}
if (exists) {
let featureData;
if (isValidated) {

View File

@ -9,5 +9,6 @@ export const projectSchema = joi
description: joi.string().allow(null).allow('').optional(),
mode: joi.string().valid('open', 'protected').default('open'),
defaultStickiness: joi.string().default('default'),
featureLimit: joi.number().allow(null).optional(),
})
.options({ allowUnknown: false, stripUnknown: true });

View File

@ -29,7 +29,6 @@ import {
ProjectGroupAddedEvent,
ProjectGroupRemovedEvent,
ProjectGroupUpdateRoleEvent,
ProjectMode,
ProjectUserAddedEvent,
ProjectUserRemovedEvent,
ProjectUserUpdateRoleEvent,
@ -37,11 +36,7 @@ import {
IFlagResolver,
ProjectAccessAddedEvent,
} from '../types';
import {
IProjectQuery,
IProjectSettings,
IProjectStore,
} from '../types/stores/project-store';
import { IProjectQuery, IProjectStore } from '../types/stores/project-store';
import {
IProjectAccessModel,
IRoleDescriptor,
@ -836,6 +831,7 @@ export default class ProjectService {
name: project.name,
description: project.description,
mode: project.mode,
featureLimit: project.featureLimit,
defaultStickiness: project.defaultStickiness,
health: project.health || 0,
favorite: favorite,
@ -847,20 +843,4 @@ export default class ProjectService {
version: 1,
};
}
async getProjectSettings(projectId: string): Promise<IProjectSettings> {
return this.store.getProjectSettings(projectId);
}
async setProjectSettings(
projectId: string,
defaultStickiness: string,
mode: ProjectMode,
): Promise<void> {
return this.store.setProjectSettings(
projectId,
defaultStickiness,
mode,
);
}
}

View File

@ -196,7 +196,7 @@ export interface IProjectOverview {
createdAt: Date | undefined;
stats?: IProjectStats;
mode: ProjectMode;
featureLimit?: number;
defaultStickiness: string;
}
@ -384,6 +384,7 @@ export interface IProject {
changeRequestsEnabled?: boolean;
mode: ProjectMode;
defaultStickiness: string;
featureLimit?: number;
}
/**

View File

@ -18,11 +18,13 @@ export interface IProjectInsert {
updatedAt?: Date;
changeRequestsEnabled?: boolean;
mode: ProjectMode;
featureLimit?: number;
}
export interface IProjectSettings {
mode: ProjectMode;
defaultStickiness: string;
featureLimit?: number;
}
export interface IProjectSettingsRow {
@ -55,11 +57,6 @@ export type ProjectEnvironment = {
defaultStrategy?: CreateFeatureStrategySchema;
};
export interface IProjectEnvironmentWithChangeRequests {
environment: string;
changeRequestsEnabled: boolean;
}
export interface IProjectStore extends Store<IProject, string> {
hasProject(id: string): Promise<boolean>;
@ -109,13 +106,6 @@ export interface IProjectStore extends Store<IProject, string> {
projects: string[],
): Promise<void>;
getProjectSettings(projectId: string): Promise<IProjectSettings>;
setProjectSettings(
projectId: string,
defaultStickiness: string,
mode: ProjectMode,
): Promise<void>;
getDefaultStrategy(
projectId: string,
environment: string,
@ -125,4 +115,6 @@ export interface IProjectStore extends Store<IProject, string> {
environment: string,
strategy: CreateFeatureStrategySchema,
): Promise<CreateFeatureStrategySchema>;
isFeatureLimitReached(id: string): Promise<boolean>;
}

View File

@ -0,0 +1,20 @@
'use strict';
exports.up = function (db, cb) {
db.runSql(
`
ALTER TABLE project_settings
ADD COLUMN IF NOT EXISTS "feature_limit" integer;
`,
cb(),
);
};
exports.down = function (db, cb) {
db.runSql(
`
ALTER TABLE project_settings DROP COLUMN IF EXISTS "feature_limit";
`,
cb,
);
};

View File

@ -1,16 +1,10 @@
import {
IProjectHealthUpdate,
IProjectInsert,
IProjectSettings,
IProjectStore,
ProjectEnvironment,
} from '../../lib/types/stores/project-store';
import {
IEnvironment,
IProject,
IProjectWithCount,
ProjectMode,
} from '../../lib/types';
import { IEnvironment, IProject, IProjectWithCount } from '../../lib/types';
import NotFoundError from '../../lib/error/notfound-error';
import {
IEnvironmentProjectLink,
@ -167,22 +161,6 @@ export default class FakeProjectStore implements IProjectStore {
throw new Error('Method not implemented');
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getProjectSettings(projectId: string): Promise<IProjectSettings> {
throw new Error('Method not implemented.');
}
setProjectSettings(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
defaultStickiness: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
mode: ProjectMode,
): Promise<void> {
throw new Error('Method not implemented.');
}
updateDefaultStrategy(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
@ -202,4 +180,9 @@ export default class FakeProjectStore implements IProjectStore {
): Promise<CreateFeatureStrategySchema | undefined> {
throw new Error('Method not implemented.');
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
isFeatureLimitReached(id: string): Promise<boolean> {
return Promise.resolve(false);
}
}