1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-29 01:15:48 +02:00

feat: project mode (#3334)

This commit is contained in:
Mateusz Kwasniewski 2023-03-16 15:29:52 +01:00 committed by GitHub
parent a983cf15b9
commit 1064dfa40c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 272 additions and 41 deletions

View File

@ -19,6 +19,7 @@ const CreateProject = () => {
const { const {
projectId, projectId,
projectName, projectName,
projectMode,
projectDesc, projectDesc,
setProjectId, setProjectId,
setProjectName, setProjectName,
@ -28,6 +29,7 @@ const CreateProject = () => {
validateProjectId, validateProjectId,
validateName, validateName,
setProjectStickiness, setProjectStickiness,
setProjectMode,
projectStickiness, projectStickiness,
errors, errors,
} = useProjectForm(); } = useProjectForm();
@ -92,8 +94,10 @@ const CreateProject = () => {
projectId={projectId} projectId={projectId}
setProjectId={setProjectId} setProjectId={setProjectId}
projectName={projectName} projectName={projectName}
projectMode={projectMode}
projectStickiness={projectStickiness} projectStickiness={projectStickiness}
setProjectStickiness={setProjectStickiness} setProjectStickiness={setProjectStickiness}
setProjectMode={setProjectMode}
setProjectName={setProjectName} setProjectName={setProjectName}
projectDesc={projectDesc} projectDesc={projectDesc}
setProjectDesc={setProjectDesc} setProjectDesc={setProjectDesc}

View File

@ -31,10 +31,12 @@ const EditProject = () => {
projectName, projectName,
projectDesc, projectDesc,
projectStickiness, projectStickiness,
projectMode,
setProjectId, setProjectId,
setProjectName, setProjectName,
setProjectDesc, setProjectDesc,
setProjectStickiness, setProjectStickiness,
setProjectMode,
getProjectPayload, getProjectPayload,
clearErrors, clearErrors,
validateProjectId, validateProjectId,
@ -44,7 +46,8 @@ const EditProject = () => {
id, id,
project.name, project.name,
project.description, project.description,
defaultStickiness defaultStickiness,
project.mode
); );
const formatApiCode = () => { const formatApiCode = () => {
@ -111,9 +114,11 @@ const EditProject = () => {
projectId={projectId} projectId={projectId}
setProjectId={setProjectId} setProjectId={setProjectId}
projectName={projectName} projectName={projectName}
projectMode={projectMode}
setProjectName={setProjectName} setProjectName={setProjectName}
projectStickiness={projectStickiness} projectStickiness={projectStickiness}
setProjectStickiness={setProjectStickiness} setProjectStickiness={setProjectStickiness}
setProjectMode={setProjectMode}
projectDesc={projectDesc} projectDesc={projectDesc}
setProjectDesc={setProjectDesc} setProjectDesc={setProjectDesc}
mode="Edit" mode="Edit"

View File

@ -1,23 +1,27 @@
import React from 'react'; import React from 'react';
import { trim } from 'component/common/util'; import { trim } from 'component/common/util';
import { import {
StyledForm, StyledButton,
StyledButtonContainer,
StyledContainer, StyledContainer,
StyledDescription, StyledDescription,
StyledForm,
StyledInput, StyledInput,
StyledTextField, StyledTextField,
StyledButtonContainer,
StyledButton,
} from './ProjectForm.styles'; } from './ProjectForm.styles';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect'; import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import Select from 'component/common/select';
interface IProjectForm { interface IProjectForm {
projectId: string; projectId: string;
projectName: string; projectName: string;
projectDesc: string; projectDesc: string;
projectStickiness?: string; projectStickiness?: string;
projectMode?: string;
setProjectStickiness?: React.Dispatch<React.SetStateAction<string>>; setProjectStickiness?: React.Dispatch<React.SetStateAction<string>>;
setProjectMode?: React.Dispatch<React.SetStateAction<string>>;
setProjectId: React.Dispatch<React.SetStateAction<string>>; setProjectId: React.Dispatch<React.SetStateAction<string>>;
setProjectName: React.Dispatch<React.SetStateAction<string>>; setProjectName: React.Dispatch<React.SetStateAction<string>>;
setProjectDesc: React.Dispatch<React.SetStateAction<string>>; setProjectDesc: React.Dispatch<React.SetStateAction<string>>;
@ -37,17 +41,20 @@ const ProjectForm: React.FC<IProjectForm> = ({
projectName, projectName,
projectDesc, projectDesc,
projectStickiness, projectStickiness,
projectMode,
setProjectId, setProjectId,
setProjectName, setProjectName,
setProjectDesc, setProjectDesc,
setProjectStickiness, setProjectStickiness,
setProjectMode,
errors, errors,
mode, mode,
validateProjectId, validateProjectId,
clearErrors, clearErrors,
}) => { }) => {
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const { projectScopedStickiness } = uiConfig.flags; const { projectScopedStickiness, projectMode: projectModeFlag } =
uiConfig.flags;
return ( return (
<StyledForm onSubmit={handleSubmit}> <StyledForm onSubmit={handleSubmit}>
@ -113,6 +120,30 @@ const ProjectForm: React.FC<IProjectForm> = ({
</> </>
} }
/> />
<ConditionallyRender
condition={Boolean(projectModeFlag)}
show={
<>
<StyledDescription>
What is your project mode?
</StyledDescription>
<Select
id="project-mode"
value={projectMode}
label="Project mode"
name="Project mode"
onChange={e => {
setProjectMode?.(e.target.value);
}}
options={[
{ key: 'open', label: 'open' },
{ key: 'protected', label: 'protected' },
]}
style={{ minWidth: '150px' }}
></Select>
</>
}
/>
</StyledContainer> </StyledContainer>
<StyledButtonContainer> <StyledButtonContainer>

View File

@ -7,7 +7,8 @@ const useProjectForm = (
initialProjectId = '', initialProjectId = '',
initialProjectName = '', initialProjectName = '',
initialProjectDesc = '', initialProjectDesc = '',
initialProjectStickiness = 'default' initialProjectStickiness = 'default',
initialProjectMode = 'open'
) => { ) => {
const [projectId, setProjectId] = useState(initialProjectId); const [projectId, setProjectId] = useState(initialProjectId);
const { defaultStickiness } = useDefaultProjectSettings(projectId); const { defaultStickiness } = useDefaultProjectSettings(projectId);
@ -17,6 +18,7 @@ const useProjectForm = (
const [projectStickiness, setProjectStickiness] = useState( const [projectStickiness, setProjectStickiness] = useState(
defaultStickiness || initialProjectStickiness defaultStickiness || initialProjectStickiness
); );
const [projectMode, setProjectMode] = useState(initialProjectMode);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const { validateId } = useProjectApi(); const { validateId } = useProjectApi();
@ -33,12 +35,17 @@ const useProjectForm = (
setProjectDesc(initialProjectDesc); setProjectDesc(initialProjectDesc);
}, [initialProjectDesc]); }, [initialProjectDesc]);
useEffect(() => {
setProjectMode(initialProjectMode);
}, [initialProjectMode]);
const getProjectPayload = () => { const getProjectPayload = () => {
return { return {
id: projectId, id: projectId,
name: projectName, name: projectName,
description: projectDesc, description: projectDesc,
projectStickiness, projectStickiness,
mode: projectMode,
}; };
}; };
@ -74,10 +81,12 @@ const useProjectForm = (
projectName, projectName,
projectDesc, projectDesc,
projectStickiness, projectStickiness,
projectMode,
setProjectId, setProjectId,
setProjectName, setProjectName,
setProjectDesc, setProjectDesc,
setProjectStickiness, setProjectStickiness,
setProjectMode,
getProjectPayload, getProjectPayload,
validateName, validateName,
validateProjectId, validateProjectId,

View File

@ -12,6 +12,7 @@ const fallbackProject: IProject = {
version: '1', version: '1',
description: 'Default', description: 'Default',
favorite: false, favorite: false,
mode: 'open',
stats: { stats: {
archivedCurrentWindow: 0, archivedCurrentWindow: 0,
archivedPastWindow: 0, archivedPastWindow: 0,

View File

@ -23,6 +23,7 @@ export interface IProject {
stats: ProjectStatsSchema; stats: ProjectStatsSchema;
favorite: boolean; favorite: boolean;
features: IFeatureToggleListItem[]; features: IFeatureToggleListItem[];
mode: 'open' | 'protected';
} }
export interface IProjectHealthReport extends IProject { export interface IProjectHealthReport extends IProject {

View File

@ -51,6 +51,7 @@ export interface IFlags {
bulkOperations?: boolean; bulkOperations?: boolean;
projectScopedSegments?: boolean; projectScopedSegments?: boolean;
projectScopedStickiness?: boolean; projectScopedStickiness?: boolean;
projectMode?: boolean;
} }
export interface IVersionInfo { export interface IVersionInfo {

View File

@ -2,7 +2,12 @@ import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import { IEnvironment, IProject, IProjectWithCount } from '../types/model'; import {
IEnvironment,
IProject,
IProjectWithCount,
ProjectMode,
} from '../types/model';
import { import {
IProjectHealthUpdate, IProjectHealthUpdate,
IProjectInsert, IProjectInsert,
@ -26,6 +31,8 @@ const COLUMNS = [
'updated_at', 'updated_at',
]; ];
const TABLE = 'projects'; const TABLE = 'projects';
const SETTINGS_COLUMNS = ['project_mode'];
const SETTINGS_TABLE = 'project_settings';
export interface IEnvironmentProjectLink { export interface IEnvironmentProjectLink {
environmentName: string; environmentName: string;
@ -63,7 +70,7 @@ class ProjectStore implements IProjectStore {
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
fieldToRow(data): IProjectInsert { fieldToRow(data): Omit<IProjectInsert, 'mode'> {
return { return {
id: data.id, id: data.id,
name: data.name, name: data.name,
@ -168,8 +175,13 @@ class ProjectStore implements IProjectStore {
async get(id: string): Promise<IProject> { async get(id: string): Promise<IProject> {
return this.db return this.db
.first(COLUMNS) .first([...COLUMNS, ...SETTINGS_COLUMNS])
.from(TABLE) .from(TABLE)
.leftJoin(
SETTINGS_TABLE,
`${SETTINGS_TABLE}.project`,
`${TABLE}.id`,
)
.where({ id }) .where({ id })
.then(this.mapRow); .then(this.mapRow);
} }
@ -189,11 +201,19 @@ class ProjectStore implements IProjectStore {
.update({ health: healthUpdate.health, updated_at: new Date() }); .update({ health: healthUpdate.health, updated_at: new Date() });
} }
async create(project: IProjectInsert): Promise<IProject> { async create(
project: IProjectInsert & { mode: ProjectMode },
): Promise<IProject> {
const row = await this.db(TABLE) const row = await this.db(TABLE)
.insert(this.fieldToRow(project)) .insert(this.fieldToRow(project))
.returning('*'); .returning('*');
return this.mapRow(row[0]); const settingsRow = await this.db(SETTINGS_TABLE)
.insert({
project: project.id,
project_mode: project.mode,
})
.returning('*');
return this.mapRow({ ...row[0], ...settingsRow[0] });
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@ -202,6 +222,12 @@ class ProjectStore implements IProjectStore {
await this.db(TABLE) await this.db(TABLE)
.where({ id: data.id }) .where({ id: data.id })
.update(this.fieldToRow(data)); .update(this.fieldToRow(data));
await this.db(SETTINGS_TABLE)
.where({ project: data.id })
.update({
project_mode: data.mode,
})
.returning('*');
} catch (err) { } catch (err) {
this.logger.error('Could not update project, error: ', err); this.logger.error('Could not update project, error: ', err);
} }
@ -460,7 +486,7 @@ class ProjectStore implements IProjectStore {
createdAt: row.created_at, createdAt: row.created_at,
health: row.health ?? 100, health: row.health ?? 100,
updatedAt: row.updated_at || new Date(), updatedAt: row.updated_at || new Date(),
mode: 'open', mode: row.project_mode || 'open',
}; };
} }
} }

View File

@ -87,6 +87,7 @@ const createProject = async () => {
name: DEFAULT_PROJECT, name: DEFAULT_PROJECT,
description: '', description: '',
id: DEFAULT_PROJECT, id: DEFAULT_PROJECT,
mode: 'open' as const,
}); });
}; };

View File

@ -109,6 +109,7 @@ const createProject = async () => {
name: DEFAULT_PROJECT, name: DEFAULT_PROJECT,
description: '', description: '',
id: DEFAULT_PROJECT, id: DEFAULT_PROJECT,
mode: 'open' as const,
}); });
await app.request await app.request
.post(`/api/admin/projects/${DEFAULT_PROJECT}/environments`) .post(`/api/admin/projects/${DEFAULT_PROJECT}/environments`)

View File

@ -25,6 +25,14 @@ export const healthOverviewSchema = {
type: 'string', type: 'string',
nullable: true, nullable: true,
}, },
mode: {
type: 'string',
enum: ['open', 'protected'],
example: 'open',
nullable: true,
description:
'A mode of the project affecting what actions are possible in this project. During a rollout of project modes this feature can be optional or `null`',
},
members: { members: {
type: 'number', type: 'number',
}, },

View File

@ -36,6 +36,13 @@ export const projectOverviewSchema = {
example: 'DX squad feature release', example: 'DX squad feature release',
description: 'Additional information about the project', description: 'Additional information about the project',
}, },
mode: {
type: 'string',
enum: ['open', 'protected'],
example: 'open',
description:
'A mode of the project affecting what actions are possible in this project',
},
members: { members: {
type: 'number', type: 'number',
example: 4, example: 4,

View File

@ -59,9 +59,8 @@ export const projectSchema = {
type: 'string', type: 'string',
enum: ['open', 'protected'], enum: ['open', 'protected'],
example: 'open', example: 'open',
nullable: true,
description: description:
'A mode of the project affecting what actions are possible in this project. During a rollout of project modes this feature can be optional or `null`', 'A mode of the project affecting what actions are possible in this project',
}, },
}, },
components: {}, components: {},

View File

@ -7,5 +7,6 @@ export const projectSchema = joi
id: nameType, id: nameType,
name: joi.string().required(), name: joi.string().required(),
description: joi.string().allow(null).allow('').optional(), description: joi.string().allow(null).allow('').optional(),
mode: joi.string().valid('open', 'protected').default('open'),
}) })
.options({ allowUnknown: false, stripUnknown: true }); .options({ allowUnknown: false, stripUnknown: true });

View File

@ -166,7 +166,7 @@ export default class ProjectService {
} }
async createProject( async createProject(
newProject: Pick<IProject, 'id' | 'name'>, newProject: Pick<IProject, 'id' | 'name' | 'mode'>,
user: IUser, user: IUser,
): Promise<IProject> { ): Promise<IProject> {
const data = await projectSchema.validateAsync(newProject); const data = await projectSchema.validateAsync(newProject);
@ -853,6 +853,7 @@ export default class ProjectService {
stats: projectStats, stats: projectStats,
name: project.name, name: project.name,
description: project.description, description: project.description,
mode: project.mode,
health: project.health || 0, health: project.health || 0,
favorite: favorite, favorite: favorite,
updatedAt: project.updatedAt, updatedAt: project.updatedAt,

View File

@ -518,6 +518,7 @@ test('Should not import an existing project', async () => {
id: 'default', id: 'default',
name: 'default', name: 'default',
description: 'Some fancy description for project', description: 'Some fancy description for project',
mode: 'open' as const,
}, },
], ],
}; };
@ -546,6 +547,7 @@ test('Should drop projects before import if specified', async () => {
id: 'fancy', id: 'fancy',
name: 'extra', name: 'extra',
description: 'Not expected to be seen after import', description: 'Not expected to be seen after import',
mode: 'open' as const,
}); });
await stateService.import({ data, dropBeforeImport: true }); await stateService.import({ data, dropBeforeImport: true });
const hasProject = await stores.projectStore.hasProject('fancy'); const hasProject = await stores.projectStore.hasProject('fancy');
@ -558,6 +560,7 @@ test('Should export projects', async () => {
id: 'fancy', id: 'fancy',
name: 'extra', name: 'extra',
description: 'No surprises here', description: 'No surprises here',
mode: 'open' as const,
}); });
const exported = await stateService.export({ const exported = await stateService.export({
includeFeatureToggles: false, includeFeatureToggles: false,
@ -579,6 +582,7 @@ test('exporting to new format works', async () => {
id: 'fancy', id: 'fancy',
name: 'extra', name: 'extra',
description: 'No surprises here', description: 'No surprises here',
mode: 'open' as const,
}); });
await stores.environmentStore.create({ await stores.environmentStore.create({
name: 'dev', name: 'dev',
@ -622,7 +626,7 @@ test('exporting variants to v4 format should not include variants in features',
exported.featureEnvironments.forEach((fe) => { exported.featureEnvironments.forEach((fe) => {
expect(fe.variants).toHaveLength(1); expect(fe.variants).toHaveLength(1);
expect(fe.variants[0].name).toBe(`${fe.environment}-variant`); expect(fe.variants?.[0].name).toBe(`${fe.environment}-variant`);
}); });
expect(exported.environments).toHaveLength(3); expect(exported.environments).toHaveLength(3);
}); });
@ -633,6 +637,7 @@ test('featureStrategies can keep existing', async () => {
id: 'fancy', id: 'fancy',
name: 'extra', name: 'extra',
description: 'No surprises here', description: 'No surprises here',
mode: 'open' as const,
}); });
await stores.environmentStore.create({ await stores.environmentStore.create({
name: 'dev', name: 'dev',
@ -679,6 +684,7 @@ test('featureStrategies should not keep existing if dropBeforeImport', async ()
id: 'fancy', id: 'fancy',
name: 'extra', name: 'extra',
description: 'No surprises here', description: 'No surprises here',
mode: 'open' as const,
}); });
await stores.environmentStore.create({ await stores.environmentStore.create({
name: 'dev', name: 'dev',

View File

@ -173,6 +173,8 @@ export interface IFeatureOverview {
environments: IEnvironmentOverview[]; environments: IEnvironmentOverview[];
} }
export type ProjectMode = 'open' | 'protected';
export interface IProjectOverview { export interface IProjectOverview {
name: string; name: string;
description: string; description: string;
@ -184,6 +186,7 @@ export interface IProjectOverview {
favorite?: boolean; favorite?: boolean;
updatedAt?: Date; updatedAt?: Date;
stats?: IProjectStats; stats?: IProjectStats;
mode: ProjectMode;
} }
export interface IProjectHealthReport extends IProjectOverview { export interface IProjectHealthReport extends IProjectOverview {
@ -368,7 +371,6 @@ export interface IProject {
changeRequestsEnabled?: boolean; changeRequestsEnabled?: boolean;
mode: ProjectMode; mode: ProjectMode;
} }
export type ProjectMode = 'open' | 'protected';
export interface ICustomRole { export interface ICustomRole {
id: number; id: number;

View File

@ -2,7 +2,12 @@ import {
IEnvironmentProjectLink, IEnvironmentProjectLink,
IProjectMembersCount, IProjectMembersCount,
} from '../../db/project-store'; } from '../../db/project-store';
import { IEnvironment, IProject, IProjectWithCount } from '../model'; import {
IEnvironment,
IProject,
IProjectWithCount,
ProjectMode,
} from '../model';
import { Store } from './store'; import { Store } from './store';
export interface IProjectInsert { export interface IProjectInsert {
@ -11,6 +16,7 @@ export interface IProjectInsert {
description: string; description: string;
updatedAt?: Date; updatedAt?: Date;
changeRequestsEnabled?: boolean; changeRequestsEnabled?: boolean;
mode: ProjectMode;
} }
export interface IProjectArchived { export interface IProjectArchived {

View File

@ -75,11 +75,13 @@ test('Should get archived toggles via project', async () => {
id: 'proj-1', id: 'proj-1',
name: 'proj-1', name: 'proj-1',
description: '', description: '',
mode: 'open' as const,
}); });
await db.stores.projectStore.create({ await db.stores.projectStore.create({
id: 'proj-2', id: 'proj-2',
name: 'proj-2', name: 'proj-2',
description: '', description: '',
mode: 'open' as const,
}); });
await db.stores.featureToggleStore.create('proj-1', { await db.stores.featureToggleStore.create('proj-1', {

View File

@ -36,6 +36,7 @@ test('should return instance statistics with correct number of projects', async
id: 'test', id: 'test',
name: 'Test', name: 'Test',
description: 'lorem', description: 'lorem',
mode: 'open' as const,
}); });
return app.request return app.request

View File

@ -544,7 +544,7 @@ describe('Interacting with features using project IDs that belong to other proje
rootRole: RoleName.ADMIN, rootRole: RoleName.ADMIN,
}); });
await app.services.projectService.createProject( await app.services.projectService.createProject(
{ name: otherProject, id: otherProject }, { name: otherProject, id: otherProject, mode: 'open' },
dummyAdmin, dummyAdmin,
); );
@ -701,8 +701,8 @@ test('Should patch feature toggle', async () => {
}); });
const updateForOurToggle = events.find((e) => e.data.name === name); const updateForOurToggle = events.find((e) => e.data.name === name);
expect(updateForOurToggle).toBeTruthy(); expect(updateForOurToggle).toBeTruthy();
expect(updateForOurToggle.data.description).toBe('New desc'); expect(updateForOurToggle?.data.description).toBe('New desc');
expect(updateForOurToggle.data.type).toBe('kill-switch'); expect(updateForOurToggle?.data.type).toBe('kill-switch');
}); });
test('Should patch feature toggle and not remove variants', async () => { test('Should patch feature toggle and not remove variants', async () => {
@ -1956,6 +1956,7 @@ test('Should not allow changing project to target project without the same enabl
name: 'Project to be moved to', name: 'Project to be moved to',
id: targetProject, id: targetProject,
description: '', description: '',
mode: 'open',
}); });
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
@ -2042,6 +2043,7 @@ test('Should allow changing project to target project with the same enabled envi
name: 'Project to be moved to', name: 'Project to be moved to',
id: targetProject, id: targetProject,
description: '', description: '',
mode: 'open',
}); });
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
@ -2284,6 +2286,7 @@ test('Can create toggle with impression data on different project', async () =>
id: 'impression-data', id: 'impression-data',
name: 'ImpressionData', name: 'ImpressionData',
description: '', description: '',
mode: 'open',
}); });
const toggle = { const toggle = {
@ -2313,6 +2316,7 @@ test('should reject invalid constraint values for multi-valued constraints', asy
id: uuidv4(), id: uuidv4(),
name: uuidv4(), name: uuidv4(),
description: '', description: '',
mode: 'open',
}); });
const toggle = await db.stores.featureToggleStore.create(project.id, { const toggle = await db.stores.featureToggleStore.create(project.id, {
@ -2359,6 +2363,7 @@ test('should add default constraint values for single-valued constraints', async
id: uuidv4(), id: uuidv4(),
name: uuidv4(), name: uuidv4(),
description: '', description: '',
mode: 'open',
}); });
const toggle = await db.stores.featureToggleStore.create(project.id, { const toggle = await db.stores.featureToggleStore.create(project.id, {
@ -2418,6 +2423,7 @@ test('should allow long parameter values', async () => {
id: uuidv4(), id: uuidv4(),
name: uuidv4(), name: uuidv4(),
description: uuidv4(), description: uuidv4(),
mode: 'open',
}); });
const toggle = await db.stores.featureToggleStore.create(project.id, { const toggle = await db.stores.featureToggleStore.create(project.id, {

View File

@ -26,7 +26,12 @@ afterAll(async () => {
}); });
test('Should ONLY return default project', async () => { test('Should ONLY return default project', async () => {
projectStore.create({ id: 'test2', name: 'test', description: '' }); projectStore.create({
id: 'test2',
name: 'test',
description: '',
mode: 'open',
});
const { body } = await app.request const { body } = await app.request
.get('/api/admin/projects') .get('/api/admin/projects')

View File

@ -154,6 +154,7 @@ test('Can roundtrip. I.e. export and then import', async () => {
name: projectId, name: projectId,
id: projectId, id: projectId,
description: 'Project for export', description: 'Project for export',
mode: 'open' as const,
}); });
await app.services.environmentService.addEnvironmentToProject( await app.services.environmentService.addEnvironmentToProject(
environment, environment,
@ -201,6 +202,7 @@ test('Roundtrip with tags works', async () => {
name: projectId, name: projectId,
id: projectId, id: projectId,
description: 'Project for export', description: 'Project for export',
mode: 'open' as const,
}); });
await app.services.environmentService.addEnvironmentToProject( await app.services.environmentService.addEnvironmentToProject(
environment, environment,
@ -265,6 +267,7 @@ test('Roundtrip with strategies in multiple environments works', async () => {
name: projectId, name: projectId,
id: projectId, id: projectId,
description: 'Project for export', description: 'Project for export',
mode: 'open' as const,
}); });
await app.services.featureToggleServiceV2.createFeatureToggle( await app.services.featureToggleServiceV2.createFeatureToggle(
@ -364,6 +367,7 @@ test(`should not delete api_tokens on import when drop-flag is set`, async () =>
name: projectId, name: projectId,
id: projectId, id: projectId,
description: 'Project for export', description: 'Project for export',
mode: 'open' as const,
}); });
await app.services.environmentService.addEnvironmentToProject( await app.services.environmentService.addEnvironmentToProject(
environment, environment,

View File

@ -289,6 +289,7 @@ test('returns a feature toggles impression data for a different project', async
id: 'impression-data-client', id: 'impression-data-client',
name: 'ImpressionData', name: 'ImpressionData',
description: '', description: '',
mode: 'open' as const,
}; };
await db.stores.projectStore.create(project); await db.stores.projectStore.create(project);

View File

@ -35,6 +35,7 @@ beforeAll(async () => {
id: project2, id: project2,
name: 'Test Project 2', name: 'Test Project 2',
description: '', description: '',
mode: 'open' as const,
}); });
await environmentService.addEnvironmentToProject(environment, project); await environmentService.addEnvironmentToProject(environment, project);

View File

@ -1859,6 +1859,16 @@ exports[`should serve the OpenAPI spec 1`] = `
"members": { "members": {
"type": "number", "type": "number",
}, },
"mode": {
"description": "A mode of the project affecting what actions are possible in this project. During a rollout of project modes this feature can be optional or \`null\`",
"enum": [
"open",
"protected",
],
"example": "open",
"nullable": true,
"type": "string",
},
"name": { "name": {
"type": "string", "type": "string",
}, },
@ -1912,6 +1922,16 @@ exports[`should serve the OpenAPI spec 1`] = `
"members": { "members": {
"type": "number", "type": "number",
}, },
"mode": {
"description": "A mode of the project affecting what actions are possible in this project. During a rollout of project modes this feature can be optional or \`null\`",
"enum": [
"open",
"protected",
],
"example": "open",
"nullable": true,
"type": "string",
},
"name": { "name": {
"type": "string", "type": "string",
}, },
@ -2771,6 +2791,15 @@ exports[`should serve the OpenAPI spec 1`] = `
"example": 4, "example": 4,
"type": "number", "type": "number",
}, },
"mode": {
"description": "A mode of the project affecting what actions are possible in this project",
"enum": [
"open",
"protected",
],
"example": "open",
"type": "string",
},
"name": { "name": {
"description": "The name of this project", "description": "The name of this project",
"example": "dx-squad", "example": "dx-squad",
@ -2837,13 +2866,12 @@ exports[`should serve the OpenAPI spec 1`] = `
"type": "number", "type": "number",
}, },
"mode": { "mode": {
"description": "A mode of the project affecting what actions are possible in this project. During a rollout of project modes this feature can be optional or \`null\`", "description": "A mode of the project affecting what actions are possible in this project",
"enum": [ "enum": [
"open", "open",
"protected", "protected",
], ],
"example": "open", "example": "open",
"nullable": true,
"type": "string", "type": "string",
}, },
"name": { "name": {

View File

@ -100,7 +100,10 @@ const createProject = async (id: string, name: string): Promise<void> => {
name: randomId(), name: randomId(),
email: `${randomId()}@example.com`, email: `${randomId()}@example.com`,
}); });
await app.services.projectService.createProject({ id, name }, user); await app.services.projectService.createProject(
{ id, name, mode: 'open' },
user,
);
}; };
test('should require a frontend token or an admin token', async () => { test('should require a frontend token or an admin token', async () => {
@ -188,14 +191,14 @@ test('should allow requests with a token secret alias', async () => {
.expect((res) => expect(res.body.toggles[0].name).toEqual(featureB)); .expect((res) => expect(res.body.toggles[0].name).toEqual(featureB));
await app.request await app.request
.get('/api/frontend') .get('/api/frontend')
.set('Authorization', tokenA.alias) .set('Authorization', tokenA.alias!)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => expect(res.body.toggles).toHaveLength(1)) .expect((res) => expect(res.body.toggles).toHaveLength(1))
.expect((res) => expect(res.body.toggles[0].name).toEqual(featureA)); .expect((res) => expect(res.body.toggles[0].name).toEqual(featureA));
await app.request await app.request
.get('/api/frontend') .get('/api/frontend')
.set('Authorization', tokenB.alias) .set('Authorization', tokenB.alias!)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => expect(res.body.toggles).toHaveLength(1)) .expect((res) => expect(res.body.toggles).toHaveLength(1))
@ -952,7 +955,7 @@ test('should terminate data polling when stop is called', async () => {
frontendToken.secret, frontendToken.secret,
); );
const logTrap = []; const logTrap: any[] = [];
const getDebugLogger = (): Logger => { const getDebugLogger = (): Logger => {
return { return {
/* eslint-disable-next-line */ /* eslint-disable-next-line */
@ -978,7 +981,7 @@ test('should terminate data polling when stop is called', async () => {
}, },
db.stores, db.stores,
app.services, app.services,
user, user!,
); );
await proxyRepository.start(); await proxyRepository.start();

View File

@ -36,6 +36,7 @@ beforeAll(async () => {
id: 'test-project', id: 'test-project',
name: 'Test Project', name: 'Test Project',
description: 'Fancy', description: 'Fancy',
mode: 'open' as const,
}; };
const user = await stores.userStore.insert({ const user = await stores.userStore.insert({
name: 'Some Name', name: 'Some Name',
@ -219,11 +220,11 @@ test('should return user with multiple projects', async () => {
tokens[1].secret, tokens[1].secret,
); );
expect(multiProjectUser.projects).toStrictEqual([ expect(multiProjectUser!.projects).toStrictEqual([
'test-project', 'test-project',
'default', 'default',
]); ]);
expect(singleProjectUser.projects).toStrictEqual(['test-project']); expect(singleProjectUser!.projects).toStrictEqual(['test-project']);
}); });
test('should not partially create token if projects are invalid', async () => { test('should not partially create token if projects are invalid', async () => {

View File

@ -100,6 +100,7 @@ test('should list all projects', async () => {
id: 'test-list', id: 'test-list',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -113,6 +114,7 @@ test('should create new project', async () => {
id: 'test', id: 'test',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'protected' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -121,6 +123,7 @@ test('should create new project', async () => {
expect(project.name).toEqual(ret.name); expect(project.name).toEqual(ret.name);
expect(project.description).toEqual(ret.description); expect(project.description).toEqual(ret.description);
expect(ret.createdAt).toBeTruthy(); expect(ret.createdAt).toBeTruthy();
expect(ret.mode).toEqual('protected');
}); });
test('should delete project', async () => { test('should delete project', async () => {
@ -128,6 +131,7 @@ test('should delete project', async () => {
id: 'test-delete', id: 'test-delete',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -145,6 +149,7 @@ test('should not be able to delete project with toggles', async () => {
id: 'test-delete-with-toggles', id: 'test-delete-with-toggles',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
await stores.featureToggleStore.create(project.id, { await stores.featureToggleStore.create(project.id, {
@ -180,6 +185,7 @@ test('should not be able to create existing project', async () => {
id: 'test-delete', id: 'test-delete',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
try { try {
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -210,13 +216,14 @@ test('should update project', async () => {
id: 'test-update', id: 'test-update',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
const updatedProject = { const updatedProject = {
id: 'test-update', id: 'test-update',
name: 'New name', name: 'New name',
description: 'Blah longer desc', description: 'Blah longer desc',
mode: 'open' as const, mode: 'protected' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -226,6 +233,7 @@ test('should update project', async () => {
expect(updatedProject.name).toBe(readProject.name); expect(updatedProject.name).toBe(readProject.name);
expect(updatedProject.description).toBe(readProject.description); expect(updatedProject.description).toBe(readProject.description);
expect(updatedProject.mode).toBe('protected');
}); });
test('should give error when getting unknown project', async () => { test('should give error when getting unknown project', async () => {
@ -241,6 +249,7 @@ test('should get list of users with access to project', async () => {
id: 'test-roles-access', id: 'test-roles-access',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
const { users } = await projectService.getAccessToProject(project.id); const { users } = await projectService.getAccessToProject(project.id);
@ -262,6 +271,7 @@ test('should add a member user to the project', async () => {
id: 'add-users', id: 'add-users',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -317,6 +327,7 @@ test('should add admin users to the project', async () => {
id: 'add-admin-users', id: 'add-admin-users',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -363,6 +374,7 @@ test('add user should fail if user already have access', async () => {
id: 'add-users-twice', id: 'add-users-twice',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -397,6 +409,7 @@ test('should remove user from the project', async () => {
id: 'remove-users', id: 'remove-users',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -431,6 +444,7 @@ test('should not remove user from the project', async () => {
id: 'remove-users-not-allowed', id: 'remove-users-not-allowed',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -454,6 +468,7 @@ test('should not change project if feature toggle project does not match current
id: 'test-change-project', id: 'test-change-project',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
const toggle = { name: 'test-toggle' }; const toggle = { name: 'test-toggle' };
@ -480,6 +495,7 @@ test('should return 404 if no project is found with the project id', async () =>
id: 'test-change-project-2', id: 'test-change-project-2',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
const toggle = { name: 'test-toggle-2' }; const toggle = { name: 'test-toggle-2' };
@ -504,12 +520,14 @@ test('should fail if user is not authorized', async () => {
id: 'test-change-project-3', id: 'test-change-project-3',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
const projectDestination = { const projectDestination = {
id: 'test-change-project-dest', id: 'test-change-project-dest',
name: 'New project 2', name: 'New project 2',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
const toggle = { name: 'test-toggle-3' }; const toggle = { name: 'test-toggle-3' };
@ -537,8 +555,16 @@ test('should fail if user is not authorized', async () => {
}); });
test('should change project when checks pass', async () => { test('should change project when checks pass', async () => {
const projectA = { id: randomId(), name: randomId() }; const projectA = {
const projectB = { id: randomId(), name: randomId() }; id: randomId(),
name: randomId(),
mode: 'open' as const,
};
const projectB = {
id: randomId(),
name: randomId(),
mode: 'open' as const,
};
const toggle = { name: randomId() }; const toggle = { name: randomId() };
await projectService.createProject(projectA, user); await projectService.createProject(projectA, user);
@ -558,8 +584,16 @@ test('should change project when checks pass', async () => {
}); });
test('changing project should emit event even if user does not have a username set', async () => { test('changing project should emit event even if user does not have a username set', async () => {
const projectA = { id: randomId(), name: randomId() }; const projectA = {
const projectB = { id: randomId(), name: randomId() }; id: randomId(),
name: randomId(),
mode: 'open' as const,
};
const projectB = {
id: randomId(),
name: randomId(),
mode: 'open' as const,
};
const toggle = { name: randomId() }; const toggle = { name: randomId() };
await projectService.createProject(projectA, user); await projectService.createProject(projectA, user);
await projectService.createProject(projectB, user); await projectService.createProject(projectB, user);
@ -576,8 +610,16 @@ test('changing project should emit event even if user does not have a username s
}, 10000); }, 10000);
test('should require equal project environments to move features', async () => { test('should require equal project environments to move features', async () => {
const projectA = { id: randomId(), name: randomId() }; const projectA = {
const projectB = { id: randomId(), name: randomId() }; id: randomId(),
name: randomId(),
mode: 'open' as const,
};
const projectB = {
id: randomId(),
name: randomId(),
mode: 'open' as const,
};
const environment = { name: randomId(), type: 'production' }; const environment = { name: randomId(), type: 'production' };
const toggle = { name: randomId() }; const toggle = { name: randomId() };
@ -605,6 +647,7 @@ test('A newly created project only gets connected to enabled environments', asyn
id: 'environment-test', id: 'environment-test',
name: 'New environment project', name: 'New environment project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
const enabledEnv = 'connection_test'; const enabledEnv = 'connection_test';
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
@ -631,6 +674,7 @@ test('should have environments sorted in order', async () => {
id: 'environment-order-test', id: 'environment-order-test',
name: 'Environment testing project', name: 'Environment testing project',
description: '', description: '',
mode: 'open' as const,
}; };
const first = 'test'; const first = 'test';
const second = 'abc'; const second = 'abc';
@ -669,6 +713,7 @@ test('should add a user to the project with a custom role', async () => {
id: 'add-users-custom-role', id: 'add-users-custom-role',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -719,6 +764,7 @@ test('should delete role entries when deleting project', async () => {
id: 'test-delete-users-1', id: 'test-delete-users-1',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -770,6 +816,7 @@ test('should change a users role in the project', async () => {
id: 'test-change-user-role', id: 'test-change-user-role',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -836,6 +883,7 @@ test('should update role for user on project', async () => {
id: 'update-users', id: 'update-users',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -873,6 +921,7 @@ test('should able to assign role without existing members', async () => {
id: 'update-users-test', id: 'update-users-test',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -915,6 +964,7 @@ test('should not update role for user on project when she is the owner', async (
id: 'update-users-not-allowed', id: 'update-users-not-allowed',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -948,6 +998,7 @@ test('Should allow bulk update of group permissions', async () => {
const project = { const project = {
id: 'bulk-update-project', id: 'bulk-update-project',
name: 'bulk-update-project', name: 'bulk-update-project',
mode: 'open' as const,
}; };
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
const groupStore = stores.groupStore; const groupStore = stores.groupStore;
@ -1024,6 +1075,7 @@ test('Should allow bulk update of only groups', async () => {
const project = { const project = {
id: 'bulk-update-project-only', id: 'bulk-update-project-only',
name: 'bulk-update-project-only', name: 'bulk-update-project-only',
mode: 'open' as const,
}; };
const groupStore = stores.groupStore; const groupStore = stores.groupStore;
@ -1064,6 +1116,7 @@ test('should only count active feature toggles for project', async () => {
id: 'only-active', id: 'only-active',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -1091,6 +1144,7 @@ test('should list projects with all features archived', async () => {
id: 'only-archived', id: 'only-archived',
name: 'Listed project', name: 'Listed project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectService.createProject(project, user); await projectService.createProject(project, user);
@ -1126,6 +1180,7 @@ test('should calculate average time to production', async () => {
const project = { const project = {
id: 'average-time-to-prod', id: 'average-time-to-prod',
name: 'average-time-to-prod', name: 'average-time-to-prod',
mode: 'open' as const,
}; };
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
@ -1184,6 +1239,7 @@ test('should get correct amount of features created in current and past window',
const project = { const project = {
id: 'features-created', id: 'features-created',
name: 'features-created', name: 'features-created',
mode: 'open' as const,
}; };
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
@ -1219,6 +1275,7 @@ test('should get correct amount of features archived in current and past window'
const project = { const project = {
id: 'features-archived', id: 'features-archived',
name: 'features-archived', name: 'features-archived',
mode: 'open' as const,
}; };
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
@ -1268,6 +1325,7 @@ test('should get correct amount of project members for current and past window',
const project = { const project = {
id: 'features-members', id: 'features-members',
name: 'features-members', name: 'features-members',
mode: 'open' as const,
}; };
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);

View File

@ -31,6 +31,7 @@ test('should create new project', async () => {
id: 'test', id: 'test',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectStore.create(project); await projectStore.create(project);
const ret = await projectStore.get('test'); const ret = await projectStore.get('test');
@ -48,6 +49,7 @@ test('should delete project', async () => {
id: 'test-delete', id: 'test-delete',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await projectStore.create(project); await projectStore.create(project);
await projectStore.delete(project.id); await projectStore.delete(project.id);
@ -64,12 +66,14 @@ test('should update project', async () => {
id: 'test-update', id: 'test-update',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
const updatedProject = { const updatedProject = {
id: 'test-update', id: 'test-update',
name: 'New name', name: 'New name',
description: 'Blah longer desc', description: 'Blah longer desc',
mode: 'open' as const,
}; };
await projectStore.create(project); await projectStore.create(project);
@ -93,11 +97,17 @@ test('should import projects', async () => {
const projectsCount = (await projectStore.getAll()).length; const projectsCount = (await projectStore.getAll()).length;
const projectsToImport: IProjectInsert[] = [ const projectsToImport: IProjectInsert[] = [
{ description: 'some project desc', name: 'some name', id: 'someId' }, {
description: 'some project desc',
name: 'some name',
id: 'someId',
mode: 'open' as const,
},
{ {
description: 'another project', description: 'another project',
name: 'another name', name: 'another name',
id: 'anotherId', id: 'anotherId',
mode: 'open' as const,
}, },
]; ];
@ -110,8 +120,8 @@ test('should import projects', async () => {
expect(projects.length - projectsCount).toBe(2); expect(projects.length - projectsCount).toBe(2);
expect(someId).toBeDefined(); expect(someId).toBeDefined();
expect(someId.name).toBe('some name'); expect(someId?.name).toBe('some name');
expect(someId.description).toBe('some project desc'); expect(someId?.description).toBe('some project desc');
expect(anotherId).toBeDefined(); expect(anotherId).toBeDefined();
}); });
@ -120,6 +130,7 @@ test('should add environment to project', async () => {
id: 'test-env', id: 'test-env',
name: 'New project with env', name: 'New project with env',
description: 'Blah', description: 'Blah',
mode: 'open' as const,
}; };
await environmentStore.create({ await environmentStore.create({