1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-14 01:16:17 +02:00

Feat/add cypress tests for project scoped stickiness (#3340)

<!-- Thanks for creating a PR! To make it easier for reviewers and
everyone else to understand what your changes relate to, please add some
relevant content to the headings below. Feel free to ignore or delete
sections that you don't think are relevant. Thank you! ❤️ -->


## About the changes
<!-- Describe the changes introduced. What are they and why are they
being introduced? Feel free to also add screenshots or steps to view the
changes if they're visual. -->

<!-- Does it close an issue? Multiple? -->
Closes #

<!-- (For internal contributors): Does it relate to an issue on public
roadmap? -->
<!--
Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item:
#
-->

### Important files
<!-- PRs can contain a lot of changes, but not all changes are equally
important. Where should a reviewer start looking to get an overview of
the changes? Are any files particularly important? -->


## Discussion points
<!-- Anything about the PR you'd like to discuss before it gets merged?
Got any questions or doubts? -->

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
andreas-unleash 2023-03-17 14:41:59 +02:00 committed by GitHub
parent bc011e9876
commit 32e1ad44ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 386 additions and 293 deletions

View File

@ -0,0 +1,117 @@
/// <reference types="cypress" />
type UserCredentials = { email: string; password: string };
const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE'));
const randomId = String(Math.random()).split('.')[1];
const featureToggleName = `settings-${randomId}`;
const baseUrl = Cypress.config().baseUrl;
let strategyId = '';
const userName = `settings-user-${randomId}`;
const projectName = `stickiness-project-${randomId}`;
// Disable all active splash pages by visiting them.
const disableActiveSplashScreens = () => {
cy.visit(`/splash/operators`);
};
const disableFeatureStrategiesProdGuard = () => {
localStorage.setItem(
'useFeatureStrategyProdGuardSettings:v2',
JSON.stringify({ hide: true })
);
};
describe('notifications', () => {
before(() => {
disableFeatureStrategiesProdGuard();
disableActiveSplashScreens();
cy.login();
});
after(() => {
cy.request(
'DELETE',
`${baseUrl}/api/admin/features/${featureToggleName}`
);
cy.request('DELETE', `${baseUrl}/api/admin/projects/${projectName}`);
});
beforeEach(() => {
cy.login();
cy.visit(`/projects`);
if (document.querySelector("[data-testid='CLOSE_SPLASH']")) {
cy.get("[data-testid='CLOSE_SPLASH']").click();
}
});
afterEach(() => {
cy.request('DELETE', `${baseUrl}/api/admin/projects/${projectName}`);
});
const createFeature = () => {
cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click();
cy.intercept('POST', `/api/admin/projects/${projectName}/features`).as(
'createFeature'
);
cy.get("[data-testid='CF_NAME_ID'").type(featureToggleName);
cy.get("[data-testid='CF_DESC_ID'").type('hello-world');
cy.get("[data-testid='CF_CREATE_BTN_ID']").click();
cy.wait('@createFeature');
};
const createProject = () => {
cy.get('[data-testid=NAVIGATE_TO_CREATE_PROJECT').click();
cy.get("[data-testid='PROJECT_ID_INPUT']").type(projectName);
cy.get("[data-testid='PROJECT_NAME_INPUT']").type(projectName);
cy.get("[id='stickiness-select']")
.first()
.click()
.get('[data-testid=SELECT_ITEM_ID-userId')
.first()
.click();
cy.get("[data-testid='CREATE_PROJECT_BTN']").click();
};
it('should store default project stickiness when creating, retrieve it when editing a project', () => {
createProject();
cy.visit(`/projects/${projectName}`);
if (document.querySelector("[data-testid='CLOSE_SPLASH']")) {
cy.get("[data-testid='CLOSE_SPLASH']").click();
}
cy.get("[data-testid='NAVIGATE_TO_EDIT_PROJECT']").click();
//then
cy.get("[id='stickiness-select']")
.first()
.should('have.text', 'userId');
});
it('should respect the default project stickiness when creating a Gradual Rollout Strategy', () => {
createProject();
createFeature();
cy.visit(
`/projects/default/features/${featureToggleName}/strategies/create?environmentId=development&strategyName=flexibleRollout`
);
//then
cy.get("[id='stickiness-select']")
.first()
.should('have.text', 'userId');
});
it('should respect the default project stickiness when creating a variant', () => {
createProject();
createFeature();
cy.visit(`/projects/default/features/${featureToggleName}/variants`);
cy.get("[data-testid='EDIT_VARIANTS_BUTTON']").click();
//then
cy.get('#menu-stickiness').first().should('have.text', 'userId');
});
});

View File

@ -1,5 +1,5 @@
import Select from 'component/common/select';
import { SelectChangeEvent } from '@mui/material';
import { SelectChangeEvent, useTheme } from '@mui/material';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
const builtInStickinessOptions = [
{ key: 'default', label: 'default' },
@ -23,6 +23,7 @@ export const StickinessSelect = ({
dataTestId,
}: IStickinessSelectProps) => {
const { context } = useUnleashContext();
const theme = useTheme();
const resolveStickinessOptions = () =>
builtInStickinessOptions.concat(
@ -54,7 +55,7 @@ export const StickinessSelect = ({
style={{
width: 'inherit',
minWidth: '100%',
marginBottom: '16px',
marginBottom: theme.spacing(2),
}}
/>
);

View File

@ -11,6 +11,8 @@ import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { GO_BACK } from 'constants/navigate';
const CREATE_PROJECT_BTN = 'CREATE_PROJECT_BTN';
const CreateProject = () => {
const { setToastData, setToastApiError } = useToast();
const { refetchUser } = useAuthUser();
@ -34,8 +36,7 @@ const CreateProject = () => {
errors,
} = useProjectForm();
const { createProject, setDefaultProjectStickiness, loading } =
useProjectApi();
const { createProject, loading } = useProjectApi();
const handleSubmit = async (e: Event) => {
e.preventDefault();
@ -47,10 +48,6 @@ const CreateProject = () => {
const payload = getProjectPayload();
try {
await createProject(payload);
setDefaultProjectStickiness(
projectId,
payload.projectStickiness
);
refetchUser();
navigate(`/projects/${projectId}`);
setToastData({
@ -105,7 +102,11 @@ const CreateProject = () => {
clearErrors={clearErrors}
validateProjectId={validateProjectId}
>
<CreateButton name="project" permission={CREATE_PROJECT} />
<CreateButton
name="project"
permission={CREATE_PROJECT}
data-testid={CREATE_PROJECT_BTN}
/>
</ProjectForm>
</FormTemplate>
);

View File

@ -16,6 +16,8 @@ import { Alert } from '@mui/material';
import { GO_BACK } from 'constants/navigate';
import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings';
const EDIT_PROJECT_BTN = 'EDIT_PROJECT_BTN';
const EditProject = () => {
const { uiConfig } = useUiConfig();
const { setToastData, setToastApiError } = useToast();
@ -71,9 +73,9 @@ const EditProject = () => {
if (validName) {
try {
await editProject(id, payload);
setDefaultProjectStickiness(
await setDefaultProjectStickiness(
projectId,
payload.projectStickiness
payload.defaultStickiness
);
refetch();
navigate(`/projects/${id}`);
@ -128,6 +130,7 @@ const EditProject = () => {
<UpdateButton
permission={UPDATE_PROJECT}
projectId={projectId}
data-testid={EDIT_PROJECT_BTN}
/>
</ProjectForm>
</FormTemplate>

View File

@ -44,6 +44,8 @@ import { useFavoriteProjectsApi } from 'hooks/api/actions/useFavoriteProjectsApi
import { ImportModal } from './Import/ImportModal';
import { IMPORT_BUTTON } from 'utils/testIds';
const NAVIGATE_TO_EDIT_PROJECT = 'NAVIGATE_TO_EDIT_PROJECT';
export const Project = () => {
const projectId = useRequiredPathParam('projectId');
const params = useQueryParams();
@ -167,6 +169,7 @@ export const Project = () => {
}
tooltipProps={{ title: 'Edit project' }}
data-loading
data-testid={NAVIGATE_TO_EDIT_PROJECT}
>
<Edit />
</PermissionIconButton>
@ -246,6 +249,7 @@ export const Project = () => {
label={tab.title}
value={tab.path}
onClick={() => navigate(tab.path)}
data-testid={`TAB_${tab.title}`}
/>
))}
</Tabs>

View File

@ -13,6 +13,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import Select from 'component/common/select';
import { DefaultStickiness, ProjectMode } from '../hooks/useProjectForm';
interface IProjectForm {
projectId: string;
@ -20,8 +21,10 @@ interface IProjectForm {
projectDesc: string;
projectStickiness?: string;
projectMode?: string;
setProjectStickiness?: React.Dispatch<React.SetStateAction<string>>;
setProjectMode?: React.Dispatch<React.SetStateAction<string>>;
setProjectStickiness?: React.Dispatch<
React.SetStateAction<DefaultStickiness>
>;
setProjectMode?: React.Dispatch<React.SetStateAction<ProjectMode>>;
setProjectId: React.Dispatch<React.SetStateAction<string>>;
setProjectName: React.Dispatch<React.SetStateAction<string>>;
setProjectDesc: React.Dispatch<React.SetStateAction<string>>;
@ -33,6 +36,11 @@ interface IProjectForm {
validateProjectId: () => void;
}
const PROJECT_STICKINESS_SELECT = 'PROJECT_STICKINESS_SELECT';
const PROJECT_ID_INPUT = 'PROJECT_ID_INPUT';
const PROJECT_NAME_INPUT = 'PROJECT_NAME_INPUT';
const PROJECT_DESCRIPTION_INPUT = 'PROJECT_DESCRIPTION_INPUT';
const ProjectForm: React.FC<IProjectForm> = ({
children,
handleSubmit,
@ -69,6 +77,7 @@ const ProjectForm: React.FC<IProjectForm> = ({
onFocus={() => clearErrors()}
onBlur={validateProjectId}
disabled={mode === 'Edit'}
data-testid={PROJECT_ID_INPUT}
autoFocus
required
/>
@ -83,6 +92,7 @@ const ProjectForm: React.FC<IProjectForm> = ({
error={Boolean(errors.name)}
errorText={errors.name}
onFocus={() => clearErrors()}
data-testid={PROJECT_NAME_INPUT}
required
/>
@ -96,6 +106,7 @@ const ProjectForm: React.FC<IProjectForm> = ({
maxRows={4}
value={projectDesc}
onChange={e => setProjectDesc(e.target.value)}
data-testid={PROJECT_DESCRIPTION_INPUT}
/>
<ConditionallyRender
@ -111,9 +122,12 @@ const ProjectForm: React.FC<IProjectForm> = ({
<StickinessSelect
label="Stickiness"
value={projectStickiness}
data-testid={PROJECT_STICKINESS_SELECT}
onChange={e =>
setProjectStickiness &&
setProjectStickiness(e.target.value)
setProjectStickiness(
e.target.value as DefaultStickiness
)
}
editable
/>
@ -133,7 +147,9 @@ const ProjectForm: React.FC<IProjectForm> = ({
label="Project mode"
name="Project mode"
onChange={e => {
setProjectMode?.(e.target.value);
setProjectMode?.(
e.target.value as ProjectMode
);
}}
options={[
{ key: 'open', label: 'open' },

View File

@ -3,22 +3,27 @@ import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings';
export type ProjectMode = 'open' | 'protected';
export type DefaultStickiness = 'default' | 'userId' | 'sessionId' | 'random';
const useProjectForm = (
initialProjectId = '',
initialProjectName = '',
initialProjectDesc = '',
initialProjectStickiness = 'default',
initialProjectMode = 'open'
initialProjectStickiness: DefaultStickiness = 'default',
initialProjectMode: ProjectMode = 'open'
) => {
const [projectId, setProjectId] = useState(initialProjectId);
const { defaultStickiness } = useDefaultProjectSettings(projectId);
const [projectName, setProjectName] = useState(initialProjectName);
const [projectDesc, setProjectDesc] = useState(initialProjectDesc);
const [projectStickiness, setProjectStickiness] = useState(
defaultStickiness || initialProjectStickiness
);
const [projectMode, setProjectMode] = useState(initialProjectMode);
const [projectStickiness, setProjectStickiness] =
useState<DefaultStickiness>(
defaultStickiness || initialProjectStickiness
);
const [projectMode, setProjectMode] =
useState<ProjectMode>(initialProjectMode);
const [errors, setErrors] = useState({});
const { validateId } = useProjectApi();
@ -39,12 +44,16 @@ const useProjectForm = (
setProjectMode(initialProjectMode);
}, [initialProjectMode]);
useEffect(() => {
setProjectStickiness(initialProjectStickiness);
}, [initialProjectStickiness]);
const getProjectPayload = () => {
return {
id: projectId,
name: projectName,
description: projectDesc,
projectStickiness,
defaultStickiness: projectStickiness,
mode: projectMode,
};
};
@ -55,7 +64,7 @@ const useProjectForm = (
return false;
}
try {
await validateId(getProjectPayload());
await validateId(getProjectPayload().id);
return true;
} catch (error: unknown) {
setErrors(prev => ({ ...prev, id: formatUnknownError(error) }));

View File

@ -59,6 +59,8 @@ interface ICreateButtonData {
endIcon?: React.ReactNode;
}
const NAVIGATE_TO_CREATE_PROJECT = 'NAVIGATE_TO_CREATE_PROJECT';
function resolveCreateButtonData(
isOss: boolean,
hasAccess: boolean
@ -185,6 +187,7 @@ export const ProjectListNew = () => {
permission={CREATE_PROJECT}
disabled={createButtonData.disabled}
tooltipProps={createButtonData.tooltip}
data-testid={NAVIGATE_TO_CREATE_PROJECT}
>
New project
</ResponsiveButton>

View File

@ -5,6 +5,8 @@ interface ICreatePayload {
id: string;
name: string;
description: string;
mode: 'open' | 'protected';
defaultStickiness: 'default' | 'userId' | 'sessionId' | 'random';
}
interface IAccessesPayload {
@ -33,13 +35,12 @@ const useProjectApi = () => {
}
};
const validateId = async (payload: ICreatePayload) => {
const validateId = async (id: ICreatePayload['id']) => {
const path = `api/admin/projects/validate`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(payload),
body: JSON.stringify({ id }),
});
try {
const res = await makeRequest(req.caller, req.id);
@ -207,10 +208,10 @@ const useProjectApi = () => {
projectId: string,
stickiness: string
) => {
const path = `api/admin/projects/${projectId}/stickiness`;
const path = `api/admin/projects/${projectId}/settings`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify({ stickiness }),
body: JSON.stringify({ defaultStickiness: stickiness }),
});
return makeRequest(req.caller, req.id);

View File

@ -3,10 +3,14 @@ import { SWRConfiguration } from 'swr';
import { useCallback } from 'react';
import handleErrorResponses from './api/getters/httpErrorResponseHandler';
import { useConditionalSWR } from './api/getters/useConditionalSWR/useConditionalSWR';
import {
DefaultStickiness,
ProjectMode,
} from 'component/project/Project/hooks/useProjectForm';
export interface IStickinessResponse {
defaultStickiness?: string;
mode?: string;
export interface ISettingsResponse {
defaultStickiness?: DefaultStickiness;
mode?: ProjectMode;
}
const DEFAULT_STICKINESS = 'default';
export const useDefaultProjectSettings = (
@ -18,7 +22,7 @@ export const useDefaultProjectSettings = (
const PATH = `/api/admin/projects/${projectId}/settings`;
const { projectScopedStickiness } = uiConfig.flags;
const { data, error, mutate } = useConditionalSWR<IStickinessResponse>(
const { data, error, mutate } = useConditionalSWR<ISettingsResponse>(
Boolean(projectId) && Boolean(projectScopedStickiness),
{},
['useDefaultProjectSettings', PATH],
@ -26,7 +30,8 @@ export const useDefaultProjectSettings = (
options
);
const defaultStickiness = data?.defaultStickiness ?? DEFAULT_STICKINESS;
const defaultStickiness: DefaultStickiness =
data?.defaultStickiness ?? DEFAULT_STICKINESS;
const refetch = useCallback(() => {
mutate().catch(console.warn);

View File

@ -3,24 +3,27 @@ import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error';
import {
DefaultStickiness,
IEnvironment,
IFlagResolver,
IProject,
IProjectWithCount,
ProjectMode,
} from '../types/model';
} from '../types';
import {
IProjectHealthUpdate,
IProjectInsert,
IProjectQuery,
IProjectSettings,
IProjectSettingsRow,
IProjectStore,
} from '../types/stores/project-store';
import { DEFAULT_ENV } from '../util/constants';
import { DEFAULT_ENV } from '../util';
import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events';
import EventEmitter from 'events';
import { IFlagResolver } from '../types';
import Raw = Knex.Raw;
import { Db } from './db';
import Raw = Knex.Raw;
const COLUMNS = [
'id',
@ -160,6 +163,7 @@ class ProjectStore implements IProjectStore {
memberCount: Number(row.number_of_users) || 0,
updatedAt: row.updated_at,
mode: 'open',
defaultStickiness: 'default',
};
}
@ -202,7 +206,7 @@ class ProjectStore implements IProjectStore {
}
async create(
project: IProjectInsert & { mode: ProjectMode },
project: IProjectInsert & IProjectSettings,
): Promise<IProject> {
const row = await this.db(TABLE)
.insert(this.fieldToRow(project))
@ -211,6 +215,7 @@ class ProjectStore implements IProjectStore {
.insert({
project: project.id,
project_mode: project.mode,
default_stickiness: project.defaultStickiness,
})
.returning('*');
return this.mapRow({ ...row[0], ...settingsRow[0] });
@ -226,6 +231,7 @@ class ProjectStore implements IProjectStore {
.where({ project: data.id })
.update({
project_mode: data.mode,
default_stickiness: data.defaultStickiness,
})
.returning('*');
} catch (err) {
@ -458,6 +464,24 @@ 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: DefaultStickiness,
mode: ProjectMode,
): Promise<void> {
await this.db(SETTINGS_TABLE)
.update({
default_stickiness: defaultStickiness,
project_mode: mode,
})
.where({ project: projectId });
}
async count(): Promise<number> {
return this.db
.from(TABLE)
@ -465,6 +489,13 @@ 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 {

View File

@ -133,7 +133,6 @@ import {
importTogglesSchema,
importTogglesValidateSchema,
importTogglesValidateItemSchema,
projectSettingsSchema,
} from './spec';
import { IServerOption } from '../types';
import { mapValues, omitKeys } from '../util';
@ -257,7 +256,6 @@ export const schemas = {
stateSchema,
strategiesSchema,
strategySchema,
projectSettingsSchema,
tagsBulkAddSchema,
tagSchema,
tagsSchema,

View File

@ -25,6 +25,13 @@ export const healthOverviewSchema = {
type: 'string',
nullable: true,
},
defaultStickiness: {
type: 'string',
enum: ['default', 'userId', 'sessionId', 'random'],
example: 'userId',
description:
'A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy',
},
mode: {
type: 'string',
enum: ['open', 'protected'],

View File

@ -129,7 +129,6 @@ export * from './project-overview-schema';
export * from './import-toggles-validate-item-schema';
export * from './import-toggles-validate-schema';
export * from './import-toggles-schema';
export * from './project-settings-schema';
export * from './tags-bulk-add-schema';
export * from './upsert-segment-schema';
export * from './batch-features-schema';

View File

@ -36,6 +36,13 @@ export const projectOverviewSchema = {
example: 'DX squad feature release',
description: 'Additional information about the project',
},
defaultStickiness: {
type: 'string',
enum: ['default', 'userId', 'sessionId', 'random'],
example: 'userId',
description:
'A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy',
},
mode: {
type: 'string',
enum: ['open', 'protected'],

View File

@ -62,6 +62,13 @@ export const projectSchema = {
description:
'A mode of the project affecting what actions are possible in this project',
},
defaultStickiness: {
type: 'string',
enum: ['default', 'userId', 'sessionId', 'random'],
example: 'userId',
description:
'A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy',
},
},
components: {},
} as const;

View File

@ -1,23 +0,0 @@
import { FromSchema } from 'json-schema-to-ts';
export const projectSettingsSchema = {
$id: '#/components/schemas/projectSettingsSchema',
type: 'object',
additionalProperties: false,
required: ['defaultStickiness'],
properties: {
defaultStickiness: {
type: 'string',
example: 'userId',
nullable: true,
},
mode: {
type: 'string',
enum: ['open', 'protected', 'private'],
nullable: true,
},
},
components: {},
} as const;
export type ProjectSettingsSchema = FromSchema<typeof projectSettingsSchema>;

View File

@ -7,7 +7,6 @@ import {
IUnleashServices,
NONE,
serializeDates,
UPDATE_PROJECT,
} from '../../../types';
import ProjectFeaturesController from './project-features';
import EnvironmentsController from './environments';
@ -16,11 +15,8 @@ import ProjectService from '../../../services/project-service';
import VariantsController from './variants';
import {
createResponseSchema,
emptyResponse,
ProjectOverviewSchema,
projectOverviewSchema,
ProjectSettingsSchema,
projectSettingsSchema,
projectsSchema,
ProjectsSchema,
} from '../../../openapi';
@ -28,10 +24,6 @@ import { OpenApiService, SettingService } from '../../../services';
import { IAuthRequest } from '../../unleash-types';
import { ProjectApiTokenController } from './api-token';
import ProjectArchiveController from './project-archive';
import NotFoundError from '../../../error/notfound-error';
const STICKINESS_KEY = 'stickiness';
const DEFAULT_STICKINESS = 'default';
export default class ProjectApi extends Controller {
private projectService: ProjectService;
@ -78,40 +70,6 @@ export default class ProjectApi extends Controller {
],
});
this.route({
method: 'get',
path: '/:projectId/settings',
handler: this.getProjectSettings,
permission: NONE,
middleware: [
services.openApiService.validPath({
tags: ['Projects'],
operationId: 'getProjectSettings',
responses: {
200: createResponseSchema('projectSettingsSchema'),
404: emptyResponse,
},
}),
],
});
this.route({
method: 'post',
path: '/:projectId/settings',
handler: this.setProjectSettings,
permission: UPDATE_PROJECT,
middleware: [
services.openApiService.validPath({
tags: ['Projects'],
operationId: 'setProjectSettings',
responses: {
200: createResponseSchema('projectSettingsSchema'),
404: emptyResponse,
},
}),
],
});
this.use('/', new ProjectFeaturesController(config, services).router);
this.use('/', new EnvironmentsController(config, services).router);
this.use('/', new ProjectHealthReport(config, services).router);
@ -159,61 +117,4 @@ export default class ProjectApi extends Controller {
serializeDates(overview),
);
}
async getProjectSettings(
req: IAuthRequest<IProjectParam, unknown, unknown, unknown>,
res: Response<ProjectSettingsSchema>,
): Promise<void> {
if (!this.config.flagResolver.isEnabled('projectScopedStickiness')) {
throw new NotFoundError('Project scoped stickiness is not enabled');
}
const { projectId } = req.params;
const stickinessSettings = await this.settingService.get<object>(
STICKINESS_KEY,
{
[projectId]: 'default',
},
);
this.openApiService.respondWithValidation(
200,
res,
projectSettingsSchema.$id,
{ defaultStickiness: stickinessSettings[projectId] },
);
}
async setProjectSettings(
req: IAuthRequest<
IProjectParam,
ProjectSettingsSchema,
ProjectSettingsSchema,
unknown
>,
res: Response<ProjectSettingsSchema>,
): Promise<void> {
if (!this.config.flagResolver.isEnabled('projectScopedStickiness')) {
throw new NotFoundError('Project scoped stickiness is not enabled');
}
const { projectId } = req.params;
const { defaultStickiness } = req.body;
const stickinessSettings = await this.settingService.get<{}>(
STICKINESS_KEY,
{
[projectId]: DEFAULT_STICKINESS,
},
);
stickinessSettings[projectId] = defaultStickiness;
await this.settingService.insert(
STICKINESS_KEY,
stickinessSettings,
req.user.name,
);
this.openApiService.respondWithValidation(
200,
res,
projectSettingsSchema.$id,
{ defaultStickiness },
);
}
}

View File

@ -8,5 +8,9 @@ export const projectSchema = joi
name: joi.string().required(),
description: joi.string().allow(null).allow('').optional(),
mode: joi.string().valid('open', 'protected').default('open'),
defaultStickiness: joi
.string()
.valid('default', 'userId', 'sessionId', 'random')
.default('default'),
})
.options({ allowUnknown: false, stripUnknown: true });

View File

@ -7,44 +7,50 @@ import { nameType } from '../routes/util';
import { projectSchema } from './project-schema';
import NotFoundError from '../error/notfound-error';
import {
DEFAULT_PROJECT,
DefaultStickiness,
FEATURE_ENVIRONMENT_ENABLED,
FeatureToggle,
IAccountStore,
IEnvironmentStore,
IEventStore,
IFeatureEnvironmentStore,
IFeatureToggleStore,
IFeatureTypeStore,
IProject,
IProjectOverview,
IProjectWithCount,
IUnleashConfig,
IUnleashStores,
IUserWithRole,
MOVE_FEATURE_TOGGLE,
PROJECT_CREATED,
PROJECT_DELETED,
PROJECT_UPDATED,
ProjectGroupAddedEvent,
ProjectGroupRemovedEvent,
ProjectGroupUpdateRoleEvent,
ProjectMode,
ProjectUserAddedEvent,
ProjectUserRemovedEvent,
ProjectUserUpdateRoleEvent,
} from '../types/events';
import { IAccountStore, IUnleashConfig, IUnleashStores } from '../types';
import {
FeatureToggle,
IProject,
IProjectOverview,
IProjectWithCount,
IUserWithRole,
RoleName,
} from '../types/model';
import { IEnvironmentStore } from '../types/stores/environment-store';
import { IFeatureTypeStore } from '../types/stores/feature-type-store';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
import { IProjectQuery, IProjectStore } from '../types/stores/project-store';
} from '../types';
import {
IProjectQuery,
IProjectSettings,
IProjectStore,
} from '../types/stores/project-store';
import {
IProjectAccessModel,
IRoleDescriptor,
} from '../types/stores/access-store';
import { IEventStore } from '../types/stores/event-store';
import FeatureToggleService from './feature-toggle-service';
import { MOVE_FEATURE_TOGGLE } from '../types/permissions';
import NoAccessError from '../error/no-access-error';
import IncompatibleProjectError from '../error/incompatible-project-error';
import { DEFAULT_PROJECT } from '../types/project';
import { IFeatureTagStore } from 'lib/types/stores/feature-tag-store';
import ProjectWithoutOwnerError from '../error/project-without-owner-error';
import { arraysHaveSameItems } from '../util/arraysHaveSameItems';
import { arraysHaveSameItems } from '../util';
import { GroupService } from './group-service';
import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
import { FavoritesService } from './favorites-service';
@ -165,7 +171,10 @@ export default class ProjectService {
}
async createProject(
newProject: Pick<IProject, 'id' | 'name' | 'mode'>,
newProject: Pick<
IProject,
'id' | 'name' | 'mode' | 'defaultStickiness'
>,
user: IUser,
): Promise<IProject> {
const data = await projectSchema.validateAsync(newProject);
@ -827,6 +836,7 @@ export default class ProjectService {
name: project.name,
description: project.description,
mode: project.mode,
defaultStickiness: project.defaultStickiness || 'default',
health: project.health || 0,
favorite: favorite,
updatedAt: project.updatedAt,
@ -836,4 +846,20 @@ export default class ProjectService {
version: 1,
};
}
async getProjectSettings(projectId: string): Promise<IProjectSettings> {
return this.store.getProjectSettings(projectId);
}
async setProjectSettings(
projectId: string,
defaultStickiness: DefaultStickiness,
mode: ProjectMode,
): Promise<void> {
return this.store.setProjectSettings(
projectId,
defaultStickiness,
mode,
);
}
}

View File

@ -175,6 +175,8 @@ export interface IFeatureOverview {
export type ProjectMode = 'open' | 'protected';
export type DefaultStickiness = 'default' | 'sessionId' | 'userId' | 'random';
export interface IProjectOverview {
name: string;
description: string;
@ -187,6 +189,8 @@ export interface IProjectOverview {
updatedAt?: Date;
stats?: IProjectStats;
mode: ProjectMode;
defaultStickiness: DefaultStickiness;
}
export interface IProjectHealthReport extends IProjectOverview {
@ -370,6 +374,7 @@ export interface IProject {
updatedAt?: Date;
changeRequestsEnabled?: boolean;
mode: ProjectMode;
defaultStickiness?: DefaultStickiness;
}
export interface ICustomRole {

View File

@ -3,6 +3,7 @@ import {
IProjectMembersCount,
} from '../../db/project-store';
import {
DefaultStickiness,
IEnvironment,
IProject,
IProjectWithCount,
@ -19,6 +20,16 @@ export interface IProjectInsert {
mode: ProjectMode;
}
export interface IProjectSettings {
mode: ProjectMode;
defaultStickiness: DefaultStickiness;
}
export interface IProjectSettingsRow {
project_mode: ProjectMode;
default_stickiness: DefaultStickiness;
}
export interface IProjectArchived {
id: string;
archived: boolean;
@ -86,4 +97,11 @@ export interface IProjectStore extends Store<IProject, string> {
environment: string,
projects: string[],
): Promise<void>;
getProjectSettings(projectId: string): Promise<IProjectSettings>;
setProjectSettings(
projectId: string,
defaultStickiness: DefaultStickiness,
mode: ProjectMode,
): Promise<void>;
}

View File

@ -31,6 +31,7 @@ test('Should ONLY return default project', async () => {
name: 'test',
description: '',
mode: 'open',
defaultStickiness: 'default',
});
const { body } = await app.request
@ -41,27 +42,3 @@ test('Should ONLY return default project', async () => {
expect(body.projects).toHaveLength(1);
expect(body.projects[0].id).toBe('default');
});
test('Should store and retrieve default project stickiness', async () => {
const appWithDefaultStickiness = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
projectScopedStickiness: true,
strictSchemaValidation: true,
},
},
});
const reqBody = { defaultStickiness: 'userId' };
await appWithDefaultStickiness.request
.post('/api/admin/projects/default/settings')
.send(reqBody)
.expect(200);
const { body } = await appWithDefaultStickiness.request
.get('/api/admin/projects/default/settings')
.expect(200)
.expect('Content-Type', /json/);
expect(body).toStrictEqual(reqBody);
});

View File

@ -1841,6 +1841,17 @@ exports[`should serve the OpenAPI spec 1`] = `
"healthOverviewSchema": {
"additionalProperties": false,
"properties": {
"defaultStickiness": {
"description": "A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy",
"enum": [
"default",
"userId",
"sessionId",
"random",
],
"example": "userId",
"type": "string",
},
"description": {
"nullable": true,
"type": "string",
@ -1904,6 +1915,17 @@ exports[`should serve the OpenAPI spec 1`] = `
"activeCount": {
"type": "number",
},
"defaultStickiness": {
"description": "A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy",
"enum": [
"default",
"userId",
"sessionId",
"random",
],
"example": "userId",
"type": "string",
},
"description": {
"nullable": true,
"type": "string",
@ -2759,6 +2781,17 @@ exports[`should serve the OpenAPI spec 1`] = `
"additionalProperties": false,
"description": "A high-level overview of a project. It contains information such as project statistics, the name of the project, what members and what features it contains, etc.",
"properties": {
"defaultStickiness": {
"description": "A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy",
"enum": [
"default",
"userId",
"sessionId",
"random",
],
"example": "userId",
"type": "string",
},
"description": {
"description": "Additional information about the project",
"example": "DX squad feature release",
@ -2841,6 +2874,17 @@ exports[`should serve the OpenAPI spec 1`] = `
"format": "date-time",
"type": "string",
},
"defaultStickiness": {
"description": "A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy",
"enum": [
"default",
"userId",
"sessionId",
"random",
],
"example": "userId",
"type": "string",
},
"description": {
"description": "Additional information about the project",
"example": "DX squad feature release",
@ -2898,29 +2942,6 @@ exports[`should serve the OpenAPI spec 1`] = `
],
"type": "object",
},
"projectSettingsSchema": {
"additionalProperties": false,
"properties": {
"defaultStickiness": {
"example": "userId",
"nullable": true,
"type": "string",
},
"mode": {
"enum": [
"open",
"protected",
"private",
],
"nullable": true,
"type": "string",
},
},
"required": [
"defaultStickiness",
],
"type": "object",
},
"projectStatsSchema": {
"additionalProperties": false,
"description": "Statistics for a project, including the average time to production, number of features created, the project activity and more.
@ -7561,70 +7582,6 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/projects/{projectId}/settings": {
"get": {
"operationId": "getProjectSettings",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/projectSettingsSchema",
},
},
},
"description": "projectSettingsSchema",
},
"404": {
"description": "This response has no body.",
},
},
"tags": [
"Projects",
],
},
"post": {
"operationId": "setProjectSettings",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/projectSettingsSchema",
},
},
},
"description": "projectSettingsSchema",
},
"404": {
"description": "This response has no body.",
},
},
"tags": [
"Projects",
],
},
},
"/api/admin/projects/{projectId}/stale": {
"post": {
"description": "This endpoint stales the specified features.",

View File

@ -1,12 +1,15 @@
import {
IProjectHealthUpdate,
IProjectInsert,
IProjectSettings,
IProjectStore,
} from '../../lib/types/stores/project-store';
import {
DefaultStickiness,
IEnvironment,
IProject,
IProjectWithCount,
ProjectMode,
} from '../../lib/types/model';
import NotFoundError from '../../lib/error/notfound-error';
import {
@ -161,4 +164,20 @@ export default class FakeProjectStore implements IProjectStore {
): Promise<number> {
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: DefaultStickiness,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
mode: ProjectMode,
): Promise<void> {
throw new Error('Method not implemented.');
}
}