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:
parent
c387a19831
commit
3da1cbba47
@ -216,7 +216,6 @@ const getDeleteButtons = async () => {
|
||||
deleteButtons.push(...removeButton);
|
||||
})
|
||||
);
|
||||
console.log(deleteButtons);
|
||||
return deleteButtons;
|
||||
};
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
});
|
@ -236,7 +236,6 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
|
||||
</StyledRow>
|
||||
</StyledFormControl>
|
||||
</StyledContainer>
|
||||
|
||||
<StyledButtonContainer>
|
||||
{children}
|
||||
<StyledCancelButton onClick={handleCancel}>
|
||||
|
@ -230,8 +230,7 @@ const ProjectForm: React.FC<IProjectForm> = ({
|
||||
<ConditionallyRender
|
||||
condition={
|
||||
featureCount !== undefined &&
|
||||
featureLimit !== undefined &&
|
||||
featureLimit.length > 0
|
||||
Boolean(featureLimit)
|
||||
}
|
||||
show={
|
||||
<Box>
|
||||
|
@ -54,7 +54,8 @@ const EditProject = () => {
|
||||
project.name,
|
||||
project.description,
|
||||
defaultStickiness,
|
||||
project.mode
|
||||
project.mode,
|
||||
project.featureLimit ? String(project.featureLimit) : ''
|
||||
);
|
||||
|
||||
const formatApiCode = () => {
|
||||
|
@ -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.' }));
|
||||
|
@ -26,6 +26,7 @@ export interface IProject {
|
||||
features: IFeatureToggleListItem[];
|
||||
mode: 'open' | 'protected';
|
||||
defaultStickiness: string;
|
||||
featureLimit?: number;
|
||||
}
|
||||
|
||||
export interface IProjectHealthReport extends IProject {
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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.',
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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 });
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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>;
|
||||
}
|
||||
|
20
src/migrations/20230711163311-project-feature-limit.js
Normal file
20
src/migrations/20230711163311-project-feature-limit.js
Normal 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,
|
||||
);
|
||||
};
|
29
src/test/fixtures/fake-project-store.ts
vendored
29
src/test/fixtures/fake-project-store.ts
vendored
@ -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);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user