mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-20 00:08:02 +01:00
feat: enterprise project settings (#4844)
This commit is contained in:
parent
ebb76a5354
commit
960bc110ce
@ -14,6 +14,7 @@ import {
|
||||
IProjectInsert,
|
||||
IProjectQuery,
|
||||
IProjectSettings,
|
||||
IProjectEnterpriseSettingsUpdate,
|
||||
IProjectStore,
|
||||
ProjectEnvironment,
|
||||
} from '../types/stores/project-store';
|
||||
@ -244,10 +245,6 @@ class ProjectStore implements IProjectStore {
|
||||
project: project.id,
|
||||
project_mode: project.mode,
|
||||
default_stickiness: project.defaultStickiness,
|
||||
feature_limit: project.featureLimit,
|
||||
feature_naming_pattern: project.featureNaming?.pattern,
|
||||
feature_naming_example: project.featureNaming?.example,
|
||||
feature_naming_description: project.featureNaming?.description,
|
||||
})
|
||||
.returning('*');
|
||||
return this.mapRow({ ...row[0], ...settingsRow[0] });
|
||||
@ -272,9 +269,30 @@ class ProjectStore implements IProjectStore {
|
||||
await this.db(SETTINGS_TABLE)
|
||||
.where({ project: data.id })
|
||||
.update({
|
||||
project_mode: data.mode,
|
||||
default_stickiness: data.defaultStickiness,
|
||||
feature_limit: data.featureLimit,
|
||||
});
|
||||
} else {
|
||||
await this.db(SETTINGS_TABLE).insert({
|
||||
project: data.id,
|
||||
default_stickiness: data.defaultStickiness,
|
||||
feature_limit: data.featureLimit,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Could not update project, error: ', err);
|
||||
}
|
||||
}
|
||||
|
||||
async updateProjectEnterpriseSettings(
|
||||
data: IProjectEnterpriseSettingsUpdate,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (await this.hasProjectSettings(data.id)) {
|
||||
await this.db(SETTINGS_TABLE)
|
||||
.where({ project: data.id })
|
||||
.update({
|
||||
project_mode: data.mode,
|
||||
feature_naming_pattern: data.featureNaming?.pattern,
|
||||
feature_naming_example: data.featureNaming?.example,
|
||||
feature_naming_description:
|
||||
@ -284,15 +302,16 @@ class ProjectStore implements IProjectStore {
|
||||
await this.db(SETTINGS_TABLE).insert({
|
||||
project: data.id,
|
||||
project_mode: data.mode,
|
||||
default_stickiness: data.defaultStickiness,
|
||||
feature_limit: data.featureLimit,
|
||||
feature_naming_pattern: data.featureNaming?.pattern,
|
||||
feature_naming_example: data.featureNaming?.example,
|
||||
feature_naming_description: data.featureNaming?.description,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Could not update project, error: ', err);
|
||||
this.logger.error(
|
||||
'Could not update project settings, error: ',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -113,13 +113,16 @@ const createProjects = async (
|
||||
type: 'production',
|
||||
});
|
||||
for (const project of projects) {
|
||||
await db.stores.projectStore.create({
|
||||
const storedProject = {
|
||||
name: project,
|
||||
description: '',
|
||||
id: project,
|
||||
mode: 'open' as const,
|
||||
featureLimit,
|
||||
});
|
||||
};
|
||||
await db.stores.projectStore.create(storedProject);
|
||||
await db.stores.projectStore.update(storedProject);
|
||||
|
||||
await app.linkProjectToEnvironment(project, DEFAULT_ENV);
|
||||
}
|
||||
};
|
||||
@ -884,10 +887,8 @@ test('validate import data', async () => {
|
||||
|
||||
// note: this must be done after creating the feature on the earlier lines,
|
||||
// to prevent the pattern from blocking the creation.
|
||||
await projectStore.update({
|
||||
await projectStore.updateProjectEnterpriseSettings({
|
||||
id: DEFAULT_PROJECT,
|
||||
name: 'default',
|
||||
description: '',
|
||||
mode: 'open',
|
||||
featureNaming: { pattern: 'testpattern.+' },
|
||||
});
|
||||
@ -996,6 +997,9 @@ test(`should give errors with flag names if the flags don't match the project pa
|
||||
description: '',
|
||||
id: project,
|
||||
mode: 'open' as const,
|
||||
});
|
||||
await db.stores.projectStore.updateProjectEnterpriseSettings({
|
||||
id: project,
|
||||
featureNaming: { pattern },
|
||||
});
|
||||
await app.linkProjectToEnvironment(project, DEFAULT_ENV);
|
||||
|
@ -0,0 +1,18 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`updateProjectEnterpriseSettings schema 1`] = `
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"instancePath": "",
|
||||
"keyword": "required",
|
||||
"message": "must have required property 'version'",
|
||||
"params": {
|
||||
"missingProperty": "version",
|
||||
},
|
||||
"schemaPath": "#/required",
|
||||
},
|
||||
],
|
||||
"schema": "#/components/schemas/projectOverviewSchema",
|
||||
}
|
||||
`;
|
22
src/lib/openapi/spec/project-overview-schema.test.ts
Normal file
22
src/lib/openapi/spec/project-overview-schema.test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ProjectOverviewSchema } from './project-overview-schema';
|
||||
import { validateSchema } from '../validate';
|
||||
|
||||
test('updateProjectEnterpriseSettings schema', () => {
|
||||
const data: ProjectOverviewSchema = {
|
||||
name: 'project',
|
||||
version: 3,
|
||||
featureNaming: {
|
||||
description: 'naming description',
|
||||
example: 'a',
|
||||
pattern: '[aZ]',
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
validateSchema('#/components/schemas/projectOverviewSchema', data),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(
|
||||
validateSchema('#/components/schemas/projectOverviewSchema', {}),
|
||||
).toMatchSnapshot();
|
||||
});
|
@ -41,7 +41,11 @@ import {
|
||||
IFeatureNaming,
|
||||
CreateProject,
|
||||
} from '../types';
|
||||
import { IProjectQuery, IProjectStore } from '../types/stores/project-store';
|
||||
import {
|
||||
IProjectQuery,
|
||||
IProjectEnterpriseSettingsUpdate,
|
||||
IProjectStore,
|
||||
} from '../types/stores/project-store';
|
||||
import {
|
||||
IProjectAccessModel,
|
||||
IRoleDescriptor,
|
||||
@ -83,7 +87,7 @@ interface ICalculateStatus {
|
||||
}
|
||||
|
||||
export default class ProjectService {
|
||||
private store: IProjectStore;
|
||||
private projectStore: IProjectStore;
|
||||
|
||||
private accessService: AccessService;
|
||||
|
||||
@ -113,6 +117,8 @@ export default class ProjectService {
|
||||
|
||||
private flagResolver: IFlagResolver;
|
||||
|
||||
private isEnterprise: boolean;
|
||||
|
||||
constructor(
|
||||
{
|
||||
projectStore,
|
||||
@ -141,7 +147,7 @@ export default class ProjectService {
|
||||
favoriteService: FavoritesService,
|
||||
privateProjectChecker: IPrivateProjectChecker,
|
||||
) {
|
||||
this.store = projectStore;
|
||||
this.projectStore = projectStore;
|
||||
this.environmentStore = environmentStore;
|
||||
this.featureEnvironmentStore = featureEnvironmentStore;
|
||||
this.accessService = accessService;
|
||||
@ -156,13 +162,17 @@ export default class ProjectService {
|
||||
this.projectStatsStore = projectStatsStore;
|
||||
this.logger = config.getLogger('services/project-service.js');
|
||||
this.flagResolver = config.flagResolver;
|
||||
this.isEnterprise = config.isEnterprise;
|
||||
}
|
||||
|
||||
async getProjects(
|
||||
query?: IProjectQuery,
|
||||
userId?: number,
|
||||
): Promise<IProjectWithCount[]> {
|
||||
const projects = await this.store.getProjectsWithCounts(query, userId);
|
||||
const projects = await this.projectStore.getProjectsWithCounts(
|
||||
query,
|
||||
userId,
|
||||
);
|
||||
if (this.flagResolver.isEnabled('privateProjects') && userId) {
|
||||
const projectAccess =
|
||||
await this.privateProjectChecker.getUserAccessibleProjects(
|
||||
@ -181,7 +191,7 @@ export default class ProjectService {
|
||||
}
|
||||
|
||||
async getProject(id: string): Promise<IProject> {
|
||||
return this.store.get(id);
|
||||
return this.projectStore.get(id);
|
||||
}
|
||||
|
||||
private validateAndProcessFeatureNamingPattern = (
|
||||
@ -214,14 +224,11 @@ export default class ProjectService {
|
||||
newProject: CreateProject,
|
||||
user: IUser,
|
||||
): Promise<IProject> {
|
||||
const data = await projectSchema.validateAsync(newProject);
|
||||
const validatedData = await projectSchema.validateAsync(newProject);
|
||||
const data = this.removeModeForNonEnterprise(validatedData);
|
||||
await this.validateUniqueId(data.id);
|
||||
|
||||
if (data.featureNaming) {
|
||||
this.validateAndProcessFeatureNamingPattern(data.featureNaming);
|
||||
}
|
||||
|
||||
await this.store.create(data);
|
||||
await this.projectStore.create(data);
|
||||
|
||||
const enabledEnvironments = await this.environmentStore.getAll({
|
||||
enabled: true,
|
||||
@ -250,7 +257,24 @@ export default class ProjectService {
|
||||
}
|
||||
|
||||
async updateProject(updatedProject: IProject, user: User): Promise<void> {
|
||||
const preData = await this.store.get(updatedProject.id);
|
||||
const preData = await this.projectStore.get(updatedProject.id);
|
||||
|
||||
await this.projectStore.update(updatedProject);
|
||||
|
||||
await this.eventStore.store({
|
||||
type: PROJECT_UPDATED,
|
||||
project: updatedProject.id,
|
||||
createdBy: getCreatedBy(user),
|
||||
data: updatedProject,
|
||||
preData,
|
||||
});
|
||||
}
|
||||
|
||||
async updateProjectEnterpriseSettings(
|
||||
updatedProject: IProjectEnterpriseSettingsUpdate,
|
||||
user: User,
|
||||
): Promise<void> {
|
||||
const preData = await this.projectStore.get(updatedProject.id);
|
||||
|
||||
if (updatedProject.featureNaming) {
|
||||
this.validateAndProcessFeatureNamingPattern(
|
||||
@ -258,7 +282,7 @@ export default class ProjectService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.store.update(updatedProject);
|
||||
await this.projectStore.updateProjectEnterpriseSettings(updatedProject);
|
||||
|
||||
await this.eventStore.store({
|
||||
type: PROJECT_UPDATED,
|
||||
@ -276,7 +300,7 @@ export default class ProjectService {
|
||||
const featureEnvs = await this.featureEnvironmentStore.getAll({
|
||||
feature_name: feature.name,
|
||||
});
|
||||
const newEnvs = await this.store.getEnvironmentsForProject(
|
||||
const newEnvs = await this.projectStore.getEnvironmentsForProject(
|
||||
newProjectId,
|
||||
);
|
||||
return arraysHaveSameItems(
|
||||
@ -289,7 +313,7 @@ export default class ProjectService {
|
||||
project: string,
|
||||
environment: string,
|
||||
): Promise<void> {
|
||||
await this.store.addEnvironmentToProject(project, environment);
|
||||
await this.projectStore.addEnvironmentToProject(project, environment);
|
||||
}
|
||||
|
||||
async changeProject(
|
||||
@ -355,7 +379,7 @@ export default class ProjectService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.store.delete(id);
|
||||
await this.projectStore.delete(id);
|
||||
|
||||
await this.eventStore.store({
|
||||
type: PROJECT_DELETED,
|
||||
@ -373,7 +397,7 @@ export default class ProjectService {
|
||||
}
|
||||
|
||||
async validateUniqueId(id: string): Promise<void> {
|
||||
const exists = await this.store.hasProject(id);
|
||||
const exists = await this.projectStore.hasProject(id);
|
||||
if (exists) {
|
||||
throw new NameExistsError('A project with this id already exists.');
|
||||
}
|
||||
@ -872,7 +896,7 @@ export default class ProjectService {
|
||||
}
|
||||
|
||||
async getMembers(projectId: string): Promise<number> {
|
||||
return this.store.getMembersCountByProject(projectId);
|
||||
return this.projectStore.getMembersCountByProject(projectId);
|
||||
}
|
||||
|
||||
async getProjectUsers(
|
||||
@ -903,7 +927,7 @@ export default class ProjectService {
|
||||
}
|
||||
|
||||
async getProjectsByUser(userId: number): Promise<string[]> {
|
||||
return this.store.getProjectsByUser(userId);
|
||||
return this.projectStore.getProjectsByUser(userId);
|
||||
}
|
||||
|
||||
async getProjectRoleUsage(roleId: number): Promise<IProjectRoleUsage[]> {
|
||||
@ -911,7 +935,7 @@ export default class ProjectService {
|
||||
}
|
||||
|
||||
async statusJob(): Promise<void> {
|
||||
const projects = await this.store.getAll();
|
||||
const projects = await this.projectStore.getAll();
|
||||
|
||||
const statusUpdates = await Promise.all(
|
||||
projects.map((project) => this.getStatusUpdates(project.id)),
|
||||
@ -990,7 +1014,7 @@ export default class ProjectService {
|
||||
);
|
||||
|
||||
const projectMembersAddedCurrentWindow =
|
||||
await this.store.getMembersCountByProjectAfterDate(
|
||||
await this.projectStore.getMembersCountByProjectAfterDate(
|
||||
projectId,
|
||||
dateMinusThirtyDays,
|
||||
);
|
||||
@ -1023,14 +1047,14 @@ export default class ProjectService {
|
||||
favorite,
|
||||
projectStats,
|
||||
] = await Promise.all([
|
||||
this.store.get(projectId),
|
||||
this.store.getEnvironmentsForProject(projectId),
|
||||
this.projectStore.get(projectId),
|
||||
this.projectStore.getEnvironmentsForProject(projectId),
|
||||
this.featureToggleService.getFeatureOverview({
|
||||
projectId,
|
||||
archived,
|
||||
userId,
|
||||
}),
|
||||
this.store.getMembersCountByProject(projectId),
|
||||
this.projectStore.getMembersCountByProject(projectId),
|
||||
userId
|
||||
? this.favoritesService.isFavoriteProject({
|
||||
project: projectId,
|
||||
@ -1058,4 +1082,13 @@ export default class ProjectService {
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
removeModeForNonEnterprise(data): any {
|
||||
if (this.isEnterprise) {
|
||||
return data;
|
||||
}
|
||||
const { mode, ...proData } = data;
|
||||
return proData;
|
||||
}
|
||||
}
|
||||
|
@ -420,7 +420,7 @@ export type CreateProject = Pick<IProject, 'id' | 'name'> & {
|
||||
export interface IProject {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
health?: number;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
|
@ -16,14 +16,20 @@ import { CreateFeatureStrategySchema } from '../../openapi';
|
||||
export interface IProjectInsert {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
updatedAt?: Date;
|
||||
changeRequestsEnabled?: boolean;
|
||||
mode: ProjectMode;
|
||||
mode?: ProjectMode;
|
||||
featureLimit?: number;
|
||||
featureNaming?: IFeatureNaming;
|
||||
}
|
||||
|
||||
export interface IProjectEnterpriseSettingsUpdate {
|
||||
id: string;
|
||||
mode?: ProjectMode;
|
||||
featureNaming?: IFeatureNaming;
|
||||
}
|
||||
|
||||
export interface IProjectSettings {
|
||||
mode: ProjectMode;
|
||||
defaultStickiness: string;
|
||||
@ -57,6 +63,10 @@ export interface IProjectStore extends Store<IProject, string> {
|
||||
|
||||
update(update: IProjectInsert): Promise<void>;
|
||||
|
||||
updateProjectEnterpriseSettings(
|
||||
update: IProjectEnterpriseSettingsUpdate,
|
||||
): Promise<void>;
|
||||
|
||||
importProjects(
|
||||
projects: IProjectInsert[],
|
||||
environments?: IEnvironment[],
|
||||
|
@ -9,7 +9,12 @@ import {
|
||||
} from '../../../lib/services';
|
||||
import { FeatureStrategySchema } from '../../../lib/openapi';
|
||||
import User from '../../../lib/types/user';
|
||||
import { IConstraint, IVariant, SKIP_CHANGE_REQUEST } from '../../../lib/types';
|
||||
import {
|
||||
IConstraint,
|
||||
IUnleashStores,
|
||||
IVariant,
|
||||
SKIP_CHANGE_REQUEST,
|
||||
} from '../../../lib/types';
|
||||
import EnvironmentService from '../../../lib/services/environment-service';
|
||||
import {
|
||||
ForbiddenError,
|
||||
@ -20,7 +25,7 @@ import { ISegmentService } from '../../../lib/segments/segment-service-interface
|
||||
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
|
||||
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
|
||||
|
||||
let stores;
|
||||
let stores: IUnleashStores;
|
||||
let db;
|
||||
let service: FeatureToggleService;
|
||||
let segmentService: ISegmentService;
|
||||
@ -677,10 +682,13 @@ describe('flag name validation', () => {
|
||||
name: projectId,
|
||||
mode: 'open' as const,
|
||||
defaultStickiness: 'default',
|
||||
featureNaming,
|
||||
};
|
||||
|
||||
await stores.projectStore.create(project);
|
||||
await stores.projectStore.updateProjectEnterpriseSettings({
|
||||
id: projectId,
|
||||
featureNaming,
|
||||
});
|
||||
|
||||
const validFeatures = ['testpattern-feature', 'testpattern-feature2'];
|
||||
const invalidFeatures = ['a', 'b', 'c'];
|
||||
|
@ -135,7 +135,6 @@ test('should create new project', async () => {
|
||||
id: 'test',
|
||||
name: 'New project',
|
||||
description: 'Blah',
|
||||
mode: 'protected' as const,
|
||||
defaultStickiness: 'default',
|
||||
};
|
||||
|
||||
@ -145,7 +144,6 @@ test('should create new project', async () => {
|
||||
expect(project.name).toEqual(ret.name);
|
||||
expect(project.description).toEqual(ret.description);
|
||||
expect(ret.createdAt).toBeTruthy();
|
||||
expect(ret.mode).toEqual('protected');
|
||||
});
|
||||
|
||||
test('should create new private project', async () => {
|
||||
@ -153,7 +151,6 @@ test('should create new private project', async () => {
|
||||
id: 'testPrivate',
|
||||
name: 'New private project',
|
||||
description: 'Blah',
|
||||
mode: 'private' as const,
|
||||
defaultStickiness: 'default',
|
||||
};
|
||||
|
||||
@ -163,7 +160,6 @@ test('should create new private project', async () => {
|
||||
expect(project.name).toEqual(ret.name);
|
||||
expect(project.description).toEqual(ret.description);
|
||||
expect(ret.createdAt).toBeTruthy();
|
||||
expect(ret.mode).toEqual('private');
|
||||
});
|
||||
|
||||
test('should delete project', async () => {
|
||||
@ -1829,12 +1825,14 @@ describe('feature flag naming patterns', () => {
|
||||
|
||||
await projectService.createProject(project, user.id);
|
||||
|
||||
await projectService.updateProjectEnterpriseSettings(project, user);
|
||||
|
||||
expect(
|
||||
(await projectService.getProject(project.id)).featureNaming,
|
||||
).toMatchObject(featureNaming);
|
||||
|
||||
const newPattern = 'new-pattern.+';
|
||||
await projectService.updateProject(
|
||||
await projectService.updateProjectEnterpriseSettings(
|
||||
{
|
||||
...project,
|
||||
featureNaming: { pattern: newPattern },
|
||||
|
5
src/test/fixtures/fake-project-store.ts
vendored
5
src/test/fixtures/fake-project-store.ts
vendored
@ -190,4 +190,9 @@ export default class FakeProjectStore implements IProjectStore {
|
||||
getProjectModeCounts(): Promise<ProjectModeCount[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
updateProjectEnterpriseSettings(update: IProjectInsert): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user